1import { withRequestOptions } from "@distilled.cloud/stripe";
2import type { StripeOpError } from "@distilled.cloud/stripe";
3import {
4 GetIssuingCards,
5 GetIssuingCard,
6 CreateIssuingCard,
7 UpdateIssuingCard,
8 type IssuingCard as StripeIssuingCard,
9 type CreateIssuingCardRequestSpendingControls,
10} from "@distilled.cloud/stripe/stripe";
11import * as Data from "effect/Data";
12import * as Effect from "effect/Effect";
13import { Unowned } from "../AdoptPolicy.ts";
14import { deepEqual, isResolved } from "../Diff.ts";
15import * as Provider from "../Provider.ts";
16import { Resource } from "../Resource.ts";
17import { tagRecord } from "../Tags.ts";
18import {
19 alchemyMetadataKeys,
20 createInternalMetadata,
21 diffMetadata,
22 hasAlchemyMetadata,
23 stripInternalMetadata,
24 toMetadata,
25} from "./Metadata.ts";
26import type { Providers } from "./Providers.ts";
27import { isMissingStripeResource } from "./missing.ts";
28
29const LIST_PAGE_SIZE = 100;
30const LIST_MAX_PAGES = 100;
31
32/** Whether authorizations can be approved on this card. */
33export type IssuingCardStatus = "active" | "canceled" | "inactive";
34
35/** The type of Issuing card. Create-only — changing it replaces the card. */
36export type IssuingCardType = "physical" | "virtual";
37
38/** Why a previous card is being replaced. Create-only. */
39export type IssuingCardReplacementReason =
40 | "damaged"
41 | "expired"
42 | "lost"
43 | "stolen";
44
45/** Card-present vs card-not-present authorization presence. */
46export type IssuingCardPresence = "not_present" | "present";
47
48/** Interval (or event) a spending limit applies to. */
49export type IssuingCardSpendingLimitInterval =
50 | "all_time"
51 | "daily"
52 | "monthly"
53 | "per_authorization"
54 | "weekly"
55 | "yearly";
56
57export interface IssuingCardSpendingLimit {
58 /**
59 * Maximum amount allowed to spend per interval, in the card's currency
60 * minor units.
61 */
62 amount: number;
63 /**
64 * Interval (or event) to which the amount applies.
65 */
66 interval: IssuingCardSpendingLimitInterval;
67 /**
68 * Merchant categories this limit applies to. Omit to apply to all
69 * categories.
70 */
71 categories?: string[];
72}
73
74export interface IssuingCardSpendingControls {
75 /**
76 * Card-presence statuses from which authorizations are allowed. Cannot
77 * be set with `blockedCardPresences`.
78 */
79 allowedCardPresences?: IssuingCardPresence[];
80 /**
81 * Merchant categories to allow. All other categories are blocked.
82 * Cannot be set with `blockedCategories`.
83 */
84 allowedCategories?: string[];
85 /**
86 * ISO 3166 alpha-2 countries from which authorizations are allowed.
87 * Cannot be set with `blockedMerchantCountries`.
88 */
89 allowedMerchantCountries?: string[];
90 /**
91 * Card-presence statuses from which authorizations are declined. Cannot
92 * be set with `allowedCardPresences`.
93 */
94 blockedCardPresences?: IssuingCardPresence[];
95 /**
96 * Merchant categories to decline. All other categories are allowed.
97 * Cannot be set with `allowedCategories`.
98 */
99 blockedCategories?: string[];
100 /**
101 * ISO 3166 alpha-2 countries from which authorizations are declined.
102 * Cannot be set with `allowedMerchantCountries`.
103 */
104 blockedMerchantCountries?: string[];
105 /**
106 * Amount-based spending limits that apply across this card and any
107 * cards it replaced.
108 */
109 spendingLimits?: IssuingCardSpendingLimit[];
110}
111
112export interface IssuingCardProps {
113 /**
114 * Id of the Issuing Cardholder this card is issued to (`ich_…`).
115 * Create-only — changing it replaces the card.
116 */
117 cardholder: string;
118 /**
119 * Three-letter ISO currency code, lowercase. Supported currencies are
120 * `usd` (US), `eur` (EU), and `gbp` (UK). Create-only — changing it
121 * replaces the card.
122 */
123 currency: string;
124 /**
125 * The type of card to issue.
126 * @default "virtual"
127 */
128 type?: IssuingCardType;
129 /**
130 * Whether authorizations can be approved on this card. Stripe defaults
131 * new cards to `inactive`. Destroying this resource cancels the card
132 * (`canceled`); canceled cards cannot be reactivated.
133 * @default "inactive"
134 */
135 status?: "active" | "inactive";
136 /**
137 * Second line printed on the card (max 24 characters). Create-only —
138 * changing it replaces the card.
139 */
140 secondLine?: string;
141 /**
142 * Desired expiration month (1–12) when specifying a custom expiration
143 * date. Create-only.
144 */
145 expMonth?: number;
146 /**
147 * Desired 4-digit expiration year when specifying a custom expiration
148 * date. Create-only.
149 */
150 expYear?: number;
151 /**
152 * Financial account id this card is attached to. Create-only —
153 * changing it replaces the card.
154 */
155 financialAccount?: string;
156 /**
157 * Personalization design id belonging to this card. Mutable.
158 */
159 personalizationDesign?: string;
160 /**
161 * Card this one replaces, if any. Create-only.
162 */
163 replacementFor?: string;
164 /**
165 * Why `replacementFor` is being replaced. Create-only.
166 */
167 replacementReason?: IssuingCardReplacementReason;
168 /**
169 * Rules that control spending for this card. Mutable.
170 */
171 spendingControls?: IssuingCardSpendingControls;
172 /**
173 * User-defined metadata. Alchemy ownership keys (`alchemy_stack` /
174 * `alchemy_stage` / `alchemy_id`) are merged in automatically. Keys may
175 * not contain `:`. Mutable.
176 */
177 metadata?: Record<string, string>;
178}
179
180export type IssuingCard = Resource<
181 "Stripe.IssuingCard",
182 IssuingCardProps,
183 {
184 /** Stripe Issuing card id (`ic_…`). */
185 id: string;
186 /** Id of the cardholder this card belongs to (`ich_…`). */
187 cardholder: string;
188 /** Three-letter ISO currency code. */
189 currency: string;
190 /** `virtual` or `physical`. */
191 type: IssuingCardType;
192 /** Whether authorizations can be approved on this card. */
193 status: IssuingCardStatus;
194 /** Card brand (e.g. `Visa`). */
195 brand: string;
196 /** Last 4 digits of the card number. */
197 last4: string;
198 /** Expiration month. */
199 expMonth: number;
200 /** Expiration year. */
201 expYear: number;
202 /** Second line printed on the card, if set. */
203 secondLine: string | undefined;
204 /** Financial account id, if attached. */
205 financialAccount: string | undefined;
206 /** Personalization design id, if set. */
207 personalizationDesign: string | undefined;
208 /** Card this one replaces, if any. */
209 replacementFor: string | undefined;
210 /** Why the previous card was replaced, if this is a replacement. */
211 replacementReason: IssuingCardReplacementReason | undefined;
212 /** Why the card was canceled, if canceled. */
213 cancellationReason: string | undefined;
214 /** Spending controls currently applied to this card. */
215 spendingControls: IssuingCardSpendingControls | undefined;
216 /** User-defined metadata (Alchemy ownership keys stripped). */
217 metadata: Record<string, string>;
218 /** Unix timestamp when the card was created. */
219 created: number;
220 /** Whether the card exists in live mode. */
221 livemode: boolean;
222 },
223 never,
224 Providers
225>;
226
227/**
228 * A Stripe Issuing Card — a virtual or physical card issued to a
229 * Cardholder. `cardholder`, `currency`, `type`, `secondLine`, and
230 * custom expiration are immutable (changing them replaces the card).
231 * Status, spending controls, personalization design, and metadata
232 * update in place.
233 *
234 * Cards cannot be hard-deleted; destroying this resource cancels the
235 * card (`status: canceled`). Canceled cards cannot be reactivated — a
236 * later create with the same logical id provisions a new card.
237 *
238 * Issuing must be enabled on the Stripe account.
239 *
240 * @see https://docs.stripe.com/api/issuing/cards
241 *
242 * ### Creating a Card
243 * **Example:** Virtual card
244 * ```typescript
245 * const card = yield* Stripe.IssuingCard("expense-card", {
246 * cardholder: cardholder.id,
247 * currency: "usd",
248 * type: "virtual",
249 * status: "inactive",
250 * });
251 * ```
252 *
253 * **Example:** Spending limits and metadata
254 * ```typescript
255 * const card = yield* Stripe.IssuingCard("expense-card", {
256 * cardholder: cardholder.id,
257 * currency: "usd",
258 * type: "virtual",
259 * status: "active",
260 * spendingControls: {
261 * spendingLimits: [{ amount: 50_000, interval: "monthly" }],
262 * },
263 * metadata: { team: "ops" },
264 * });
265 * ```
266 *
267 * ### Updating a Card
268 * **Example:** Freeze and retag
269 * ```typescript
270 * const card = yield* Stripe.IssuingCard("expense-card", {
271 * cardholder: cardholder.id,
272 * currency: "usd",
273 * type: "virtual",
274 * status: "inactive",
275 * metadata: { team: "ops", frozen: "true" },
276 * });
277 * ```
278 *
279 * ### Replacing a Card
280 * **Example:** Changing type replaces the card
281 * ```typescript
282 * const card = yield* Stripe.IssuingCard("expense-card", {
283 * cardholder: cardholder.id,
284 * currency: "usd",
285 * type: "physical",
286 * });
287 * ```
288 *
289 * @resource
290 * @product Issuing
291 */
292export const IssuingCard = Resource<IssuingCard>("Stripe.IssuingCard");
293
294export class IssuingCardNotResolved extends Data.TaggedError(
295 "Stripe.IssuingCardNotResolved",
296)<{
297 cardholder: string;
298 currency: string;
299}> {}
300
301type IssuingCardAttributes = IssuingCard["Attributes"];
302
303const userMetadata = (
304 metadata: Record<string, string | undefined> | null | undefined,
305): Record<string, string> => stripInternalMetadata(tagRecord(metadata));
306
307const expandedId = (
308 value: string | { readonly id: string } | null | undefined,
309): string | undefined => {
310 if (value == null) return undefined;
311 if (typeof value === "string") return value;
312 return value.id;
313};
314
315const toSpendingControls = (
316 controls: StripeIssuingCard["spending_controls"] | null | undefined,
317): IssuingCardSpendingControls | undefined => {
318 if (controls == null) return undefined;
319 const spendingLimits = (controls.spending_limits ?? []).map((limit) => ({
320 amount: limit.amount,
321 interval: limit.interval as IssuingCardSpendingLimitInterval,
322 ...(limit.categories != null && limit.categories.length > 0
323 ? { categories: [...limit.categories] }
324 : {}),
325 }));
326 const result: IssuingCardSpendingControls = {
327 ...(controls.allowed_card_presences != null
328 ? {
329 allowedCardPresences: [
330 ...controls.allowed_card_presences,
331 ] as IssuingCardPresence[],
332 }
333 : {}),
334 ...(controls.allowed_categories != null
335 ? { allowedCategories: [...controls.allowed_categories] }
336 : {}),
337 ...(controls.allowed_merchant_countries != null
338 ? { allowedMerchantCountries: [...controls.allowed_merchant_countries] }
339 : {}),
340 ...(controls.blocked_card_presences != null
341 ? {
342 blockedCardPresences: [
343 ...controls.blocked_card_presences,
344 ] as IssuingCardPresence[],
345 }
346 : {}),
347 ...(controls.blocked_categories != null
348 ? { blockedCategories: [...controls.blocked_categories] }
349 : {}),
350 ...(controls.blocked_merchant_countries != null
351 ? { blockedMerchantCountries: [...controls.blocked_merchant_countries] }
352 : {}),
353 ...(spendingLimits.length > 0 ? { spendingLimits } : {}),
354 };
355 if (
356 result.allowedCardPresences === undefined &&
357 result.allowedCategories === undefined &&
358 result.allowedMerchantCountries === undefined &&
359 result.blockedCardPresences === undefined &&
360 result.blockedCategories === undefined &&
361 result.blockedMerchantCountries === undefined &&
362 result.spendingLimits === undefined
363 ) {
364 return undefined;
365 }
366 return result;
367};
368
369const toWireSpendingControls = (
370 controls: IssuingCardSpendingControls,
371): CreateIssuingCardRequestSpendingControls => ({
372 ...(controls.allowedCardPresences !== undefined
373 ? { allowed_card_presences: controls.allowedCardPresences }
374 : {}),
375 ...(controls.allowedCategories !== undefined
376 ? { allowed_categories: controls.allowedCategories }
377 : {}),
378 ...(controls.allowedMerchantCountries !== undefined
379 ? { allowed_merchant_countries: controls.allowedMerchantCountries }
380 : {}),
381 ...(controls.blockedCardPresences !== undefined
382 ? { blocked_card_presences: controls.blockedCardPresences }
383 : {}),
384 ...(controls.blockedCategories !== undefined
385 ? { blocked_categories: controls.blockedCategories }
386 : {}),
387 ...(controls.blockedMerchantCountries !== undefined
388 ? { blocked_merchant_countries: controls.blockedMerchantCountries }
389 : {}),
390 ...(controls.spendingLimits !== undefined
391 ? {
392 spending_limits: controls.spendingLimits.map((limit) => ({
393 amount: limit.amount,
394 interval: limit.interval,
395 ...(limit.categories !== undefined
396 ? { categories: limit.categories }
397 : {}),
398 })),
399 }
400 : {}),
401});
402
403const toAttrs = (card: StripeIssuingCard): IssuingCardAttributes => ({
404 id: card.id,
405 cardholder: card.cardholder.id,
406 currency: card.currency,
407 type: card.type,
408 status: card.status,
409 brand: card.brand,
410 last4: card.last4,
411 expMonth: card.exp_month,
412 expYear: card.exp_year,
413 secondLine: card.second_line ?? undefined,
414 financialAccount: card.financial_account ?? undefined,
415 personalizationDesign: expandedId(card.personalization_design),
416 replacementFor: expandedId(card.replacement_for),
417 replacementReason:
418 (card.replacement_reason as IssuingCardReplacementReason | null) ??
419 undefined,
420 cancellationReason: card.cancellation_reason ?? undefined,
421 spendingControls: toSpendingControls(card.spending_controls),
422 metadata: userMetadata(card.metadata),
423 created: card.created,
424 livemode: card.livemode,
425});
426
427const isMissingCard = isMissingStripeResource;
428
429const isIssuingUnavailable = (error: StripeOpError): boolean =>
430 error._tag === "InvalidRequestError" || error._tag === "Forbidden";
431
432const getById = (card: string) =>
433 GetIssuingCard({ card }).pipe(
434 Effect.map((live) => (live.status === "canceled" ? undefined : live)),
435 Effect.catchIf(isMissingCard, () => Effect.succeed(undefined)),
436 );
437
438const getByIdAny = (card: string) =>
439 GetIssuingCard({ card }).pipe(
440 Effect.catchIf(isMissingCard, () => Effect.succeed(undefined)),
441 );
442
443const listByStatus = Effect.fn(function* (status: "active" | "inactive") {
444 const cards: StripeIssuingCard[] = [];
445 let startingAfter: string | undefined;
446 for (let page = 0; page < LIST_MAX_PAGES; page++) {
447 const response = yield* GetIssuingCards({
448 status,
449 limit: LIST_PAGE_SIZE,
450 ...(startingAfter !== undefined ? { starting_after: startingAfter } : {}),
451 });
452 cards.push(...response.data);
453 if (!response.has_more || response.data.length === 0) {
454 break;
455 }
456 startingAfter = response.data[response.data.length - 1]?.id;
457 if (startingAfter === undefined) {
458 break;
459 }
460 }
461 return cards;
462});
463
464const listLiveCards = Effect.fn(function* () {
465 const listed = yield* Effect.all(
466 [listByStatus("active"), listByStatus("inactive")],
467 { concurrency: 2 },
468 ).pipe(
469 Effect.catchIf(isIssuingUnavailable, () =>
470 Effect.succeed([[], []] as [StripeIssuingCard[], StripeIssuingCard[]]),
471 ),
472 );
473 const seen = new Set<string>();
474 const cards: StripeIssuingCard[] = [];
475 for (const card of [...listed[0], ...listed[1]]) {
476 if (seen.has(card.id)) continue;
477 seen.add(card.id);
478 cards.push(card);
479 }
480 return cards;
481});
482
483const findByAlchemyId = Effect.fn(function* (id: string) {
484 const cards = yield* listLiveCards();
485 const matches: StripeIssuingCard[] = [];
486 for (const card of cards) {
487 if (yield* hasAlchemyMetadata(id, tagRecord(card.metadata))) {
488 matches.push(card);
489 }
490 }
491 matches.sort((a, b) => b.created - a.created);
492 return matches[0];
493});
494
495const observe = Effect.fn(function* (input: {
496 id?: string;
497 logicalId: string;
498}) {
499 if (input.id !== undefined) {
500 const byId = yield* getById(input.id);
501 if (byId !== undefined) return byId;
502 }
503 return yield* findByAlchemyId(input.logicalId);
504});
505
506const desiredMetadata = Effect.fn(function* (
507 id: string,
508 metadata: Record<string, string> | undefined,
509) {
510 return {
511 ...toMetadata(metadata),
512 ...(yield* createInternalMetadata(id)),
513 };
514});
515
516const shouldReplace = (
517 news: IssuingCardProps,
518 output: IssuingCardAttributes | undefined,
519): boolean => {
520 if (output === undefined) return false;
521 if (news.cardholder !== output.cardholder) return true;
522 if (news.currency !== output.currency) return true;
523 if ((news.type ?? "virtual") !== output.type) return true;
524 if (
525 news.secondLine !== undefined &&
526 news.secondLine !== (output.secondLine ?? undefined)
527 ) {
528 return true;
529 }
530 if (news.expMonth !== undefined && news.expMonth !== output.expMonth) {
531 return true;
532 }
533 if (news.expYear !== undefined && news.expYear !== output.expYear) {
534 return true;
535 }
536 if (
537 news.financialAccount !== undefined &&
538 news.financialAccount !== (output.financialAccount ?? undefined)
539 ) {
540 return true;
541 }
542 if (
543 news.replacementFor !== undefined &&
544 news.replacementFor !== (output.replacementFor ?? undefined)
545 ) {
546 return true;
547 }
548 return false;
549};
550
551export const IssuingCardProvider = () =>
552 Provider.succeed(IssuingCard, {
553 stables: [
554 "id",
555 "cardholder",
556 "currency",
557 "type",
558 "brand",
559 "last4",
560 "expMonth",
561 "expYear",
562 "created",
563 "livemode",
564 ],
565
566 diff: Effect.fn(function* ({ news, output }) {
567 if (!isResolved(news)) return undefined;
568 if (shouldReplace(news, output)) {
569 return { action: "replace" } as const;
570 }
571 return undefined;
572 }),
573
574 read: Effect.fn(function* ({ id, output }) {
575 const existing = yield* observe({
576 id: output?.id,
577 logicalId: id,
578 });
579 if (existing === undefined) return undefined;
580 const attrs = toAttrs(existing);
581 return (yield* hasAlchemyMetadata(id, tagRecord(existing.metadata)))
582 ? attrs
583 : Unowned(attrs);
584 }),
585
586 list: Effect.fn(function* () {
587 // Canceled cards stay in Stripe but must not re-enter nuke. Filter
588 // to alchemy_stack so account-wide teardown only touches our rows.
589 const cards = yield* listLiveCards();
590 return cards
591 .filter((card) => {
592 const metadata = tagRecord(card.metadata);
593 return metadata[alchemyMetadataKeys.stack] !== undefined;
594 })
595 .map(toAttrs);
596 }),
597
598 reconcile: Effect.fn(function* ({ id, news, output, instanceId }) {
599 const metadata = yield* desiredMetadata(id, news.metadata);
600 const desiredType = news.type ?? "virtual";
601 const desiredStatus = news.status ?? "inactive";
602 const spendingControls =
603 news.spendingControls !== undefined
604 ? toWireSpendingControls(news.spendingControls)
605 : undefined;
606
607 let current: StripeIssuingCard | undefined = yield* observe({
608 id: output?.id,
609 logicalId: id,
610 });
611 // A previous generation (same logical id, different immutable
612 // fields) must not be reused — Stripe cards cannot change
613 // cardholder, currency, type, or printed second line.
614 if (current !== undefined && shouldReplace(news, toAttrs(current))) {
615 current = undefined;
616 }
617
618 if (current === undefined) {
619 current = yield* CreateIssuingCard({
620 cardholder: news.cardholder,
621 currency: news.currency,
622 type: desiredType,
623 status: desiredStatus,
624 metadata,
625 ...(news.secondLine !== undefined
626 ? { second_line: news.secondLine }
627 : {}),
628 ...(news.expMonth !== undefined ? { exp_month: news.expMonth } : {}),
629 ...(news.expYear !== undefined ? { exp_year: news.expYear } : {}),
630 ...(news.financialAccount !== undefined
631 ? { financial_account: news.financialAccount }
632 : {}),
633 ...(news.personalizationDesign !== undefined
634 ? { personalization_design: news.personalizationDesign }
635 : {}),
636 ...(news.replacementFor !== undefined
637 ? { replacement_for: news.replacementFor }
638 : {}),
639 ...(news.replacementReason !== undefined
640 ? { replacement_reason: news.replacementReason }
641 : {}),
642 ...(spendingControls !== undefined
643 ? { spending_controls: spendingControls }
644 : {}),
645 }).pipe(
646 withRequestOptions({
647 idempotencyKey: `alchemy-issuing-card-${instanceId}`,
648 }),
649 );
650 }
651
652 if (current === undefined) {
653 return yield* new IssuingCardNotResolved({
654 cardholder: news.cardholder,
655 currency: news.currency,
656 });
657 }
658
659 const observedMetadata = tagRecord(current.metadata);
660 const { upsert, removed } = diffMetadata(observedMetadata, metadata);
661 const metadataChanged = upsert.length > 0 || removed.length > 0;
662 const statusChanged = current.status !== desiredStatus;
663 const personalizationChanged =
664 news.personalizationDesign !== undefined &&
665 expandedId(current.personalization_design) !==
666 news.personalizationDesign;
667 const spendingChanged =
668 news.spendingControls !== undefined &&
669 !deepEqual(
670 news.spendingControls,
671 toSpendingControls(current.spending_controls),
672 { stripNullish: true },
673 );
674
675 if (
676 !statusChanged &&
677 !personalizationChanged &&
678 !spendingChanged &&
679 !metadataChanged
680 ) {
681 return toAttrs(current);
682 }
683
684 const updated = yield* UpdateIssuingCard({
685 card: current.id,
686 ...(statusChanged ? { status: desiredStatus } : {}),
687 ...(personalizationChanged
688 ? { personalization_design: news.personalizationDesign }
689 : {}),
690 ...(spendingChanged ? { spending_controls: spendingControls } : {}),
691 ...(metadataChanged
692 ? {
693 metadata: {
694 ...Object.fromEntries(
695 upsert.map((tag) => [tag.Key, tag.Value]),
696 ),
697 ...Object.fromEntries(removed.map((key) => [key, ""])),
698 },
699 }
700 : {}),
701 });
702 return toAttrs(updated);
703 }),
704
705 delete: Effect.fn(function* ({ output }) {
706 const existing = yield* getByIdAny(output.id);
707 if (existing === undefined || existing.status === "canceled") return;
708 yield* UpdateIssuingCard({
709 card: existing.id,
710 status: "canceled",
711 }).pipe(Effect.catchIf(isMissingCard, () => Effect.void));
712 }),
713 });