@volter/twin-stripe 2.0.2 → 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.
- package/dist/src/semantics/checkout.d.ts +5 -0
- package/dist/src/semantics/checkout.js +27 -4
- package/dist/src/semantics/payment-intents.js +48 -0
- package/dist/src/stripe-capabilities.js +141 -0
- package/dist/src/stripe-params.d.ts +11 -0
- package/dist/src/stripe-params.js +74 -4
- package/dist/src/stripe-server.js +31 -1
- package/package.json +4 -4
- package/src/semantics/checkout.ts +27 -4
- package/src/semantics/payment-intents.ts +43 -0
- package/src/stripe-capabilities.ts +151 -0
- package/src/stripe-params.ts +75 -3
- package/src/stripe-server.ts +27 -1
|
@@ -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:
|
|
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
|
|
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
|
|
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) {
|
|
@@ -213,6 +213,20 @@ export const STRIPE_CAPABILITIES = [
|
|
|
213
213
|
const l = await h({ m: 'GET', p: '/v1/customers' });
|
|
214
214
|
return ok(g) && id(g) === id(c) && ok(u) && field(u, 'name') === 'Ada Lovelace' && field(l, 'object') === 'list';
|
|
215
215
|
})),
|
|
216
|
+
// stripe-node posts a null metadata value as `metadata[key]=` (Cal.com's PaymentIntent metadata, a guest with no phone)
|
|
217
|
+
done('stripe.metadata.create_empty_value_unset', 'metadata', 'A create\'s metadata key posted empty is not set (an empty value unsets a key); metadata posted empty is {}', 'api', 'common', () => withRoot(async (h) => {
|
|
218
|
+
const c = await h({ m: 'POST', p: '/v1/customers', b: 'email=sam@twin.test&metadata[identifier]=cal.com&metadata[bookerPhoneNumber]=' });
|
|
219
|
+
const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: `amount=5000¤cy=usd&customer=${id(c)}&metadata[bookingId]=12&metadata[calAccountId]=` });
|
|
220
|
+
const none = await h({ m: 'POST', p: '/v1/products', b: 'name=Consultation&metadata=' });
|
|
221
|
+
const got = await h({ m: 'GET', p: `/v1/payment_intents/${id(pi)}` });
|
|
222
|
+
const md = (r) => r.body.metadata;
|
|
223
|
+
return ok(c) && JSON.stringify(md(c)) === JSON.stringify({ identifier: 'cal.com' })
|
|
224
|
+
&& ok(pi) && JSON.stringify(md(pi)) === JSON.stringify({ bookingId: '12' }) && JSON.stringify(md(got)) === JSON.stringify({ bookingId: '12' })
|
|
225
|
+
&& ok(none) && JSON.stringify(md(none)) === '{}';
|
|
226
|
+
})),
|
|
227
|
+
// Objects on a connected account are that account's: Stripe answers a retrieve of one without its Stripe-Account
|
|
228
|
+
// header (or with another account's) 404 resource_missing. The twin keeps one store for the platform and its accounts.
|
|
229
|
+
todo('stripe.connect.account_scoped_objects', 'connect', 'Objects made with a Stripe-Account header are visible only as that account (retrieve/list/update from the platform or another account is resource_missing)', 'api', 'common'),
|
|
216
230
|
done('stripe.customers.list_filter', 'customers', 'Customer list filter by email', 'api', 'core', () => withRoot(async (h) => {
|
|
217
231
|
await h({ m: 'POST', p: '/v1/customers', b: 'email=match@twin.test' });
|
|
218
232
|
await h({ m: 'POST', p: '/v1/customers', b: 'email=other@twin.test' });
|
|
@@ -519,6 +533,91 @@ export const STRIPE_CAPABILITIES = [
|
|
|
519
533
|
const plain = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=500¤cy=usd' });
|
|
520
534
|
return field(plain, 'automatic_payment_methods') === null;
|
|
521
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¤cy=usd&payment_method_types[]=card&metadata[bookingId]=12');
|
|
547
|
+
const indexed = await pinned('/v1/payment_intents', 'amount=5000¤cy=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¤cy=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¤cy=usd&payment_method_types[]=card');
|
|
558
|
+
const atRemoval = await at('2026-08-26.preview', '/v1/payment_intents', 'amount=5000¤cy=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¤cy=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¤cy=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¤cy=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¤cy=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¤cy=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¤cy=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'),
|
|
522
621
|
// PaymentIntent search: GET /v1/payment_intents/search?query=… returns the search_result
|
|
523
622
|
// envelope; supports AND-combined clauses (currency:"usd" AND amount>=num). Missing query 400s.
|
|
524
623
|
done('stripe.payment_intents.search', 'payment_intents', 'PaymentIntent search', 'api', 'common', () => withRoot(async (h) => {
|
|
@@ -1945,6 +2044,48 @@ export const STRIPE_CAPABILITIES = [
|
|
|
1945
2044
|
&& typeof field(sub, 'trial_end') === 'number' && field(sub, 'trial_end') === item?.current_period_end
|
|
1946
2045
|
&& field(sub, 'current_period_end') === undefined;
|
|
1947
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
|
+
})),
|
|
1948
2089
|
// A subscription session's customer pays its first invoice on the page: the invoice is paid through a succeeded
|
|
1949
2090
|
// PaymentIntent and its charge, and the card is the subscription's default payment method
|
|
1950
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
|
-
|
|
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}` });
|
|
@@ -346,6 +346,34 @@ export async function createStripeTwinServer(options) {
|
|
|
346
346
|
});
|
|
347
347
|
return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
|
|
348
348
|
}
|
|
349
|
+
/** A create's `metadata` holds no key posted empty. "Individual keys can be unset by posting an empty value to them. All
|
|
350
|
+
* keys can be unset by posting an empty value to `metadata`" (every metadata parameter of docs.stripe.com/api): an empty
|
|
351
|
+
* value is the absence of the key, on a create as on an update, so an object never holds a key set to "". Clients post
|
|
352
|
+
* one without meaning to: stripe-node encodes a `null` value as `metadata[key]=` (Cal.com's PaymentIntent metadata
|
|
353
|
+
* carries `bookerPhoneNumber: null` for a guest who gave no phone). The request is rewritten as JSON without them. */
|
|
354
|
+
async function createMetadata(request, path) {
|
|
355
|
+
const op = CREATES.find((c) => c.re.test(path))?.op;
|
|
356
|
+
if (!op)
|
|
357
|
+
return request;
|
|
358
|
+
const params = await readParams(manifest, request.clone(), op);
|
|
359
|
+
if (!('metadata' in params))
|
|
360
|
+
return request;
|
|
361
|
+
const given = params.metadata;
|
|
362
|
+
let metadata;
|
|
363
|
+
if (given === '')
|
|
364
|
+
metadata = {};
|
|
365
|
+
else if (given && typeof given === 'object' && !Array.isArray(given) && Object.values(given).includes('')) {
|
|
366
|
+
metadata = Object.fromEntries(Object.entries(given).filter(([, v]) => v !== ''));
|
|
367
|
+
}
|
|
368
|
+
else
|
|
369
|
+
return request;
|
|
370
|
+
const headers = new Headers(request.headers);
|
|
371
|
+
headers.set('content-type', 'application/json');
|
|
372
|
+
return new Request(request.url, { method: request.method, headers, body: JSON.stringify({ ...params, metadata }) });
|
|
373
|
+
}
|
|
374
|
+
// the create operations, by their path as a pattern, for createMetadata
|
|
375
|
+
const CREATES = surface.operations.filter((o) => o.class === 'create' && o.method.toUpperCase() === 'POST')
|
|
376
|
+
.map((op) => ({ op, re: new RegExp(`^${op.path.replace(/[.*+?^$()|[\]\\]/g, '\\$&').replace(/\\\{[^}]+\\\}|\{[^}]+\}/g, '([^/]+)')}/?$`) }));
|
|
349
377
|
// the update operations, by their path as a pattern, for mergeMetadata
|
|
350
378
|
const UPDATES = surface.operations.filter((o) => o.class === 'update' && o.method.toUpperCase() === 'POST')
|
|
351
379
|
.map((op) => ({ op, re: new RegExp(`^${op.path.replace(/[.*+?^$()|[\]\\]/g, '\\$&').replace(/\\\{[^}]+\\\}|\{[^}]+\}/g, '([^/]+)')}/?$`) }));
|
|
@@ -371,7 +399,9 @@ async function mergeMetadata(request, coreMerges, scope) {
|
|
|
371
399
|
break;
|
|
372
400
|
}
|
|
373
401
|
}
|
|
374
|
-
if (!op
|
|
402
|
+
if (!op)
|
|
403
|
+
return createMetadata(request, path);
|
|
404
|
+
if (!op.resource)
|
|
375
405
|
return request;
|
|
376
406
|
const params = await readParams(manifest, request.clone(), op);
|
|
377
407
|
if (!('metadata' in params))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/twin-stripe",
|
|
3
|
-
"version": "2.0.
|
|
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",
|
|
@@ -56,15 +56,15 @@
|
|
|
56
56
|
"postpack": "node ../../../scripts/publish/prepare-publish.mjs postpack"
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
|
-
"@volter/world-ui": "0.1.
|
|
59
|
+
"@volter/world-ui": "0.1.3",
|
|
60
60
|
"react": "^19.2.7",
|
|
61
61
|
"react-dom": "^19.2.7"
|
|
62
62
|
},
|
|
63
63
|
"peerDependencies": {
|
|
64
|
-
"@volter/world-core": "2.0.
|
|
64
|
+
"@volter/world-core": "2.0.3"
|
|
65
65
|
},
|
|
66
66
|
"devDependencies": {
|
|
67
|
-
"@volter/world-core": "2.0.
|
|
67
|
+
"@volter/world-core": "2.0.3",
|
|
68
68
|
"@volter/world-tooling": "0.1.0",
|
|
69
69
|
"@types/bun": "^1.2.20",
|
|
70
70
|
"@types/node": "^24.0.0",
|
|
@@ -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:
|
|
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
|
|
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 =
|
|
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) {
|
|
@@ -256,6 +256,22 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
256
256
|
return ok(g) && id(g) === id(c) && ok(u) && field(u, 'name') === 'Ada Lovelace' && field(l, 'object') === 'list';
|
|
257
257
|
}),
|
|
258
258
|
),
|
|
259
|
+
// stripe-node posts a null metadata value as `metadata[key]=` (Cal.com's PaymentIntent metadata, a guest with no phone)
|
|
260
|
+
done('stripe.metadata.create_empty_value_unset', 'metadata', 'A create\'s metadata key posted empty is not set (an empty value unsets a key); metadata posted empty is {}', 'api', 'common', () =>
|
|
261
|
+
withRoot(async (h) => {
|
|
262
|
+
const c = await h({ m: 'POST', p: '/v1/customers', b: 'email=sam@twin.test&metadata[identifier]=cal.com&metadata[bookerPhoneNumber]=' });
|
|
263
|
+
const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: `amount=5000¤cy=usd&customer=${id(c)}&metadata[bookingId]=12&metadata[calAccountId]=` });
|
|
264
|
+
const none = await h({ m: 'POST', p: '/v1/products', b: 'name=Consultation&metadata=' });
|
|
265
|
+
const got = await h({ m: 'GET', p: `/v1/payment_intents/${id(pi)}` });
|
|
266
|
+
const md = (r: StripeResponse) => (r.body as Body).metadata as Body;
|
|
267
|
+
return ok(c) && JSON.stringify(md(c)) === JSON.stringify({ identifier: 'cal.com' })
|
|
268
|
+
&& ok(pi) && JSON.stringify(md(pi)) === JSON.stringify({ bookingId: '12' }) && JSON.stringify(md(got)) === JSON.stringify({ bookingId: '12' })
|
|
269
|
+
&& ok(none) && JSON.stringify(md(none)) === '{}';
|
|
270
|
+
}),
|
|
271
|
+
),
|
|
272
|
+
// Objects on a connected account are that account's: Stripe answers a retrieve of one without its Stripe-Account
|
|
273
|
+
// header (or with another account's) 404 resource_missing. The twin keeps one store for the platform and its accounts.
|
|
274
|
+
todo('stripe.connect.account_scoped_objects', 'connect', 'Objects made with a Stripe-Account header are visible only as that account (retrieve/list/update from the platform or another account is resource_missing)', 'api', 'common'),
|
|
259
275
|
done('stripe.customers.list_filter', 'customers', 'Customer list filter by email', 'api', 'core', () =>
|
|
260
276
|
withRoot(async (h) => {
|
|
261
277
|
await h({ m: 'POST', p: '/v1/customers', b: 'email=match@twin.test' });
|
|
@@ -558,6 +574,97 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
558
574
|
return field(plain, 'automatic_payment_methods') === null;
|
|
559
575
|
}),
|
|
560
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¤cy=usd&payment_method_types[]=card&metadata[bookingId]=12');
|
|
589
|
+
const indexed = await pinned('/v1/payment_intents', 'amount=5000¤cy=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¤cy=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¤cy=usd&payment_method_types[]=card');
|
|
600
|
+
const atRemoval = await at('2026-08-26.preview', '/v1/payment_intents', 'amount=5000¤cy=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¤cy=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¤cy=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¤cy=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¤cy=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¤cy=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¤cy=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'),
|
|
561
668
|
// PaymentIntent search: GET /v1/payment_intents/search?query=… returns the search_result
|
|
562
669
|
// envelope; supports AND-combined clauses (currency:"usd" AND amount>=num). Missing query 400s.
|
|
563
670
|
done('stripe.payment_intents.search', 'payment_intents', 'PaymentIntent search', 'api', 'common', () =>
|
|
@@ -1956,6 +2063,50 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1956
2063
|
&& field(sub, 'current_period_end') === undefined;
|
|
1957
2064
|
}),
|
|
1958
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
|
+
),
|
|
1959
2110
|
// A subscription session's customer pays its first invoice on the page: the invoice is paid through a succeeded
|
|
1960
2111
|
// PaymentIntent and its charge, and the card is the subscription's default payment method
|
|
1961
2112
|
// (docs.stripe.com/payments/checkout/how-checkout-works).
|
package/src/stripe-params.ts
CHANGED
|
@@ -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)
|
|
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
|
}
|
package/src/stripe-server.ts
CHANGED
|
@@ -327,6 +327,31 @@ export async function createStripeTwinServer(options: StripeTwinOptions): Promis
|
|
|
327
327
|
return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
|
|
328
328
|
}
|
|
329
329
|
|
|
330
|
+
/** A create's `metadata` holds no key posted empty. "Individual keys can be unset by posting an empty value to them. All
|
|
331
|
+
* keys can be unset by posting an empty value to `metadata`" (every metadata parameter of docs.stripe.com/api): an empty
|
|
332
|
+
* value is the absence of the key, on a create as on an update, so an object never holds a key set to "". Clients post
|
|
333
|
+
* one without meaning to: stripe-node encodes a `null` value as `metadata[key]=` (Cal.com's PaymentIntent metadata
|
|
334
|
+
* carries `bookerPhoneNumber: null` for a guest who gave no phone). The request is rewritten as JSON without them. */
|
|
335
|
+
async function createMetadata(request: Request, path: string): Promise<Request> {
|
|
336
|
+
const op = CREATES.find((c) => c.re.test(path))?.op;
|
|
337
|
+
if (!op) return request;
|
|
338
|
+
const params = await readParams(manifest, request.clone(), op);
|
|
339
|
+
if (!('metadata' in params)) return request;
|
|
340
|
+
const given = params.metadata;
|
|
341
|
+
let metadata: Record<string, unknown>;
|
|
342
|
+
if (given === '') metadata = {};
|
|
343
|
+
else if (given && typeof given === 'object' && !Array.isArray(given) && Object.values(given).includes('')) {
|
|
344
|
+
metadata = Object.fromEntries(Object.entries(given as Record<string, unknown>).filter(([, v]) => v !== ''));
|
|
345
|
+
} else return request;
|
|
346
|
+
const headers = new Headers(request.headers);
|
|
347
|
+
headers.set('content-type', 'application/json');
|
|
348
|
+
return new Request(request.url, { method: request.method, headers, body: JSON.stringify({ ...params, metadata }) });
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// the create operations, by their path as a pattern, for createMetadata
|
|
352
|
+
const CREATES = (surface.operations as Array<DerivedCall['operation']>).filter((o) => o.class === 'create' && o.method.toUpperCase() === 'POST')
|
|
353
|
+
.map((op) => ({ op, re: new RegExp(`^${op.path.replace(/[.*+?^$()|[\]\\]/g, '\\$&').replace(/\\\{[^}]+\\\}|\{[^}]+\}/g, '([^/]+)')}/?$`) }));
|
|
354
|
+
|
|
330
355
|
// the update operations, by their path as a pattern, for mergeMetadata
|
|
331
356
|
const UPDATES = (surface.operations as Array<DerivedCall['operation']>).filter((o) => o.class === 'update' && o.method.toUpperCase() === 'POST')
|
|
332
357
|
.map((op) => ({ op, re: new RegExp(`^${op.path.replace(/[.*+?^$()|[\]\\]/g, '\\$&').replace(/\\\{[^}]+\\\}|\{[^}]+\}/g, '([^/]+)')}/?$`) }));
|
|
@@ -345,7 +370,8 @@ async function mergeMetadata(request: Request, coreMerges: (op: DerivedCall['ope
|
|
|
345
370
|
let op: DerivedCall['operation'] | undefined;
|
|
346
371
|
let id = '';
|
|
347
372
|
for (const u of UPDATES) { const m = u.re.exec(path); if (m) { op = u.op; id = decodeURIComponent(m.at(-1) ?? ''); break; } }
|
|
348
|
-
if (!op
|
|
373
|
+
if (!op) return createMetadata(request, path);
|
|
374
|
+
if (!op.resource) return request;
|
|
349
375
|
const params = await readParams(manifest, request.clone(), op);
|
|
350
376
|
if (!('metadata' in params)) return request;
|
|
351
377
|
const given = params.metadata;
|