@volter/twin-stripe 2.0.3 → 2.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,10 @@
1
1
  import type { Semantics, SemanticsContext } from '@volter/world-core';
2
2
  import { type Row } from './shared.js';
3
+ /** Metadata passed on to what a session makes (payment_intent_data[metadata], subscription_data[metadata]) as a
4
+ * create's own is kept: "Individual keys can be unset by posting an empty value to them. All keys can be unset by
5
+ * posting an empty value to `metadata`" (every metadata parameter, docs.stripe.com/api/metadata), so a key posted
6
+ * empty is not set, and metadata posted empty is {} (stripe-server.ts createMetadata, the top-level case). */
7
+ export declare function nestedMetadata(given: unknown): Row | undefined;
3
8
  /** The card the customer entered on the page. */
4
9
  export type EnteredCard = {
5
10
  number: string;
@@ -61,6 +61,28 @@ function sessionTrialRefusal(ctx, param) {
61
61
  const message = param === 'trial_end' ? 'The `subscription_data[trial_end]` timestamp has to be at least 48 hours in the future.' : 'The `subscription_data[trial_period_days]` has to be at least 1.';
62
62
  return ctx.refuse({ status: 400, param: `subscription_data[${param}]`, message });
63
63
  }
64
+ /** The payment method types a session takes: the list a caller pinned to an earlier version gives as
65
+ * payment_method_types ("A list of the types of payment methods (e.g., `card`) this Checkout Session can accept", the
66
+ * parameter the served version's allowed_payment_method_types is described against: "Unlike `payment_method_types`,
67
+ * this acts as a filter on the dynamically computed set of eligible payment methods", docs.stripe.com/api/checkout/
68
+ * sessions/create), else the allowed_payment_method_types of the served one, else card. Where the documentation stops
69
+ * and the twin decides: an allowed list is answered whole (the twin does not compute eligibility), and without one the
70
+ * session takes card (the account's enabled methods are not modelled). */
71
+ function sessionMethodTypes(params) {
72
+ const given = Array.isArray(params.payment_method_types) ? params.payment_method_types : params.allowed_payment_method_types;
73
+ return Array.isArray(given) && given.length ? given.map(String) : ['card'];
74
+ }
75
+ /** Metadata passed on to what a session makes (payment_intent_data[metadata], subscription_data[metadata]) as a
76
+ * create's own is kept: "Individual keys can be unset by posting an empty value to them. All keys can be unset by
77
+ * posting an empty value to `metadata`" (every metadata parameter, docs.stripe.com/api/metadata), so a key posted
78
+ * empty is not set, and metadata posted empty is {} (stripe-server.ts createMetadata, the top-level case). */
79
+ export function nestedMetadata(given) {
80
+ if (given === '')
81
+ return {};
82
+ if (!given || typeof given !== 'object' || Array.isArray(given))
83
+ return undefined;
84
+ return Object.fromEntries(Object.entries(given).filter(([, v]) => v !== ''));
85
+ }
64
86
  const create = async (ctx) => {
65
87
  const params = ctx.params;
66
88
  const mode = typeof params.mode === 'string' ? params.mode : 'payment';
@@ -99,8 +121,9 @@ const create = async (ctx) => {
99
121
  customer_creation: mode === 'payment' ? (params.customer_creation === 'always' ? 'always' : 'if_required') : null,
100
122
  amount_subtotal: entries.length ? items.reduce((s, it) => s + it.amount_subtotal, 0) : null,
101
123
  amount_total: entries.length ? items.reduce((s, it) => s + it.amount_total, 0) - off : null,
102
- currency: items[0]?.currency ?? (entries.length ? sessionCurrency : null), line_items: { object: 'list', data: items, has_more: false, url: `/v1/checkout/sessions/${id}/line_items` }, payment_intent: null, subscription: null, setup_intent: null, invoice: null, payment_method_types: ['card'], expires_at: now + 24 * 3600, custom_fields: [], shipping_options: [], custom_text: { after_submit: null, shipping_address: null, submit: null, terms_of_service_acceptance: null }, automatic_tax: { enabled: false, liability: null, status: null, provider: null }, total_details: { amount_discount: off, amount_shipping: 0, amount_tax: 0 },
124
+ currency: items[0]?.currency ?? (entries.length ? sessionCurrency : null), line_items: { object: 'list', data: items, has_more: false, url: `/v1/checkout/sessions/${id}/line_items` }, payment_intent: null, subscription: null, setup_intent: null, invoice: null, payment_method_types: sessionMethodTypes(params), expires_at: now + 24 * 3600, custom_fields: [], shipping_options: [], custom_text: { after_submit: null, shipping_address: null, submit: null, terms_of_service_acceptance: null }, automatic_tax: { enabled: false, liability: null, status: null, provider: null }, total_details: { amount_discount: off, amount_shipping: 0, amount_tax: 0 },
103
125
  discounts: discount ? [{ coupon: String(discount.coupon.id), promotion_code: discount.promotion ?? null }] : [],
126
+ ...(Array.isArray(params.allowed_payment_method_types) ? { allowed_payment_method_types: params.allowed_payment_method_types.map(String) } : {}),
104
127
  metadata: params.metadata && typeof params.metadata === 'object' ? params.metadata : {}, livemode: false,
105
128
  // "Details on the state of phone number collection for the session" (docs.stripe.com/api/checkout/sessions/object),
106
129
  // off unless the create enables it
@@ -182,10 +205,10 @@ async function complete(ctx, id, existing, card) {
182
205
  ...(typeof pid.description === 'string' ? { description: pid.description } : {}),
183
206
  // the session's setup_future_usage is its PaymentIntent's (payment_intent_data: "A subset of parameters to be passed to PaymentIntent creation")
184
207
  ...(typeof pid.setup_future_usage === 'string' ? { setup_future_usage: pid.setup_future_usage } : {}),
185
- ...(pid.metadata && typeof pid.metadata === 'object' ? { metadata: pid.metadata } : {}),
208
+ ...(nestedMetadata(pid.metadata) ? { metadata: nestedMetadata(pid.metadata) } : {}),
186
209
  };
187
210
  const fields = { amount, currency, id: piId, ...(customer ? { customer } : {}), ...(pm ? { payment_method: pm.id } : {}), ...connect };
188
- await created(ctx, 'payment_intent', fields, { status: 'succeeded', amount_received: amount, client_secret: mintClientSecret(piId), livemode: false, payment_method_types: ['card'] });
211
+ await created(ctx, 'payment_intent', fields, { status: 'succeeded', amount_received: amount, client_secret: mintClientSecret(piId), livemode: false, payment_method_types: Array.isArray(existing.payment_method_types) ? existing.payment_method_types : ['card'] });
189
212
  const charge = await mintCharge(ctx, piId, fields, amount);
190
213
  await ctx.write('payment_intent', piId, { latest_charge: charge }, 'payment_intent.succeeded');
191
214
  link.payment_intent = piId;
@@ -208,7 +231,7 @@ async function complete(ctx, id, existing, card) {
208
231
  // the session's view never carries its create-only subscription_data: read the stored row
209
232
  const raw = ctx.row(CS, id)?._subscription_data;
210
233
  const subData = (raw && typeof raw === 'object' ? raw : {});
211
- const subMetadata = subData.metadata && typeof subData.metadata === 'object' ? subData.metadata : {};
234
+ const subMetadata = nestedMetadata(subData.metadata) ?? {};
212
235
  // a trial of `trial_period_days` from now, or to `trial_end` (docs.stripe.com/payments/checkout/free-trials)
213
236
  const trialEnd = resolveTrial(subData, now)?.end ?? null;
214
237
  const sub = await created(ctx, 'subscription', { customer, id: subId }, {
@@ -5,6 +5,16 @@ import { settleCharge } from "./ledger.js";
5
5
  import { materializeTestMethod, methodFromData, microdepositsAsked, paymentMethodDetails } from "./payment-methods.js";
6
6
  import { at_, confirmOnly, created, fail, finder, search } from "./shared.js";
7
7
  const PI = 'payment_intent';
8
+ /** Bookkeeping: the intents whose caller named the methods (payment_method_types, allowed_payment_method_types) on
9
+ * create. The twin's default list (['card']) stands for Stripe's choice and restricts nothing, so only a named list
10
+ * refuses a confirm with a method of another type. Kept apart from the intent: a `_` subject is never pushed
11
+ * (world-core isTwinBookkeeping). */
12
+ const TYPES_GIVEN = '_payment_intent_types_given';
13
+ /** Whether the intent's caller named its methods: on create (TYPES_GIVEN), or by an update that set its
14
+ * payment_method_types ("Alternatively, update the allowed payment_method_types for this PaymentIntent",
15
+ * methodNotAllowed below), which the generic update writes as given, so the intent's history holds it. */
16
+ const typesGiven = (ctx, id) => ctx.tree().some((r) => r.type === TYPES_GIVEN && r.id === id)
17
+ || ctx.history(PI, id).some((e) => e.operation !== 'payment_intent.create' && Array.isArray(e.fields?.payment_method_types));
8
18
  const send = (ctx, r) => ctx.reply(r.body, r.status);
9
19
  // ── THE MONEY MODEL, continued: a succeeded intent has a Charge ──
10
20
  //
@@ -143,6 +153,8 @@ const create = async (ctx) => {
143
153
  payment_method_options: params.payment_method_options ?? {},
144
154
  ...rest,
145
155
  }, 'payment_intent.create');
156
+ if (Array.isArray(given))
157
+ await ctx.record(TYPES_GIVEN, { payment_intent: id }, id);
146
158
  if (asBool(confirmNow))
147
159
  return confirmIntent(ctx, body, {});
148
160
  return ctx.reply(ctx.expand(PI, body));
@@ -156,6 +168,39 @@ const confirm = async (ctx) => {
156
168
  const made = await methodFromData(ctx, data);
157
169
  return confirmIntent(ctx, loaded.pi, made ? { ...params, payment_method: made } : params);
158
170
  };
171
+ /** The type of the payment method an intent is paid with: its PaymentMethod's, or a documented test name's (pm_card_*,
172
+ * tok_* a card; pm_usBankAccount_* a US bank account), else unknown. */
173
+ function methodType(ctx, method) {
174
+ if (typeof method !== 'string' || !method)
175
+ return undefined;
176
+ const row = ctx.get('payment_method', method);
177
+ if (row)
178
+ return typeof row.type === 'string' ? row.type : undefined;
179
+ if (/^pm_us_?bank_?account/i.test(method))
180
+ return 'us_bank_account';
181
+ if (/^(pm_card_|tok_)/.test(method))
182
+ return 'card';
183
+ return undefined;
184
+ }
185
+ /** A confirm with a payment method of a type the intent's caller did not allow, refused as Stripe refuses it: "The
186
+ * PaymentMethod provided (card_present) is not allowed for this PaymentIntent. Please attach a PaymentMethod of one of
187
+ * the following types: card. Alternatively, update the allowed payment_method_types for this PaymentIntent to include
188
+ * "card_present"" (a real answer quoted in github.com/stripe/stripe-terminal-react-native/issues/745, an intent made
189
+ * with payment_method_types ['card']). Only a list the caller gave restricts: without one Stripe offers the methods
190
+ * the account enables, which the twin does not model. Where the evidence stops: that answer's code is not quoted; the
191
+ * twin answers payment_intent_invalid_parameter ("One or more provided parameters wasn't allowed for the given
192
+ * operation on the PaymentIntent", docs.stripe.com/error-codes), param payment_method. */
193
+ function methodNotAllowed(ctx, existing, params) {
194
+ const types = Array.isArray(params.payment_method_types) ? params.payment_method_types.map(String)
195
+ : Array.isArray(params.allowed_payment_method_types) ? params.allowed_payment_method_types.map(String)
196
+ : typesGiven(ctx, String(existing.id)) && Array.isArray(existing.payment_method_types) ? existing.payment_method_types.map(String) : undefined;
197
+ if (!types)
198
+ return undefined;
199
+ const type = methodType(ctx, params.payment_method ?? existing.payment_method);
200
+ if (!type || types.includes(type))
201
+ return undefined;
202
+ return ctx.refuse({ status: 400, code: 'payment_intent_invalid_parameter', param: 'payment_method', message: `The PaymentMethod provided (${type}) is not allowed for this PaymentIntent. Please attach a PaymentMethod of one of the following types: ${types.join(', ')}. Alternatively, update the allowed payment_method_types for this PaymentIntent to include "${type}"` });
203
+ }
159
204
  /** Confirm an intent the confirm machine allows to move: the confirm action, and a create with `confirm=true`. */
160
205
  async function confirmIntent(ctx, existing, params) {
161
206
  const id = String(existing.id);
@@ -166,6 +211,9 @@ async function confirmIntent(ctx, existing, params) {
166
211
  const named = typeof params.mandate === 'string' ? params.mandate : typeof existing.mandate === 'string' ? existing.mandate : undefined;
167
212
  if (named && ctx.get('mandate', named)?.status !== 'active')
168
213
  return fail(ctx, 'The provided mandate is invalid and can\'t be used for the payment intent.', 400, 'payment_intent_mandate_invalid');
214
+ const notAllowed = methodNotAllowed(ctx, existing, params);
215
+ if (notAllowed)
216
+ return notAllowed;
169
217
  // a declining test card leaves the intent needing a payment method, with the error on it and a 402
170
218
  const decline = declineFor(finder(ctx), params, existing);
171
219
  if (decline) {
@@ -533,6 +533,91 @@ export const STRIPE_CAPABILITIES = [
533
533
  const plain = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=500&currency=usd' });
534
534
  return field(plain, 'automatic_payment_methods') === null;
535
535
  })),
536
+ // payment_method_types: a caller pinned before 2026-08-26.preview (stripe-node sends the version it is configured
537
+ // with; Cal.com's paid booking posts payment_method_types[]=card under 2020-08-27) names the methods and the intent
538
+ // echoes them; a value naming no payment method type is refused in the intents' words ("The payment method type "…"
539
+ // is invalid. See … for the full list of supported payment method types.", stripe-node issues/1472). From the removal
540
+ // on the parameter is gone: "Passing `payment_method_types` now returns a 400 error with the code
541
+ // `payment_method_types_no_longer_supported`", "at 2026-08-26.preview or later" (docs.stripe.com/changelog/dahlia/
542
+ // 2026-08-26/removes-payment-method-types-parameter-from-payment-intents-setup-intents), on create, update and confirm.
543
+ done('stripe.payment_intents.payment_method_types', 'payment_intents', 'PaymentIntent payment_method_types: taken and echoed on create/update for a caller pinned before 2026-08-26.preview (an unknown type refused); refused payment_method_types_no_longer_supported at 2026-08-26.preview or later', 'api', 'core', () => withRoot(async (h, root) => {
544
+ const at = (v, p, b) => handleStripeTwinRequest({ method: 'POST', path: p, body: b, apiVersion: v, root });
545
+ const pinned = (p, b) => at('2020-08-27', p, b);
546
+ const pi = await pinned('/v1/payment_intents', 'amount=5000&currency=usd&payment_method_types[]=card&metadata[bookingId]=12');
547
+ const indexed = await pinned('/v1/payment_intents', 'amount=5000&currency=usd&payment_method_types[0]=card&payment_method_types[1]=us_bank_account');
548
+ const updated = await pinned(`/v1/payment_intents/${id(pi)}`, 'payment_method_types[0]=card&payment_method_types[1]=link');
549
+ const bogus = await pinned('/v1/payment_intents', 'amount=5000&currency=usd&payment_method_types[0]=card&payment_method_types[1]=credit_card');
550
+ const err = (r) => r.body.error;
551
+ const echoed = ok(pi) && JSON.stringify(field(pi, 'payment_method_types')) === '["card"]' && field(pi, 'metadata')?.bookingId === '12'
552
+ && ok(indexed) && JSON.stringify(field(indexed, 'payment_method_types')) === '["card","us_bank_account"]'
553
+ && ok(updated) && JSON.stringify(field(updated, 'payment_method_types')) === '["card","link"]';
554
+ const unknown = bogus.status === 400 && err(bogus)?.param === 'payment_method_types'
555
+ && err(bogus)?.message === 'The payment method type "credit_card" is invalid. See https://stripe.com/docs/api/payment_intents/create#create_payment_intent-payment_method_types for the full list of supported payment method types.';
556
+ // the cut is the removal version, not the served one: a basil/clover pin is taken, a pin at or after the removal refused
557
+ const clover = await at('2025-09-30.clover', '/v1/payment_intents', 'amount=5000&currency=usd&payment_method_types[]=card');
558
+ const atRemoval = await at('2026-08-26.preview', '/v1/payment_intents', 'amount=5000&currency=usd&payment_method_types[]=card');
559
+ const afterRemoval = await at('2026-09-15.preview', `/v1/payment_intents/${id(pi)}`, 'payment_method_types[0]=card');
560
+ const cut = ok(clover) && JSON.stringify(field(clover, 'payment_method_types')) === '["card"]'
561
+ && [atRemoval, afterRemoval].every((r) => r.status === 400 && err(r)?.code === 'payment_method_types_no_longer_supported');
562
+ // the served version: gone from create, update and confirm; allowed_payment_method_types names the methods instead
563
+ const created = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=5000&currency=usd&payment_method_types[]=card' });
564
+ const upd = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}`, b: 'payment_method_types[0]=card' });
565
+ const conf = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/confirm`, b: 'payment_method=pm_card_visa&payment_method_types[0]=card' });
566
+ const gone = [created, upd, conf].every((r) => r.status === 400 && err(r)?.code === 'payment_method_types_no_longer_supported' && err(r)?.param === 'payment_method_types');
567
+ const allowed = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=5000&currency=usd&allowed_payment_method_types[0]=card' });
568
+ return echoed && unknown && cut && gone && ok(allowed) && JSON.stringify(field(allowed, 'payment_method_types')) === '["card"]';
569
+ })),
570
+ // A confirm with a payment method of a type the intent's caller did not name is refused: "The PaymentMethod provided
571
+ // (card_present) is not allowed for this PaymentIntent. Please attach a PaymentMethod of one of the following types:
572
+ // card. Alternatively, update the allowed payment_method_types for this PaymentIntent to include "card_present""
573
+ // (Stripe's answer quoted in github.com/stripe/stripe-terminal-react-native/issues/745). An intent whose caller named
574
+ // none takes the method (the twin's default list stands for the account's enabled methods).
575
+ done('stripe.payment_intents.confirm_method_type_refused', 'payment_intents', 'PaymentIntent confirm with a payment method of a type outside the payment_method_types the caller gave on create or update is refused (the intent stays unpaid)', 'api', 'common', () => withRoot(async (h, root) => {
576
+ const bank = await h({ m: 'POST', p: '/v1/payment_methods', b: 'type=us_bank_account&us_bank_account[routing_number]=110000000&us_bank_account[account_number]=000123456789&us_bank_account[account_holder_type]=individual&billing_details[name]=Sam' });
577
+ const cardOnly = await handleStripeTwinRequest({ method: 'POST', path: '/v1/payment_intents', body: 'amount=5000&currency=usd&payment_method_types[]=card', apiVersion: '2020-08-27', root });
578
+ const refused = await h({ m: 'POST', p: `/v1/payment_intents/${id(cardOnly)}/confirm`, b: `payment_method=${id(bank)}` });
579
+ const e = refused.body.error;
580
+ const after = await h({ m: 'GET', p: `/v1/payment_intents/${id(cardOnly)}` });
581
+ const paid = await h({ m: 'POST', p: `/v1/payment_intents/${id(cardOnly)}/confirm`, b: 'payment_method=pm_card_visa' });
582
+ // allowed_payment_method_types restricts the same way; an intent naming none takes the bank account
583
+ const allowed = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=5000&currency=usd&allowed_payment_method_types[0]=card' });
584
+ const refusedToo = await h({ m: 'POST', p: `/v1/payment_intents/${id(allowed)}/confirm`, b: `payment_method=${id(bank)}` });
585
+ const open = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=5000&currency=usd' });
586
+ const taken = await h({ m: 'POST', p: `/v1/payment_intents/${id(open)}/confirm`, b: `payment_method=${id(bank)}` });
587
+ // an update naming the methods restricts as a create's does: made naming none, updated to card, then refused a bank account
588
+ const later = await handleStripeTwinRequest({ method: 'POST', path: '/v1/payment_intents', body: 'amount=5000&currency=usd', apiVersion: '2020-08-27', root });
589
+ const narrowed = await handleStripeTwinRequest({ method: 'POST', path: `/v1/payment_intents/${id(later)}`, body: 'payment_method_types[0]=card', apiVersion: '2020-08-27', root });
590
+ const refusedLater = await h({ m: 'POST', p: `/v1/payment_intents/${id(later)}/confirm`, b: `payment_method=${id(bank)}` });
591
+ return ok(bank) && ok(cardOnly) && refused.status === 400 && e?.param === 'payment_method'
592
+ && String(e?.message).startsWith('The PaymentMethod provided (us_bank_account) is not allowed for this PaymentIntent. Please attach a PaymentMethod of one of the following types: card.')
593
+ && field(after, 'status') === 'requires_payment_method' && field(after, 'payment_method') == null
594
+ && ok(paid) && field(paid, 'status') === 'succeeded'
595
+ && refusedToo.status === 400 && ok(taken) && field(taken, 'status') === 'processing'
596
+ && ok(narrowed) && refusedLater.status === 400 && refusedLater.body.error?.param === 'payment_method';
597
+ })),
598
+ // The same parameter on SetupIntents: taken from a caller pinned to an earlier version, gone in the served one, whose
599
+ // changelog names "the create and update methods on Setup Intents" (payment_method_types_no_longer_supported).
600
+ done('stripe.setup_intents.payment_method_types', 'payment_methods', 'SetupIntent payment_method_types: taken and echoed for a caller pinned before 2026-08-26.preview (an unknown type refused in Stripe\'s words); refused payment_method_types_no_longer_supported at 2026-08-26.preview or later', 'api', 'common', () => withRoot(async (h, root) => {
601
+ const at = (v, p, b) => handleStripeTwinRequest({ method: 'POST', path: p, body: b, apiVersion: v, root });
602
+ const pinned = (p, b) => at('2020-08-27', p, b);
603
+ const si = await pinned('/v1/setup_intents', 'payment_method_types[]=us_bank_account');
604
+ const bogus = await pinned('/v1/setup_intents', 'payment_method_types[0]=bank');
605
+ const atRemoval = await at('2026-08-26.preview', '/v1/setup_intents', 'payment_method_types[]=card');
606
+ const err = (r) => r.body.error;
607
+ const created = await h({ m: 'POST', p: '/v1/setup_intents', b: 'payment_method_types[]=card' });
608
+ const upd = await h({ m: 'POST', p: `/v1/setup_intents/${id(si)}`, b: 'payment_method_types[0]=card' });
609
+ const allowed = await h({ m: 'POST', p: '/v1/setup_intents', b: 'allowed_payment_method_types[]=us_bank_account' });
610
+ return ok(si) && JSON.stringify(field(si, 'payment_method_types')) === '["us_bank_account"]'
611
+ && bogus.status === 400 && err(bogus)?.param === 'payment_method_types'
612
+ && err(bogus)?.message === 'The payment method type "bank" is invalid. See https://stripe.com/docs/api/setup_intents/create#create_setup_intent-payment_method_types for the full list of supported payment method types.'
613
+ && [created, upd, atRemoval].every((r) => r.status === 400 && err(r)?.code === 'payment_method_types_no_longer_supported')
614
+ && ok(allowed) && JSON.stringify(field(allowed, 'payment_method_types')) === '["us_bank_account"]';
615
+ })),
616
+ // Both ways of choosing methods on one create (payment_method_types with automatic_payment_methods[enabled]=true)
617
+ // for a caller pinned before the removal: Stripe's documentation states no refusal (docs.stripe.com/api/
618
+ // payment_intents/create, docs.stripe.com/upgrades/manage-payment-methods, docs.stripe.com/changelog/2023-08-16/
619
+ // automatic-payment-methods describe each alone), so the twin's answer waits for a captured one.
620
+ todo('stripe.payment_intents.method_types_with_automatic', 'payment_intents', 'PaymentIntent/SetupIntent create with both payment_method_types and automatic_payment_methods[enabled]=true (pinned before 2026-08-26.preview): Stripe\'s answer (refusal and its words) is undocumented', 'api', 'niche'),
536
621
  // PaymentIntent search: GET /v1/payment_intents/search?query=… returns the search_result
537
622
  // envelope; supports AND-combined clauses (currency:"usd" AND amount>=num). Missing query 400s.
538
623
  done('stripe.payment_intents.search', 'payment_intents', 'PaymentIntent search', 'api', 'common', () => withRoot(async (h) => {
@@ -1959,6 +2044,48 @@ export const STRIPE_CAPABILITIES = [
1959
2044
  && typeof field(sub, 'trial_end') === 'number' && field(sub, 'trial_end') === item?.current_period_end
1960
2045
  && field(sub, 'current_period_end') === undefined;
1961
2046
  })),
2047
+ // A session's payment method types: payment_method_types from a caller pinned to an earlier version ("A list of the
2048
+ // types of payment methods (e.g., `card`) this Checkout Session can accept"), allowed_payment_method_types in the served
2049
+ // version, whose spec takes no payment_method_types on a session's create (an unknown parameter there: the changelog's
2050
+ // payment_method_types_no_longer_supported names only the intents). The PaymentIntent paying makes carries the list.
2051
+ done('stripe.checkout.payment_method_types', 'checkout', 'Checkout Session payment_method_types (earlier version) and allowed_payment_method_types (served) name the session\'s methods and its PaymentIntent\'s; an unknown type refused', 'api', 'common', () => withRoot(async (h, root) => {
2052
+ const line = 'mode=payment&success_url=https://x.test&line_items[0][price_data][currency]=usd&line_items[0][price_data][unit_amount]=5000&line_items[0][price_data][product_data][name]=Consultation&line_items[0][quantity]=1';
2053
+ const pinned = (b) => handleStripeTwinRequest({ method: 'POST', path: '/v1/checkout/sessions', body: `${line}&${b}`, apiVersion: '2020-08-27', root });
2054
+ const cs = await pinned('payment_method_types[0]=card&payment_method_types[1]=link');
2055
+ const bogus = await pinned('payment_method_types[0]=cards');
2056
+ // Checkout's own list: a PaymentMethod type it does not take (in person) is refused, and is not in the list offered
2057
+ const inPerson = await pinned('payment_method_types[0]=card&payment_method_types[1]=card_present');
2058
+ const served = await h({ m: 'POST', p: '/v1/checkout/sessions', b: `${line}&payment_method_types[0]=card` });
2059
+ const allowed = await h({ m: 'POST', p: '/v1/checkout/sessions', b: `${line}&allowed_payment_method_types[0]=card` });
2060
+ await h({ m: 'POST', p: `/c/pay/${id(cs)}`, b: 'email=pay%40twin.test&cardNumber=4242424242424242&cardExpiry=12%2F34&cardCvc=123&billingName=Twin&billingCountry=US&billingPostalCode=94105' });
2061
+ const done = await h({ m: 'GET', p: `/v1/checkout/sessions/${id(cs)}` });
2062
+ const pi = await h({ m: 'GET', p: `/v1/payment_intents/${String(field(done, 'payment_intent'))}` });
2063
+ const err = (r) => r.body.error;
2064
+ return ok(cs) && JSON.stringify(field(cs, 'payment_method_types')) === '["card","link"]'
2065
+ && bogus.status === 400 && err(bogus)?.param === 'payment_method_types[0]' && /^Invalid payment_method_types\[0\]: must be one of acss_debit, .*\bcard\b.*, or zip$/.test(String(err(bogus)?.message))
2066
+ && inPerson.status === 400 && err(inPerson)?.param === 'payment_method_types[1]' && !/card_present|interac_present/.test(String(err(inPerson)?.message))
2067
+ && served.status === 400 && err(served)?.code === 'parameter_unknown'
2068
+ && ok(allowed) && JSON.stringify(field(allowed, 'payment_method_types')) === '["card"]' && JSON.stringify(field(allowed, 'allowed_payment_method_types')) === '["card"]'
2069
+ && ok(pi) && JSON.stringify(field(pi, 'payment_method_types')) === '["card","link"]';
2070
+ })),
2071
+ // Metadata a session passes on is kept as a create's own: "Individual keys can be unset by posting an empty value to
2072
+ // them. All keys can be unset by posting an empty value to `metadata`" (docs.stripe.com/api/metadata). stripe-node
2073
+ // posts a null as `…[metadata][key]=`; the PaymentIntent and the Subscription paying makes hold no such key.
2074
+ done('stripe.checkout.nested_metadata_empty_unset', 'checkout', 'A session\'s payment_intent_data[metadata] and subscription_data[metadata] keys posted empty are not set on the PaymentIntent / Subscription completion makes; metadata posted empty is {}', 'api', 'common', () => withRoot(async (h) => {
2075
+ const pay = (cs) => h({ m: 'POST', p: `/c/pay/${id(cs)}`, b: 'email=pay%40twin.test&cardNumber=4242424242424242&cardExpiry=12%2F34&cardCvc=123&billingName=Twin&billingCountry=US&billingPostalCode=94105' });
2076
+ const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=md@twin.test' });
2077
+ const item = 'line_items[0][price_data][currency]=usd&line_items[0][price_data][unit_amount]=5000&line_items[0][price_data][product_data][name]=Plan&line_items[0][quantity]=1';
2078
+ const payment = await h({ m: 'POST', p: '/v1/checkout/sessions', b: `mode=payment&success_url=https://x.test&${item}&payment_intent_data[metadata][bookingId]=12&payment_intent_data[metadata][phone]=` });
2079
+ const sub = await h({ m: 'POST', p: '/v1/checkout/sessions', b: `mode=subscription&success_url=https://x.test&customer=${id(cust)}&${item}&line_items[0][price_data][recurring][interval]=month&subscription_data[metadata][teamId]=7&subscription_data[metadata][coupon]=` });
2080
+ const none = await h({ m: 'POST', p: '/v1/checkout/sessions', b: `mode=payment&success_url=https://x.test&${item}&payment_intent_data[metadata]=` });
2081
+ await pay(payment);
2082
+ await pay(sub);
2083
+ await pay(none);
2084
+ const get = async (cs, kind, link) => field(await h({ m: 'GET', p: `/v1/${kind}/${String(field(await h({ m: 'GET', p: `/v1/checkout/sessions/${id(cs)}` }), link))}` }), 'metadata');
2085
+ return JSON.stringify(await get(payment, 'payment_intents', 'payment_intent')) === '{"bookingId":"12"}'
2086
+ && JSON.stringify(await get(sub, 'subscriptions', 'subscription')) === '{"teamId":"7"}'
2087
+ && JSON.stringify(await get(none, 'payment_intents', 'payment_intent')) === '{}';
2088
+ })),
1962
2089
  // A subscription session's customer pays its first invoice on the page: the invoice is paid through a succeeded
1963
2090
  // PaymentIntent and its charge, and the card is the subscription's default payment method
1964
2091
  // (docs.stripe.com/payments/checkout/how-checkout-works).
@@ -1,3 +1,14 @@
1
1
  import { type DerivedCall } from '@volter/world-core';
2
+ /** The version that removed payment_method_types from the intents: a caller pinned to it or later is refused. */
3
+ export declare const METHOD_TYPES_REMOVED_IN = "2026-08-26.preview";
4
+ /** Whether a pinned version is at or after the removal: versions order by their date (YYYY-MM-DD). */
5
+ export declare const methodTypesRemoved: (pinned: string) => boolean;
6
+ /** The payment method types there are: the PaymentMethod object's `type` in the served spec. */
7
+ export declare const PAYMENT_METHOD_TYPES: readonly string[];
8
+ /** The payment method types a Checkout Session takes: the vendored spec's (2026-09-30.endive) PostCheckoutSessions
9
+ * allowed_payment_method_types item enum, the parameter described as payment_method_types is ("A list of the types of
10
+ * payment methods (e.g., `card`) this Checkout Session can accept"). It holds no in-person type (card_present,
11
+ * interac_present) and no `custom`. The surface keeps no request enums, so it is copied here. */
12
+ export declare const CHECKOUT_METHOD_TYPES: readonly string[];
2
13
  /** Stripe's refusal of a request whose top-level parameters the operation does not take, or undefined. */
3
14
  export declare function refuseParameters(call: DerivedCall): Promise<Response | undefined>;
@@ -7,8 +7,8 @@
7
7
  // 2025-09-30.clover) was accepted.
8
8
  //
9
9
  // Where the evidence stops: the check knows only the served version's parameters (the spec's, surface.version). A
10
- // request pinning an earlier version with Stripe-Version is not checked, since the pack holds no earlier spec;
11
- // nested parameters are not checked.
10
+ // request pinning an earlier version with Stripe-Version is not checked, since the pack holds no earlier spec, beyond
11
+ // the values of the payment_method_types it still takes (PAYMENT_METHOD_TYPES below); nested parameters are not checked.
12
12
  import { readParams, vendorError } from '@volter/world-core';
13
13
  import surface from './generated/surface.gen.json' with { type: 'json' };
14
14
  import { manifest } from "./manifest.js";
@@ -19,17 +19,87 @@ const DOCUMENTED = {
19
19
  // a top-up into the Issuing balance (docs.stripe.com/issuing/funding/balance: "destination_balance=issuing")
20
20
  PostTopups: ['destination_balance'],
21
21
  };
22
+ // PAYMENT_METHOD_TYPES. The operations that took `payment_method_types` until 2026-08-26.preview, which "Removes
23
+ // `payment_method_types` as a writable parameter from the create, update, and confirm methods on Payment Intents, and
24
+ // from the create and update methods on Setup Intents. Passing `payment_method_types` now returns a 400 error with the
25
+ // code `payment_method_types_no_longer_supported`", for a caller "at 2026-08-26.preview or later"
26
+ // (docs.stripe.com/changelog/dahlia/2026-08-26/removes-payment-method-types-parameter-from-payment-intents-setup-
27
+ // intents). The served spec (2026-09-30.endive) has no such parameter on them, nor on a Checkout Session's create,
28
+ // which the changelog does not name: there it is an unknown parameter for a caller on the served version, and taken
29
+ // from any caller pinned before it.
30
+ // A caller pinned before the removal (stripe-node sends the version it is configured with: Cal.com's 2020-08-27)
31
+ // still sends it, "The list of payment method types (e.g. card) that this PaymentIntent is allowed to use. A
32
+ // comprehensive list of valid payment method types can be found here" (the served spec's payment_intent object, the
33
+ // link being the PaymentMethod object's `type`). A PaymentIntent's or SetupIntent's value outside that list is refused
34
+ // as Stripe refuses one there: "The payment method type "us_bank_account" is invalid. See
35
+ // https://stripe.com/docs/api/setup_intents/create#create_setup_intent-payment_method_types for the full list of
36
+ // supported payment method types." (a real SetupIntent answer quoted in github.com/stripe/stripe-node/issues/1472).
37
+ // A Checkout Session's value outside its own enum is refused as Stripe refuses any value outside a parameter's enum:
38
+ // "Invalid payment_settings[payment_method_types][1]: must be one of ach_credit_transfer, …, or wechat_pay" (a real
39
+ // answer quoted in github.com/stripe/stripe-node/issues/1755), with no code. Where the evidence stops: the message
40
+ // Stripe gives with payment_method_types_no_longer_supported is not documented (the twin's own words below); the
41
+ // PaymentIntent's link is the SetupIntent's with its own resource (no PaymentIntent answer is quoted), and neither
42
+ // answer's param nor code is quoted (the twin gives param payment_method_types, no code); the lists are the served
43
+ // version's, not the pinned one's.
44
+ /** The version that removed payment_method_types from the intents: a caller pinned to it or later is refused. */
45
+ export const METHOD_TYPES_REMOVED_IN = '2026-08-26.preview';
46
+ /** Whether a pinned version is at or after the removal: versions order by their date (YYYY-MM-DD). */
47
+ export const methodTypesRemoved = (pinned) => pinned.slice(0, 10) >= METHOD_TYPES_REMOVED_IN.slice(0, 10);
48
+ const REMOVED_TYPES = new Set(['PostPaymentIntents', 'PostPaymentIntentsIntent', 'PostPaymentIntentsIntentConfirm', 'PostSetupIntents', 'PostSetupIntentsIntent']);
49
+ const TYPED = new Set([...REMOVED_TYPES, 'PostCheckoutSessions']);
50
+ /** The payment method types there are: the PaymentMethod object's `type` in the served spec. */
51
+ export const PAYMENT_METHOD_TYPES = surface.resources.find((r) => r.name === 'payment_method')?.fields.find((f) => f.name === 'type')?.enum ?? [];
52
+ /** The payment method types a Checkout Session takes: the vendored spec's (2026-09-30.endive) PostCheckoutSessions
53
+ * allowed_payment_method_types item enum, the parameter described as payment_method_types is ("A list of the types of
54
+ * payment methods (e.g., `card`) this Checkout Session can accept"). It holds no in-person type (card_present,
55
+ * interac_present) and no `custom`. The surface keeps no request enums, so it is copied here. */
56
+ export const CHECKOUT_METHOD_TYPES = ['acss_debit', 'affirm', 'afterpay_clearpay', 'alipay', 'alma', 'amazon_pay', 'au_becs_debit', 'bacs_debit', 'bancontact', 'billie', 'bizum', 'blik', 'boleto', 'card', 'cashapp', 'crypto', 'customer_balance', 'eps', 'fpx', 'giropay', 'grabpay', 'ideal', 'kakao_pay', 'klarna', 'konbini', 'kr_card', 'link', 'mb_way', 'mobilepay', 'multibanco', 'naver_pay', 'nz_bank_account', 'oxxo', 'p24', 'pay_by_bank', 'payco', 'paynow', 'paypal', 'paypay', 'payto', 'pix', 'promptpay', 'revolut_pay', 'samsung_pay', 'satispay', 'scalapay', 'sepa_debit', 'sequra', 'sofort', 'sunbit', 'swish', 'twint', 'upi', 'us_bank_account', 'wechat_pay', 'zip'];
57
+ /** The create page each intent's refusal links to. */
58
+ const TYPES_DOC = {
59
+ payment_intents: 'https://stripe.com/docs/api/payment_intents/create#create_payment_intent-payment_method_types',
60
+ setup_intents: 'https://stripe.com/docs/api/setup_intents/create#create_setup_intent-payment_method_types',
61
+ };
62
+ /** An enum's values as Stripe lists them in a refusal: "a, b, or c". */
63
+ const oneOf = (values) => (values.length > 1 ? `${values.slice(0, -1).join(', ')}, or ${values.at(-1)}` : values.join(''));
64
+ /** An earlier version's payment_method_types refused: the first value that names no payment method type the
65
+ * operation takes, in the intent's words or (a Checkout Session) the enum's. */
66
+ function unknownMethodType(opId, given) {
67
+ const values = Array.isArray(given) ? given : given && typeof given === 'object' ? Object.values(given) : [given];
68
+ const checkout = opId === 'PostCheckoutSessions';
69
+ const known = checkout ? CHECKOUT_METHOD_TYPES : PAYMENT_METHOD_TYPES;
70
+ const i = values.findIndex((v) => !known.includes(String(v)));
71
+ if (i < 0)
72
+ return undefined;
73
+ if (checkout) {
74
+ const param = `payment_method_types[${i}]`;
75
+ return vendorError(manifest, { status: 400, param, message: `Invalid ${param}: must be one of ${oneOf(known)}` });
76
+ }
77
+ const doc = TYPES_DOC[opId.startsWith('PostSetupIntents') ? 'setup_intents' : 'payment_intents'];
78
+ return vendorError(manifest, { status: 400, param: 'payment_method_types', message: `The payment method type "${String(values[i])}" is invalid. See ${doc} for the full list of supported payment method types.` });
79
+ }
80
+ /** payment_method_types refused where the caller's version removed it. */
81
+ const noLongerSupported = (version) => vendorError(manifest, { status: 400, code: 'payment_method_types_no_longer_supported', param: 'payment_method_types', message: `payment_method_types is no longer supported in API version ${version}. Use allowed_payment_method_types, or dynamic payment methods, instead.` });
22
82
  /** Stripe's refusal of a request whose top-level parameters the operation does not take, or undefined. */
23
83
  export async function refuseParameters(call) {
24
84
  const op = OPS.get(call.operation.id);
25
85
  if (!op)
26
86
  return undefined;
27
87
  const pinned = call.request.headers.get('stripe-version');
28
- if (pinned && pinned < SERVED_VERSION)
29
- return undefined;
88
+ if (pinned && pinned < SERVED_VERSION) {
89
+ if (!TYPED.has(op.id))
90
+ return undefined;
91
+ const earlier = await readParams(manifest, call.request.clone(), call.operation);
92
+ if (earlier.payment_method_types === undefined)
93
+ return undefined;
94
+ if (REMOVED_TYPES.has(op.id) && methodTypesRemoved(pinned))
95
+ return noLongerSupported(pinned);
96
+ return unknownMethodType(op.id, earlier.payment_method_types);
97
+ }
30
98
  const method = call.request.method.toUpperCase();
31
99
  const known = new Set([...(op.query ?? []), ...(op.body ?? [])].map((p) => p.name).concat(DOCUMENTED[op.id] ?? []));
32
100
  const params = await readParams(manifest, call.request.clone(), call.operation);
101
+ if (REMOVED_TYPES.has(op.id) && params.payment_method_types !== undefined)
102
+ return noLongerSupported(pinned ?? SERVED_VERSION);
33
103
  for (const name of Object.keys(params)) {
34
104
  if (!known.has(name))
35
105
  return vendorError(manifest, { status: 400, code: 'parameter_unknown', param: name, message: `Received unknown parameter: ${name}` });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/twin-stripe",
3
- "version": "2.0.3",
3
+ "version": "2.0.4",
4
4
  "description": "Local Stripe twin — a faithful, stateful local Stripe API your real `stripe` SDK talks to unmodified. Mirror, simulate, and fork. Built on @volter/world-core.",
5
5
  "keywords": [
6
6
  "twin",
@@ -67,6 +67,28 @@ function sessionTrialRefusal(ctx: SemanticsContext, param: 'trial_end' | 'trial_
67
67
  return ctx.refuse({ status: 400, param: `subscription_data[${param}]`, message });
68
68
  }
69
69
 
70
+ /** The payment method types a session takes: the list a caller pinned to an earlier version gives as
71
+ * payment_method_types ("A list of the types of payment methods (e.g., `card`) this Checkout Session can accept", the
72
+ * parameter the served version's allowed_payment_method_types is described against: "Unlike `payment_method_types`,
73
+ * this acts as a filter on the dynamically computed set of eligible payment methods", docs.stripe.com/api/checkout/
74
+ * sessions/create), else the allowed_payment_method_types of the served one, else card. Where the documentation stops
75
+ * and the twin decides: an allowed list is answered whole (the twin does not compute eligibility), and without one the
76
+ * session takes card (the account's enabled methods are not modelled). */
77
+ function sessionMethodTypes(params: Row): string[] {
78
+ const given = Array.isArray(params.payment_method_types) ? params.payment_method_types : params.allowed_payment_method_types;
79
+ return Array.isArray(given) && given.length ? given.map(String) : ['card'];
80
+ }
81
+
82
+ /** Metadata passed on to what a session makes (payment_intent_data[metadata], subscription_data[metadata]) as a
83
+ * create's own is kept: "Individual keys can be unset by posting an empty value to them. All keys can be unset by
84
+ * posting an empty value to `metadata`" (every metadata parameter, docs.stripe.com/api/metadata), so a key posted
85
+ * empty is not set, and metadata posted empty is {} (stripe-server.ts createMetadata, the top-level case). */
86
+ export function nestedMetadata(given: unknown): Row | undefined {
87
+ if (given === '') return {};
88
+ if (!given || typeof given !== 'object' || Array.isArray(given)) return undefined;
89
+ return Object.fromEntries(Object.entries(given as Row).filter(([, v]) => v !== ''));
90
+ }
91
+
70
92
  const create: Semantics = async (ctx) => {
71
93
  const params = ctx.params;
72
94
  const mode = typeof params.mode === 'string' ? params.mode : 'payment';
@@ -99,8 +121,9 @@ const create: Semantics = async (ctx) => {
99
121
  customer_creation: mode === 'payment' ? (params.customer_creation === 'always' ? 'always' : 'if_required') : null,
100
122
  amount_subtotal: entries.length ? items.reduce((s, it) => s + it.amount_subtotal, 0) : null,
101
123
  amount_total: entries.length ? items.reduce((s, it) => s + it.amount_total, 0) - off : null,
102
- currency: items[0]?.currency ?? (entries.length ? sessionCurrency : null), line_items: { object: 'list', data: items, has_more: false, url: `/v1/checkout/sessions/${id}/line_items` }, payment_intent: null, subscription: null, setup_intent: null, invoice: null, payment_method_types: ['card'], expires_at: now + 24 * 3600, custom_fields: [], shipping_options: [], custom_text: { after_submit: null, shipping_address: null, submit: null, terms_of_service_acceptance: null }, automatic_tax: { enabled: false, liability: null, status: null, provider: null }, total_details: { amount_discount: off, amount_shipping: 0, amount_tax: 0 },
124
+ currency: items[0]?.currency ?? (entries.length ? sessionCurrency : null), line_items: { object: 'list', data: items, has_more: false, url: `/v1/checkout/sessions/${id}/line_items` }, payment_intent: null, subscription: null, setup_intent: null, invoice: null, payment_method_types: sessionMethodTypes(params), expires_at: now + 24 * 3600, custom_fields: [], shipping_options: [], custom_text: { after_submit: null, shipping_address: null, submit: null, terms_of_service_acceptance: null }, automatic_tax: { enabled: false, liability: null, status: null, provider: null }, total_details: { amount_discount: off, amount_shipping: 0, amount_tax: 0 },
103
125
  discounts: discount ? [{ coupon: String(discount.coupon.id), promotion_code: discount.promotion ?? null }] : [],
126
+ ...(Array.isArray(params.allowed_payment_method_types) ? { allowed_payment_method_types: params.allowed_payment_method_types.map(String) } : {}),
104
127
  metadata: params.metadata && typeof params.metadata === 'object' ? params.metadata : {}, livemode: false,
105
128
  // "Details on the state of phone number collection for the session" (docs.stripe.com/api/checkout/sessions/object),
106
129
  // off unless the create enables it
@@ -179,10 +202,10 @@ async function complete(ctx: SemanticsContext, id: string, existing: Row, card?:
179
202
  ...(typeof pid.description === 'string' ? { description: pid.description } : {}),
180
203
  // the session's setup_future_usage is its PaymentIntent's (payment_intent_data: "A subset of parameters to be passed to PaymentIntent creation")
181
204
  ...(typeof pid.setup_future_usage === 'string' ? { setup_future_usage: pid.setup_future_usage } : {}),
182
- ...(pid.metadata && typeof pid.metadata === 'object' ? { metadata: pid.metadata } : {}),
205
+ ...(nestedMetadata(pid.metadata) ? { metadata: nestedMetadata(pid.metadata) } : {}),
183
206
  };
184
207
  const fields = { amount, currency, id: piId, ...(customer ? { customer } : {}), ...(pm ? { payment_method: pm.id } : {}), ...connect };
185
- await created(ctx, 'payment_intent', fields, { status: 'succeeded', amount_received: amount, client_secret: mintClientSecret(piId), livemode: false, payment_method_types: ['card'] });
208
+ await created(ctx, 'payment_intent', fields, { status: 'succeeded', amount_received: amount, client_secret: mintClientSecret(piId), livemode: false, payment_method_types: Array.isArray(existing.payment_method_types) ? existing.payment_method_types : ['card'] });
186
209
  const charge = await mintCharge(ctx, piId, fields, amount);
187
210
  await ctx.write('payment_intent', piId, { latest_charge: charge }, 'payment_intent.succeeded');
188
211
  link.payment_intent = piId;
@@ -204,7 +227,7 @@ async function complete(ctx: SemanticsContext, id: string, existing: Row, card?:
204
227
  // the session's view never carries its create-only subscription_data: read the stored row
205
228
  const raw = ctx.row(CS, id)?._subscription_data;
206
229
  const subData = (raw && typeof raw === 'object' ? raw : {}) as Row;
207
- const subMetadata = subData.metadata && typeof subData.metadata === 'object' ? (subData.metadata as Row) : {};
230
+ const subMetadata = nestedMetadata(subData.metadata) ?? {};
208
231
  // a trial of `trial_period_days` from now, or to `trial_end` (docs.stripe.com/payments/checkout/free-trials)
209
232
  const trialEnd = resolveTrial(subData, now)?.end ?? null;
210
233
  const sub = await created(ctx, 'subscription', { customer, id: subId }, {
@@ -23,6 +23,17 @@ import { materializeTestMethod, methodFromData, microdepositsAsked, paymentMetho
23
23
  import { at_, confirmOnly, created, fail, finder, search, type Row } from './shared.ts';
24
24
 
25
25
  const PI = 'payment_intent';
26
+ /** Bookkeeping: the intents whose caller named the methods (payment_method_types, allowed_payment_method_types) on
27
+ * create. The twin's default list (['card']) stands for Stripe's choice and restricts nothing, so only a named list
28
+ * refuses a confirm with a method of another type. Kept apart from the intent: a `_` subject is never pushed
29
+ * (world-core isTwinBookkeeping). */
30
+ const TYPES_GIVEN = '_payment_intent_types_given';
31
+ /** Whether the intent's caller named its methods: on create (TYPES_GIVEN), or by an update that set its
32
+ * payment_method_types ("Alternatively, update the allowed payment_method_types for this PaymentIntent",
33
+ * methodNotAllowed below), which the generic update writes as given, so the intent's history holds it. */
34
+ const typesGiven = (ctx: SemanticsContext, id: string): boolean =>
35
+ ctx.tree().some((r) => r.type === TYPES_GIVEN && r.id === id)
36
+ || ctx.history(PI, id).some((e) => e.operation !== 'payment_intent.create' && Array.isArray(e.fields?.payment_method_types));
26
37
 
27
38
  const send = (ctx: SemanticsContext, r: StripeResponse): Response => ctx.reply(r.body, r.status);
28
39
 
@@ -165,6 +176,7 @@ const create: Semantics = async (ctx) => {
165
176
  },
166
177
  'payment_intent.create',
167
178
  );
179
+ if (Array.isArray(given)) await ctx.record(TYPES_GIVEN, { payment_intent: id }, id);
168
180
  if (asBool(confirmNow)) return confirmIntent(ctx, body, {});
169
181
  return ctx.reply(ctx.expand(PI, body));
170
182
  };
@@ -178,6 +190,35 @@ const confirm: Semantics = async (ctx) => {
178
190
  return confirmIntent(ctx, loaded.pi, made ? { ...params, payment_method: made } : params);
179
191
  };
180
192
 
193
+ /** The type of the payment method an intent is paid with: its PaymentMethod's, or a documented test name's (pm_card_*,
194
+ * tok_* a card; pm_usBankAccount_* a US bank account), else unknown. */
195
+ function methodType(ctx: SemanticsContext, method: unknown): string | undefined {
196
+ if (typeof method !== 'string' || !method) return undefined;
197
+ const row = ctx.get('payment_method', method);
198
+ if (row) return typeof row.type === 'string' ? row.type : undefined;
199
+ if (/^pm_us_?bank_?account/i.test(method)) return 'us_bank_account';
200
+ if (/^(pm_card_|tok_)/.test(method)) return 'card';
201
+ return undefined;
202
+ }
203
+
204
+ /** A confirm with a payment method of a type the intent's caller did not allow, refused as Stripe refuses it: "The
205
+ * PaymentMethod provided (card_present) is not allowed for this PaymentIntent. Please attach a PaymentMethod of one of
206
+ * the following types: card. Alternatively, update the allowed payment_method_types for this PaymentIntent to include
207
+ * "card_present"" (a real answer quoted in github.com/stripe/stripe-terminal-react-native/issues/745, an intent made
208
+ * with payment_method_types ['card']). Only a list the caller gave restricts: without one Stripe offers the methods
209
+ * the account enables, which the twin does not model. Where the evidence stops: that answer's code is not quoted; the
210
+ * twin answers payment_intent_invalid_parameter ("One or more provided parameters wasn't allowed for the given
211
+ * operation on the PaymentIntent", docs.stripe.com/error-codes), param payment_method. */
212
+ function methodNotAllowed(ctx: SemanticsContext, existing: Row, params: Row): Response | undefined {
213
+ const types = Array.isArray(params.payment_method_types) ? params.payment_method_types.map(String)
214
+ : Array.isArray(params.allowed_payment_method_types) ? params.allowed_payment_method_types.map(String)
215
+ : typesGiven(ctx, String(existing.id)) && Array.isArray(existing.payment_method_types) ? (existing.payment_method_types as unknown[]).map(String) : undefined;
216
+ if (!types) return undefined;
217
+ const type = methodType(ctx, params.payment_method ?? existing.payment_method);
218
+ if (!type || types.includes(type)) return undefined;
219
+ return ctx.refuse({ status: 400, code: 'payment_intent_invalid_parameter', param: 'payment_method', message: `The PaymentMethod provided (${type}) is not allowed for this PaymentIntent. Please attach a PaymentMethod of one of the following types: ${types.join(', ')}. Alternatively, update the allowed payment_method_types for this PaymentIntent to include "${type}"` });
220
+ }
221
+
181
222
  /** Confirm an intent the confirm machine allows to move: the confirm action, and a create with `confirm=true`. */
182
223
  async function confirmIntent(ctx: SemanticsContext, existing: Record<string, unknown>, params: Record<string, unknown>): Promise<Response> {
183
224
  const id = String(existing.id);
@@ -187,6 +228,8 @@ async function confirmIntent(ctx: SemanticsContext, existing: Record<string, unk
187
228
  // (docs.stripe.com/error-codes)
188
229
  const named = typeof params.mandate === 'string' ? params.mandate : typeof existing.mandate === 'string' ? existing.mandate : undefined;
189
230
  if (named && ctx.get('mandate', named)?.status !== 'active') return fail(ctx, 'The provided mandate is invalid and can\'t be used for the payment intent.', 400, 'payment_intent_mandate_invalid');
231
+ const notAllowed = methodNotAllowed(ctx, existing, params);
232
+ if (notAllowed) return notAllowed;
190
233
  // a declining test card leaves the intent needing a payment method, with the error on it and a 402
191
234
  const decline = declineFor(finder(ctx), params, existing);
192
235
  if (decline) {
@@ -574,6 +574,97 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
574
574
  return field(plain, 'automatic_payment_methods') === null;
575
575
  }),
576
576
  ),
577
+ // payment_method_types: a caller pinned before 2026-08-26.preview (stripe-node sends the version it is configured
578
+ // with; Cal.com's paid booking posts payment_method_types[]=card under 2020-08-27) names the methods and the intent
579
+ // echoes them; a value naming no payment method type is refused in the intents' words ("The payment method type "…"
580
+ // is invalid. See … for the full list of supported payment method types.", stripe-node issues/1472). From the removal
581
+ // on the parameter is gone: "Passing `payment_method_types` now returns a 400 error with the code
582
+ // `payment_method_types_no_longer_supported`", "at 2026-08-26.preview or later" (docs.stripe.com/changelog/dahlia/
583
+ // 2026-08-26/removes-payment-method-types-parameter-from-payment-intents-setup-intents), on create, update and confirm.
584
+ done('stripe.payment_intents.payment_method_types', 'payment_intents', 'PaymentIntent payment_method_types: taken and echoed on create/update for a caller pinned before 2026-08-26.preview (an unknown type refused); refused payment_method_types_no_longer_supported at 2026-08-26.preview or later', 'api', 'core', () =>
585
+ withRoot(async (h, root) => {
586
+ const at = (v: string, p: string, b: string) => handleStripeTwinRequest({ method: 'POST', path: p, body: b, apiVersion: v, root });
587
+ const pinned = (p: string, b: string) => at('2020-08-27', p, b);
588
+ const pi = await pinned('/v1/payment_intents', 'amount=5000&currency=usd&payment_method_types[]=card&metadata[bookingId]=12');
589
+ const indexed = await pinned('/v1/payment_intents', 'amount=5000&currency=usd&payment_method_types[0]=card&payment_method_types[1]=us_bank_account');
590
+ const updated = await pinned(`/v1/payment_intents/${id(pi)}`, 'payment_method_types[0]=card&payment_method_types[1]=link');
591
+ const bogus = await pinned('/v1/payment_intents', 'amount=5000&currency=usd&payment_method_types[0]=card&payment_method_types[1]=credit_card');
592
+ const err = (r: StripeResponse) => (r.body as Body).error as Body | undefined;
593
+ const echoed = ok(pi) && JSON.stringify(field(pi, 'payment_method_types')) === '["card"]' && (field(pi, 'metadata') as Body)?.bookingId === '12'
594
+ && ok(indexed) && JSON.stringify(field(indexed, 'payment_method_types')) === '["card","us_bank_account"]'
595
+ && ok(updated) && JSON.stringify(field(updated, 'payment_method_types')) === '["card","link"]';
596
+ const unknown = bogus.status === 400 && err(bogus)?.param === 'payment_method_types'
597
+ && err(bogus)?.message === 'The payment method type "credit_card" is invalid. See https://stripe.com/docs/api/payment_intents/create#create_payment_intent-payment_method_types for the full list of supported payment method types.';
598
+ // the cut is the removal version, not the served one: a basil/clover pin is taken, a pin at or after the removal refused
599
+ const clover = await at('2025-09-30.clover', '/v1/payment_intents', 'amount=5000&currency=usd&payment_method_types[]=card');
600
+ const atRemoval = await at('2026-08-26.preview', '/v1/payment_intents', 'amount=5000&currency=usd&payment_method_types[]=card');
601
+ const afterRemoval = await at('2026-09-15.preview', `/v1/payment_intents/${id(pi)}`, 'payment_method_types[0]=card');
602
+ const cut = ok(clover) && JSON.stringify(field(clover, 'payment_method_types')) === '["card"]'
603
+ && [atRemoval, afterRemoval].every((r) => r.status === 400 && err(r)?.code === 'payment_method_types_no_longer_supported');
604
+ // the served version: gone from create, update and confirm; allowed_payment_method_types names the methods instead
605
+ const created = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=5000&currency=usd&payment_method_types[]=card' });
606
+ const upd = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}`, b: 'payment_method_types[0]=card' });
607
+ const conf = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/confirm`, b: 'payment_method=pm_card_visa&payment_method_types[0]=card' });
608
+ const gone = [created, upd, conf].every((r) => r.status === 400 && err(r)?.code === 'payment_method_types_no_longer_supported' && err(r)?.param === 'payment_method_types');
609
+ const allowed = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=5000&currency=usd&allowed_payment_method_types[0]=card' });
610
+ return echoed && unknown && cut && gone && ok(allowed) && JSON.stringify(field(allowed, 'payment_method_types')) === '["card"]';
611
+ }),
612
+ ),
613
+ // A confirm with a payment method of a type the intent's caller did not name is refused: "The PaymentMethod provided
614
+ // (card_present) is not allowed for this PaymentIntent. Please attach a PaymentMethod of one of the following types:
615
+ // card. Alternatively, update the allowed payment_method_types for this PaymentIntent to include "card_present""
616
+ // (Stripe's answer quoted in github.com/stripe/stripe-terminal-react-native/issues/745). An intent whose caller named
617
+ // none takes the method (the twin's default list stands for the account's enabled methods).
618
+ done('stripe.payment_intents.confirm_method_type_refused', 'payment_intents', 'PaymentIntent confirm with a payment method of a type outside the payment_method_types the caller gave on create or update is refused (the intent stays unpaid)', 'api', 'common', () =>
619
+ withRoot(async (h, root) => {
620
+ const bank = await h({ m: 'POST', p: '/v1/payment_methods', b: 'type=us_bank_account&us_bank_account[routing_number]=110000000&us_bank_account[account_number]=000123456789&us_bank_account[account_holder_type]=individual&billing_details[name]=Sam' });
621
+ const cardOnly = await handleStripeTwinRequest({ method: 'POST', path: '/v1/payment_intents', body: 'amount=5000&currency=usd&payment_method_types[]=card', apiVersion: '2020-08-27', root });
622
+ const refused = await h({ m: 'POST', p: `/v1/payment_intents/${id(cardOnly)}/confirm`, b: `payment_method=${id(bank)}` });
623
+ const e = (refused.body as Body).error as Body | undefined;
624
+ const after = await h({ m: 'GET', p: `/v1/payment_intents/${id(cardOnly)}` });
625
+ const paid = await h({ m: 'POST', p: `/v1/payment_intents/${id(cardOnly)}/confirm`, b: 'payment_method=pm_card_visa' });
626
+ // allowed_payment_method_types restricts the same way; an intent naming none takes the bank account
627
+ const allowed = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=5000&currency=usd&allowed_payment_method_types[0]=card' });
628
+ const refusedToo = await h({ m: 'POST', p: `/v1/payment_intents/${id(allowed)}/confirm`, b: `payment_method=${id(bank)}` });
629
+ const open = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=5000&currency=usd' });
630
+ const taken = await h({ m: 'POST', p: `/v1/payment_intents/${id(open)}/confirm`, b: `payment_method=${id(bank)}` });
631
+ // an update naming the methods restricts as a create's does: made naming none, updated to card, then refused a bank account
632
+ const later = await handleStripeTwinRequest({ method: 'POST', path: '/v1/payment_intents', body: 'amount=5000&currency=usd', apiVersion: '2020-08-27', root });
633
+ const narrowed = await handleStripeTwinRequest({ method: 'POST', path: `/v1/payment_intents/${id(later)}`, body: 'payment_method_types[0]=card', apiVersion: '2020-08-27', root });
634
+ const refusedLater = await h({ m: 'POST', p: `/v1/payment_intents/${id(later)}/confirm`, b: `payment_method=${id(bank)}` });
635
+ return ok(bank) && ok(cardOnly) && refused.status === 400 && e?.param === 'payment_method'
636
+ && String(e?.message).startsWith('The PaymentMethod provided (us_bank_account) is not allowed for this PaymentIntent. Please attach a PaymentMethod of one of the following types: card.')
637
+ && field(after, 'status') === 'requires_payment_method' && field(after, 'payment_method') == null
638
+ && ok(paid) && field(paid, 'status') === 'succeeded'
639
+ && refusedToo.status === 400 && ok(taken) && field(taken, 'status') === 'processing'
640
+ && ok(narrowed) && refusedLater.status === 400 && ((refusedLater.body as Body).error as Body | undefined)?.param === 'payment_method';
641
+ }),
642
+ ),
643
+ // The same parameter on SetupIntents: taken from a caller pinned to an earlier version, gone in the served one, whose
644
+ // changelog names "the create and update methods on Setup Intents" (payment_method_types_no_longer_supported).
645
+ done('stripe.setup_intents.payment_method_types', 'payment_methods', 'SetupIntent payment_method_types: taken and echoed for a caller pinned before 2026-08-26.preview (an unknown type refused in Stripe\'s words); refused payment_method_types_no_longer_supported at 2026-08-26.preview or later', 'api', 'common', () =>
646
+ withRoot(async (h, root) => {
647
+ const at = (v: string, p: string, b: string) => handleStripeTwinRequest({ method: 'POST', path: p, body: b, apiVersion: v, root });
648
+ const pinned = (p: string, b: string) => at('2020-08-27', p, b);
649
+ const si = await pinned('/v1/setup_intents', 'payment_method_types[]=us_bank_account');
650
+ const bogus = await pinned('/v1/setup_intents', 'payment_method_types[0]=bank');
651
+ const atRemoval = await at('2026-08-26.preview', '/v1/setup_intents', 'payment_method_types[]=card');
652
+ const err = (r: StripeResponse) => (r.body as Body).error as Body | undefined;
653
+ const created = await h({ m: 'POST', p: '/v1/setup_intents', b: 'payment_method_types[]=card' });
654
+ const upd = await h({ m: 'POST', p: `/v1/setup_intents/${id(si)}`, b: 'payment_method_types[0]=card' });
655
+ const allowed = await h({ m: 'POST', p: '/v1/setup_intents', b: 'allowed_payment_method_types[]=us_bank_account' });
656
+ return ok(si) && JSON.stringify(field(si, 'payment_method_types')) === '["us_bank_account"]'
657
+ && bogus.status === 400 && err(bogus)?.param === 'payment_method_types'
658
+ && err(bogus)?.message === 'The payment method type "bank" is invalid. See https://stripe.com/docs/api/setup_intents/create#create_setup_intent-payment_method_types for the full list of supported payment method types.'
659
+ && [created, upd, atRemoval].every((r) => r.status === 400 && err(r)?.code === 'payment_method_types_no_longer_supported')
660
+ && ok(allowed) && JSON.stringify(field(allowed, 'payment_method_types')) === '["us_bank_account"]';
661
+ }),
662
+ ),
663
+ // Both ways of choosing methods on one create (payment_method_types with automatic_payment_methods[enabled]=true)
664
+ // for a caller pinned before the removal: Stripe's documentation states no refusal (docs.stripe.com/api/
665
+ // payment_intents/create, docs.stripe.com/upgrades/manage-payment-methods, docs.stripe.com/changelog/2023-08-16/
666
+ // automatic-payment-methods describe each alone), so the twin's answer waits for a captured one.
667
+ todo('stripe.payment_intents.method_types_with_automatic', 'payment_intents', 'PaymentIntent/SetupIntent create with both payment_method_types and automatic_payment_methods[enabled]=true (pinned before 2026-08-26.preview): Stripe\'s answer (refusal and its words) is undocumented', 'api', 'niche'),
577
668
  // PaymentIntent search: GET /v1/payment_intents/search?query=… returns the search_result
578
669
  // envelope; supports AND-combined clauses (currency:"usd" AND amount>=num). Missing query 400s.
579
670
  done('stripe.payment_intents.search', 'payment_intents', 'PaymentIntent search', 'api', 'common', () =>
@@ -1972,6 +2063,50 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1972
2063
  && field(sub, 'current_period_end') === undefined;
1973
2064
  }),
1974
2065
  ),
2066
+ // A session's payment method types: payment_method_types from a caller pinned to an earlier version ("A list of the
2067
+ // types of payment methods (e.g., `card`) this Checkout Session can accept"), allowed_payment_method_types in the served
2068
+ // version, whose spec takes no payment_method_types on a session's create (an unknown parameter there: the changelog's
2069
+ // payment_method_types_no_longer_supported names only the intents). The PaymentIntent paying makes carries the list.
2070
+ done('stripe.checkout.payment_method_types', 'checkout', 'Checkout Session payment_method_types (earlier version) and allowed_payment_method_types (served) name the session\'s methods and its PaymentIntent\'s; an unknown type refused', 'api', 'common', () =>
2071
+ withRoot(async (h, root) => {
2072
+ const line = 'mode=payment&success_url=https://x.test&line_items[0][price_data][currency]=usd&line_items[0][price_data][unit_amount]=5000&line_items[0][price_data][product_data][name]=Consultation&line_items[0][quantity]=1';
2073
+ const pinned = (b: string) => handleStripeTwinRequest({ method: 'POST', path: '/v1/checkout/sessions', body: `${line}&${b}`, apiVersion: '2020-08-27', root });
2074
+ const cs = await pinned('payment_method_types[0]=card&payment_method_types[1]=link');
2075
+ const bogus = await pinned('payment_method_types[0]=cards');
2076
+ // Checkout's own list: a PaymentMethod type it does not take (in person) is refused, and is not in the list offered
2077
+ const inPerson = await pinned('payment_method_types[0]=card&payment_method_types[1]=card_present');
2078
+ const served = await h({ m: 'POST', p: '/v1/checkout/sessions', b: `${line}&payment_method_types[0]=card` });
2079
+ const allowed = await h({ m: 'POST', p: '/v1/checkout/sessions', b: `${line}&allowed_payment_method_types[0]=card` });
2080
+ await h({ m: 'POST', p: `/c/pay/${id(cs)}`, b: 'email=pay%40twin.test&cardNumber=4242424242424242&cardExpiry=12%2F34&cardCvc=123&billingName=Twin&billingCountry=US&billingPostalCode=94105' });
2081
+ const done = await h({ m: 'GET', p: `/v1/checkout/sessions/${id(cs)}` });
2082
+ const pi = await h({ m: 'GET', p: `/v1/payment_intents/${String(field(done, 'payment_intent'))}` });
2083
+ const err = (r: StripeResponse) => (r.body as Body).error as Body | undefined;
2084
+ return ok(cs) && JSON.stringify(field(cs, 'payment_method_types')) === '["card","link"]'
2085
+ && bogus.status === 400 && err(bogus)?.param === 'payment_method_types[0]' && /^Invalid payment_method_types\[0\]: must be one of acss_debit, .*\bcard\b.*, or zip$/.test(String(err(bogus)?.message))
2086
+ && inPerson.status === 400 && err(inPerson)?.param === 'payment_method_types[1]' && !/card_present|interac_present/.test(String(err(inPerson)?.message))
2087
+ && served.status === 400 && err(served)?.code === 'parameter_unknown'
2088
+ && ok(allowed) && JSON.stringify(field(allowed, 'payment_method_types')) === '["card"]' && JSON.stringify(field(allowed, 'allowed_payment_method_types')) === '["card"]'
2089
+ && ok(pi) && JSON.stringify(field(pi, 'payment_method_types')) === '["card","link"]';
2090
+ }),
2091
+ ),
2092
+ // Metadata a session passes on is kept as a create's own: "Individual keys can be unset by posting an empty value to
2093
+ // them. All keys can be unset by posting an empty value to `metadata`" (docs.stripe.com/api/metadata). stripe-node
2094
+ // posts a null as `…[metadata][key]=`; the PaymentIntent and the Subscription paying makes hold no such key.
2095
+ done('stripe.checkout.nested_metadata_empty_unset', 'checkout', 'A session\'s payment_intent_data[metadata] and subscription_data[metadata] keys posted empty are not set on the PaymentIntent / Subscription completion makes; metadata posted empty is {}', 'api', 'common', () =>
2096
+ withRoot(async (h) => {
2097
+ const pay = (cs: StripeResponse) => h({ m: 'POST', p: `/c/pay/${id(cs)}`, b: 'email=pay%40twin.test&cardNumber=4242424242424242&cardExpiry=12%2F34&cardCvc=123&billingName=Twin&billingCountry=US&billingPostalCode=94105' });
2098
+ const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=md@twin.test' });
2099
+ const item = 'line_items[0][price_data][currency]=usd&line_items[0][price_data][unit_amount]=5000&line_items[0][price_data][product_data][name]=Plan&line_items[0][quantity]=1';
2100
+ const payment = await h({ m: 'POST', p: '/v1/checkout/sessions', b: `mode=payment&success_url=https://x.test&${item}&payment_intent_data[metadata][bookingId]=12&payment_intent_data[metadata][phone]=` });
2101
+ const sub = await h({ m: 'POST', p: '/v1/checkout/sessions', b: `mode=subscription&success_url=https://x.test&customer=${id(cust)}&${item}&line_items[0][price_data][recurring][interval]=month&subscription_data[metadata][teamId]=7&subscription_data[metadata][coupon]=` });
2102
+ const none = await h({ m: 'POST', p: '/v1/checkout/sessions', b: `mode=payment&success_url=https://x.test&${item}&payment_intent_data[metadata]=` });
2103
+ await pay(payment); await pay(sub); await pay(none);
2104
+ const get = async (cs: StripeResponse, kind: 'payment_intents' | 'subscriptions', link: string) => field(await h({ m: 'GET', p: `/v1/${kind}/${String(field(await h({ m: 'GET', p: `/v1/checkout/sessions/${id(cs)}` }), link))}` }), 'metadata');
2105
+ return JSON.stringify(await get(payment, 'payment_intents', 'payment_intent')) === '{"bookingId":"12"}'
2106
+ && JSON.stringify(await get(sub, 'subscriptions', 'subscription')) === '{"teamId":"7"}'
2107
+ && JSON.stringify(await get(none, 'payment_intents', 'payment_intent')) === '{}';
2108
+ }),
2109
+ ),
1975
2110
  // A subscription session's customer pays its first invoice on the page: the invoice is paid through a succeeded
1976
2111
  // PaymentIntent and its charge, and the card is the subscription's default payment method
1977
2112
  // (docs.stripe.com/payments/checkout/how-checkout-works).
@@ -7,8 +7,8 @@
7
7
  // 2025-09-30.clover) was accepted.
8
8
  //
9
9
  // Where the evidence stops: the check knows only the served version's parameters (the spec's, surface.version). A
10
- // request pinning an earlier version with Stripe-Version is not checked, since the pack holds no earlier spec;
11
- // nested parameters are not checked.
10
+ // request pinning an earlier version with Stripe-Version is not checked, since the pack holds no earlier spec, beyond
11
+ // the values of the payment_method_types it still takes (PAYMENT_METHOD_TYPES below); nested parameters are not checked.
12
12
  import { readParams, vendorError, type DerivedCall } from '@volter/world-core';
13
13
  import surface from './generated/surface.gen.json' with { type: 'json' };
14
14
  import { manifest } from './manifest.ts';
@@ -24,15 +24,87 @@ const DOCUMENTED: Record<string, string[]> = {
24
24
  PostTopups: ['destination_balance'],
25
25
  };
26
26
 
27
+ // PAYMENT_METHOD_TYPES. The operations that took `payment_method_types` until 2026-08-26.preview, which "Removes
28
+ // `payment_method_types` as a writable parameter from the create, update, and confirm methods on Payment Intents, and
29
+ // from the create and update methods on Setup Intents. Passing `payment_method_types` now returns a 400 error with the
30
+ // code `payment_method_types_no_longer_supported`", for a caller "at 2026-08-26.preview or later"
31
+ // (docs.stripe.com/changelog/dahlia/2026-08-26/removes-payment-method-types-parameter-from-payment-intents-setup-
32
+ // intents). The served spec (2026-09-30.endive) has no such parameter on them, nor on a Checkout Session's create,
33
+ // which the changelog does not name: there it is an unknown parameter for a caller on the served version, and taken
34
+ // from any caller pinned before it.
35
+ // A caller pinned before the removal (stripe-node sends the version it is configured with: Cal.com's 2020-08-27)
36
+ // still sends it, "The list of payment method types (e.g. card) that this PaymentIntent is allowed to use. A
37
+ // comprehensive list of valid payment method types can be found here" (the served spec's payment_intent object, the
38
+ // link being the PaymentMethod object's `type`). A PaymentIntent's or SetupIntent's value outside that list is refused
39
+ // as Stripe refuses one there: "The payment method type "us_bank_account" is invalid. See
40
+ // https://stripe.com/docs/api/setup_intents/create#create_setup_intent-payment_method_types for the full list of
41
+ // supported payment method types." (a real SetupIntent answer quoted in github.com/stripe/stripe-node/issues/1472).
42
+ // A Checkout Session's value outside its own enum is refused as Stripe refuses any value outside a parameter's enum:
43
+ // "Invalid payment_settings[payment_method_types][1]: must be one of ach_credit_transfer, …, or wechat_pay" (a real
44
+ // answer quoted in github.com/stripe/stripe-node/issues/1755), with no code. Where the evidence stops: the message
45
+ // Stripe gives with payment_method_types_no_longer_supported is not documented (the twin's own words below); the
46
+ // PaymentIntent's link is the SetupIntent's with its own resource (no PaymentIntent answer is quoted), and neither
47
+ // answer's param nor code is quoted (the twin gives param payment_method_types, no code); the lists are the served
48
+ // version's, not the pinned one's.
49
+ /** The version that removed payment_method_types from the intents: a caller pinned to it or later is refused. */
50
+ export const METHOD_TYPES_REMOVED_IN = '2026-08-26.preview';
51
+ /** Whether a pinned version is at or after the removal: versions order by their date (YYYY-MM-DD). */
52
+ export const methodTypesRemoved = (pinned: string): boolean => pinned.slice(0, 10) >= METHOD_TYPES_REMOVED_IN.slice(0, 10);
53
+ const REMOVED_TYPES = new Set(['PostPaymentIntents', 'PostPaymentIntentsIntent', 'PostPaymentIntentsIntentConfirm', 'PostSetupIntents', 'PostSetupIntentsIntent']);
54
+ const TYPED = new Set([...REMOVED_TYPES, 'PostCheckoutSessions']);
55
+ type Resource = { name: string; fields: Array<{ name: string; enum?: string[] }> };
56
+ /** The payment method types there are: the PaymentMethod object's `type` in the served spec. */
57
+ export const PAYMENT_METHOD_TYPES: readonly string[] = (surface.resources as Resource[]).find((r) => r.name === 'payment_method')?.fields.find((f) => f.name === 'type')?.enum ?? [];
58
+ /** The payment method types a Checkout Session takes: the vendored spec's (2026-09-30.endive) PostCheckoutSessions
59
+ * allowed_payment_method_types item enum, the parameter described as payment_method_types is ("A list of the types of
60
+ * payment methods (e.g., `card`) this Checkout Session can accept"). It holds no in-person type (card_present,
61
+ * interac_present) and no `custom`. The surface keeps no request enums, so it is copied here. */
62
+ export const CHECKOUT_METHOD_TYPES: readonly string[] = ['acss_debit', 'affirm', 'afterpay_clearpay', 'alipay', 'alma', 'amazon_pay', 'au_becs_debit', 'bacs_debit', 'bancontact', 'billie', 'bizum', 'blik', 'boleto', 'card', 'cashapp', 'crypto', 'customer_balance', 'eps', 'fpx', 'giropay', 'grabpay', 'ideal', 'kakao_pay', 'klarna', 'konbini', 'kr_card', 'link', 'mb_way', 'mobilepay', 'multibanco', 'naver_pay', 'nz_bank_account', 'oxxo', 'p24', 'pay_by_bank', 'payco', 'paynow', 'paypal', 'paypay', 'payto', 'pix', 'promptpay', 'revolut_pay', 'samsung_pay', 'satispay', 'scalapay', 'sepa_debit', 'sequra', 'sofort', 'sunbit', 'swish', 'twint', 'upi', 'us_bank_account', 'wechat_pay', 'zip'];
63
+ /** The create page each intent's refusal links to. */
64
+ const TYPES_DOC: Record<string, string> = {
65
+ payment_intents: 'https://stripe.com/docs/api/payment_intents/create#create_payment_intent-payment_method_types',
66
+ setup_intents: 'https://stripe.com/docs/api/setup_intents/create#create_setup_intent-payment_method_types',
67
+ };
68
+
69
+ /** An enum's values as Stripe lists them in a refusal: "a, b, or c". */
70
+ const oneOf = (values: readonly string[]): string => (values.length > 1 ? `${values.slice(0, -1).join(', ')}, or ${values.at(-1)}` : values.join(''));
71
+
72
+ /** An earlier version's payment_method_types refused: the first value that names no payment method type the
73
+ * operation takes, in the intent's words or (a Checkout Session) the enum's. */
74
+ function unknownMethodType(opId: string, given: unknown): Response | undefined {
75
+ const values = Array.isArray(given) ? given : given && typeof given === 'object' ? Object.values(given) : [given];
76
+ const checkout = opId === 'PostCheckoutSessions';
77
+ const known = checkout ? CHECKOUT_METHOD_TYPES : PAYMENT_METHOD_TYPES;
78
+ const i = values.findIndex((v) => !known.includes(String(v)));
79
+ if (i < 0) return undefined;
80
+ if (checkout) {
81
+ const param = `payment_method_types[${i}]`;
82
+ return vendorError(manifest, { status: 400, param, message: `Invalid ${param}: must be one of ${oneOf(known)}` });
83
+ }
84
+ const doc = TYPES_DOC[opId.startsWith('PostSetupIntents') ? 'setup_intents' : 'payment_intents'];
85
+ return vendorError(manifest, { status: 400, param: 'payment_method_types', message: `The payment method type "${String(values[i])}" is invalid. See ${doc} for the full list of supported payment method types.` });
86
+ }
87
+
88
+ /** payment_method_types refused where the caller's version removed it. */
89
+ const noLongerSupported = (version: string): Response =>
90
+ vendorError(manifest, { status: 400, code: 'payment_method_types_no_longer_supported', param: 'payment_method_types', message: `payment_method_types is no longer supported in API version ${version}. Use allowed_payment_method_types, or dynamic payment methods, instead.` });
91
+
27
92
  /** Stripe's refusal of a request whose top-level parameters the operation does not take, or undefined. */
28
93
  export async function refuseParameters(call: DerivedCall): Promise<Response | undefined> {
29
94
  const op = OPS.get(call.operation.id);
30
95
  if (!op) return undefined;
31
96
  const pinned = call.request.headers.get('stripe-version');
32
- if (pinned && pinned < SERVED_VERSION) return undefined;
97
+ if (pinned && pinned < SERVED_VERSION) {
98
+ if (!TYPED.has(op.id)) return undefined;
99
+ const earlier = await readParams(manifest, call.request.clone(), call.operation);
100
+ if (earlier.payment_method_types === undefined) return undefined;
101
+ if (REMOVED_TYPES.has(op.id) && methodTypesRemoved(pinned)) return noLongerSupported(pinned);
102
+ return unknownMethodType(op.id, earlier.payment_method_types);
103
+ }
33
104
  const method = call.request.method.toUpperCase();
34
105
  const known = new Set([...(op.query ?? []), ...(op.body ?? [])].map((p) => p.name).concat(DOCUMENTED[op.id] ?? []));
35
106
  const params = await readParams(manifest, call.request.clone(), call.operation);
107
+ if (REMOVED_TYPES.has(op.id) && params.payment_method_types !== undefined) return noLongerSupported(pinned ?? SERVED_VERSION);
36
108
  for (const name of Object.keys(params)) {
37
109
  if (!known.has(name)) return vendorError(manifest, { status: 400, code: 'parameter_unknown', param: name, message: `Received unknown parameter: ${name}` });
38
110
  }