1import { withRequestOptions } from "@distilled.cloud/stripe";
2import {
3 GetPaymentMethodConfigurations,
4 GetPaymentMethodConfiguration,
5 CreatePaymentMethodConfiguration,
6 UpdatePaymentMethodConfiguration,
7 type PaymentMethodConfigResourcePaymentMethodProperties,
8 type PaymentMethodConfiguration as StripePaymentMethodConfiguration,
9} from "@distilled.cloud/stripe/stripe";
10import * as Data from "effect/Data";
11import * as Effect from "effect/Effect";
12import { Unowned } from "../AdoptPolicy.ts";
13import { isResolved } from "../Diff.ts";
14import { createPhysicalName } from "../PhysicalName.ts";
15import * as Provider from "../Provider.ts";
16import { Resource } from "../Resource.ts";
17import type { Providers } from "./Providers.ts";
18import { isMissingStripeResource } from "./missing.ts";
19
20const NAME_MAX_LENGTH = 100;
21const LIST_PAGE_SIZE = 100;
22const LIST_MAX_PAGES = 100;
23
24/** Account preference for whether a payment method is displayed. */
25export type PaymentMethodPreference = "none" | "off" | "on";
26
27/** Effective display value after parent/override resolution. */
28export type PaymentMethodEffectivePreference = "off" | "on";
29
30export interface PaymentMethodDisplayPreference {
31 /**
32 * Account preference for whether this payment method is displayed at
33 * checkout. `on` shows it, `off` hides it, `none` defers to the parent
34 * configuration or Stripe's default.
35 */
36 preference?: PaymentMethodPreference;
37}
38
39export interface PaymentMethodPreferenceInput {
40 /**
41 * Whether or not the payment method should be displayed at checkout.
42 */
43 displayPreference?: PaymentMethodDisplayPreference;
44}
45
46export interface PaymentMethodState {
47 /**
48 * Whether this payment method may be offered at checkout. True when
49 * `displayPreference` is `on` and the payment method's capability is
50 * active.
51 */
52 available: boolean;
53 /**
54 * Observed display preference, including the effective value.
55 */
56 displayPreference: {
57 /**
58 * For child configs, whether the connected account may override.
59 */
60 overridable: boolean | undefined;
61 /**
62 * Configured preference (`on` / `off` / `none`).
63 */
64 preference: PaymentMethodPreference;
65 /**
66 * Effective display value after parent/override resolution.
67 */
68 value: PaymentMethodEffectivePreference;
69 };
70}
71
72export interface PaymentMethodConfigurationProps {
73 /**
74 * Configuration name. If omitted, a unique name is generated from the
75 * stack, stage, and logical id. Mutable. Max 100 characters. Used as
76 * the lookup key when the Stripe id is missing — this object has no
77 * metadata.
78 */
79 name?: string;
80 /**
81 * Whether the configuration can be used for new payments. New
82 * configurations are created active. Destroy sets this to `false`.
83 * @default true
84 */
85 active?: boolean;
86 /**
87 * Parent configuration id. Specify to create a child configuration for
88 * a connected account. Create-only — changing it replaces the
89 * configuration.
90 */
91 parent?: string;
92 /** Display preference for ACSS Debit. */
93 acssDebit?: PaymentMethodPreferenceInput;
94 /** Display preference for Affirm. */
95 affirm?: PaymentMethodPreferenceInput;
96 /** Display preference for Afterpay / Clearpay. */
97 afterpayClearpay?: PaymentMethodPreferenceInput;
98 /** Display preference for Alipay. */
99 alipay?: PaymentMethodPreferenceInput;
100 /** Display preference for Alma. */
101 alma?: PaymentMethodPreferenceInput;
102 /** Display preference for Amazon Pay. */
103 amazonPay?: PaymentMethodPreferenceInput;
104 /** Display preference for Apple Pay. */
105 applePay?: PaymentMethodPreferenceInput;
106 /** Display preference for AU BECS Debit. */
107 auBecsDebit?: PaymentMethodPreferenceInput;
108 /** Display preference for Bacs Direct Debit. */
109 bacsDebit?: PaymentMethodPreferenceInput;
110 /** Display preference for Bancontact. */
111 bancontact?: PaymentMethodPreferenceInput;
112 /** Display preference for Billie. */
113 billie?: PaymentMethodPreferenceInput;
114 /** Display preference for Bizum. */
115 bizum?: PaymentMethodPreferenceInput;
116 /** Display preference for BLIK. */
117 blik?: PaymentMethodPreferenceInput;
118 /** Display preference for Boleto. */
119 boleto?: PaymentMethodPreferenceInput;
120 /** Display preference for cards. */
121 card?: PaymentMethodPreferenceInput;
122 /** Display preference for Cartes Bancaires. */
123 cartesBancaires?: PaymentMethodPreferenceInput;
124 /** Display preference for Cash App Pay. */
125 cashapp?: PaymentMethodPreferenceInput;
126 /** Display preference for crypto / stablecoin payments. */
127 crypto?: PaymentMethodPreferenceInput;
128 /** Display preference for customer balance (bank transfer). */
129 customerBalance?: PaymentMethodPreferenceInput;
130 /** Display preference for EPS. */
131 eps?: PaymentMethodPreferenceInput;
132 /** Display preference for FPX. */
133 fpx?: PaymentMethodPreferenceInput;
134 /** Display preference for giropay. */
135 giropay?: PaymentMethodPreferenceInput;
136 /** Display preference for Google Pay. */
137 googlePay?: PaymentMethodPreferenceInput;
138 /** Display preference for GrabPay. */
139 grabpay?: PaymentMethodPreferenceInput;
140 /** Display preference for iDEAL. */
141 ideal?: PaymentMethodPreferenceInput;
142 /** Display preference for JCB. */
143 jcb?: PaymentMethodPreferenceInput;
144 /** Display preference for Kakao Pay. */
145 kakaoPay?: PaymentMethodPreferenceInput;
146 /** Display preference for Klarna. */
147 klarna?: PaymentMethodPreferenceInput;
148 /** Display preference for Konbini. */
149 konbini?: PaymentMethodPreferenceInput;
150 /** Display preference for Korean cards. */
151 krCard?: PaymentMethodPreferenceInput;
152 /** Display preference for Link. */
153 link?: PaymentMethodPreferenceInput;
154 /** Display preference for MB WAY. */
155 mbWay?: PaymentMethodPreferenceInput;
156 /** Display preference for MobilePay. */
157 mobilepay?: PaymentMethodPreferenceInput;
158 /** Display preference for Multibanco. */
159 multibanco?: PaymentMethodPreferenceInput;
160 /** Display preference for Naver Pay. */
161 naverPay?: PaymentMethodPreferenceInput;
162 /** Display preference for NZ BECS Debit. */
163 nzBankAccount?: PaymentMethodPreferenceInput;
164 /** Display preference for OXXO. */
165 oxxo?: PaymentMethodPreferenceInput;
166 /** Display preference for Przelewy24. */
167 p24?: PaymentMethodPreferenceInput;
168 /** Display preference for Pay by Bank. */
169 payByBank?: PaymentMethodPreferenceInput;
170 /** Display preference for PAYCO. */
171 payco?: PaymentMethodPreferenceInput;
172 /** Display preference for PayNow. */
173 paynow?: PaymentMethodPreferenceInput;
174 /** Display preference for PayPal. */
175 paypal?: PaymentMethodPreferenceInput;
176 /** Display preference for PayTo. */
177 payto?: PaymentMethodPreferenceInput;
178 /** Display preference for Pix. */
179 pix?: PaymentMethodPreferenceInput;
180 /** Display preference for PromptPay. */
181 promptpay?: PaymentMethodPreferenceInput;
182 /** Display preference for Revolut Pay. */
183 revolutPay?: PaymentMethodPreferenceInput;
184 /** Display preference for Samsung Pay. */
185 samsungPay?: PaymentMethodPreferenceInput;
186 /** Display preference for Satispay. */
187 satispay?: PaymentMethodPreferenceInput;
188 /** Display preference for Scalapay. */
189 scalapay?: PaymentMethodPreferenceInput;
190 /** Display preference for SEPA Debit. */
191 sepaDebit?: PaymentMethodPreferenceInput;
192 /** Display preference for Sofort. */
193 sofort?: PaymentMethodPreferenceInput;
194 /** Display preference for Sunbit. */
195 sunbit?: PaymentMethodPreferenceInput;
196 /** Display preference for Swish. */
197 swish?: PaymentMethodPreferenceInput;
198 /** Display preference for Twint. */
199 twint?: PaymentMethodPreferenceInput;
200 /** Display preference for UPI. */
201 upi?: PaymentMethodPreferenceInput;
202 /** Display preference for US bank account (ACH). */
203 usBankAccount?: PaymentMethodPreferenceInput;
204 /** Display preference for WeChat Pay. */
205 wechatPay?: PaymentMethodPreferenceInput;
206 /** Display preference for Zip. */
207 zip?: PaymentMethodPreferenceInput;
208}
209
210type MethodKey = Exclude<
211 keyof PaymentMethodConfigurationProps,
212 "name" | "active" | "parent"
213>;
214
215type SnakeMethodKey = (typeof METHOD_MAP)[number][1];
216
217const METHOD_MAP = [
218 ["acssDebit", "acss_debit"],
219 ["affirm", "affirm"],
220 ["afterpayClearpay", "afterpay_clearpay"],
221 ["alipay", "alipay"],
222 ["alma", "alma"],
223 ["amazonPay", "amazon_pay"],
224 ["applePay", "apple_pay"],
225 ["auBecsDebit", "au_becs_debit"],
226 ["bacsDebit", "bacs_debit"],
227 ["bancontact", "bancontact"],
228 ["billie", "billie"],
229 ["bizum", "bizum"],
230 ["blik", "blik"],
231 ["boleto", "boleto"],
232 ["card", "card"],
233 ["cartesBancaires", "cartes_bancaires"],
234 ["cashapp", "cashapp"],
235 ["crypto", "crypto"],
236 ["customerBalance", "customer_balance"],
237 ["eps", "eps"],
238 ["fpx", "fpx"],
239 ["giropay", "giropay"],
240 ["googlePay", "google_pay"],
241 ["grabpay", "grabpay"],
242 ["ideal", "ideal"],
243 ["jcb", "jcb"],
244 ["kakaoPay", "kakao_pay"],
245 ["klarna", "klarna"],
246 ["konbini", "konbini"],
247 ["krCard", "kr_card"],
248 ["link", "link"],
249 ["mbWay", "mb_way"],
250 ["mobilepay", "mobilepay"],
251 ["multibanco", "multibanco"],
252 ["naverPay", "naver_pay"],
253 ["nzBankAccount", "nz_bank_account"],
254 ["oxxo", "oxxo"],
255 ["p24", "p24"],
256 ["payByBank", "pay_by_bank"],
257 ["payco", "payco"],
258 ["paynow", "paynow"],
259 ["paypal", "paypal"],
260 ["payto", "payto"],
261 ["pix", "pix"],
262 ["promptpay", "promptpay"],
263 ["revolutPay", "revolut_pay"],
264 ["samsungPay", "samsung_pay"],
265 ["satispay", "satispay"],
266 ["scalapay", "scalapay"],
267 ["sepaDebit", "sepa_debit"],
268 ["sofort", "sofort"],
269 ["sunbit", "sunbit"],
270 ["swish", "swish"],
271 ["twint", "twint"],
272 ["upi", "upi"],
273 ["usBankAccount", "us_bank_account"],
274 ["wechatPay", "wechat_pay"],
275 ["zip", "zip"],
276] as const satisfies ReadonlyArray<readonly [MethodKey, string]>;
277
278type PaymentMethodAttributes = {
279 [K in MethodKey]: PaymentMethodState | undefined;
280};
281
282export type PaymentMethodConfiguration = Resource<
283 "Stripe.PaymentMethodConfiguration",
284 PaymentMethodConfigurationProps,
285 {
286 /** Stripe payment method configuration id (`pmc_…`). */
287 id: string;
288 /** Configuration name. */
289 name: string;
290 /** Whether the configuration can be used for new payments. */
291 active: boolean;
292 /** Whether this is the account's default configuration. */
293 isDefault: boolean;
294 /** Parent configuration id, if this is a child config. */
295 parent: string | undefined;
296 /** Connect application id for child configs, if any. */
297 application: string | undefined;
298 /** Whether the configuration exists in live mode. */
299 livemode: boolean;
300 } & PaymentMethodAttributes,
301 never,
302 Providers
303>;
304
305/**
306 * A Stripe Payment Method Configuration — which payment methods Checkout,
307 * PaymentIntents, and SetupIntents show when payment method types are not
308 * set explicitly. `name` and per-method `displayPreference` update in
309 * place. `parent` is create-only and changing it replaces the
310 * configuration.
311 *
312 * Payment method configurations have no metadata field and cannot be
313 * hard-deleted. Destroying this resource deactivates it (`active: false`).
314 * Identity is the Stripe id plus the unique name.
315 *
316 * @see https://docs.stripe.com/api/payment_method_configurations
317 *
318 * ### Creating a Configuration
319 * **Example:** Named checkout configuration
320 * ```typescript
321 * const checkout = yield* Stripe.PaymentMethodConfiguration("checkout", {
322 * name: "Checkout",
323 * card: { displayPreference: { preference: "on" } },
324 * link: { displayPreference: { preference: "on" } },
325 * });
326 * ```
327 *
328 * **Example:** Cards only
329 * ```typescript
330 * const cards = yield* Stripe.PaymentMethodConfiguration("cards", {
331 * name: "Cards only",
332 * card: { displayPreference: { preference: "on" } },
333 * link: { displayPreference: { preference: "off" } },
334 * applePay: { displayPreference: { preference: "off" } },
335 * googlePay: { displayPreference: { preference: "off" } },
336 * });
337 * ```
338 *
339 * ### Updating a Configuration
340 * **Example:** Rename and enable US bank account
341 * ```typescript
342 * const checkout = yield* Stripe.PaymentMethodConfiguration("checkout", {
343 * name: "Checkout (updated)",
344 * card: { displayPreference: { preference: "on" } },
345 * usBankAccount: { displayPreference: { preference: "on" } },
346 * });
347 * ```
348 *
349 * ### Deactivating a Configuration
350 * **Example:** Destroy deactivates rather than deleting
351 * ```typescript
352 * // stack.destroy() / resource removal sets active: false
353 * const checkout = yield* Stripe.PaymentMethodConfiguration("checkout", {
354 * name: "Checkout",
355 * });
356 * ```
357 *
358 * @resource
359 * @product Payment Methods
360 */
361export const PaymentMethodConfiguration = Resource<PaymentMethodConfiguration>(
362 "Stripe.PaymentMethodConfiguration",
363);
364
365export class PaymentMethodConfigurationNotResolved extends Data.TaggedError(
366 "Stripe.PaymentMethodConfigurationNotResolved",
367)<{
368 name: string;
369}> {}
370
371type ConfigurationAttributes = PaymentMethodConfiguration["Attributes"];
372
373type WireMethod = {
374 display_preference: { preference: PaymentMethodPreference };
375};
376
377const toName = (id: string, name: string | undefined, existing?: string) =>
378 Effect.gen(function* () {
379 return (
380 name ??
381 existing ??
382 (yield* createPhysicalName({ id, maxLength: NAME_MAX_LENGTH }))
383 );
384 });
385
386const toMethodState = (
387 value: PaymentMethodConfigResourcePaymentMethodProperties | undefined,
388): PaymentMethodState | undefined => {
389 if (value === undefined) return undefined;
390 return {
391 available: value.available,
392 displayPreference: {
393 overridable: value.display_preference.overridable ?? undefined,
394 preference: value.display_preference.preference,
395 value: value.display_preference.value,
396 },
397 };
398};
399
400const readMethod = (
401 configuration: StripePaymentMethodConfiguration,
402 snake: SnakeMethodKey,
403): PaymentMethodState | undefined => toMethodState(configuration[snake]);
404
405const toAttrs = (
406 configuration: StripePaymentMethodConfiguration,
407): ConfigurationAttributes => {
408 const methods = {} as PaymentMethodAttributes;
409 for (const [camel, snake] of METHOD_MAP) {
410 methods[camel] = readMethod(configuration, snake);
411 }
412 return {
413 id: configuration.id,
414 name: configuration.name,
415 active: configuration.active,
416 isDefault: configuration.is_default,
417 parent: configuration.parent ?? undefined,
418 application: configuration.application ?? undefined,
419 livemode: configuration.livemode,
420 ...methods,
421 };
422};
423
424const toWireMethods = (
425 news: PaymentMethodConfigurationProps,
426): Partial<Record<SnakeMethodKey, WireMethod>> => {
427 const payload: Partial<Record<SnakeMethodKey, WireMethod>> = {};
428 for (const [camel, snake] of METHOD_MAP) {
429 const preference = news[camel]?.displayPreference?.preference;
430 if (preference !== undefined) {
431 payload[snake] = { display_preference: { preference } };
432 }
433 }
434 return payload;
435};
436
437const toChangedWireMethods = (
438 news: PaymentMethodConfigurationProps,
439 current: StripePaymentMethodConfiguration,
440): Partial<Record<SnakeMethodKey, WireMethod>> => {
441 const payload: Partial<Record<SnakeMethodKey, WireMethod>> = {};
442 for (const [camel, snake] of METHOD_MAP) {
443 const desired = news[camel]?.displayPreference?.preference;
444 if (desired === undefined) continue;
445 const observed = readMethod(current, snake)?.displayPreference.preference;
446 if (desired !== observed) {
447 payload[snake] = { display_preference: { preference: desired } };
448 }
449 }
450 return payload;
451};
452
453const isMissingConfiguration = isMissingStripeResource;
454
455const getById = (configuration: string) =>
456 GetPaymentMethodConfiguration({ configuration }).pipe(
457 Effect.catchIf(isMissingConfiguration, () => Effect.succeed(undefined)),
458 );
459
460const listByActive = Effect.fn(function* (active: boolean) {
461 const configurations: StripePaymentMethodConfiguration[] = [];
462 let startingAfter: string | undefined;
463 for (let page = 0; page < LIST_MAX_PAGES; page++) {
464 const response = yield* GetPaymentMethodConfigurations({
465 active,
466 limit: LIST_PAGE_SIZE,
467 ...(startingAfter !== undefined ? { starting_after: startingAfter } : {}),
468 });
469 configurations.push(...response.data);
470 if (!response.has_more || response.data.length === 0) {
471 break;
472 }
473 startingAfter = response.data[response.data.length - 1]?.id;
474 if (startingAfter === undefined) {
475 break;
476 }
477 }
478 return configurations;
479});
480
481const listAllConfigurations = Effect.fn(function* () {
482 const [active, inactive] = yield* Effect.all(
483 [listByActive(true), listByActive(false)],
484 { concurrency: 2 },
485 );
486 const seen = new Set<string>();
487 const configurations: StripePaymentMethodConfiguration[] = [];
488 for (const configuration of [...active, ...inactive]) {
489 if (seen.has(configuration.id)) continue;
490 seen.add(configuration.id);
491 configurations.push(configuration);
492 }
493 return configurations;
494});
495
496const findByName = Effect.fn(function* (name: string) {
497 const configurations = yield* listAllConfigurations();
498 const matches = configurations.filter(
499 (configuration) => configuration.name === name && !configuration.is_default,
500 );
501 return matches.find((configuration) => configuration.active) ?? matches[0];
502});
503
504const observe = Effect.fn(function* (input: { id?: string; name?: string }) {
505 if (input.id !== undefined) {
506 const byId = yield* getById(input.id);
507 if (byId !== undefined) return byId;
508 }
509 if (input.name !== undefined) {
510 return yield* findByName(input.name);
511 }
512 return undefined;
513});
514
515const shouldReplace = (
516 news: PaymentMethodConfigurationProps,
517 output: ConfigurationAttributes | undefined,
518): boolean => {
519 if (output === undefined) return false;
520 if (news.parent !== undefined && news.parent !== output.parent) {
521 return true;
522 }
523 return false;
524};
525
526export const PaymentMethodConfigurationProvider = () =>
527 Provider.succeed(PaymentMethodConfiguration, {
528 stables: ["id", "parent", "application", "isDefault", "livemode"],
529
530 diff: Effect.fn(function* ({ news, output }) {
531 if (!isResolved(news)) return undefined;
532 if (shouldReplace(news, output)) {
533 return { action: "replace" } as const;
534 }
535 return undefined;
536 }),
537
538 read: Effect.fn(function* ({ output }) {
539 const existing = yield* observe({
540 id: output?.id,
541 name: output?.name,
542 });
543 if (existing === undefined) return undefined;
544 const attrs = toAttrs(existing);
545 // No metadata. Identity is the Stripe id and unique name. Never
546 // take over the account default configuration.
547 return existing.is_default ? Unowned(attrs) : attrs;
548 }),
549
550 list: Effect.fn(function* () {
551 // No metadata on this resource. Default list is active, non-default
552 // configurations; deactivated rows stay in Stripe but must not
553 // re-enter nuke. The account default is never returned.
554 const configurations = yield* listByActive(true);
555 return configurations
556 .filter((configuration) => !configuration.is_default)
557 .map(toAttrs);
558 }),
559
560 reconcile: Effect.fn(function* ({ id, news, output, instanceId }) {
561 const name = yield* toName(id, news.name, output?.name);
562 const desiredActive = news.active ?? true;
563 const wireMethods = toWireMethods(news);
564
565 let current = yield* observe({
566 id: output?.id,
567 name,
568 });
569 if (current !== undefined && shouldReplace(news, toAttrs(current))) {
570 current = undefined;
571 }
572 if (current?.is_default) {
573 current = undefined;
574 }
575
576 if (current === undefined) {
577 current = yield* CreatePaymentMethodConfiguration({
578 name,
579 ...(news.parent !== undefined ? { parent: news.parent } : {}),
580 ...wireMethods,
581 }).pipe(
582 withRequestOptions({
583 idempotencyKey: `alchemy-payment-method-configuration-${instanceId}`,
584 }),
585 );
586 }
587
588 if (current === undefined) {
589 return yield* new PaymentMethodConfigurationNotResolved({ name });
590 }
591
592 const nameChanged = current.name !== name;
593 const activeChanged = current.active !== desiredActive;
594 const changedMethods = toChangedWireMethods(news, current);
595 const methodsChanged = Object.keys(changedMethods).length > 0;
596
597 if (!nameChanged && !activeChanged && !methodsChanged) {
598 return toAttrs(current);
599 }
600
601 const updated = yield* UpdatePaymentMethodConfiguration({
602 configuration: current.id,
603 ...(nameChanged ? { name } : {}),
604 ...(activeChanged ? { active: desiredActive } : {}),
605 ...(methodsChanged ? changedMethods : {}),
606 });
607 return toAttrs(updated);
608 }),
609
610 delete: Effect.fn(function* ({ output }) {
611 const existing = yield* getById(output.id);
612 if (existing === undefined || !existing.active || existing.is_default) {
613 return;
614 }
615 yield* UpdatePaymentMethodConfiguration({
616 configuration: existing.id,
617 active: false,
618 }).pipe(Effect.catchIf(isMissingConfiguration, () => Effect.void));
619 }),
620 });