1import {
2 withRequestOptions,
3 type StripeOpError,
4} from "@distilled.cloud/stripe";
5import {
6 GetIssuingCardholders,
7 GetIssuingCardholder,
8 CreateIssuingCardholder,
9 UpdateIssuingCardholder,
10 type IssuingCardholder as StripeIssuingCardholder,
11 type CreateIssuingCardholderRequestBilling,
12 type CreateIssuingCardholderRequestCompany,
13 type CreateIssuingCardholderRequestIndividual,
14 type CreateIssuingCardholderRequestSpendingControls,
15} from "@distilled.cloud/stripe/stripe";
16import * as Data from "effect/Data";
17import * as Effect from "effect/Effect";
18import { Unowned } from "../AdoptPolicy.ts";
19import { deepEqual, isResolved } from "../Diff.ts";
20import * as Provider from "../Provider.ts";
21import { Resource } from "../Resource.ts";
22import { tagRecord } from "../Tags.ts";
23import { arrayEquals } from "../Util/equal.ts";
24import {
25 alchemyMetadataKeys,
26 createInternalMetadata,
27 diffMetadata,
28 hasAlchemyMetadata,
29 stripInternalMetadata,
30 toMetadata,
31} from "./Metadata.ts";
32import type { Providers } from "./Providers.ts";
33import { isMissingStripeResource } from "./missing.ts";
34
35const LIST_PAGE_SIZE = 100;
36const LIST_MAX_PAGES = 100;
37
38/** Specifies whether to permit authorizations on this cardholder's cards. */
39export type IssuingCardholderStatus = "active" | "blocked" | "inactive";
40
41/** One of `individual` or `company`. */
42export type IssuingCardholderType = "company" | "individual";
43
44/** Preferred locale for 3D Secure and one-time password messages. */
45export type IssuingCardholderPreferredLocale = "de" | "en" | "es" | "fr" | "it";
46
47/** Interval (or event) a spending limit applies to. */
48export type IssuingCardholderSpendingLimitInterval =
49 | "all_time"
50 | "daily"
51 | "monthly"
52 | "per_authorization"
53 | "weekly"
54 | "yearly";
55
56/** Card-present vs card-not-present authorization presence. */
57export type IssuingCardholderCardPresence = "not_present" | "present";
58
59export interface IssuingCardholderBillingAddress {
60 /**
61 * City, district, suburb, town, or village.
62 */
63 city: string;
64 /**
65 * Two-letter ISO 3166-1 alpha-2 country code.
66 */
67 country: string;
68 /**
69 * Address line 1 (street, PO Box, or company name).
70 */
71 line1: string;
72 /**
73 * Address line 2 (apartment, suite, unit, or building).
74 */
75 line2?: string;
76 /**
77 * ZIP or postal code.
78 */
79 postalCode: string;
80 /**
81 * State, county, province, or region (ISO 3166-2).
82 */
83 state?: string;
84}
85
86export interface IssuingCardholderBilling {
87 /**
88 * The cardholder's billing address. Required on create.
89 */
90 address: IssuingCardholderBillingAddress;
91}
92
93export interface IssuingCardholderCompany {
94 /**
95 * The entity's business ID number (tax id). Stripe does not return the
96 * value; only whether it was provided.
97 */
98 taxId?: string;
99}
100
101export interface IssuingCardholderDob {
102 /**
103 * Day of birth, 1–31. Cardholders must be older than 13.
104 */
105 day: number;
106 /**
107 * Month of birth, 1–12.
108 */
109 month: number;
110 /**
111 * Four-digit year of birth.
112 */
113 year: number;
114}
115
116export interface IssuingCardholderIndividual {
117 /**
118 * First name. Required before activating cards. Letters, spaces,
119 * periods, commas, hyphens, and apostrophes only.
120 */
121 firstName?: string;
122 /**
123 * Last name. Required before activating cards. Letters, spaces,
124 * periods, commas, hyphens, and apostrophes only.
125 */
126 lastName?: string;
127 /**
128 * Date of birth. Cardholders must be older than 13.
129 */
130 dob?: IssuingCardholderDob;
131}
132
133export interface IssuingCardholderSpendingLimit {
134 /**
135 * Maximum amount allowed to spend per interval, in the smallest
136 * currency unit.
137 */
138 amount: number;
139 /**
140 * Merchant categories this limit applies to. Omit to apply to all.
141 */
142 categories?: string[];
143 /**
144 * Interval (or event) to which the amount applies.
145 */
146 interval: IssuingCardholderSpendingLimitInterval;
147}
148
149export interface IssuingCardholderSpendingControls {
150 /**
151 * Card-presence statuses to allow. Cannot be set with
152 * `blockedCardPresences`. Empty unsets.
153 */
154 allowedCardPresences?: IssuingCardholderCardPresence[];
155 /**
156 * Merchant categories to allow. Cannot be set with `blockedCategories`.
157 */
158 allowedCategories?: string[];
159 /**
160 * ISO 3166 alpha-2 countries to allow. Cannot be set with
161 * `blockedMerchantCountries`. Empty unsets.
162 */
163 allowedMerchantCountries?: string[];
164 /**
165 * Card-presence statuses to decline. Cannot be set with
166 * `allowedCardPresences`. Empty unsets.
167 */
168 blockedCardPresences?: IssuingCardholderCardPresence[];
169 /**
170 * Merchant categories to decline. Cannot be set with
171 * `allowedCategories`.
172 */
173 blockedCategories?: string[];
174 /**
175 * ISO 3166 alpha-2 countries to decline. Cannot be set with
176 * `allowedMerchantCountries`. Empty unsets.
177 */
178 blockedMerchantCountries?: string[];
179 /**
180 * Amount-based spending limits across this cardholder's cards.
181 */
182 spendingLimits?: IssuingCardholderSpendingLimit[];
183 /**
184 * Currency of amounts within `spendingLimits`. Defaults to the
185 * merchant country's currency.
186 */
187 spendingLimitsCurrency?: string;
188}
189
190export interface IssuingCardholderProps {
191 /**
192 * The cardholder's name, printed on cards issued to them. Max 24
193 * characters; letters and spaces only (no numbers or special
194 * characters). Create-only — changing it replaces the cardholder.
195 */
196 name: string;
197 /**
198 * The cardholder's billing address. Mutable.
199 */
200 billing: IssuingCardholderBilling;
201 /**
202 * `individual` or `company`. Create-only — changing it replaces the
203 * cardholder.
204 * @default "individual"
205 */
206 type?: IssuingCardholderType;
207 /**
208 * The cardholder's email address.
209 */
210 email?: string;
211 /**
212 * The cardholder's phone number (E.164). Required for EU cardholders
213 * who will create cards (3D Secure).
214 */
215 phoneNumber?: string;
216 /**
217 * Preferred locales for 3D Secure and OTP messages, ordered by
218 * preference.
219 */
220 preferredLocales?: IssuingCardholderPreferredLocale[];
221 /**
222 * Whether to permit authorizations on this cardholder's cards.
223 * `blocked` is Stripe-imposed and cannot be set. Destroy sets
224 * `inactive`.
225 * @default "active"
226 */
227 status?: "active" | "inactive";
228 /**
229 * Additional information about a `company` cardholder.
230 */
231 company?: IssuingCardholderCompany;
232 /**
233 * Additional information about an `individual` cardholder.
234 */
235 individual?: IssuingCardholderIndividual;
236 /**
237 * Spending controls applied across this cardholder's cards.
238 */
239 spendingControls?: IssuingCardholderSpendingControls;
240 /**
241 * User-defined metadata. Alchemy ownership keys (`alchemy_stack` /
242 * `alchemy_stage` / `alchemy_id`) are merged in automatically. Keys may
243 * not contain `:`. Mutable.
244 */
245 metadata?: Record<string, string>;
246}
247
248export interface IssuingCardholderRequirements {
249 /**
250 * If set, cards decline authorizations with
251 * `cardholder_verification_required`.
252 */
253 disabledReason: string | undefined;
254 /**
255 * Fields that need to be collected to verify and re-enable the
256 * cardholder.
257 */
258 pastDue: string[] | undefined;
259}
260
261export type IssuingCardholder = Resource<
262 "Stripe.IssuingCardholder",
263 IssuingCardholderProps,
264 {
265 /** Stripe issuing cardholder id (`ich_…`). */
266 id: string;
267 /** The cardholder's name, printed on cards. */
268 name: string;
269 /** `individual` or `company`. */
270 type: IssuingCardholderType;
271 /** The cardholder's billing address. */
272 billing: IssuingCardholderBilling;
273 /** The cardholder's email address, if set. */
274 email: string | undefined;
275 /** The cardholder's phone number, if set. */
276 phoneNumber: string | undefined;
277 /** Preferred locales, if set. */
278 preferredLocales: IssuingCardholderPreferredLocale[] | undefined;
279 /** Whether authorizations are permitted. */
280 status: IssuingCardholderStatus;
281 /** Whether a company tax id was provided. */
282 taxIdProvided: boolean | undefined;
283 /** Individual first name, if set. */
284 firstName: string | undefined;
285 /** Individual last name, if set. */
286 lastName: string | undefined;
287 /** Individual date of birth, if set. */
288 dob: IssuingCardholderDob | undefined;
289 /** Spending controls, if set. */
290 spendingControls: IssuingCardholderSpendingControls | undefined;
291 /** Verification requirements Stripe reports on this cardholder. */
292 requirements: IssuingCardholderRequirements;
293 /** User-defined metadata (Alchemy ownership keys stripped). */
294 metadata: Record<string, string>;
295 /** Unix timestamp when the cardholder was created. */
296 created: number;
297 /** Whether the cardholder exists in live mode. */
298 livemode: boolean;
299 },
300 never,
301 Providers
302>;
303
304/**
305 * A Stripe Issuing Cardholder — an individual or company who can be
306 * issued cards. Billing address, email, phone, preferred locales,
307 * spending controls, status, and metadata update in place. Name and
308 * type are immutable; changing them replaces the cardholder.
309 *
310 * Stripe does not hard-delete cardholders; destroying this resource
311 * sets `status` to `inactive`. Inactive cardholders can be reactivated
312 * by a later reconcile.
313 *
314 * Requires the Stripe Issuing entitlement.
315 *
316 * @see https://docs.stripe.com/api/issuing/cardholders
317 *
318 * ### Creating a Cardholder
319 * **Example:** Individual with billing address
320 * ```typescript
321 * const alice = yield* Stripe.IssuingCardholder("alice", {
322 * name: "Alice Example",
323 * type: "individual",
324 * email: "alice@example.com",
325 * billing: {
326 * address: {
327 * line1: "123 Main Street",
328 * city: "San Francisco",
329 * state: "CA",
330 * postalCode: "94111",
331 * country: "US",
332 * },
333 * },
334 * individual: { firstName: "Alice", lastName: "Example" },
335 * });
336 * ```
337 *
338 * **Example:** Company cardholder
339 * ```typescript
340 * const acme = yield* Stripe.IssuingCardholder("acme", {
341 * name: "Acme Corp",
342 * type: "company",
343 * billing: {
344 * address: {
345 * line1: "1 Market Street",
346 * city: "San Francisco",
347 * state: "CA",
348 * postalCode: "94105",
349 * country: "US",
350 * },
351 * },
352 * company: { taxId: "12-3456789" },
353 * });
354 * ```
355 *
356 * ### Spending controls
357 * **Example:** Monthly spending limit
358 * ```typescript
359 * const alice = yield* Stripe.IssuingCardholder("alice", {
360 * name: "Alice Example",
361 * billing: {
362 * address: {
363 * line1: "123 Main Street",
364 * city: "San Francisco",
365 * state: "CA",
366 * postalCode: "94111",
367 * country: "US",
368 * },
369 * },
370 * spendingControls: {
371 * spendingLimits: [{ amount: 10000, interval: "monthly" }],
372 * spendingLimitsCurrency: "usd",
373 * },
374 * });
375 * ```
376 *
377 * ### Updating a Cardholder
378 * **Example:** Email, phone, and metadata
379 * ```typescript
380 * const alice = yield* Stripe.IssuingCardholder("alice", {
381 * name: "Alice Example",
382 * email: "alice+updated@example.com",
383 * phoneNumber: "+15555550100",
384 * billing: {
385 * address: {
386 * line1: "123 Main Street",
387 * city: "San Francisco",
388 * state: "CA",
389 * postalCode: "94111",
390 * country: "US",
391 * },
392 * },
393 * metadata: { team: "ops" },
394 * });
395 * ```
396 *
397 * ### Deactivating a Cardholder
398 * **Example:** Destroy deactivates rather than deleting
399 * ```typescript
400 * // stack.destroy() / resource removal sets status to inactive
401 * const alice = yield* Stripe.IssuingCardholder("alice", {
402 * name: "Alice Example",
403 * billing: {
404 * address: {
405 * line1: "123 Main Street",
406 * city: "San Francisco",
407 * state: "CA",
408 * postalCode: "94111",
409 * country: "US",
410 * },
411 * },
412 * });
413 * ```
414 *
415 * @resource
416 * @product Issuing
417 */
418export const IssuingCardholder = Resource<IssuingCardholder>(
419 "Stripe.IssuingCardholder",
420);
421
422export class IssuingCardholderNotResolved extends Data.TaggedError(
423 "Stripe.IssuingCardholderNotResolved",
424)<{
425 name: string;
426}> {}
427
428type IssuingCardholderAttributes = IssuingCardholder["Attributes"];
429
430const userMetadata = (
431 metadata: Record<string, string | undefined> | null | undefined,
432): Record<string, string> => stripInternalMetadata(tagRecord(metadata));
433
434const fromObservedBilling = (
435 billing: StripeIssuingCardholder["billing"],
436): IssuingCardholderBilling => ({
437 address: {
438 city: billing.address.city ?? "",
439 country: billing.address.country ?? "",
440 line1: billing.address.line1 ?? "",
441 ...(billing.address.line2 ? { line2: billing.address.line2 } : {}),
442 postalCode: billing.address.postal_code ?? "",
443 ...(billing.address.state ? { state: billing.address.state } : {}),
444 },
445});
446
447const toWireBilling = (
448 billing: IssuingCardholderBilling,
449): CreateIssuingCardholderRequestBilling => ({
450 address: {
451 city: billing.address.city,
452 country: billing.address.country,
453 line1: billing.address.line1,
454 ...(billing.address.line2 !== undefined
455 ? { line2: billing.address.line2 }
456 : {}),
457 postal_code: billing.address.postalCode,
458 ...(billing.address.state !== undefined
459 ? { state: billing.address.state }
460 : {}),
461 },
462});
463
464const fromObservedDob = (
465 dob: NonNullable<StripeIssuingCardholder["individual"]>["dob"] | undefined,
466): IssuingCardholderDob | undefined => {
467 if (dob === null || dob === undefined) return undefined;
468 if (dob.day === null || dob.month === null || dob.year === null) {
469 return undefined;
470 }
471 return { day: dob.day, month: dob.month, year: dob.year };
472};
473
474const fromObservedSpendingControls = (
475 controls: StripeIssuingCardholder["spending_controls"],
476): IssuingCardholderSpendingControls | undefined => {
477 if (controls === null || controls === undefined) return undefined;
478 const spendingLimits = controls.spending_limits?.map((limit) => ({
479 amount: limit.amount,
480 ...(limit.categories !== null && limit.categories !== undefined
481 ? { categories: [...limit.categories] }
482 : {}),
483 interval: limit.interval,
484 }));
485 const mapped: IssuingCardholderSpendingControls = {
486 ...(controls.allowed_card_presences !== null &&
487 controls.allowed_card_presences !== undefined
488 ? { allowedCardPresences: [...controls.allowed_card_presences] }
489 : {}),
490 ...(controls.allowed_categories !== null &&
491 controls.allowed_categories !== undefined
492 ? { allowedCategories: [...controls.allowed_categories] }
493 : {}),
494 ...(controls.allowed_merchant_countries !== null &&
495 controls.allowed_merchant_countries !== undefined
496 ? { allowedMerchantCountries: [...controls.allowed_merchant_countries] }
497 : {}),
498 ...(controls.blocked_card_presences !== null &&
499 controls.blocked_card_presences !== undefined
500 ? { blockedCardPresences: [...controls.blocked_card_presences] }
501 : {}),
502 ...(controls.blocked_categories !== null &&
503 controls.blocked_categories !== undefined
504 ? { blockedCategories: [...controls.blocked_categories] }
505 : {}),
506 ...(controls.blocked_merchant_countries !== null &&
507 controls.blocked_merchant_countries !== undefined
508 ? { blockedMerchantCountries: [...controls.blocked_merchant_countries] }
509 : {}),
510 ...(spendingLimits !== undefined && spendingLimits !== null
511 ? { spendingLimits }
512 : {}),
513 ...(controls.spending_limits_currency
514 ? { spendingLimitsCurrency: controls.spending_limits_currency }
515 : {}),
516 };
517 return Object.keys(mapped).length > 0 ? mapped : undefined;
518};
519
520const toWireSpendingControls = (
521 controls: IssuingCardholderSpendingControls,
522): CreateIssuingCardholderRequestSpendingControls => ({
523 ...(controls.allowedCardPresences !== undefined
524 ? { allowed_card_presences: controls.allowedCardPresences }
525 : {}),
526 ...(controls.allowedCategories !== undefined
527 ? { allowed_categories: controls.allowedCategories }
528 : {}),
529 ...(controls.allowedMerchantCountries !== undefined
530 ? { allowed_merchant_countries: controls.allowedMerchantCountries }
531 : {}),
532 ...(controls.blockedCardPresences !== undefined
533 ? { blocked_card_presences: controls.blockedCardPresences }
534 : {}),
535 ...(controls.blockedCategories !== undefined
536 ? { blocked_categories: controls.blockedCategories }
537 : {}),
538 ...(controls.blockedMerchantCountries !== undefined
539 ? { blocked_merchant_countries: controls.blockedMerchantCountries }
540 : {}),
541 ...(controls.spendingLimits !== undefined
542 ? {
543 spending_limits: controls.spendingLimits.map((limit) => ({
544 amount: limit.amount,
545 ...(limit.categories !== undefined
546 ? { categories: limit.categories }
547 : {}),
548 interval: limit.interval,
549 })),
550 }
551 : {}),
552 ...(controls.spendingLimitsCurrency !== undefined
553 ? { spending_limits_currency: controls.spendingLimitsCurrency }
554 : {}),
555});
556
557const toWireCompany = (
558 company: IssuingCardholderCompany,
559): CreateIssuingCardholderRequestCompany => ({
560 ...(company.taxId !== undefined ? { tax_id: company.taxId } : {}),
561});
562
563const toWireIndividual = (
564 individual: IssuingCardholderIndividual,
565): CreateIssuingCardholderRequestIndividual => ({
566 ...(individual.firstName !== undefined
567 ? { first_name: individual.firstName }
568 : {}),
569 ...(individual.lastName !== undefined
570 ? { last_name: individual.lastName }
571 : {}),
572 ...(individual.dob !== undefined
573 ? {
574 dob: {
575 day: individual.dob.day,
576 month: individual.dob.month,
577 year: individual.dob.year,
578 },
579 }
580 : {}),
581});
582
583const toAttrs = (
584 cardholder: StripeIssuingCardholder,
585): IssuingCardholderAttributes => ({
586 id: cardholder.id,
587 name: cardholder.name,
588 type: cardholder.type,
589 billing: fromObservedBilling(cardholder.billing),
590 email: cardholder.email ?? undefined,
591 phoneNumber: cardholder.phone_number ?? undefined,
592 preferredLocales:
593 cardholder.preferred_locales === null ||
594 cardholder.preferred_locales === undefined
595 ? undefined
596 : [...cardholder.preferred_locales],
597 status: cardholder.status,
598 taxIdProvided: cardholder.company?.tax_id_provided,
599 firstName: cardholder.individual?.first_name ?? undefined,
600 lastName: cardholder.individual?.last_name ?? undefined,
601 dob: fromObservedDob(cardholder.individual?.dob),
602 spendingControls: fromObservedSpendingControls(cardholder.spending_controls),
603 requirements: {
604 disabledReason: cardholder.requirements.disabled_reason ?? undefined,
605 pastDue:
606 cardholder.requirements.past_due === null ||
607 cardholder.requirements.past_due === undefined
608 ? undefined
609 : [...cardholder.requirements.past_due],
610 },
611 metadata: userMetadata(cardholder.metadata),
612 created: cardholder.created,
613 livemode: cardholder.livemode,
614});
615
616const isMissingCardholder = isMissingStripeResource;
617
618const isIssuingNotEnabled = (error: StripeOpError): boolean =>
619 error._tag === "InvalidRequestError" &&
620 typeof error.message === "string" &&
621 error.message.includes("not set up to use Issuing");
622
623const getById = (cardholder: string) =>
624 GetIssuingCardholder({ cardholder }).pipe(
625 Effect.catchIf(isMissingCardholder, () => Effect.succeed(undefined)),
626 Effect.catchIf(isIssuingNotEnabled, () => Effect.succeed(undefined)),
627 );
628
629const listByStatus = Effect.fn(function* (status: IssuingCardholderStatus) {
630 const cardholders: StripeIssuingCardholder[] = [];
631 let startingAfter: string | undefined;
632 for (let page = 0; page < LIST_MAX_PAGES; page++) {
633 const response = yield* GetIssuingCardholders({
634 status,
635 limit: LIST_PAGE_SIZE,
636 ...(startingAfter !== undefined ? { starting_after: startingAfter } : {}),
637 }).pipe(
638 Effect.catchIf(isIssuingNotEnabled, () =>
639 Effect.succeed({
640 data: [] as StripeIssuingCardholder[],
641 has_more: false,
642 object: "list" as const,
643 url: "/v1/issuing/cardholders",
644 }),
645 ),
646 );
647 cardholders.push(...response.data);
648 if (!response.has_more || response.data.length === 0) {
649 break;
650 }
651 startingAfter = response.data[response.data.length - 1]?.id;
652 if (startingAfter === undefined) {
653 break;
654 }
655 }
656 return cardholders;
657});
658
659const listAllCardholders = Effect.fn(function* () {
660 const [active, inactive, blocked] = yield* Effect.all(
661 [listByStatus("active"), listByStatus("inactive"), listByStatus("blocked")],
662 { concurrency: 3 },
663 );
664 const seen = new Set<string>();
665 const cardholders: StripeIssuingCardholder[] = [];
666 for (const cardholder of [...active, ...inactive, ...blocked]) {
667 if (seen.has(cardholder.id)) continue;
668 seen.add(cardholder.id);
669 cardholders.push(cardholder);
670 }
671 return cardholders;
672});
673
674const findByAlchemyId = Effect.fn(function* (id: string) {
675 const cardholders = yield* listAllCardholders();
676 const matches: StripeIssuingCardholder[] = [];
677 for (const cardholder of cardholders) {
678 if (yield* hasAlchemyMetadata(id, tagRecord(cardholder.metadata))) {
679 matches.push(cardholder);
680 }
681 }
682 matches.sort((a, b) => b.created - a.created);
683 return matches[0];
684});
685
686const observe = Effect.fn(function* (input: {
687 id?: string;
688 logicalId: string;
689}) {
690 if (input.id !== undefined) {
691 const byId = yield* getById(input.id);
692 if (byId !== undefined) return byId;
693 }
694 return yield* findByAlchemyId(input.logicalId);
695});
696
697const desiredMetadata = Effect.fn(function* (
698 id: string,
699 metadata: Record<string, string> | undefined,
700) {
701 return {
702 ...toMetadata(metadata),
703 ...(yield* createInternalMetadata(id)),
704 };
705});
706
707const shouldReplace = (
708 news: IssuingCardholderProps,
709 output: IssuingCardholderAttributes | undefined,
710): boolean => {
711 if (output === undefined) return false;
712 if (news.name !== output.name) return true;
713 if ((news.type ?? "individual") !== output.type && news.type !== undefined) {
714 return true;
715 }
716 return false;
717};
718
719export const IssuingCardholderProvider = () =>
720 Provider.succeed(IssuingCardholder, {
721 stables: ["id", "name", "type", "created", "livemode"],
722
723 diff: Effect.fn(function* ({ news, output }) {
724 if (!isResolved(news)) return undefined;
725 if (shouldReplace(news, output)) {
726 return { action: "replace" } as const;
727 }
728 return undefined;
729 }),
730
731 read: Effect.fn(function* ({ id, output }) {
732 const existing = yield* observe({
733 id: output?.id,
734 logicalId: id,
735 });
736 if (existing === undefined) return undefined;
737 const attrs = toAttrs(existing);
738 return (yield* hasAlchemyMetadata(id, tagRecord(existing.metadata)))
739 ? attrs
740 : Unowned(attrs);
741 }),
742
743 list: Effect.fn(function* () {
744 // Inactive (deactivated) rows stay in Stripe but must not re-enter
745 // nuke. Filter to alchemy_stack so account-wide teardown only
746 // touches our rows.
747 const [active, blocked] = yield* Effect.all(
748 [listByStatus("active"), listByStatus("blocked")],
749 { concurrency: 2 },
750 );
751 const seen = new Set<string>();
752 const cardholders: StripeIssuingCardholder[] = [];
753 for (const cardholder of [...active, ...blocked]) {
754 if (seen.has(cardholder.id)) continue;
755 seen.add(cardholder.id);
756 cardholders.push(cardholder);
757 }
758 return cardholders
759 .filter((cardholder) => {
760 const metadata = tagRecord(cardholder.metadata);
761 return metadata[alchemyMetadataKeys.stack] !== undefined;
762 })
763 .map(toAttrs);
764 }),
765
766 reconcile: Effect.fn(function* ({ id, news, output, instanceId }) {
767 const metadata = yield* desiredMetadata(id, news.metadata);
768 const desiredEmail = news.email ?? "";
769 const desiredPhone = news.phoneNumber ?? "";
770 const desiredStatus = news.status ?? "active";
771 const desiredType = news.type ?? "individual";
772 const desiredLocales = news.preferredLocales ?? [];
773 const billing = toWireBilling(news.billing);
774
775 let current: StripeIssuingCardholder | undefined = yield* observe({
776 id: output?.id,
777 logicalId: id,
778 });
779 if (current !== undefined && shouldReplace(news, toAttrs(current))) {
780 current = undefined;
781 }
782
783 if (current === undefined) {
784 current = yield* CreateIssuingCardholder({
785 name: news.name,
786 billing,
787 type: desiredType,
788 status: desiredStatus,
789 metadata,
790 ...(desiredEmail.length > 0 ? { email: desiredEmail } : {}),
791 ...(desiredPhone.length > 0 ? { phone_number: desiredPhone } : {}),
792 ...(desiredLocales.length > 0
793 ? { preferred_locales: desiredLocales }
794 : {}),
795 ...(news.company !== undefined
796 ? { company: toWireCompany(news.company) }
797 : {}),
798 ...(news.individual !== undefined
799 ? { individual: toWireIndividual(news.individual) }
800 : {}),
801 ...(news.spendingControls !== undefined
802 ? {
803 spending_controls: toWireSpendingControls(
804 news.spendingControls,
805 ),
806 }
807 : {}),
808 }).pipe(
809 withRequestOptions({
810 idempotencyKey: `alchemy-issuing-cardholder-${instanceId}`,
811 }),
812 );
813 }
814
815 if (current === undefined) {
816 return yield* new IssuingCardholderNotResolved({ name: news.name });
817 }
818
819 const observedMetadata = tagRecord(current.metadata);
820 const { upsert, removed } = diffMetadata(observedMetadata, metadata);
821 const metadataChanged = upsert.length > 0 || removed.length > 0;
822 const billingChanged = !deepEqual(
823 fromObservedBilling(current.billing),
824 news.billing,
825 { stripNullish: true },
826 );
827 const emailChanged = (current.email ?? "") !== desiredEmail;
828 const phoneChanged = (current.phone_number ?? "") !== desiredPhone;
829 const statusChanged = current.status !== desiredStatus;
830 const localesChanged = !arrayEquals(
831 current.preferred_locales ?? [],
832 desiredLocales,
833 );
834 const individualChanged =
835 news.individual !== undefined &&
836 !deepEqual(
837 {
838 firstName: current.individual?.first_name ?? undefined,
839 lastName: current.individual?.last_name ?? undefined,
840 dob: fromObservedDob(current.individual?.dob),
841 },
842 news.individual,
843 { stripNullish: true },
844 );
845 const companyChanged =
846 news.company?.taxId !== undefined &&
847 current.company?.tax_id_provided !== true;
848 const spendingControlsChanged =
849 news.spendingControls !== undefined &&
850 !deepEqual(
851 fromObservedSpendingControls(current.spending_controls),
852 news.spendingControls,
853 { stripNullish: true },
854 );
855
856 if (
857 !billingChanged &&
858 !emailChanged &&
859 !phoneChanged &&
860 !statusChanged &&
861 !localesChanged &&
862 !individualChanged &&
863 !companyChanged &&
864 !spendingControlsChanged &&
865 !metadataChanged
866 ) {
867 return toAttrs(current);
868 }
869
870 const updated = yield* UpdateIssuingCardholder({
871 cardholder: current.id,
872 ...(billingChanged ? { billing } : {}),
873 ...(emailChanged ? { email: desiredEmail } : {}),
874 ...(phoneChanged ? { phone_number: desiredPhone } : {}),
875 ...(statusChanged ? { status: desiredStatus } : {}),
876 ...(localesChanged ? { preferred_locales: desiredLocales } : {}),
877 ...(individualChanged && news.individual !== undefined
878 ? { individual: toWireIndividual(news.individual) }
879 : {}),
880 ...(companyChanged && news.company !== undefined
881 ? { company: toWireCompany(news.company) }
882 : {}),
883 ...(spendingControlsChanged && news.spendingControls !== undefined
884 ? { spending_controls: toWireSpendingControls(news.spendingControls) }
885 : {}),
886 ...(metadataChanged
887 ? {
888 metadata: {
889 ...Object.fromEntries(
890 upsert.map((tag) => [tag.Key, tag.Value]),
891 ),
892 ...Object.fromEntries(removed.map((key) => [key, ""])),
893 },
894 }
895 : {}),
896 });
897 return toAttrs(updated);
898 }),
899
900 delete: Effect.fn(function* ({ output }) {
901 const existing = yield* getById(output.id);
902 if (existing === undefined || existing.status === "inactive") return;
903 yield* UpdateIssuingCardholder({
904 cardholder: existing.id,
905 status: "inactive",
906 }).pipe(Effect.catchIf(isMissingCardholder, () => Effect.void));
907 }),
908 });