@volter/twin-stripe 2.0.0 → 2.0.2
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/README.md +33 -1
- package/dist/src/index.js +6 -4
- package/dist/src/manifest.js +8 -3
- package/dist/src/screens/checkout.js +20 -6
- package/dist/src/screens/connect-oauth.d.ts +27 -0
- package/dist/src/screens/connect-oauth.js +414 -0
- package/dist/src/screens/connect-settings.d.ts +22 -0
- package/dist/src/screens/connect-settings.js +103 -0
- package/dist/src/screens/portal.js +2 -0
- package/dist/src/semantics/after-payment.d.ts +1 -1
- package/dist/src/semantics/after-payment.js +6 -0
- package/dist/src/semantics/charges.js +10 -2
- package/dist/src/semantics/checkout.js +19 -6
- package/dist/src/semantics/connect.js +20 -3
- package/dist/src/semantics/invoices.js +4 -0
- package/dist/src/semantics/issuing.js +7 -2
- package/dist/src/semantics/ledger.d.ts +11 -6
- package/dist/src/semantics/ledger.js +40 -21
- package/dist/src/semantics/payment-methods.js +2 -0
- package/dist/src/semantics/shared.d.ts +5 -1
- package/dist/src/semantics/shared.js +14 -3
- package/dist/src/semantics/test-cards.d.ts +4 -0
- package/dist/src/semantics/test-cards.js +7 -0
- package/dist/src/semantics/transfers.js +1 -1
- package/dist/src/stripe-capabilities.js +829 -186
- package/dist/src/stripe-conformance.d.ts +2 -0
- package/dist/src/stripe-conformance.js +11 -2
- package/dist/src/stripe-emit.js +2 -2
- package/dist/src/stripe-events.js +14 -10
- package/dist/src/stripe-mirror-ui.js +3 -3
- package/dist/src/stripe-server.js +97 -24
- package/dist/src/stripe-shared.d.ts +3 -0
- package/dist/src/stripe-shared.js +3 -0
- package/dist/src/stripe-twin.js +7 -1
- package/dist/src/stripe-version.d.ts +2 -0
- package/dist/src/stripe-version.js +2 -0
- package/dist/test-fixtures/stripe-known-deviations.json +6 -1
- package/dist/test-fixtures/stripe-schemas.json +85 -12
- package/package.json +4 -4
- package/src/index.ts +6 -4
- package/src/manifest.ts +8 -3
- package/src/screens/checkout.tsx +21 -6
- package/src/screens/connect-oauth.tsx +400 -0
- package/src/screens/connect-settings.tsx +121 -0
- package/src/screens/portal.tsx +2 -0
- package/src/semantics/after-payment.ts +6 -1
- package/src/semantics/charges.ts +11 -2
- package/src/semantics/checkout.ts +19 -6
- package/src/semantics/connect.ts +19 -3
- package/src/semantics/invoices.ts +4 -0
- package/src/semantics/issuing.ts +7 -2
- package/src/semantics/ledger.ts +60 -23
- package/src/semantics/payment-methods.ts +2 -0
- package/src/semantics/shared.ts +14 -3
- package/src/semantics/test-cards.ts +7 -0
- package/src/semantics/transfers.ts +1 -1
- package/src/stripe-capabilities.ts +826 -182
- package/src/stripe-conformance.ts +13 -2
- package/src/stripe-emit.ts +2 -2
- package/src/stripe-events.ts +14 -10
- package/src/stripe-mirror-ui.ts +3 -3
- package/src/stripe-server.ts +85 -24
- package/src/stripe-shared.ts +3 -0
- package/src/stripe-twin.ts +6 -1
- package/src/stripe-version.ts +3 -0
- package/test-fixtures/stripe-known-deviations.json +6 -1
- package/test-fixtures/stripe-schemas.json +85 -12
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
9
9
|
import { createServer } from 'node:http';
|
|
10
10
|
import type { AddressInfo } from 'node:net';
|
|
11
|
-
import { emitTwinEvent } from '@volter/world-core';
|
|
11
|
+
import { applyTwinWrite, emitTwinEvent } from '@volter/world-core';
|
|
12
12
|
import { stripeEmitter } from './stripe-emit.ts';
|
|
13
13
|
import { tmpdir } from 'node:os';
|
|
14
14
|
import { join } from 'node:path';
|
|
@@ -31,6 +31,7 @@ import { fullSyncStripe } from './stripe-connector.ts';
|
|
|
31
31
|
import { buildStripeMirrorClient, createStripeMirrorServer, formatStripeAmount } from './stripe-mirror-ui.ts';
|
|
32
32
|
import { handleStripeTwinRequest, type StripeResponse } from './stripe-twin.ts';
|
|
33
33
|
import { createStripeTwinFetch } from './stripe-server.ts';
|
|
34
|
+
import { SERVED_VERSION } from './stripe-version.ts';
|
|
34
35
|
import { pack } from './index.ts';
|
|
35
36
|
import { stripeIdentityFlow } from './screens/identity.tsx';
|
|
36
37
|
import { stripeFinancialConnectionsFlow } from './screens/financial-connections.tsx';
|
|
@@ -54,7 +55,7 @@ type ServerFetch = (path: string) => Promise<Body>;
|
|
|
54
55
|
type ServerClient = { get: ServerFetch; post: (path: string, body?: string) => Promise<Body> };
|
|
55
56
|
function uiDataCoupled(opts: {
|
|
56
57
|
markers: string[];
|
|
57
|
-
seed: (h: (s: Step) => Promise<StripeResponse
|
|
58
|
+
seed: (h: (s: Step) => Promise<StripeResponse>, root: string) => Promise<void>;
|
|
58
59
|
check: (client: ServerClient) => Promise<boolean>;
|
|
59
60
|
}): () => Promise<boolean> {
|
|
60
61
|
return async () => {
|
|
@@ -64,7 +65,7 @@ function uiDataCoupled(opts: {
|
|
|
64
65
|
const server = await createStripeMirrorServer({ root, port: 0 });
|
|
65
66
|
try {
|
|
66
67
|
const h = (s: Step) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root });
|
|
67
|
-
await opts.seed(h);
|
|
68
|
+
await opts.seed(h, root);
|
|
68
69
|
const base = `http://127.0.0.1:${server.port}`;
|
|
69
70
|
const get: ServerFetch = (path) => fetch(`${base}${path}`).then((r) => r.json() as Promise<Body>);
|
|
70
71
|
const post = (path: string, body?: string) => fetch(`${base}${path}`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: body ?? '' }).then((r) => r.json() as Promise<Body>);
|
|
@@ -124,9 +125,11 @@ async function fundIssuing(root: string, amount: number, at?: string): Promise<S
|
|
|
124
125
|
|
|
125
126
|
const ok = (r: StripeResponse) => r.status >= 200 && r.status < 300;
|
|
126
127
|
/** A seller the platform can pay: a Custom account onboarded through the API (its business profile, the owner's
|
|
127
|
-
* acceptance of the terms, a bank account), and funds in the platform's balance to send it.
|
|
128
|
+
* acceptance of the terms, the individual's name, a bank account), and funds in the platform's balance to send it.
|
|
129
|
+
* Answers the account id. The name is owed at once for an individual with transfers (e0e3b88c6: what Stripe's
|
|
130
|
+
* requirements endpoint lists for the business type and capabilities), so without it transfers stay inactive. */
|
|
128
131
|
async function onboardedSeller(h: (s: Step) => Promise<StripeResponse>): Promise<string> {
|
|
129
|
-
const a = await h({ m: 'POST', p: '/v1/accounts', b: 'type=custom&country=US&capabilities[transfers][requested]=true&business_profile[mcc]=5462&business_profile[url]=https://seller.test&tos_acceptance[date]=1767225600&tos_acceptance[ip]=203.0.113.9' });
|
|
132
|
+
const a = await h({ m: 'POST', p: '/v1/accounts', b: 'type=custom&country=US&capabilities[transfers][requested]=true&business_profile[mcc]=5462&business_profile[url]=https://seller.test&tos_acceptance[date]=1767225600&tos_acceptance[ip]=203.0.113.9&individual[first_name]=Sam&individual[last_name]=Seller' });
|
|
130
133
|
await h({ m: 'POST', p: `/v1/accounts/${id(a)}/external_accounts`, b: 'external_account[object]=bank_account&external_account[country]=US&external_account[currency]=usd&external_account[routing_number]=110000000&external_account[account_number]=000123456789' });
|
|
131
134
|
await h({ m: 'POST', p: '/v1/charges', b: 'amount=100000¤cy=usd&source=tok_bypassPending' });
|
|
132
135
|
return id(a);
|
|
@@ -150,6 +153,57 @@ function fetcher(f: (r: Request) => Promise<Response>): (m: string, p: string, b
|
|
|
150
153
|
method: m, headers: { authorization: 'Bearer sk_test_twin', 'content-type': 'application/x-www-form-urlencoded', ...(acct ? { 'stripe-account': acct } : {}) }, ...(b !== undefined ? { body: b } : {}),
|
|
151
154
|
}))).json()) as Body;
|
|
152
155
|
}
|
|
156
|
+
// ── Connect OAuth (screens/connect-oauth.tsx, screens/connect-settings.tsx) ──
|
|
157
|
+
/** Cal.com's callback, the redirect URI its Stripe app registers. */
|
|
158
|
+
const OAUTH_CALLBACK = 'http://localhost:3000/api/integrations/stripepayment/callback';
|
|
159
|
+
/** An authorize query as Cal.com's Stripe app builds it (qs-stringify: nested stripe_user[...] keys), with `extra` added. */
|
|
160
|
+
const authorizeQuery = (extra = '', redirect: string | null = OAUTH_CALLBACK): string =>
|
|
161
|
+
`client_id=ca_twin_self&scope=read_write&response_type=code${redirect === null ? '' : `&redirect_uri=${encodeURIComponent(redirect)}`}&state=st8${extra}`;
|
|
162
|
+
type OAuthWorld = {
|
|
163
|
+
f: (r: Request) => Promise<Response>;
|
|
164
|
+
root: string;
|
|
165
|
+
api: (m: string, p: string, b?: string, acct?: string) => Promise<{ status: number; body: Body }>;
|
|
166
|
+
at: (iso: string) => void;
|
|
167
|
+
settings: (enabled: boolean, uris: string) => Promise<Response>;
|
|
168
|
+
authorize: (query: string, decision?: 'skip' | 'deny') => Promise<Response>;
|
|
169
|
+
endpoint: (path: 'token' | 'deauthorize', body: string, auth?: string) => Promise<{ status: number; body: Body }>;
|
|
170
|
+
/** Connect a new account through the page (skip) and exchange its code: the redirect, the code and the token answer. */
|
|
171
|
+
connect: (extra?: string) => Promise<{ location: URL; code: string; token: Body }>;
|
|
172
|
+
};
|
|
173
|
+
/** A fresh World with a Connect platform whose OAuth is on and whose callback is registered (unless `configure` is false). */
|
|
174
|
+
async function withOAuth(run: (w: OAuthWorld) => Promise<boolean>, configure = true): Promise<boolean> {
|
|
175
|
+
const root = mkdtempSync(join(tmpdir(), 'stp-oauth-'));
|
|
176
|
+
let now = '2026-03-01T00:00:00.000Z';
|
|
177
|
+
const f = createStripeTwinFetch({ root, clock: () => now });
|
|
178
|
+
const form = { 'content-type': 'application/x-www-form-urlencoded' };
|
|
179
|
+
const w: OAuthWorld = {
|
|
180
|
+
f,
|
|
181
|
+
root,
|
|
182
|
+
api: async (m, p, b, acct) => {
|
|
183
|
+
const r = await f(new Request(`http://stripe.test${p}`, { method: m, headers: { authorization: 'Bearer sk_test_platform', ...form, ...(acct ? { 'stripe-account': acct } : {}) }, ...(b !== undefined ? { body: b } : {}) }));
|
|
184
|
+
return { status: r.status, body: (await r.json()) as Body };
|
|
185
|
+
},
|
|
186
|
+
at: (iso) => { now = iso; },
|
|
187
|
+
settings: (enabled, uris) => f(new Request('https://dashboard.stripe.com/settings/connect/onboarding-options/oauth', { method: 'POST', headers: form, body: new URLSearchParams({ ...(enabled ? { oauth_enabled: 'on' } : {}), redirect_uris: uris }).toString() })),
|
|
188
|
+
authorize: (query, decision) => f(new Request(`https://connect.stripe.com/oauth/authorize?${query}`, decision ? { method: 'POST', headers: form, body: `decision=${decision}` } : {})),
|
|
189
|
+
endpoint: async (path, body, auth = 'Bearer sk_test_platform') => {
|
|
190
|
+
const r = await f(new Request(`https://connect.stripe.com/oauth/${path}`, { method: 'POST', headers: { ...form, ...(auth ? { authorization: auth } : {}) }, body }));
|
|
191
|
+
return { status: r.status, body: (await r.json()) as Body };
|
|
192
|
+
},
|
|
193
|
+
connect: async (extra = '') => {
|
|
194
|
+
const location = new URL((await w.authorize(authorizeQuery(extra), 'skip')).headers.get('location') ?? 'about:blank');
|
|
195
|
+
const code = location.searchParams.get('code') ?? '';
|
|
196
|
+
const token = (await w.endpoint('token', `grant_type=authorization_code&code=${code}`)).body;
|
|
197
|
+
return { location, code, token };
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
try {
|
|
201
|
+
if (configure) await w.settings(true, OAUTH_CALLBACK);
|
|
202
|
+
return await verifyBoundary('stripe.withOAuth', () => run(w));
|
|
203
|
+
} finally {
|
|
204
|
+
rmSync(root, { recursive: true, force: true });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
153
207
|
/** A dispute as Stripe opens one: a charge whose test card the bank disputes (docs.stripe.com/testing#disputes). */
|
|
154
208
|
async function disputed(h: (s: Step) => Promise<StripeResponse>, amount = 1000): Promise<StripeResponse> {
|
|
155
209
|
await h({ m: 'POST', p: '/v1/charges', b: `amount=${amount}¤cy=usd&source=tok_createDispute` });
|
|
@@ -256,8 +310,10 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
256
310
|
}),
|
|
257
311
|
),
|
|
258
312
|
// Cash balance: GET /v1/customers/:id/cash_balance returns the cash_balance summary
|
|
259
|
-
// (available currency→amount map, derived from the cash-balance ledger). Funding via
|
|
260
|
-
//
|
|
313
|
+
// (available currency→amount map, derived from the cash-balance ledger). Funding via the test helper
|
|
314
|
+
// POST /v1/test_helpers/customers/:id/fund_cash_balance ("Create an incoming testmode bank transfer",
|
|
315
|
+
// docs.stripe.com/api/cash_balance/fund_cash_balance) accrues into `available`; POST updates reconciliation_mode.
|
|
316
|
+
// (The twin-invented POST .../cash_balance_transactions left with 5decb1d77: Stripe's API has no such write.)
|
|
261
317
|
// An unknown customer 404s; an invalid reconciliation_mode 400s. (Only this feature produces
|
|
262
318
|
// the available map keyed by the funded currency.)
|
|
263
319
|
done('stripe.customers.cash_balance', 'customers', 'Customer cash balance', 'api', 'niche', () =>
|
|
@@ -265,9 +321,9 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
265
321
|
const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=cash@twin.test' });
|
|
266
322
|
const empty = await h({ m: 'GET', p: `/v1/customers/${id(cust)}/cash_balance` });
|
|
267
323
|
if (!ok(empty) || field(empty, 'object') !== 'cash_balance' || field(empty, 'available') !== null) return false;
|
|
268
|
-
const fund = await h({ m: 'POST', p: `/v1/customers/${id(cust)}/
|
|
324
|
+
const fund = await h({ m: 'POST', p: `/v1/test_helpers/customers/${id(cust)}/fund_cash_balance`, b: 'amount=5000¤cy=usd' });
|
|
269
325
|
if (!ok(fund) || field(fund, 'type') !== 'funded' || field(fund, 'ending_balance') !== 5000) return false;
|
|
270
|
-
const more = await h({ m: 'POST', p: `/v1/customers/${id(cust)}/
|
|
326
|
+
const more = await h({ m: 'POST', p: `/v1/test_helpers/customers/${id(cust)}/fund_cash_balance`, b: 'amount=2500¤cy=usd' });
|
|
271
327
|
if (field(more, 'ending_balance') !== 7500) return false;
|
|
272
328
|
const bal = await h({ m: 'GET', p: `/v1/customers/${id(cust)}/cash_balance` });
|
|
273
329
|
if ((field(bal, 'available') as Body)?.usd !== 7500) return false;
|
|
@@ -336,7 +392,11 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
336
392
|
const beforeN = ((before.body as Body).data as Body[]).length;
|
|
337
393
|
const afterN = ((after.body as Body).data as Body[]).length;
|
|
338
394
|
const again = await h({ m: 'DELETE', p: `/v1/customers/${id(c)}` });
|
|
339
|
-
|
|
395
|
+
// a deleted customer still RETRIEVES, as the stub: "If it's for a deleted Customer, a subset of the customer's
|
|
396
|
+
// information is returned, including a `deleted` property that's set to true" (docs.stripe.com/api/customers/retrieve;
|
|
397
|
+
// 8154bb647). It leaves the list, and a second delete finds nothing to delete.
|
|
398
|
+
return gone.status === 200 && field(gone, 'deleted') === true && field(gone, 'id') === id(c) && field(gone, 'email') === undefined
|
|
399
|
+
&& afterN === beforeN - 1 && again.status === 404;
|
|
340
400
|
}),
|
|
341
401
|
),
|
|
342
402
|
|
|
@@ -629,7 +689,11 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
629
689
|
const c = await h({ m: 'POST', p: '/v1/charges', b: 'amount=2000¤cy=usd' });
|
|
630
690
|
const r1 = await h({ m: 'POST', p: '/v1/refunds', b: `charge=${id(c)}&amount=500` });
|
|
631
691
|
if (!ok(r1)) return false;
|
|
632
|
-
|
|
692
|
+
// `refunds` is includable on a charge, "not returned by default" (docs.stripe.com/api/charges/object; 4388f04e9):
|
|
693
|
+
// the plain read leaves it out, and the read that expands it carries the refund
|
|
694
|
+
const plain = await h({ m: 'GET', p: `/v1/charges/${id(c)}` });
|
|
695
|
+
if (field(plain, 'refunds') !== undefined) return false;
|
|
696
|
+
const partial = await h({ m: 'GET', p: `/v1/charges/${id(c)}?expand[]=refunds` });
|
|
633
697
|
if (field(partial, 'amount_refunded') !== 500 || field(partial, 'refunded') !== false) return false;
|
|
634
698
|
const listed = (field(partial, 'refunds') as Body)?.data as Body[] | undefined;
|
|
635
699
|
if (!Array.isArray(listed) || listed.length !== 1 || listed[0]?.id !== id(r1)) return false;
|
|
@@ -793,16 +857,22 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
793
857
|
done('stripe.payouts.reverse', 'payouts', 'Payout reverse (a connected account\'s paid payout)', 'api', 'niche', async () => {
|
|
794
858
|
const root = mkdtempSync(join(tmpdir(), 'stp-rev-'));
|
|
795
859
|
try {
|
|
796
|
-
|
|
860
|
+
// at noon: accounts are paid out automatically at midnight UTC on their schedule's days (5ab14297c), so a charge
|
|
861
|
+
// made at the stroke of midnight is swept into that same instant's automatic payout and nothing is left to send
|
|
862
|
+
const h = (s: Step & { acct?: string; day?: number }) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: `2026-06-${String(s.day ?? 1).padStart(2, '0')}T12:00:00Z`, ...(s.acct ? { stripeAccount: s.acct } : {}) });
|
|
797
863
|
const seller = await onboardedSeller(h);
|
|
798
864
|
await h({ m: 'POST', p: '/v1/transfers', b: `amount=5000¤cy=usd&destination=${seller}` });
|
|
799
865
|
const p = await h({ m: 'POST', p: '/v1/payouts', b: 'amount=4000¤cy=usd', acct: seller });
|
|
800
866
|
if (!ok(p) || (await h({ m: 'POST', p: `/v1/payouts/${id(p)}/reverse`, acct: seller })).status !== 400) return false;
|
|
867
|
+
// the platform's own payout, made the same day, before its automatic payout sweeps what is left (5ab14297c)
|
|
868
|
+
const plat = await h({ m: 'POST', p: '/v1/payouts', b: 'amount=1000¤cy=usd' });
|
|
869
|
+
if (!ok(plat)) return false;
|
|
801
870
|
const rev = await h({ m: 'POST', p: `/v1/payouts/${id(p)}/reverse`, acct: seller, day: 5 });
|
|
802
|
-
|
|
871
|
+
// the reversal is a payout the other way, "a negative amount" as the reverse page's example answers it
|
|
872
|
+
// (docs.stripe.com/api/payouts/reverse; 8848ebfaa)
|
|
873
|
+
if (!ok(rev) || field(rev, 'original_payout') !== id(p) || field(rev, 'amount') !== -4000) return false;
|
|
803
874
|
const orig = await h({ m: 'GET', p: `/v1/payouts/${id(p)}`, acct: seller, day: 5 });
|
|
804
875
|
if (field(orig, 'status') !== 'paid' || field(orig, 'reversed_by') !== id(rev)) return false;
|
|
805
|
-
const plat = await h({ m: 'POST', p: '/v1/payouts', b: 'amount=1000¤cy=usd' });
|
|
806
876
|
const platRev = await h({ m: 'POST', p: `/v1/payouts/${id(plat)}/reverse`, day: 5 });
|
|
807
877
|
const twice = await h({ m: 'POST', p: `/v1/payouts/${id(p)}/reverse`, acct: seller, day: 5 });
|
|
808
878
|
const nope = await h({ m: 'POST', p: '/v1/payouts/po_nope/reverse' });
|
|
@@ -872,7 +942,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
872
942
|
const h = fetcher(createStripeTwinFetch({ root, clock: () => now }));
|
|
873
943
|
await h('POST', '/v1/webhook_endpoints', `url=${rx.url('/platform')}&enabled_events[]=balance.available&enabled_events[]=payout.paid`);
|
|
874
944
|
await h('POST', '/v1/webhook_endpoints', `url=${rx.url('/connect')}&enabled_events[]=balance.available&enabled_events[]=payout.paid&connect=true`);
|
|
875
|
-
const seller = String((await h('POST', '/v1/accounts', 'type=custom&country=US&capabilities[transfers][requested]=true&business_profile[mcc]=5462&business_profile[url]=https://seller.test&tos_acceptance[date]=1767225600&tos_acceptance[ip]=203.0.113.9&settings[payouts][schedule][interval]=manual')).id);
|
|
945
|
+
const seller = String((await h('POST', '/v1/accounts', 'type=custom&country=US&capabilities[transfers][requested]=true&business_profile[mcc]=5462&business_profile[url]=https://seller.test&tos_acceptance[date]=1767225600&tos_acceptance[ip]=203.0.113.9&individual[first_name]=Sam&individual[last_name]=Seller&settings[payouts][schedule][interval]=manual')).id);
|
|
876
946
|
await h('POST', `/v1/accounts/${seller}/external_accounts`, 'external_account[object]=bank_account&external_account[country]=US&external_account[currency]=usd&external_account[routing_number]=110000000&external_account[account_number]=000123456789');
|
|
877
947
|
await h('POST', '/_twin/account', 'settings[payouts][schedule][interval]=manual');
|
|
878
948
|
const pi = await h('POST', '/v1/payment_intents', 'amount=2592¤cy=usd&payment_method=pm_card_visa&confirm=true');
|
|
@@ -906,6 +976,96 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
906
976
|
}
|
|
907
977
|
}),
|
|
908
978
|
|
|
979
|
+
// Test mode's clock, as Stripe documents it (docs.stripe.com/testing#available-balance; semantics/ledger.ts): a card
|
|
980
|
+
// that bypasses the pending balance (4000000000000077, 4000003720000278, their pm_card_/tok_ names) puts its funds in
|
|
981
|
+
// the available balance at once, a PaymentMethod saved from it too when a later charge names it by id, and a
|
|
982
|
+
// source_transaction transfer from that charge is available on the connected account at once ("takes on the pending
|
|
983
|
+
// status of the associated charge", docs.stripe.com/connect/separate-charges-and-transfers); the drain in that same
|
|
984
|
+
// instant sends each account balance.available. Every other card keeps the delay (4242 pending), and a test payout
|
|
985
|
+
// keeps live timing ("Test payouts simulate a live payout", docs.stripe.com/payouts#test-payouts): paid at its
|
|
986
|
+
// arrival_date, not before. Dub's partner payout reaches Completed this way without World time for the funds. A US
|
|
987
|
+
// bank account debit settles at once too ("Test transactions settle instantly and are added to your available test
|
|
988
|
+
// balance", docs.stripe.com/testing), and every credit available at once is sent as balance.available.
|
|
989
|
+
done('stripe.balance.test_mode_bypass_pending', 'balance', 'Test-mode funds timing: bypass cards (raw, by test name, saved as a PaymentMethod) and bank debits available at once with balance.available sent, source_transaction transfers inheriting it; 4242 and test payouts keep their timing', 'api', 'common', async () => {
|
|
990
|
+
const root = mkdtempSync(join(tmpdir(), 'stp-bypass-'));
|
|
991
|
+
const rx = await webhookReceiver();
|
|
992
|
+
try {
|
|
993
|
+
let now = '2026-06-01T12:00:00.000Z';
|
|
994
|
+
const h = fetcher(createStripeTwinFetch({ root, clock: () => now }));
|
|
995
|
+
await h('POST', '/v1/webhook_endpoints', `url=${rx.url('/connect')}&enabled_events[]=balance.available&enabled_events[]=payout.paid&connect=true`);
|
|
996
|
+
const seller = String((await h('POST', '/v1/accounts', 'type=custom&country=US&capabilities[transfers][requested]=true&business_profile[mcc]=5462&business_profile[url]=https://seller.test&tos_acceptance[date]=1767225600&tos_acceptance[ip]=203.0.113.9&business_type=individual&individual[first_name]=Jordan&individual[last_name]=Lee&settings[payouts][schedule][interval]=manual')).id);
|
|
997
|
+
await h('POST', `/v1/accounts/${seller}/external_accounts`, 'external_account[object]=bank_account&external_account[country]=US&external_account[currency]=usd&external_account[routing_number]=110000000&external_account[account_number]=000123456789');
|
|
998
|
+
await h('POST', '/_twin/account', 'settings[payouts][schedule][interval]=manual');
|
|
999
|
+
const btOf = async (charge: unknown) => h('GET', `/v1/balance_transactions/${String((await h('GET', `/v1/charges/${String(charge)}`)).balance_transaction)}`);
|
|
1000
|
+
const amounts = async (acct?: string) => {
|
|
1001
|
+
const b = await h('GET', '/v1/balance', undefined, acct);
|
|
1002
|
+
return { available: ((b.available as Body[])[0]?.amount as number) ?? 0, pending: ((b.pending as Body[])[0]?.amount as number) ?? 0 };
|
|
1003
|
+
};
|
|
1004
|
+
// 4242: the delay stands in test mode
|
|
1005
|
+
const visa = await h('POST', '/v1/payment_intents', 'amount=1000¤cy=usd&payment_method=pm_card_visa&confirm=true');
|
|
1006
|
+
if ((await btOf(visa.latest_charge)).status !== 'pending') return false;
|
|
1007
|
+
const before = await amounts();
|
|
1008
|
+
// a card saved from 4000000000000077, charged later by its id
|
|
1009
|
+
const customer = String((await h('POST', '/v1/customers', 'email=maya@acme.test')).id);
|
|
1010
|
+
const pm = String((await h('POST', '/v1/payment_methods', 'type=card&card[number]=4000000000000077&card[exp_month]=4&card[exp_year]=2030&card[cvc]=314')).id);
|
|
1011
|
+
await h('POST', `/v1/payment_methods/${pm}/attach`, `customer=${customer}`);
|
|
1012
|
+
const pi = await h('POST', '/v1/payment_intents', `amount=2592¤cy=usd&customer=${customer}&payment_method=${pm}&confirm=true&off_session=true&expand[]=latest_charge.balance_transaction`);
|
|
1013
|
+
const charge = String((pi.latest_charge as Body | undefined)?.id);
|
|
1014
|
+
const bt = await btOf(charge);
|
|
1015
|
+
const after = await amounts();
|
|
1016
|
+
// the entry reads available however it is read: its own retrieve, the balance, an expansion
|
|
1017
|
+
const embedded = ((pi.latest_charge as Body | undefined)?.balance_transaction as Body | undefined)?.status;
|
|
1018
|
+
const platformAtOnce = bt.status === 'available' && embedded === 'available' && after.available - before.available === Number(bt.net) && after.pending === before.pending;
|
|
1019
|
+
// its source_transaction transfer is available on the connected account at once, and is sent as such
|
|
1020
|
+
await h('POST', '/v1/transfers', `amount=2400¤cy=usd&destination=${seller}&source_transaction=${charge}`);
|
|
1021
|
+
const theirs = await amounts(seller);
|
|
1022
|
+
const sent = async (): Promise<string[]> => ((await h('POST', '/_twin/drain')).delivered as Body[]).map((d) => `${String(d.type)}@${String(d.account ?? 'platform')}`);
|
|
1023
|
+
const drained = await sent();
|
|
1024
|
+
const again = await sent();
|
|
1025
|
+
const hook = rx.got('/connect').find((e) => e.type === 'balance.available');
|
|
1026
|
+
const announced = drained.includes(`balance.available@${seller}`) && drained.includes('balance.available@platform') && again.length === 0
|
|
1027
|
+
&& hook?.account === seller && ((hook.data.object as Body).available as Body[])?.[0]?.amount === 2400;
|
|
1028
|
+
// a test payout to Stripe's succeeding test bank account keeps live timing: pending until its arrival_date
|
|
1029
|
+
const po = await h('POST', '/v1/payouts', 'amount=2400¤cy=usd', seller);
|
|
1030
|
+
if (!(platformAtOnce && theirs.available === 2400 && theirs.pending === 0 && announced && typeof po.id === 'string')) return false;
|
|
1031
|
+
const early = await sent();
|
|
1032
|
+
now = new Date(Number(po.arrival_date) * 1000 + 1000).toISOString();
|
|
1033
|
+
const arrived = await sent();
|
|
1034
|
+
const paid = (await h('GET', `/v1/payouts/${String(po.id)}`, undefined, seller)).status;
|
|
1035
|
+
// the international bypass card, raw and by test name
|
|
1036
|
+
const intl = await h('POST', '/v1/charges', 'amount=500¤cy=usd&card[number]=4000003720000278&card[exp_month]=4&card[exp_year]=2030');
|
|
1037
|
+
const named = String((await h('POST', `/v1/payment_methods/pm_card_bypassPendingInternational/attach`, `customer=${customer}`)).id);
|
|
1038
|
+
const byName = await h('POST', '/v1/payment_intents', `amount=500¤cy=usd&customer=${customer}&payment_method=${named}&confirm=true&off_session=true`);
|
|
1039
|
+
const intlAtOnce = (await btOf(intl.id)).status === 'available' && (await btOf(byName.latest_charge)).status === 'available';
|
|
1040
|
+
// a US bank account debit settles into the available balance at once ("Test transactions settle instantly")
|
|
1041
|
+
const ach = await h('POST', '/v1/payment_intents', 'amount=700¤cy=usd&payment_method=pm_usBankAccount_success&confirm=true');
|
|
1042
|
+
const achCharge = (await h('GET', `/v1/payment_intents/${String(ach.id)}`)).latest_charge;
|
|
1043
|
+
// and a stored us_bank_account PaymentMethod's, as an application saves one
|
|
1044
|
+
const bank = String((await h('POST', '/v1/payment_methods', 'type=us_bank_account&us_bank_account[account_number]=000123456789&us_bank_account[routing_number]=110000000&us_bank_account[account_holder_type]=individual&billing_details[name]=Maya')).id);
|
|
1045
|
+
const stored = await h('POST', '/v1/payment_intents', `amount=800¤cy=usd&payment_method=${bank}&confirm=true`);
|
|
1046
|
+
const storedCharge = (await h('GET', `/v1/payment_intents/${String(stored.id)}`)).latest_charge;
|
|
1047
|
+
const achAtOnce = typeof achCharge === 'string' && (await btOf(achCharge)).status === 'available'
|
|
1048
|
+
&& typeof storedCharge === 'string' && (await btOf(storedCharge)).status === 'available';
|
|
1049
|
+
// a plain transfer's credit is sent as the connected account's balance.available too
|
|
1050
|
+
await h('POST', '/v1/transfers', `amount=100¤cy=usd&destination=${seller}`);
|
|
1051
|
+
const plain = (await sent()).includes(`balance.available@${seller}`);
|
|
1052
|
+
// a card made from payment_method_data carries what follows its success (a dispute, docs.stripe.com/testing#disputes)
|
|
1053
|
+
const fromData = await h('POST', '/v1/payment_intents', 'amount=300¤cy=usd&payment_method_data[type]=card&payment_method_data[card][number]=4000000000000259&payment_method_data[card][exp_month]=4&payment_method_data[card][exp_year]=2030&confirm=true');
|
|
1054
|
+
const disputed = (await h('GET', `/v1/charges/${String(fromData.latest_charge)}`)).disputed === true;
|
|
1055
|
+
// a 4242 charge, three days on and before any drain records its move, reads available however it is read, an
|
|
1056
|
+
// expansion included
|
|
1057
|
+
const later = await h('POST', '/v1/payment_intents', 'amount=400¤cy=usd&payment_method=pm_card_visa&confirm=true');
|
|
1058
|
+
now = new Date(Date.parse(now) + 3 * 86_400_000).toISOString();
|
|
1059
|
+
const laterRead = await h('GET', `/v1/charges/${String(later.latest_charge)}?expand[]=balance_transaction`);
|
|
1060
|
+
const visaAvailable = (laterRead.balance_transaction as Body | undefined)?.status === 'available';
|
|
1061
|
+
return po.status === 'pending' && Number(po.arrival_date) > Number(po.created) && !early.includes(`payout.paid@${seller}`)
|
|
1062
|
+
&& arrived.includes(`payout.paid@${seller}`) && paid === 'paid' && intlAtOnce && achAtOnce && plain && visaAvailable && disputed;
|
|
1063
|
+
} finally {
|
|
1064
|
+
rx.close();
|
|
1065
|
+
rmSync(root, { recursive: true, force: true });
|
|
1066
|
+
}
|
|
1067
|
+
}),
|
|
1068
|
+
|
|
909
1069
|
// One event, one id: the webhook a consumer receives and the event the Events API stores carry the same id, and no two
|
|
910
1070
|
// events share one — Dub's queue deduplicates by event.id, and a Balance has no id of its own to tell two accounts'
|
|
911
1071
|
// balance.available apart. Two partners' funds coming due in one drain are two events, each retrievable by its id as
|
|
@@ -920,7 +1080,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
920
1080
|
await h('POST', '/v1/webhook_endpoints', `url=${rx.url('/connect')}&enabled_events[]=balance.available&connect=true`);
|
|
921
1081
|
await h('POST', '/_twin/account', 'settings[payouts][schedule][interval]=manual');
|
|
922
1082
|
const partner = async (): Promise<string> => {
|
|
923
|
-
const a = String((await h('POST', '/v1/accounts', 'type=custom&country=US&capabilities[transfers][requested]=true&business_profile[mcc]=5462&business_profile[url]=https://seller.test&tos_acceptance[date]=1767225600&tos_acceptance[ip]=203.0.113.9&settings[payouts][schedule][interval]=manual')).id);
|
|
1083
|
+
const a = String((await h('POST', '/v1/accounts', 'type=custom&country=US&capabilities[transfers][requested]=true&business_profile[mcc]=5462&business_profile[url]=https://seller.test&tos_acceptance[date]=1767225600&tos_acceptance[ip]=203.0.113.9&individual[first_name]=Sam&individual[last_name]=Seller&settings[payouts][schedule][interval]=manual')).id);
|
|
924
1084
|
await h('POST', `/v1/accounts/${a}/external_accounts`, 'external_account[object]=bank_account&external_account[country]=US&external_account[currency]=usd&external_account[routing_number]=110000000&external_account[account_number]=000123456789');
|
|
925
1085
|
const pi = await h('POST', '/v1/payment_intents', 'amount=2000¤cy=usd&payment_method=pm_card_visa&confirm=true');
|
|
926
1086
|
await h('POST', '/v1/transfers', `amount=1500¤cy=usd&destination=${a}&source_transaction=${String(pi.latest_charge)}`);
|
|
@@ -1104,7 +1264,10 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1104
1264
|
withRoot(async (h) => {
|
|
1105
1265
|
const coupon = await h({ m: 'POST', p: '/v1/coupons', b: 'percent_off=10&duration=once' });
|
|
1106
1266
|
const pc = await h({ m: 'POST', p: '/v1/promotion_codes', b: `promotion[type]=coupon&promotion[coupon]=${id(coupon)}&code=SAVE10` });
|
|
1107
|
-
|
|
1267
|
+
// what it promotes is `promotion: {type: 'coupon', coupon}` in the served version, with no top-level coupon
|
|
1268
|
+
// (2025-09-30.clover, docs.stripe.com/changelog/clover/2025-09-30/polymorphic-coupon; stripe-version.ts renders it)
|
|
1269
|
+
if (!ok(pc) || field(pc, 'object') !== 'promotion_code' || field(pc, 'code') !== 'SAVE10' || field(pc, 'active') !== true) return false;
|
|
1270
|
+
if ((field(pc, 'promotion') as Body)?.type !== 'coupon' || (field(pc, 'promotion') as Body)?.coupon !== id(coupon) || field(pc, 'coupon') !== undefined) return false;
|
|
1108
1271
|
const g = await h({ m: 'GET', p: `/v1/promotion_codes/${id(pc)}` });
|
|
1109
1272
|
const off = await h({ m: 'POST', p: `/v1/promotion_codes/${id(pc)}`, b: 'active=false' });
|
|
1110
1273
|
const on = await h({ m: 'POST', p: `/v1/promotion_codes/${id(pc)}`, b: 'active=true' });
|
|
@@ -1284,9 +1447,14 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1284
1447
|
if (!ok(sub) || field(sub, 'status') !== 'trialing' || typeof field(sub, 'trial_end') !== 'number') return false;
|
|
1285
1448
|
// upcoming invoice WITH a proration_date splits the period (credit + charge proration lines).
|
|
1286
1449
|
const up = await h({ m: 'POST', p: '/v1/invoices/create_preview', b: `subscription=${id(sub)}&subscription_details[proration_date]=100` });
|
|
1287
|
-
|
|
1450
|
+
// a preview's id is prefixed `upcoming_in` ("For preview invoices created using the create preview endpoint, this id
|
|
1451
|
+
// will be prefixed with `upcoming_in`", served spec invoice.id), and a line's proration flag is on its parent's
|
|
1452
|
+
// details in the served version (basil, docs.stripe.com/changelog/basil; stripe-version.ts line_item, 24e757f60)
|
|
1453
|
+
if (!ok(up) || field(up, 'object') !== 'invoice' || !String(field(up, 'id')).startsWith('upcoming_in_')) return false;
|
|
1288
1454
|
const lines = (field(up, 'lines') as Body).data as Body[];
|
|
1289
|
-
const
|
|
1455
|
+
const prorationOf = (l: Body): unknown => ((l.parent as Body | undefined)?.subscription_item_details as Body | undefined)?.proration ?? ((l.parent as Body | undefined)?.invoice_item_details as Body | undefined)?.proration;
|
|
1456
|
+
if (lines.some((l) => 'proration' in l)) return false;
|
|
1457
|
+
const prorations = lines.filter((l) => prorationOf(l) === true);
|
|
1290
1458
|
return prorations.length === 2 && prorations.some((l) => Number(l.amount) < 0) && prorations.some((l) => Number(l.amount) > 0);
|
|
1291
1459
|
}),
|
|
1292
1460
|
),
|
|
@@ -1296,7 +1464,10 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1296
1464
|
done('stripe.subscriptions.renewal', 'subscriptions', 'Subscriptions renew at each period end; a declined renewal is past_due until its invoice is paid', 'api', 'core', async () => {
|
|
1297
1465
|
const root = mkdtempSync(join(tmpdir(), 'stp-renew-'));
|
|
1298
1466
|
try {
|
|
1299
|
-
|
|
1467
|
+
// February's reads come two hours into the renewed period: its invoice is drafted at the period's end and charged
|
|
1468
|
+
// "an hour on" (above; cab4389eb stamps each catch-up write at that moment), so a read at the period's very end
|
|
1469
|
+
// sees the draft, not the paid invoice this claim is about
|
|
1470
|
+
const h = (s: Step & { month?: number }) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: `2026-${String(s.month ?? 1).padStart(2, '0')}-10T${(s.month ?? 1) > 1 ? '02' : '00'}:00:00Z` });
|
|
1300
1471
|
const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Box' });
|
|
1301
1472
|
const price = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=3500¤cy=usd&recurring[interval]=month&product=${id(prod)}` });
|
|
1302
1473
|
const payer = await h({ m: 'POST', p: '/v1/customers', b: 'email=renews@twin.test&payment_method=pm_card_visa&invoice_settings[default_payment_method]=pm_card_visa' });
|
|
@@ -1471,7 +1642,10 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1471
1642
|
done('stripe.subscriptions.cancel_at_period_end', 'subscriptions', 'cancel_at_period_end cancels the subscription when its period ends', 'api', 'common', async () => {
|
|
1472
1643
|
const root = mkdtempSync(join(tmpdir(), 'stp-cape-'));
|
|
1473
1644
|
try {
|
|
1474
|
-
|
|
1645
|
+
// February's reads come two hours into the renewed period: its invoice is drafted at the period's end and charged
|
|
1646
|
+
// "an hour on" (above; cab4389eb stamps each catch-up write at that moment), so a read at the period's very end
|
|
1647
|
+
// sees the draft, not the paid invoice this claim is about
|
|
1648
|
+
const h = (s: Step & { month?: number }) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: `2026-${String(s.month ?? 1).padStart(2, '0')}-10T${(s.month ?? 1) > 1 ? '02' : '00'}:00:00Z` });
|
|
1475
1649
|
const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Box' });
|
|
1476
1650
|
const price = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=3500¤cy=usd&recurring[interval]=month&product=${id(prod)}` });
|
|
1477
1651
|
const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=leaving@twin.test' });
|
|
@@ -1528,29 +1702,30 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1528
1702
|
ok(rel) && field(rel, 'status') === 'released' && relAgain.status === 400 && noTarget.status === 400;
|
|
1529
1703
|
}),
|
|
1530
1704
|
),
|
|
1531
|
-
// Metered/usage billing: a Billing Meter defines event_name + aggregation; usage is reported
|
|
1532
|
-
//
|
|
1533
|
-
// display_name/event_name/default_aggregation
|
|
1534
|
-
//
|
|
1535
|
-
|
|
1705
|
+
// Metered/usage billing: a Billing Meter defines event_name + aggregation; usage is reported as meter events and read
|
|
1706
|
+
// back as the meter's event summaries (GET /v1/billing/meters/:id/event_summaries, docs.stripe.com/api/billing/meter-event-summary).
|
|
1707
|
+
// The twin requires display_name/event_name/default_aggregation, lists/retrieves meters, deactivate transitions
|
|
1708
|
+
// active→inactive, and a summary totals the customer's reported values. The legacy per-item usage records this claim
|
|
1709
|
+
// used to drive "left the API with basil" and were removed from the twin (5decb1d77; docs.stripe.com/changelog/basil).
|
|
1710
|
+
done('stripe.billing.metered_usage', 'subscriptions', 'Usage / metered billing (meters + meter events and their summaries)', 'api', 'common', () =>
|
|
1536
1711
|
withRoot(async (h) => {
|
|
1537
|
-
const
|
|
1712
|
+
const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=metered@twin.test' });
|
|
1713
|
+
const meter = await h({ m: 'POST', p: '/v1/billing/meters', b: 'display_name=API calls&event_name=api_request&default_aggregation[formula]=sum&value_settings[event_payload_key]=value&customer_mapping[event_payload_key]=stripe_customer_id&customer_mapping[type]=by_id' });
|
|
1538
1714
|
if (!ok(meter) || field(meter, 'object') !== 'billing.meter' || field(meter, 'status') !== 'active' || field(meter, 'event_name') !== 'api_request') return false;
|
|
1539
1715
|
const noAgg = await h({ m: 'POST', p: '/v1/billing/meters', b: 'display_name=X&event_name=y' });
|
|
1540
1716
|
if (noAgg.status !== 400) return false;
|
|
1541
|
-
|
|
1717
|
+
// two events (7 + 3) so the summary proves real aggregation (a single-value / canned impl would not total 10)
|
|
1718
|
+
const evt = await h({ m: 'POST', p: '/v1/billing/meter_events', b: `event_name=api_request&payload[value]=7&payload[stripe_customer_id]=${id(cust)}` });
|
|
1542
1719
|
if (!ok(evt) || field(evt, 'object') !== 'billing.meter_event' || field(evt, 'event_name') !== 'api_request') return false;
|
|
1720
|
+
const evt2 = await h({ m: 'POST', p: '/v1/billing/meter_events', b: `event_name=api_request&payload[value]=3&payload[stripe_customer_id]=${id(cust)}` });
|
|
1721
|
+
// a window on whole days, as Stripe asks (its bounds are refused off minute boundaries), around the events just sent
|
|
1722
|
+
const now = Math.floor(Date.now() / 86_400_000) * 86_400;
|
|
1723
|
+
const summ = await h({ m: 'GET', p: `/v1/billing/meters/${id(meter)}/event_summaries?customer=${id(cust)}&start_time=${now - 86_400}&end_time=${now + 86_400}` });
|
|
1724
|
+
const sd = (summ.body as Body).data as Body[];
|
|
1543
1725
|
const g = await h({ m: 'GET', p: `/v1/billing/meters/${id(meter)}` });
|
|
1544
1726
|
const deact = await h({ m: 'POST', p: `/v1/billing/meters/${id(meter)}/deactivate` });
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
const ur = await h({ m: 'POST', p: '/v1/subscription_items/si_twin/usage_records', b: 'quantity=7' });
|
|
1548
|
-
const ur2 = await h({ m: 'POST', p: '/v1/subscription_items/si_twin/usage_records', b: 'quantity=3' });
|
|
1549
|
-
const noQty = await h({ m: 'POST', p: '/v1/subscription_items/si_twin/usage_records' });
|
|
1550
|
-
const summ = await h({ m: 'GET', p: '/v1/subscription_items/si_twin/usage_record_summaries' });
|
|
1551
|
-
const sd = (summ.body as Body).data as Body[];
|
|
1552
|
-
return ok(g) && ok(deact) && field(deact, 'status') === 'inactive' && ok(ur) && ok(ur2) && noQty.status === 400 &&
|
|
1553
|
-
sd.length === 1 && sd[0]!.total_usage === 10;
|
|
1727
|
+
return ok(evt2) && ok(summ) && sd.length === 1 && sd[0]!.object === 'billing.meter_event_summary' && sd[0]!.aggregated_value === 10
|
|
1728
|
+
&& ok(g) && ok(deact) && field(deact, 'status') === 'inactive';
|
|
1554
1729
|
}),
|
|
1555
1730
|
),
|
|
1556
1731
|
// Subscription discounts: a `coupon` on create attaches a discount (validated to exist),
|
|
@@ -1622,11 +1797,12 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1622
1797
|
const g = await h({ m: 'GET', p: `/v1/invoiceitems/${id(ii)}` });
|
|
1623
1798
|
const miss = await h({ m: 'GET', p: '/v1/invoiceitems/ii_nope' });
|
|
1624
1799
|
if (!ok(g) || id(g) !== id(ii) || miss.status !== 404) return false;
|
|
1625
|
-
// The earlier pending 400 is swept onto the invoice at creation
|
|
1800
|
+
// The earlier pending 400 is swept onto the invoice at creation, when the create asks: pending_invoice_items_behavior
|
|
1801
|
+
// "Defaults to `exclude` if the parameter is omitted" (docs.stripe.com/api/invoices/create; e0e3b88c6).
|
|
1626
1802
|
// An item on a DRAFT invoice moves the invoice's total when it changes, and again when
|
|
1627
1803
|
// it is deleted — an invoice billing for a line that no longer says that is money in
|
|
1628
1804
|
// two places at once.
|
|
1629
|
-
const inv = await h({ m: 'POST', p: '/v1/invoices', b: `customer=${id(cust)}` });
|
|
1805
|
+
const inv = await h({ m: 'POST', p: '/v1/invoices', b: `customer=${id(cust)}&pending_invoice_items_behavior=include` });
|
|
1630
1806
|
const on = await h({ m: 'POST', p: '/v1/invoiceitems', b: `customer=${id(cust)}&invoice=${id(inv)}&amount=900¤cy=usd` });
|
|
1631
1807
|
const u = await h({ m: 'POST', p: `/v1/invoiceitems/${id(on)}`, b: 'amount=1500' });
|
|
1632
1808
|
const afterUpdate = await h({ m: 'GET', p: `/v1/invoices/${id(inv)}` });
|
|
@@ -1651,11 +1827,15 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1651
1827
|
withRoot(async (h) => {
|
|
1652
1828
|
const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=send@twin.test' });
|
|
1653
1829
|
await h({ m: 'POST', p: '/v1/invoiceitems', b: `customer=${id(cust)}&amount=4000¤cy=usd` });
|
|
1654
|
-
|
|
1830
|
+
// the pending item is taken only when the create asks ("Defaults to `exclude`", docs.stripe.com/api/invoices/create)
|
|
1831
|
+
const inv = await h({ m: 'POST', p: '/v1/invoices', b: `customer=${id(cust)}&pending_invoice_items_behavior=include` });
|
|
1832
|
+
if (field(inv, 'amount_due') !== 4000) return false;
|
|
1655
1833
|
const sent = await h({ m: 'POST', p: `/v1/invoices/${id(inv)}/send` });
|
|
1656
1834
|
if (!ok(sent) || field(sent, 'status') !== 'open') return false;
|
|
1657
1835
|
const oob = await h({ m: 'POST', p: `/v1/invoices/${id(inv)}/pay`, b: 'paid_out_of_band=true' });
|
|
1658
|
-
|
|
1836
|
+
// out of band is `amount_paid_off_stripe` in the served version ("Amount ... paid on the invoice outside of
|
|
1837
|
+
// Stripe", served spec invoice); basil answers no `paid_out_of_band` (docs.stripe.com/changelog/basil; 24e757f60)
|
|
1838
|
+
if (!ok(oob) || field(oob, 'status') !== 'paid' || field(oob, 'amount_paid_off_stripe') !== 4000 || field(oob, 'amount_paid') !== 4000 || 'paid_out_of_band' in (oob.body as Body)) return false;
|
|
1659
1839
|
// a second invoice can be marked uncollectible
|
|
1660
1840
|
const inv2 = await h({ m: 'POST', p: '/v1/invoices', b: `customer=${id(cust)}` });
|
|
1661
1841
|
await h({ m: 'POST', p: `/v1/invoices/${id(inv2)}/finalize` });
|
|
@@ -1676,7 +1856,10 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1676
1856
|
const coupon = await h({ m: 'POST', p: '/v1/coupons', b: 'percent_off=10&duration=forever' });
|
|
1677
1857
|
const sub = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&items[0][price]=${id(price)}&discounts[0][coupon]=${id(coupon)}` });
|
|
1678
1858
|
const up = await h({ m: 'POST', p: '/v1/invoices/create_preview', b: `subscription=${id(sub)}` });
|
|
1679
|
-
|
|
1859
|
+
// not persisted, and named as a preview: "For preview invoices created using the create preview endpoint, this id
|
|
1860
|
+
// will be prefixed with `upcoming_in`" (served spec, invoice.id; b75e48888)
|
|
1861
|
+
if (!ok(up) || field(up, 'object') !== 'invoice' || !String(field(up, 'id')).startsWith('upcoming_in_')) return false;
|
|
1862
|
+
if ((await h({ m: 'GET', p: `/v1/invoices/${String(field(up, 'id'))}` })).status !== 404) return false;
|
|
1680
1863
|
// subtotal 3000, 10% off → total 2700.
|
|
1681
1864
|
if (field(up, 'subtotal') !== 3000 || field(up, 'total') !== 2700) return false;
|
|
1682
1865
|
const lines = (field(up, 'lines') as Body).data as Body[];
|
|
@@ -1694,13 +1877,19 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1694
1877
|
withRoot(async (h) => {
|
|
1695
1878
|
const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=cn@twin.test' });
|
|
1696
1879
|
await h({ m: 'POST', p: '/v1/invoiceitems', b: `customer=${id(cust)}&amount=5000¤cy=usd` });
|
|
1697
|
-
|
|
1880
|
+
// the pending item is taken only when the create asks ("Defaults to `exclude`", docs.stripe.com/api/invoices/create; e0e3b88c6)
|
|
1881
|
+
const inv = await h({ m: 'POST', p: '/v1/invoices', b: `customer=${id(cust)}&pending_invoice_items_behavior=include` });
|
|
1698
1882
|
await h({ m: 'POST', p: `/v1/invoices/${id(inv)}/finalize` });
|
|
1699
1883
|
await h({ m: 'POST', p: `/v1/invoices/${id(inv)}/pay`, b: 'payment_method=pm_card_visa' });
|
|
1884
|
+
// a paid invoice's credit says how it is paid back: "The sum of refunds, customer balance credits, and outside of
|
|
1885
|
+
// Stripe credits must equal the post_payment_amount" (docs.stripe.com/api/credit_notes/create; 1159092c7), so the
|
|
1886
|
+
// 2000 goes to the customer's balance (credit_amount), and a split that does not add up is refused
|
|
1887
|
+
const unsplit = await h({ m: 'GET', p: `/v1/credit_notes/preview?invoice=${id(inv)}&amount=2000` });
|
|
1888
|
+
if (unsplit.status !== 400) return false;
|
|
1700
1889
|
// preview computes the object WITHOUT persisting it
|
|
1701
|
-
const prev = await h({ m: 'GET', p: `/v1/credit_notes/preview?invoice=${id(inv)}&amount=2000` });
|
|
1890
|
+
const prev = await h({ m: 'GET', p: `/v1/credit_notes/preview?invoice=${id(inv)}&amount=2000&credit_amount=2000` });
|
|
1702
1891
|
if (!ok(prev) || field(prev, 'object') !== 'credit_note' || field(prev, 'amount') !== 2000) return false;
|
|
1703
|
-
const cn = await h({ m: 'POST', p: '/v1/credit_notes', b: `invoice=${id(inv)}&amount=2000&reason=order_change` });
|
|
1892
|
+
const cn = await h({ m: 'POST', p: '/v1/credit_notes', b: `invoice=${id(inv)}&amount=2000&credit_amount=2000&reason=order_change` });
|
|
1704
1893
|
if (!ok(cn) || field(cn, 'object') !== 'credit_note' || field(cn, 'status') !== 'issued' || field(cn, 'type') !== 'post_payment' || field(cn, 'amount') !== 2000) return false;
|
|
1705
1894
|
const g = await h({ m: 'GET', p: `/v1/credit_notes/${id(cn)}` });
|
|
1706
1895
|
const lines = await h({ m: 'GET', p: `/v1/credit_notes/${id(cn)}/lines` });
|
|
@@ -1750,15 +1939,21 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1750
1939
|
done('stripe.checkout.subscription_data', 'checkout', 'Checkout completion copies subscription_data (metadata + trial) onto the created subscription', 'api', 'core', () =>
|
|
1751
1940
|
withRoot(async (h) => {
|
|
1752
1941
|
const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=subdata@twin.test' });
|
|
1753
|
-
|
|
1942
|
+
// the inline price is a subscription's: a product and a recurring interval (docs.stripe.com/api/checkout/sessions/create,
|
|
1943
|
+
// line_items.price_data), so the subscription has an item to carry its period (c3a378808 bills inline prices)
|
|
1944
|
+
const cs = await h({ m: 'POST', p: '/v1/checkout/sessions', b: `mode=subscription&success_url=https://x.test&customer=${id(cust)}&line_items[0][price_data][currency]=usd&line_items[0][price_data][unit_amount]=999&line_items[0][price_data][product_data][name]=Plan&line_items[0][price_data][recurring][interval]=month&line_items[0][quantity]=1&subscription_data[metadata][attemptId]=att_cap_1&subscription_data[trial_period_days]=7` });
|
|
1754
1945
|
if (!ok(cs) || field(cs, 'subscription_data') !== undefined || field(cs, '_subscription_data') !== undefined) return false;
|
|
1755
1946
|
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' });
|
|
1756
1947
|
const completed = await h({ m: 'GET', p: `/v1/checkout/sessions/${id(cs)}` });
|
|
1757
1948
|
if (!ok(completed) || !field(completed, 'subscription')) return false;
|
|
1758
1949
|
const sub = await h({ m: 'GET', p: `/v1/subscriptions/${field(completed, 'subscription')}` });
|
|
1759
1950
|
const md = field(sub, 'metadata') as Body | undefined;
|
|
1951
|
+
// a subscription's billing period is its items' in the served version ("current_period_start and current_period_end
|
|
1952
|
+
// ... moved to the subscription item", docs.stripe.com/changelog/basil/2025-03-31/deprecate-subscription-current-period-start-and-end; 24e757f60)
|
|
1953
|
+
const item = (((field(sub, 'items') as Body | undefined)?.data as Body[] | undefined) ?? [])[0];
|
|
1760
1954
|
return ok(sub) && md?.attemptId === 'att_cap_1' && field(sub, 'status') === 'trialing'
|
|
1761
|
-
&& typeof field(sub, 'trial_end') === 'number' && field(sub, 'trial_end') ===
|
|
1955
|
+
&& typeof field(sub, 'trial_end') === 'number' && field(sub, 'trial_end') === item?.current_period_end
|
|
1956
|
+
&& field(sub, 'current_period_end') === undefined;
|
|
1762
1957
|
}),
|
|
1763
1958
|
),
|
|
1764
1959
|
// A subscription session's customer pays its first invoice on the page: the invoice is paid through a succeeded
|
|
@@ -1773,12 +1968,101 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1773
1968
|
const done = await h({ m: 'GET', p: `/v1/checkout/sessions/${id(cs)}` });
|
|
1774
1969
|
const sub = await h({ m: 'GET', p: `/v1/subscriptions/${String(field(done, 'subscription'))}` });
|
|
1775
1970
|
const inv = await h({ m: 'GET', p: `/v1/invoices/${String(field(done, 'invoice'))}` });
|
|
1776
|
-
|
|
1971
|
+
// the invoice's PaymentIntent is its payment in the served version: basil answers no `invoice.payment_intent` and
|
|
1972
|
+
// lists the invoice's payments (docs.stripe.com/changelog/basil/2025-03-31/add-support-for-multiple-partial-payments-on-invoices; 24e757f60)
|
|
1973
|
+
const payments = (((await h({ m: 'GET', p: `/v1/invoice_payments?invoice=${String(field(done, 'invoice'))}` })).body as Body).data as Body[]) ?? [];
|
|
1974
|
+
const pi = await h({ m: 'GET', p: `/v1/payment_intents/${String((payments[0]?.payment as Body | undefined)?.payment_intent)}` });
|
|
1975
|
+
if (payments.length !== 1 || payments[0]!.status !== 'paid' || payments[0]!.amount_paid !== 3500) return false;
|
|
1777
1976
|
return field(sub, 'status') === 'active' && typeof field(sub, 'default_payment_method') === 'string'
|
|
1778
1977
|
&& field(inv, 'status') === 'paid' && field(inv, 'billing_reason') === 'subscription_create' && field(inv, 'amount_paid') === 3500
|
|
1779
1978
|
&& field(pi, 'status') === 'succeeded' && typeof field(pi, 'latest_charge') === 'string';
|
|
1780
1979
|
}),
|
|
1781
1980
|
),
|
|
1981
|
+
// Checkout attaches the card it saves to the customer and says so: payment_method.attached ("Occurs whenever a new payment
|
|
1982
|
+
// method is attached to a customer", docs.stripe.com/api/events/types) for a subscription's card, which is also the
|
|
1983
|
+
// subscription's default payment method (docs.stripe.com/payments/checkout/how-checkout-works), for a setup session's, and
|
|
1984
|
+
// for a payment's only under payment_intent_data.setup_future_usage (docs.stripe.com/api/checkout/sessions/create
|
|
1985
|
+
// #create_checkout_session-customer); the event comes before the subscription it pays for (the twin's order; Stripe's is
|
|
1986
|
+
// not guaranteed, docs.stripe.com/webhooks#event-ordering). Rallly walk 4: its billing page read no saved card.
|
|
1987
|
+
done('stripe.checkout.payment_method_attached', 'checkout', 'Checkout attaches the saved card to the customer and emits payment_method.attached (subscription, setup, and payment with setup_future_usage)', 'api', 'core', () =>
|
|
1988
|
+
withRoot(async (h) => {
|
|
1989
|
+
const card = 'cardNumber=4242424242424242&cardExpiry=12%2F34&cardCvc=123&billingName=Twin&billingCountry=US&billingPostalCode=94105';
|
|
1990
|
+
const attachedEvents = async () => (((await h({ m: 'GET', p: '/v1/events?type=payment_method.attached&limit=100' })).body as Body).data as Body[] | undefined) ?? [];
|
|
1991
|
+
const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=saved@twin.test' });
|
|
1992
|
+
const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Pro' });
|
|
1993
|
+
const price = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=1200¤cy=usd&product=${id(prod)}&recurring[interval]=month` });
|
|
1994
|
+
const sub = await h({ m: 'POST', p: '/v1/checkout/sessions', b: `mode=subscription&success_url=https://x.test&customer=${id(cust)}&line_items[0][price]=${id(price)}&line_items[0][quantity]=1` });
|
|
1995
|
+
await h({ m: 'POST', p: `/c/pay/${id(sub)}`, b: card });
|
|
1996
|
+
const done = await h({ m: 'GET', p: `/v1/checkout/sessions/${id(sub)}` });
|
|
1997
|
+
const subscription = await h({ m: 'GET', p: `/v1/subscriptions/${String(field(done, 'subscription'))}` });
|
|
1998
|
+
const pmId = field(subscription, 'default_payment_method');
|
|
1999
|
+
const pm = await h({ m: 'GET', p: `/v1/payment_methods/${String(pmId)}` });
|
|
2000
|
+
const attached = await attachedEvents();
|
|
2001
|
+
const all = (((await h({ m: 'GET', p: '/v1/events?limit=100' })).body as Body).data as Body[] | undefined) ?? [];
|
|
2002
|
+
// the list is newest first: the attach is older than the subscription's creation and the session's completion
|
|
2003
|
+
const at = (type: string) => all.findIndex((e) => e.type === type);
|
|
2004
|
+
const subscriptionOk = field(done, 'status') === 'complete' && typeof pmId === 'string' && field(pm, 'customer') === id(cust)
|
|
2005
|
+
&& attached.length === 1 && ((attached[0]!.data as Body).object as Body).id === pmId && ((attached[0]!.data as Body).object as Body).customer === id(cust)
|
|
2006
|
+
&& at('payment_method.attached') > at('customer.subscription.created') && at('customer.subscription.created') > at('checkout.session.completed');
|
|
2007
|
+
// a payment without setup_future_usage saves nothing on the customer; with it, the card is attached
|
|
2008
|
+
const pay = (extra: string) => h({ m: 'POST', p: '/v1/checkout/sessions', b: `mode=payment&success_url=https://x.test&customer=${id(cust)}&line_items[0][price_data][currency]=usd&line_items[0][price_data][unit_amount]=2500&line_items[0][price_data][product_data][name]=Pot&line_items[0][quantity]=1${extra}` });
|
|
2009
|
+
const once = await pay('');
|
|
2010
|
+
await h({ m: 'POST', p: `/c/pay/${id(once)}`, b: card });
|
|
2011
|
+
const oncePi = await h({ m: 'GET', p: `/v1/payment_intents/${String(field(await h({ m: 'GET', p: `/v1/checkout/sessions/${id(once)}` }), 'payment_intent'))}` });
|
|
2012
|
+
const oncePm = await h({ m: 'GET', p: `/v1/payment_methods/${String(field(oncePi, 'payment_method'))}` });
|
|
2013
|
+
const afterOnce = (await attachedEvents()).length;
|
|
2014
|
+
const keep = await pay('&payment_intent_data[setup_future_usage]=off_session');
|
|
2015
|
+
await h({ m: 'POST', p: `/c/pay/${id(keep)}`, b: card });
|
|
2016
|
+
const keepPi = await h({ m: 'GET', p: `/v1/payment_intents/${String(field(await h({ m: 'GET', p: `/v1/checkout/sessions/${id(keep)}` }), 'payment_intent'))}` });
|
|
2017
|
+
const keepPm = await h({ m: 'GET', p: `/v1/payment_methods/${String(field(keepPi, 'payment_method'))}` });
|
|
2018
|
+
const afterKeep = await attachedEvents();
|
|
2019
|
+
const paymentOk = ok(oncePm) && field(oncePm, 'customer') === null && afterOnce === 1 && field(keepPi, 'setup_future_usage') === 'off_session'
|
|
2020
|
+
&& field(keepPm, 'customer') === id(cust) && afterKeep.length === 2 && ((afterKeep[0]!.data as Body).object as Body).id === id(keepPm);
|
|
2021
|
+
// a setup session saves the card: its SetupIntent names it, and it is attached
|
|
2022
|
+
const setup = await h({ m: 'POST', p: '/v1/checkout/sessions', b: `mode=setup¤cy=usd&success_url=https://x.test&customer=${id(cust)}` });
|
|
2023
|
+
await h({ m: 'POST', p: `/c/pay/${id(setup)}`, b: card });
|
|
2024
|
+
const si = await h({ m: 'GET', p: `/v1/setup_intents/${String(field(await h({ m: 'GET', p: `/v1/checkout/sessions/${id(setup)}` }), 'setup_intent'))}` });
|
|
2025
|
+
const setupPm = await h({ m: 'GET', p: `/v1/payment_methods/${String(field(si, 'payment_method'))}` });
|
|
2026
|
+
const afterSetup = await attachedEvents();
|
|
2027
|
+
const setupOk = field(si, 'status') === 'succeeded' && field(setupPm, 'customer') === id(cust) && afterSetup.length === 3 && ((afterSetup[0]!.data as Body).object as Body).id === id(setupPm);
|
|
2028
|
+
return subscriptionOk && paymentOk && setupOk;
|
|
2029
|
+
}),
|
|
2030
|
+
),
|
|
2031
|
+
// A session made for a customer with an email shows that email on the page, prefilled and not editable, and the session
|
|
2032
|
+
// is the customer's email whatever is posted; a customer with none types one, which Checkout sets on the customer ("If the
|
|
2033
|
+
// Customer already has a valid email set, the email will be prefilled and not editable in Checkout. If the Customer does
|
|
2034
|
+
// not have a valid email, Checkout will set the email entered during the session on the Customer",
|
|
2035
|
+
// docs.stripe.com/api/checkout/sessions/create#create_checkout_session-customer). Rallly walk 4: the field was empty.
|
|
2036
|
+
done('stripe.checkout.customer_email', 'checkout', "Checkout for a session's customer shows the customer's email prefilled and not editable; a customer without one gets the typed email", 'ui', 'common', async () => {
|
|
2037
|
+
const root = mkdtempSync(join(tmpdir(), 'stp-cs-email-'));
|
|
2038
|
+
try {
|
|
2039
|
+
const f = createStripeTwinFetch({ root });
|
|
2040
|
+
const api = async (method: string, path: string, body?: string) => (await f(new Request(`http://stripe.test${path}`, { method, headers: { 'content-type': 'application/x-www-form-urlencoded' }, ...(body ? { body } : {}) }))).json() as Promise<Body>;
|
|
2041
|
+
const post = (url: string, email: string) => f(new Request(url, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ email, cardNumber: '4242424242424242', cardExpiry: '12 / 34', cardCvc: '123', billingName: 'Twin', billingCountry: 'US', billingPostalCode: '94105' }).toString() }));
|
|
2042
|
+
const session = (customer: string) => api('POST', '/v1/checkout/sessions', `mode=subscription&success_url=https://x.test&customer=${customer}&line_items[0][price_data][currency]=usd&line_items[0][price_data][unit_amount]=1200&line_items[0][price_data][product_data][name]=Pro&line_items[0][price_data][recurring][interval]=month&line_items[0][quantity]=1`);
|
|
2043
|
+
const known = await api('POST', '/v1/customers', 'email=known@twin.test');
|
|
2044
|
+
const cs = await session(String(known.id));
|
|
2045
|
+
const html = await (await f(new Request(String(cs.url)))).text();
|
|
2046
|
+
const input = /<input[^>]*id="email"[^>]*>/.exec(html)?.[0] ?? '';
|
|
2047
|
+
const paid = await post(String(cs.url), 'other@twin.test');
|
|
2048
|
+
const done = await api('GET', `/v1/checkout/sessions/${String(cs.id)}`);
|
|
2049
|
+
const knownAfter = await api('GET', `/v1/customers/${String(known.id)}`);
|
|
2050
|
+
const prefilled = input.includes('value="known@twin.test"') && /\sreadonly(=""|\s|\/|>)/i.test(input)
|
|
2051
|
+
&& paid.status === 303 && (done.customer_details as Body | undefined)?.email === 'known@twin.test' && knownAfter.email === 'known@twin.test';
|
|
2052
|
+
// a customer with no email: the field is empty and editable, and the typed email becomes the customer's
|
|
2053
|
+
const bare = await api('POST', '/v1/customers', 'name=Bare');
|
|
2054
|
+
const cs2 = await session(String(bare.id));
|
|
2055
|
+
const bareInput = /<input[^>]*id="email"[^>]*>/.exec(await (await f(new Request(String(cs2.url)))).text())?.[0] ?? '';
|
|
2056
|
+
const paid2 = await post(String(cs2.url), 'typed@twin.test');
|
|
2057
|
+
const bareAfter = await api('GET', `/v1/customers/${String(bare.id)}`);
|
|
2058
|
+
const updated = ((await api('GET', '/v1/events?type=customer.updated')).data as Body[] | undefined) ?? [];
|
|
2059
|
+
const typed = bareInput !== '' && !/readonly/i.test(bareInput) && paid2.status === 303 && bareAfter.email === 'typed@twin.test'
|
|
2060
|
+
&& updated.length === 1 && ((updated[0]!.data as Body).object as Body).id === bare.id && ((updated[0]!.data as Body).object as Body).email === 'typed@twin.test';
|
|
2061
|
+
return prefilled && typed;
|
|
2062
|
+
} finally {
|
|
2063
|
+
rmSync(root, { recursive: true, force: true });
|
|
2064
|
+
}
|
|
2065
|
+
}),
|
|
1782
2066
|
// Checkout's expiry field takes the digits typed straight through, as Stripe's formats `1230` into `12 / 30`: the page
|
|
1783
2067
|
// marks the field for its formatter, and the posted digits pay and are the card's expiry (screens/checkout.tsx
|
|
1784
2068
|
// parseExpiry); two digits are still incomplete.
|
|
@@ -1786,7 +2070,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1786
2070
|
const root = mkdtempSync(join(tmpdir(), 'stp-expiry-'));
|
|
1787
2071
|
try {
|
|
1788
2072
|
const f = createStripeTwinFetch({ root, clock: () => '2026-03-01T00:00:00Z' });
|
|
1789
|
-
const api = async (method: string, path: string, body?: string) => (await f(new Request(`
|
|
2073
|
+
const api = async (method: string, path: string, body?: string) => (await f(new Request(`http://stripe.test${path}`, { method, headers: { 'content-type': 'application/x-www-form-urlencoded' }, ...(body ? { body } : {}) }))).json() as Promise<Body>;
|
|
1790
2074
|
const pay = (cs: Body, expiry: string) => f(new Request(String(cs.url), { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ email: 'pay@twin.test', cardNumber: '4242424242424242', cardExpiry: expiry, cardCvc: '123', billingName: 'Twin', billingCountry: 'US', billingPostalCode: '94105' }).toString() }));
|
|
1791
2075
|
const session = () => api('POST', '/v1/checkout/sessions', 'mode=payment&success_url=https://x.test&line_items[0][price_data][currency]=usd&line_items[0][price_data][unit_amount]=2500&line_items[0][price_data][product_data][name]=Pro&line_items[0][quantity]=1');
|
|
1792
2076
|
const cs = await session();
|
|
@@ -1822,7 +2106,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1822
2106
|
const root = mkdtempSync(join(tmpdir(), 'stp-trialpage-'));
|
|
1823
2107
|
try {
|
|
1824
2108
|
const f = createStripeTwinFetch({ root, clock: () => '2026-03-01T00:00:00Z' });
|
|
1825
|
-
const api = async (method: string, path: string, body?: string) => (await f(new Request(`
|
|
2109
|
+
const api = async (method: string, path: string, body?: string) => (await f(new Request(`http://stripe.test${path}`, { method, headers: { 'content-type': 'application/x-www-form-urlencoded' }, ...(body ? { body } : {}) }))).json() as Promise<Body>;
|
|
1826
2110
|
const text = async (cs: Body) => (await (await f(new Request(String(cs.url)))).text()).replace(/<script[\s\S]*?<\/script>/g, '').replace(/<[^>]+>/g, '|');
|
|
1827
2111
|
const prod = await api('POST', '/v1/products', 'name=Business');
|
|
1828
2112
|
const price = await api('POST', '/v1/prices', `unit_amount=9000¤cy=usd&recurring[interval]=month&product=${String(prod.id)}`);
|
|
@@ -1840,13 +2124,13 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1840
2124
|
const spent = await text(await api('POST', '/v1/checkout/sessions', `${line(price)}&subscription_data[trial_period_days]=14&discounts[0][coupon]=${String(once.id)}&line_items[1][price]=${String(setup.id)}&line_items[1][quantity]=1`));
|
|
1841
2125
|
const kept = await text(await api('POST', '/v1/checkout/sessions', `${line(price)}&subscription_data[trial_period_days]=14&discounts[0][coupon]=${String(forever.id)}`));
|
|
1842
2126
|
// Checkout refuses a trial_end under 48 hours away
|
|
1843
|
-
const soon = await f(new Request('
|
|
2127
|
+
const soon = await f(new Request('http://stripe.test/v1/checkout/sessions', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: `${line(price)}&subscription_data[trial_end]=${Date.parse('2026-03-02T12:00:00Z') / 1000}` }));
|
|
1844
2128
|
if (!spent.includes('|Then $90.00 per month|') || !kept.includes('|Then $81.00 per month|') || soon.status !== 400) return false;
|
|
1845
2129
|
// and the twin bills what the page says: the one-time price on the first invoice, then $90.00 a period
|
|
1846
2130
|
const paid = await api('POST', '/v1/checkout/sessions', `${line(price)}&subscription_data[trial_period_days]=14&line_items[1][price]=${String(setup.id)}&line_items[1][quantity]=1`);
|
|
1847
2131
|
await f(new Request(String(paid.url), { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'email=t%40twin.test&cardNumber=4242424242424242&cardExpiry=1230&cardCvc=123&billingName=T&billingCountry=US&billingPostalCode=94105' }));
|
|
1848
2132
|
const later = createStripeTwinFetch({ root, clock: () => '2026-03-20T00:00:00Z' });
|
|
1849
|
-
const read = async (path: string) => (await later(new Request(`
|
|
2133
|
+
const read = async (path: string) => (await later(new Request(`http://stripe.test${path}`))).json() as Promise<Body>;
|
|
1850
2134
|
const done = await read(`/v1/checkout/sessions/${String(paid.id)}`);
|
|
1851
2135
|
const first = await read(`/v1/invoices/${String(done.invoice)}`);
|
|
1852
2136
|
const bills = ((await read(`/v1/invoices?subscription=${String(done.subscription)}`)).data as Body[] | undefined) ?? [];
|
|
@@ -2020,7 +2304,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2020
2304
|
const root = mkdtempSync(join(tmpdir(), 'stp-public-'));
|
|
2021
2305
|
try {
|
|
2022
2306
|
const f = createStripeTwinFetch({ root });
|
|
2023
|
-
const api = async (method: string, path: string, body?: string, account?: string) => (await f(new Request(`
|
|
2307
|
+
const api = async (method: string, path: string, body?: string, account?: string) => (await f(new Request(`http://stripe.test${path}`, { method, headers: { 'content-type': 'application/x-www-form-urlencoded', ...(account ? { 'stripe-account': account } : {}) }, ...(body ? { body } : {}) }))).json() as Promise<Body>;
|
|
2024
2308
|
const page = async (url: string) => (await f(new Request(url))).text();
|
|
2025
2309
|
const save = (name: string) => f(new Request('https://dashboard.stripe.com/settings/public', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ business_name: name }).toString() }));
|
|
2026
2310
|
const cs = await api('POST', '/v1/checkout/sessions', 'mode=payment&success_url=https://x.test&cancel_url=https://x.test/c&line_items[0][price_data][currency]=usd&line_items[0][price_data][unit_amount]=2500&line_items[0][price_data][product_data][name]=Pro&line_items[0][quantity]=1');
|
|
@@ -2059,12 +2343,12 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2059
2343
|
const landed = new URL(slashSave.headers.get('location') ?? '', 'https://dashboard.stripe.com/settings/public/').pathname;
|
|
2060
2344
|
// the /_twin/account door, written first, starts the platform's record from the same default and is an update too
|
|
2061
2345
|
const door = createStripeTwinFetch({ root: join(root, 'door') });
|
|
2062
|
-
await door(new Request('
|
|
2346
|
+
await door(new Request('http://stripe.test/_twin/account', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'settings[payouts][schedule][interval]=manual&business_profile[support_email]=help@dub.test' }));
|
|
2063
2347
|
// and a save on the page keeps the public details it does not show, as the door keeps the name the page saved
|
|
2064
2348
|
await door(new Request('https://dashboard.stripe.com/settings/public', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'business_name=Dub' }));
|
|
2065
|
-
await door(new Request('
|
|
2066
|
-
const doorAccount = await (await door(new Request('
|
|
2067
|
-
const doorEvents = (((await (await door(new Request('
|
|
2349
|
+
await door(new Request('http://stripe.test/_twin/account', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'business_profile[url]=https://dub.test' }));
|
|
2350
|
+
const doorAccount = await (await door(new Request('http://stripe.test/v1/account'))).json() as Body;
|
|
2351
|
+
const doorEvents = (((await (await door(new Request('http://stripe.test/v1/events?type=account.updated'))).json()) as Body).data as Body[] | undefined) ?? [];
|
|
2068
2352
|
const long = await save('x'.repeat(5001));
|
|
2069
2353
|
return before.includes('Twin Inc.') && !before.includes('Dub') && empty.status === 400 && saved.status === 303 && long.status === 400
|
|
2070
2354
|
&& updated.length === 2 && branded.includes('Pay Dub Links') && direct.includes('Pay Grower Co') && completedHere.length === 1 && !completedHere[0]!.account && completedThere.length === 0 && !direct.includes('Dub Technologies') && behalf.includes('Return to Grower Co')
|
|
@@ -2094,7 +2378,10 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2094
2378
|
done('stripe.billing_portal.payment_method_and_pay', 'checkout', 'Billing Portal: update the payment method and pay an open invoice', 'api', 'common', async () => {
|
|
2095
2379
|
const root = mkdtempSync(join(tmpdir(), 'stp-portal-'));
|
|
2096
2380
|
try {
|
|
2097
|
-
|
|
2381
|
+
// February's reads come two hours into the renewed period: its invoice is drafted at the period's end and charged
|
|
2382
|
+
// "an hour on" (above; cab4389eb stamps each catch-up write at that moment), so a read at the period's very end
|
|
2383
|
+
// sees the draft, not the paid invoice this claim is about
|
|
2384
|
+
const h = (s: Step & { month?: number }) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: `2026-${String(s.month ?? 1).padStart(2, '0')}-10T${(s.month ?? 1) > 1 ? '02' : '00'}:00:00Z` });
|
|
2098
2385
|
const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Box' });
|
|
2099
2386
|
const price = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=3500¤cy=usd&recurring[interval]=month&product=${id(prod)}` });
|
|
2100
2387
|
const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=portal-pay@twin.test' });
|
|
@@ -2208,7 +2495,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2208
2495
|
const root = mkdtempSync(join(tmpdir(), 'stp-src-'));
|
|
2209
2496
|
try {
|
|
2210
2497
|
const h = (s: Step & { acct?: string; day?: number }) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: `2026-06-${String(s.day ?? 1).padStart(2, '0')}T12:00:00Z`, ...(s.acct ? { stripeAccount: s.acct } : {}) });
|
|
2211
|
-
const a = await h({ m: 'POST', p: '/v1/accounts', b: 'type=custom&country=US&capabilities[transfers][requested]=true&business_profile[mcc]=5462&business_profile[url]=https://seller.test&tos_acceptance[date]=1767225600&tos_acceptance[ip]=203.0.113.9&settings[payouts][schedule][interval]=manual' });
|
|
2498
|
+
const a = await h({ m: 'POST', p: '/v1/accounts', b: 'type=custom&country=US&capabilities[transfers][requested]=true&business_profile[mcc]=5462&business_profile[url]=https://seller.test&tos_acceptance[date]=1767225600&tos_acceptance[ip]=203.0.113.9&individual[first_name]=Sam&individual[last_name]=Seller&settings[payouts][schedule][interval]=manual' });
|
|
2212
2499
|
const seller = id(a);
|
|
2213
2500
|
await h({ m: 'POST', p: `/v1/accounts/${seller}/external_accounts`, b: 'external_account[object]=bank_account&external_account[country]=US&external_account[currency]=usd&external_account[routing_number]=110000000&external_account[account_number]=000123456789' });
|
|
2214
2501
|
await h({ m: 'POST', p: '/_twin/account', b: 'settings[payouts][schedule][interval]=manual' });
|
|
@@ -2288,6 +2575,11 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2288
2575
|
await h('POST', '/v1/webhook_endpoints', `url=${rx.url('/platform')}&enabled_events[]=*`);
|
|
2289
2576
|
await h('POST', '/v1/webhook_endpoints', `url=${rx.url('/connect')}&enabled_events[]=*&connect=true`);
|
|
2290
2577
|
const seller = String((await h('POST', '/v1/accounts', 'type=express&country=US')).id);
|
|
2578
|
+
// the account's own event: an update "Occurs whenever an account status or property has changed"
|
|
2579
|
+
// (account.updated, docs.stripe.com/api/events/types). Its creation was asserted to send one when this claim was
|
|
2580
|
+
// written (788199389), which no version of the twin did; an Express account's creation changes nothing Stripe
|
|
2581
|
+
// reviews here (semantics/connect.ts review: an Express account is onboarded on Stripe's pages).
|
|
2582
|
+
await h('POST', `/v1/accounts/${seller}`, 'metadata[tier]=gold');
|
|
2291
2583
|
const theirs = String((await h('POST', '/v1/customers', 'email=buyer@seller.test', seller)).id);
|
|
2292
2584
|
const ours = String((await h('POST', '/v1/customers', 'email=buyer@platform.test')).id);
|
|
2293
2585
|
const at = (path: string, type: string, object: string) => rx.got(path).filter((e) => e.type === type && (e.data.object as Body).id === object);
|
|
@@ -2313,7 +2605,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2313
2605
|
const h = fetcher(createStripeTwinFetch({ root, clock: () => now }));
|
|
2314
2606
|
await h('POST', '/v1/webhook_endpoints', `url=${rx.url('/platform')}&enabled_events[]=*`);
|
|
2315
2607
|
await h('POST', '/v1/webhook_endpoints', `url=${rx.url('/connect')}&enabled_events[]=*&connect=true`);
|
|
2316
|
-
const seller = String((await h('POST', '/v1/accounts', 'type=custom&country=US&capabilities[transfers][requested]=true&business_profile[mcc]=5462&business_profile[url]=https://seller.test&tos_acceptance[date]=1767225600&tos_acceptance[ip]=203.0.113.9')).id);
|
|
2608
|
+
const seller = String((await h('POST', '/v1/accounts', 'type=custom&country=US&capabilities[transfers][requested]=true&business_profile[mcc]=5462&business_profile[url]=https://seller.test&tos_acceptance[date]=1767225600&tos_acceptance[ip]=203.0.113.9&individual[first_name]=Sam&individual[last_name]=Seller')).id);
|
|
2317
2609
|
await h('POST', `/v1/accounts/${seller}/external_accounts`, 'external_account[object]=bank_account&external_account[country]=US&external_account[currency]=usd&external_account[routing_number]=110000000&external_account[account_number]=000123456789');
|
|
2318
2610
|
await h('POST', '/v1/charges', 'amount=100000¤cy=usd&source=tok_bypassPending');
|
|
2319
2611
|
await h('POST', '/v1/transfers', `amount=5000¤cy=usd&destination=${seller}`);
|
|
@@ -2335,27 +2627,42 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2335
2627
|
rmSync(root, { recursive: true, force: true });
|
|
2336
2628
|
}
|
|
2337
2629
|
}),
|
|
2338
|
-
|
|
2339
|
-
|
|
2630
|
+
// A top-up is pending until its funds arrive five days on ("USA (USD) ACH Debit Transfer 5 days",
|
|
2631
|
+
// docs.stripe.com/connect/top-ups; semantics/terminal.ts, c3519a064), and only a pending one can be canceled; a
|
|
2632
|
+
// succeeded one cannot. The verify runs on a World clock it moves, so the arrival is chosen, not waited for.
|
|
2633
|
+
done('stripe.connect.top_ups', 'connect', 'Top-ups (fund platform balance)', 'api', 'niche', async () => {
|
|
2634
|
+
const root = mkdtempSync(join(tmpdir(), 'stp-topup-'));
|
|
2635
|
+
try {
|
|
2636
|
+
const h = (s: Step & { day?: number }) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: `2026-06-${String(s.day ?? 1).padStart(2, '0')}T12:00:00Z` });
|
|
2340
2637
|
const tu = await h({ m: 'POST', p: '/v1/topups', b: 'amount=50000¤cy=usd&statement_descriptor=Top up' });
|
|
2341
|
-
if (!ok(tu) || field(tu, 'object') !== 'topup' || field(tu, 'status') !== '
|
|
2638
|
+
if (!ok(tu) || field(tu, 'object') !== 'topup' || field(tu, 'status') !== 'pending' || field(tu, 'expected_availability_date') !== Number(field(tu, 'created')) + 5 * 86_400) return false;
|
|
2342
2639
|
const g = await h({ m: 'GET', p: `/v1/topups/${id(tu)}` });
|
|
2343
2640
|
const u = await h({ m: 'POST', p: `/v1/topups/${id(tu)}`, b: 'metadata[ref]=q3' });
|
|
2344
|
-
const
|
|
2345
|
-
// a
|
|
2346
|
-
const
|
|
2641
|
+
const pendingList = await h({ m: 'GET', p: '/v1/topups?status=pending' });
|
|
2642
|
+
// a second one, canceled while it is still pending
|
|
2643
|
+
const other = await h({ m: 'POST', p: '/v1/topups', b: 'amount=100¤cy=usd' });
|
|
2644
|
+
const canceled = await h({ m: 'POST', p: `/v1/topups/${id(other)}/cancel` });
|
|
2645
|
+
// six days on the first has arrived: succeeded, and past canceling
|
|
2646
|
+
const arrived = await h({ m: 'GET', p: `/v1/topups/${id(tu)}`, day: 7 });
|
|
2647
|
+
const l = await h({ m: 'GET', p: '/v1/topups?status=succeeded', day: 7 });
|
|
2648
|
+
const cancel = await h({ m: 'POST', p: `/v1/topups/${id(tu)}/cancel`, day: 7 });
|
|
2347
2649
|
const noMoney = await h({ m: 'POST', p: '/v1/topups', b: 'currency=usd' });
|
|
2348
2650
|
const nope = await h({ m: 'GET', p: '/v1/topups/tu_nope' });
|
|
2349
|
-
return ok(g) && id(g) === id(tu) && ok(u) && ((
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2651
|
+
return ok(g) && id(g) === id(tu) && ok(u) && ((pendingList.body as Body).data as Body[]).length === 1
|
|
2652
|
+
&& ok(canceled) && field(canceled, 'status') === 'canceled'
|
|
2653
|
+
&& field(arrived, 'status') === 'succeeded' && typeof field(arrived, 'balance_transaction') === 'string'
|
|
2654
|
+
&& ((l.body as Body).data as Body[]).length === 1 && cancel.status === 400 && noMoney.status === 400 && nope.status === 404;
|
|
2655
|
+
} finally {
|
|
2656
|
+
rmSync(root, { recursive: true, force: true });
|
|
2657
|
+
}
|
|
2658
|
+
}),
|
|
2353
2659
|
|
|
2354
2660
|
// ── Identity / Files ──────────────────────────────────────────────────────────────
|
|
2355
2661
|
done('stripe.identity.verification_sessions', 'identity', 'Identity VerificationSessions: create + retrieve', 'api', 'niche', () =>
|
|
2356
2662
|
withRoot(async (h) => {
|
|
2357
2663
|
const v = await h({ m: 'POST', p: '/v1/identity/verification_sessions', b: 'type=document' });
|
|
2358
|
-
|
|
2664
|
+
// the object is named by its namespace: "object": "identity.verification_session" (docs.stripe.com/api/identity/verification_sessions/object)
|
|
2665
|
+
if (!ok(v) || field(v, 'object') !== 'identity.verification_session' || field(v, 'status') !== 'requires_input') return false;
|
|
2359
2666
|
const g = await h({ m: 'GET', p: `/v1/identity/verification_sessions/${id(v)}` });
|
|
2360
2667
|
return ok(g) && id(g) === id(v);
|
|
2361
2668
|
}),
|
|
@@ -2670,11 +2977,12 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2670
2977
|
const events = await handleStripeTwinRequest({ method: 'GET', path: '/v1/events', root });
|
|
2671
2978
|
const ev = ((events.body as Body).data as Body[]).find((e) => e.type === 'payment_intent.created');
|
|
2672
2979
|
if (!ev || ev.api_version !== '2022-11-15') return false;
|
|
2673
|
-
// no version → the
|
|
2980
|
+
// no version → the version the pack serves, its vendored spec's (stripe-version.ts SERVED_VERSION; "answers
|
|
2981
|
+
// render in the served API version", 24e757f60), as a request that pins none gets the account's
|
|
2674
2982
|
const pi2 = await handleStripeTwinRequest({ method: 'POST', path: '/v1/payment_intents', body: 'amount=500¤cy=usd', root });
|
|
2675
2983
|
const ev2 = (((await handleStripeTwinRequest({ method: 'GET', path: '/v1/events', root })).body as Body).data as Body[])
|
|
2676
2984
|
.find((e) => e.type === 'payment_intent.created' && ((e.data as Body)?.object as Body)?.id === id(pi2));
|
|
2677
|
-
return ev2 ? ev2.api_version === '
|
|
2985
|
+
return ev2 ? ev2.api_version === SERVED_VERSION && SERVED_VERSION > '2025-03-31' : false;
|
|
2678
2986
|
} catch (err) { if (isInfrastructureError(err)) throw harnessError('stripe.api.versioning', err); return false; } finally { rmSync(root, { recursive: true, force: true }); }
|
|
2679
2987
|
}),
|
|
2680
2988
|
|
|
@@ -2687,8 +2995,11 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2687
2995
|
withRoot(async (h, root) => {
|
|
2688
2996
|
const clock = await h({ m: 'POST', p: '/v1/test_helpers/test_clocks', b: 'frozen_time=1000&name=T' });
|
|
2689
2997
|
if (!ok(clock) || field(clock, 'object') !== 'test_helpers.test_clock' || field(clock, 'frozen_time') !== 1000) return false;
|
|
2690
|
-
// a trialing subscription whose trial_end is 5000
|
|
2691
|
-
|
|
2998
|
+
// a trialing subscription whose trial_end is 5000, for a customer ON the clock: a clock moves only what is attached
|
|
2999
|
+
// to it ("create a customer with the test_clock parameter", docs.stripe.com/billing/testing/test-clocks/api-advanced-usage),
|
|
3000
|
+
// and a trial_end is refused unless it is in the future (subscriptions.ts trialEndRefused) — for a clock's customer,
|
|
3001
|
+
// the clock's future. The customer was left off the clock when this was written, which its 1970 trial_end cannot survive.
|
|
3002
|
+
const cust = await h({ m: 'POST', p: '/v1/customers', b: `email=clock@twin.test&test_clock=${id(clock)}` });
|
|
2692
3003
|
const sub = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&trial_end=5000` });
|
|
2693
3004
|
if (field(sub, 'status') !== 'trialing') return false;
|
|
2694
3005
|
// advancing PAST trial_end: the clock advances for a few seconds (a second advance meanwhile is refused), then the
|
|
@@ -2775,20 +3086,18 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2775
3086
|
// the running mirror server → render the mirror's OWN ListPane over both → assert one
|
|
2776
3087
|
// list-row per seeded object carrying the seeded name. Failable: an empty workspace yields
|
|
2777
3088
|
// zero rows → zero list-rows.
|
|
2778
|
-
|
|
2779
|
-
|
|
3089
|
+
// Tax Rates only: Stripe publishes no list of tax calculations (a calculation is retrieved by id), so the mirror's
|
|
3090
|
+
// Tax Calculations screen, which read the invented GET /v1/tax/calculations, left with it (5decb1d77).
|
|
3091
|
+
done('stripe.ui.tax', 'ui-dashboard', 'Dashboard: Tax (Tax Rates) screen (data-coupled)', 'ui', 'common', uiDataCoupled({
|
|
3092
|
+
markers: ['Tax Rates', 'list-row'],
|
|
2780
3093
|
seed: async (h) => {
|
|
2781
3094
|
await h({ m: 'POST', p: '/v1/tax_rates', b: 'display_name=UI Check Tax&percentage=12.5&inclusive=false&jurisdiction=US' });
|
|
2782
|
-
await h({ m: 'POST', p: '/v1/tax/calculations', b: 'currency=usd&line_items[0][amount]=1000&line_items[0][reference]=ui_check_sku&customer_details[address][country]=US' });
|
|
2783
3095
|
},
|
|
2784
3096
|
check: async ({ get }) => {
|
|
2785
3097
|
const rates = ((await get('/v1/tax_rates')).data as Body[]) as unknown as StripeRow[];
|
|
2786
|
-
|
|
2787
|
-
if (!rates.length || !calcs.length) return false;
|
|
3098
|
+
if (!rates.length) return false;
|
|
2788
3099
|
const ratesMarkup = renderSectionList('tax_rates', rates);
|
|
2789
|
-
|
|
2790
|
-
return listRows(ratesMarkup) === rates.length && ratesMarkup.includes('UI Check Tax') &&
|
|
2791
|
-
listRows(calcsMarkup) === calcs.length;
|
|
3100
|
+
return listRows(ratesMarkup) === rates.length && ratesMarkup.includes('UI Check Tax');
|
|
2792
3101
|
},
|
|
2793
3102
|
})),
|
|
2794
3103
|
|
|
@@ -2873,10 +3182,16 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2873
3182
|
await fundIssuing(root, 10000000); // the Issuing balance authorizations are paid from
|
|
2874
3183
|
const ch = await h({ m: 'POST', p: '/v1/issuing/cardholders', b: 'name=Jane&type=individual&billing[address][country]=US' });
|
|
2875
3184
|
const card = await h({ m: 'POST', p: `/v1/issuing/cards`, b: `cardholder=${id(ch)}¤cy=usd&type=virtual` });
|
|
3185
|
+
// with no real-time webhook the authorization is approved at once ("If you don't have a real-time authorization
|
|
3186
|
+
// webhook, we approve the authorization without sending the issuing_authorization.request",
|
|
3187
|
+
// docs.stripe.com/issuing/purchases/authorizations), so the approve API refuses it as already decided; the merchant's
|
|
3188
|
+
// capture (the test helper) is what makes the transaction
|
|
2876
3189
|
const auth = await h({ m: 'POST', p: '/v1/test_helpers/issuing/authorizations', b: `card=${id(card)}&amount=4200` });
|
|
2877
|
-
await h({ m: 'POST', p: `/v1/issuing/authorizations/${id(auth)}/approve` });
|
|
3190
|
+
if (field(auth, 'approved') !== true || (await h({ m: 'POST', p: `/v1/issuing/authorizations/${id(auth)}/approve` })).status !== 400) return false;
|
|
3191
|
+
await h({ m: 'POST', p: `/v1/test_helpers/issuing/authorizations/${id(auth)}/capture` });
|
|
2878
3192
|
const txns = await h({ m: 'GET', p: '/v1/issuing/transactions' });
|
|
2879
3193
|
const txn = ((txns.body as Body).data as Body[])[0]!;
|
|
3194
|
+
if (((txns.body as Body).data as Body[]).length !== 1 || txn.amount !== -4200 || txn.authorization !== id(auth)) return false;
|
|
2880
3195
|
const g = await h({ m: 'GET', p: `/v1/issuing/transactions/${txn.id}` });
|
|
2881
3196
|
if (!ok(g) || field(g, 'object') !== 'issuing.transaction') return false;
|
|
2882
3197
|
const u = await h({ m: 'POST', p: `/v1/issuing/transactions/${txn.id}`, b: 'metadata[note]=checked' });
|
|
@@ -2934,6 +3249,9 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2934
3249
|
const root2 = mkdtempSync(join(tmpdir(), 'stp-cap-'));
|
|
2935
3250
|
try {
|
|
2936
3251
|
const h2 = (s: Step) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root: root2, occurredAt: '2026-06-14T00:00:00Z' });
|
|
3252
|
+
// funded as the first World is: an authorization the Issuing balance cannot cover is declined insufficient_funds
|
|
3253
|
+
// (docs.stripe.com/issuing/funding/balance), which would decide it before any endpoint could be consulted
|
|
3254
|
+
await fundIssuing(root2, 10000000, '2026-06-14T00:00:00Z');
|
|
2937
3255
|
let consulted = false;
|
|
2938
3256
|
setStripeEventDelivery((_url, event) => {
|
|
2939
3257
|
if (event.type === 'issuing_authorization.request') consulted = true;
|
|
@@ -2942,8 +3260,14 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2942
3260
|
const ch2 = await h2({ m: 'POST', p: '/v1/issuing/cardholders', b: 'name=RT2&type=individual&billing[address][country]=US' });
|
|
2943
3261
|
const card2 = await h2({ m: 'POST', p: '/v1/issuing/cards', b: `cardholder=${id(ch2)}¤cy=usd&type=virtual` });
|
|
2944
3262
|
await h2({ m: 'POST', p: '/v1/webhook_endpoints', b: `url=${encodeURIComponent('https://other.twin.test/hook')}&enabled_events[]=invoice.paid` });
|
|
3263
|
+
// ...and, not enrolled, it is approved at once by the account's default settings: "If you don't have a real-time
|
|
3264
|
+
// authorization webhook, we approve the authorization without sending the issuing_authorization.request"
|
|
3265
|
+
// (docs.stripe.com/issuing/purchases/authorizations; semantics/issuing.ts, reason card_active). It used to stay
|
|
3266
|
+
// pending for the approve API, which is the deprecated path.
|
|
2945
3267
|
const auth2 = await h2({ m: 'POST', p: '/v1/test_helpers/issuing/authorizations', b: `card=${id(card2)}&amount=100` });
|
|
2946
|
-
|
|
3268
|
+
const history2 = (field(auth2, 'request_history') as Body[] | undefined) ?? [];
|
|
3269
|
+
return !consulted && field(auth2, 'status') === 'pending' && field(auth2, 'approved') === true
|
|
3270
|
+
&& history2.length === 1 && history2[0]!.reason === 'card_active';
|
|
2947
3271
|
} finally {
|
|
2948
3272
|
rmSync(root2, { recursive: true, force: true });
|
|
2949
3273
|
}
|
|
@@ -3195,10 +3519,17 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
3195
3519
|
// not a public create). open is the only approvable state → /approve closes it (open:false,
|
|
3196
3520
|
// closed_reason 'approved'); a second approve 400s. retrieve + list round-trip; unknown id 404.
|
|
3197
3521
|
// (the open→closed approve transition is produced ONLY by this feature.)
|
|
3522
|
+
// A review is opened by Radar, never by the API (Stripe's API has no review create; the twin's seeding route for it was
|
|
3523
|
+
// removed with the invented endpoints, 5decb1d77): an elevated-risk card's charge is placed in review
|
|
3524
|
+
// (docs.stripe.com/radar/reviews; the test card 4000000000009235, pm_card_riskLevelElevated, docs.stripe.com/testing).
|
|
3198
3525
|
done('stripe.radar.reviews', 'radar', 'Radar Reviews (approve/list)', 'api', 'niche', () =>
|
|
3199
3526
|
withRoot(async (h) => {
|
|
3200
|
-
const
|
|
3201
|
-
if (!ok(
|
|
3527
|
+
const risky = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=2000¤cy=usd&payment_method=pm_card_riskLevelElevated&confirm=true' });
|
|
3528
|
+
if (!ok(risky) || field(risky, 'status') !== 'succeeded') return false;
|
|
3529
|
+
const opened = (((await h({ m: 'GET', p: '/v1/reviews' })).body as Body).data as Body[]) ?? [];
|
|
3530
|
+
if (opened.length !== 1) return false;
|
|
3531
|
+
const rv = await h({ m: 'GET', p: `/v1/reviews/${String(opened[0]!.id)}` });
|
|
3532
|
+
if (!ok(rv) || field(rv, 'object') !== 'review' || field(rv, 'open') !== true || field(rv, 'payment_intent') !== id(risky)) return false;
|
|
3202
3533
|
const g = await h({ m: 'GET', p: `/v1/reviews/${id(rv)}` });
|
|
3203
3534
|
const l = await h({ m: 'GET', p: '/v1/reviews' });
|
|
3204
3535
|
if (!ok(g) || id(g) !== id(rv) || field(l, 'object') !== 'list') return false;
|
|
@@ -3235,62 +3566,36 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
3235
3566
|
noAlias.status === 400 && badList.status === 400 && noValue.status === 400;
|
|
3236
3567
|
}),
|
|
3237
3568
|
),
|
|
3238
|
-
// Radar
|
|
3239
|
-
//
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
const badAction = await h({ m: 'POST', p: '/v1/radar/rules', b: 'action=nuke&predicate=x' });
|
|
3251
|
-
const noPred = await h({ m: 'POST', p: '/v1/radar/rules', b: 'action=block' });
|
|
3252
|
-
return ok(del) && field(del, 'deleted') === true && gone.status === 404 &&
|
|
3253
|
-
noAction.status === 400 && badAction.status === 400 && noPred.status === 400;
|
|
3254
|
-
}),
|
|
3255
|
-
),
|
|
3256
|
-
// Dashboard: Radar (Reviews + Value Lists + Rules) screens — rendered surfaces in the mirror.
|
|
3257
|
-
// Radar (Reviews + Value Lists + Rules) — RUNG-5 data-coupled: the three section markers are
|
|
3258
|
-
// bundled (the screens are wired in) AND, for each of the three Radar collections, we seed
|
|
3259
|
-
// real twin state (a review, a value-list + item, a rule), fetch the SAME /v1 endpoint the
|
|
3260
|
-
// mirror's React client reads off the running mirror server, render the mirror's OWN ListPane
|
|
3261
|
-
// component over those rows, and assert the rendered DOM emits exactly one `list-row` landmark
|
|
3262
|
-
// per seeded object carrying that object's id. Failable: an empty workspace yields zero rows →
|
|
3263
|
-
// zero list-rows → false; and a marker-only/hardcoded render could not surface the seeded ids.
|
|
3264
|
-
done('stripe.ui.radar', 'ui-dashboard', 'Dashboard: Radar (Reviews + Value Lists + Rules) screens', 'ui', 'niche', uiDataCoupled({
|
|
3265
|
-
markers: ['Radar Reviews', 'Radar Value Lists', 'Radar Rules', 'list-row'],
|
|
3569
|
+
// Radar rules are not in Stripe's API: rules are written in the Dashboard (docs.stripe.com/radar/rules), and the
|
|
3570
|
+
// published spec has no /v1/radar/rules. The twin's routes for them were invented and are gone (5decb1d77), so the
|
|
3571
|
+
// `stripe.radar.rules` claim they carried is withdrawn rather than kept as a todo: it is not vendor API surface.
|
|
3572
|
+
// Dashboard: Radar (Reviews + Value Lists) screens — RUNG-5 data-coupled: the section markers are bundled (the screens
|
|
3573
|
+
// are wired in) AND, for each Radar collection, we seed real twin state (a review Radar opens on an elevated-risk
|
|
3574
|
+
// charge, a value-list + item), fetch the SAME /v1 endpoint the mirror's React client reads off the running mirror
|
|
3575
|
+
// server, render the mirror's OWN ListPane component over those rows, and assert the rendered DOM emits exactly one
|
|
3576
|
+
// `list-row` landmark per seeded object carrying it. Failable: an empty workspace yields zero rows → false. The Rules
|
|
3577
|
+
// screen left the mirror with the invented /v1/radar/rules it read (5decb1d77: "The Dashboard mirror no longer lists
|
|
3578
|
+
// ... radar rules"), and the review is no longer seeded through the invented POST /v1/radar/reviews.
|
|
3579
|
+
done('stripe.ui.radar', 'ui-dashboard', 'Dashboard: Radar (Reviews + Value Lists) screens', 'ui', 'niche', uiDataCoupled({
|
|
3580
|
+
markers: ['Radar Reviews', 'Radar Value Lists', 'list-row'],
|
|
3266
3581
|
seed: async (h) => {
|
|
3267
|
-
|
|
3268
|
-
await h({ m: 'POST', p: '/v1/radar/reviews', b: 'charge=ch_radar_ui&payment_intent=pi_radar_ui' });
|
|
3582
|
+
await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=2000¤cy=usd&payment_method=pm_card_riskLevelElevated&confirm=true' });
|
|
3269
3583
|
const vl = await h({ m: 'POST', p: '/v1/radar/value_lists', b: 'alias=ui_block_ips&name=UI Blocked IPs&item_type=ip_address' });
|
|
3270
3584
|
await h({ m: 'POST', p: '/v1/radar/value_list_items', b: `value_list=${(vl.body as Body).id}&value=9.9.9.9` });
|
|
3271
|
-
await h({ m: 'POST', p: '/v1/radar/rules', b: 'action=block&predicate=:risk_level: = "highest"' });
|
|
3272
3585
|
},
|
|
3273
3586
|
check: async ({ get }) => {
|
|
3274
3587
|
const reviews = ((await get('/v1/reviews')).data as Body[]) ?? [];
|
|
3275
3588
|
const valueLists = ((await get('/v1/radar/value_lists')).data as Body[]) ?? [];
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
const review = reviews.find((r) => r.charge === '
|
|
3589
|
+
// the review is the one Radar opened on the seeded elevated-risk payment
|
|
3590
|
+
const risky = (((await get('/v1/payment_intents')).data as Body[]) ?? [])[0];
|
|
3591
|
+
const review = reviews.find((r) => r.open === true && r.payment_intent === risky?.id && typeof r.charge === 'string' && r.charge === risky?.latest_charge);
|
|
3279
3592
|
const vl = valueLists.find((l) => l.alias === 'ui_block_ips');
|
|
3280
|
-
|
|
3281
|
-
if (!review || !vl || !rule) return false;
|
|
3282
|
-
// RENDER the mirror's own ListPane over the fetched rows and assert the DOM emits one
|
|
3283
|
-
// list-row per object, each carrying the seeded object's id (subtitle renders r.id).
|
|
3593
|
+
if (!review || !vl) return false;
|
|
3284
3594
|
const reviewsMarkup = renderSectionList('reviews', reviews as StripeRow[]);
|
|
3285
3595
|
const valueListsMarkup = renderSectionList('radar/value_lists', valueLists as StripeRow[]);
|
|
3286
|
-
|
|
3287
|
-
return (
|
|
3288
|
-
|
|
3289
|
-
// name + alias (the id is not surfaced in that section's row), so assert those instead.
|
|
3290
|
-
listRows(reviewsMarkup) === reviews.length && reviewsMarkup.includes(String(review.id)) &&
|
|
3291
|
-
listRows(valueListsMarkup) === valueLists.length && valueListsMarkup.includes('UI Blocked IPs') && valueListsMarkup.includes('ui_block_ips') &&
|
|
3292
|
-
listRows(rulesMarkup) === rules.length && rulesMarkup.includes(String(rule.id)) && rulesMarkup.includes('block')
|
|
3293
|
-
);
|
|
3596
|
+
// a review's row names its charge; the value-list row renders its name + alias
|
|
3597
|
+
return listRows(reviewsMarkup) === reviews.length && reviewsMarkup.includes(String(review.charge))
|
|
3598
|
+
&& listRows(valueListsMarkup) === valueLists.length && valueListsMarkup.includes('UI Blocked IPs') && valueListsMarkup.includes('ui_block_ips');
|
|
3294
3599
|
},
|
|
3295
3600
|
})),
|
|
3296
3601
|
|
|
@@ -3560,8 +3865,10 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
3560
3865
|
if (!txns.length) return false;
|
|
3561
3866
|
const summaryMarkup = renderToStaticMarkup(createElement(BalanceSummary, { row: { id: 'balance', ...balance } as StripeRow }));
|
|
3562
3867
|
const txnsMarkup = renderSectionList('balance_transactions', txns);
|
|
3563
|
-
|
|
3564
|
-
|
|
3868
|
+
// the balance is the charge less Stripe's fee, 2.9% + 30¢ (stripe.com/pricing; the ledger, d5c8f9a70):
|
|
3869
|
+
// 424200 - (12302 + 30) = 411868 available, while the transaction keeps the gross amount
|
|
3870
|
+
return summaryMarkup.includes('balance-summary') && summaryMarkup.includes(formatStripeAmount(411868, 'usd')) &&
|
|
3871
|
+
listRows(txnsMarkup) === txns.length && txns.some((t) => t.amount === 424200 && t.fee === 12332 && t.net === 411868);
|
|
3565
3872
|
},
|
|
3566
3873
|
})),
|
|
3567
3874
|
// TWIN-14 (B4) migration — was a marker-grep over the literals 'Connected Accounts',
|
|
@@ -3677,7 +3984,12 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
3677
3984
|
// per seeded run carrying its report_type (fails on an empty workspace).
|
|
3678
3985
|
done('stripe.ui.reports', 'ui-dashboard', 'Dashboard: Reports / analytics screen', 'ui', 'niche', uiDataCoupled({
|
|
3679
3986
|
markers: ['Reports', 'reporting/report_runs', 'list-row'],
|
|
3680
|
-
|
|
3987
|
+
// made ten minutes ago on the World's clock: a run starts pending and succeeds a minute on ("Most runs complete within a
|
|
3988
|
+
// few minutes", docs.stripe.com/reports/api; stripe.reporting.report_runs), so one made at the instant the screen reads
|
|
3989
|
+
// it is still pending
|
|
3990
|
+
seed: async (_h, root) => {
|
|
3991
|
+
await handleStripeTwinRequest({ method: 'POST', path: '/v1/reporting/report_runs', body: 'report_type=balance.summary.1¶meters[interval_start]=0¶meters[interval_end]=100', root, occurredAt: new Date(Date.now() - 10 * 60_000).toISOString() });
|
|
3992
|
+
},
|
|
3681
3993
|
check: async ({ get }) => {
|
|
3682
3994
|
const rows = ((await get('/v1/reporting/report_runs')).data as Body[]) as unknown as StripeRow[];
|
|
3683
3995
|
if (!rows || rows.length < 1) return false;
|
|
@@ -3750,14 +4062,19 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
3750
4062
|
// ── SetupIntents full lifecycle (only confirm was tracked before) ──
|
|
3751
4063
|
done('stripe.setup_intents.lifecycle', 'payment_methods', 'SetupIntents: create / retrieve / list / cancel', 'api', 'common', () =>
|
|
3752
4064
|
withRoot(async (h) => {
|
|
4065
|
+
// made without a payment method it waits for one: "When the SetupIntent is created, it has a status of
|
|
4066
|
+
// requires_payment_method until a payment method is attached" (docs.stripe.com/payments/setupintents/lifecycle;
|
|
4067
|
+
// 45d8b30d1); with one it waits for confirmation
|
|
3753
4068
|
const c = await h({ m: 'POST', p: '/v1/setup_intents', b: 'usage=off_session' });
|
|
3754
|
-
if (!ok(c) || field(c, 'status') !== '
|
|
4069
|
+
if (!ok(c) || field(c, 'status') !== 'requires_payment_method') return false;
|
|
3755
4070
|
const g = await h({ m: 'GET', p: `/v1/setup_intents/${id(c)}` });
|
|
3756
4071
|
const l = await h({ m: 'GET', p: '/v1/setup_intents' });
|
|
3757
4072
|
const x = await h({ m: 'POST', p: `/v1/setup_intents/${id(c)}/cancel` });
|
|
3758
4073
|
// cancelling a succeeded SI is rejected (negative path)
|
|
3759
|
-
const conf = await h({ m: 'POST', p: '/v1/setup_intents', b: 'usage=off_session' });
|
|
3760
|
-
|
|
4074
|
+
const conf = await h({ m: 'POST', p: '/v1/setup_intents', b: 'usage=off_session&payment_method=pm_card_visa' });
|
|
4075
|
+
if (field(conf, 'status') !== 'requires_confirmation') return false;
|
|
4076
|
+
const confirmed = await h({ m: 'POST', p: `/v1/setup_intents/${id(conf)}/confirm` });
|
|
4077
|
+
if (field(confirmed, 'status') !== 'succeeded') return false;
|
|
3761
4078
|
const badCancel = await h({ m: 'POST', p: `/v1/setup_intents/${id(conf)}/cancel` });
|
|
3762
4079
|
return ok(g) && id(g) === id(c) && field(l, 'object') === 'list'
|
|
3763
4080
|
&& ok(x) && field(x, 'status') === 'canceled' && badCancel.status === 400;
|
|
@@ -3783,11 +4100,16 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
3783
4100
|
// ── Mandates retrieve ──
|
|
3784
4101
|
done('stripe.mandates.retrieve', 'payment_methods', 'Mandates: retrieve (404 unknown)', 'api', 'niche', () =>
|
|
3785
4102
|
withRoot(async (h) => {
|
|
3786
|
-
|
|
3787
|
-
|
|
3788
|
-
|
|
4103
|
+
// Stripe's API makes no mandate on request (the twin's seeding POST /v1/mandates was invented and is gone,
|
|
4104
|
+
// 5decb1d77): a mandate is the customer's acceptance a verified bank account carries, and a SetupIntent verified by
|
|
4105
|
+
// micro-deposits names its multi-use one (docs.stripe.com/api/mandates, docs.stripe.com/api/setup_intents/object mandate)
|
|
4106
|
+
const si = await h({ m: 'POST', p: '/v1/setup_intents', b: 'allowed_payment_method_types[]=us_bank_account' });
|
|
4107
|
+
await h({ m: 'POST', p: `/v1/setup_intents/${id(si)}/confirm`, b: 'payment_method=pm_us_bank_account' });
|
|
4108
|
+
const verified = await h({ m: 'POST', p: `/v1/setup_intents/${id(si)}/verify_microdeposits`, b: 'descriptor_code=SM11AA' });
|
|
4109
|
+
const mandateId = String(field(verified, 'mandate'));
|
|
4110
|
+
const g = await h({ m: 'GET', p: `/v1/mandates/${mandateId}` });
|
|
3789
4111
|
const miss = await h({ m: 'GET', p: '/v1/mandates/mandate_nope' });
|
|
3790
|
-
return ok(g) &&
|
|
4112
|
+
return ok(g) && field(g, 'object') === 'mandate' && id(g) === mandateId && field(g, 'status') === 'active' && field(g, 'type') === 'multi_use' && miss.status === 404;
|
|
3791
4113
|
}),
|
|
3792
4114
|
),
|
|
3793
4115
|
|
|
@@ -3833,6 +4155,304 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
3833
4155
|
}),
|
|
3834
4156
|
),
|
|
3835
4157
|
|
|
4158
|
+
// ── Connect OAuth for Standard accounts (screens/connect-oauth.tsx; docs.stripe.com/connect/oauth-reference) ──
|
|
4159
|
+
// The platform's OAuth settings: "Enable onboarding accounts with OAuth", "Copy your client_id", "Set your redirect_uri
|
|
4160
|
+
// ... If you don't include the redirect_uri parameter in your request, Stripe defaults to using the first address you've
|
|
4161
|
+
// configured" (docs.stripe.com/connect/oauth-standard-accounts). The page shows the client_id and what was saved, and
|
|
4162
|
+
// authorize holds the platform to it.
|
|
4163
|
+
done('stripe.connect.oauth_settings', 'connect', 'Dashboard Connect OAuth settings: the client_id shown, OAuth enabled and redirect URIs registered, which authorize holds the platform to', 'ui', 'common', () =>
|
|
4164
|
+
withOAuth(async (w) => {
|
|
4165
|
+
const read = async (path: string) => (await w.f(new Request(`https://dashboard.stripe.com${path}`))).text();
|
|
4166
|
+
const first = await read('/settings/connect');
|
|
4167
|
+
const shownId = /data-testid="connect-client-id">(ca_[^<]+)</.exec(first)?.[1];
|
|
4168
|
+
// a new platform has OAuth off: authorize refuses it, in JSON, with the state
|
|
4169
|
+
const off = await w.authorize(authorizeQuery());
|
|
4170
|
+
const offBody = (await off.json()) as Body;
|
|
4171
|
+
// a redirect URI that is not an http(s) URL is refused on the page, and nothing is saved
|
|
4172
|
+
const bad = await w.settings(true, 'ftp://files.test/cb');
|
|
4173
|
+
const fragment = await w.settings(true, 'https://app.test/cb#tab');
|
|
4174
|
+
const stillOff = ((await (await w.authorize(authorizeQuery())).json()) as Body).error === 'invalid_client';
|
|
4175
|
+
// OAuth on with no redirect URI: a request naming none has nowhere to go
|
|
4176
|
+
await w.settings(true, '');
|
|
4177
|
+
const nowhere = (await (await w.authorize(authorizeQuery('', null))).json()) as Body;
|
|
4178
|
+
const saved = await w.settings(true, `https://app.test/first\n${OAUTH_CALLBACK}?tab=stripe`);
|
|
4179
|
+
const listed = await read('/settings/connect/onboarding-options/oauth');
|
|
4180
|
+
const uris = [...listed.matchAll(/data-testid="connect-redirect-uri">([^<]+)</g)].map((m) => m[1]!.replace(/&/g, '&'));
|
|
4181
|
+
// no redirect_uri: the first registered; a registered one with its own query keeps it; an unregistered one is refused
|
|
4182
|
+
const defaulted = (await w.authorize(authorizeQuery('', null), 'skip')).headers.get('location') ?? '';
|
|
4183
|
+
const named = (await w.authorize(authorizeQuery('', `${OAUTH_CALLBACK}?tab=stripe`), 'skip')).headers.get('location') ?? '';
|
|
4184
|
+
const unregistered = await w.authorize(authorizeQuery('', OAUTH_CALLBACK));
|
|
4185
|
+
const unregisteredBody = (await unregistered.json()) as Body;
|
|
4186
|
+
// turned off again, authorize refuses again
|
|
4187
|
+
await w.settings(false, OAUTH_CALLBACK);
|
|
4188
|
+
const offAgain = ((await (await w.authorize(authorizeQuery())).json()) as Body).error === 'invalid_client';
|
|
4189
|
+
const testLink = (await read('/test/settings/connect/onboarding-options/oauth')).includes('OAuth is disabled.');
|
|
4190
|
+
return shownId === 'ca_twin_self' && first.includes('OAuth is disabled.') && off.status === 400 && offBody.error === 'invalid_client' && offBody.state === 'st8'
|
|
4191
|
+
&& bad.status === 400 && fragment.status === 400 && stillOff && nowhere.error === 'invalid_redirect_uri' && saved.status === 303 && uris.length === 2 && uris[0] === 'https://app.test/first' && uris[1] === `${OAUTH_CALLBACK}?tab=stripe`
|
|
4192
|
+
&& listed.includes('OAuth is enabled.') && defaulted.startsWith('https://app.test/first?scope=read_write&code=ac_') && named.startsWith(`${OAUTH_CALLBACK}?tab=stripe&scope=read_write&code=ac_`)
|
|
4193
|
+
&& unregistered.status === 400 && unregisteredBody.error === 'invalid_redirect_uri' && String(unregisteredBody.error_description).includes(OAUTH_CALLBACK)
|
|
4194
|
+
&& offAgain && testLink;
|
|
4195
|
+
}, false),
|
|
4196
|
+
),
|
|
4197
|
+
// GET connect.stripe.com/oauth/authorize: the request checked as the reference lists its errors (a JSON dictionary with
|
|
4198
|
+
// error, error_description and state, never a redirect), the page the person answers, "Skip this form" in test mode
|
|
4199
|
+
// creating a new Standard account from the prefill ("Any parameters with invalid values are silently ignored") and
|
|
4200
|
+
// redirecting with code, scope and state; a denial redirects with access_denied and creates nothing.
|
|
4201
|
+
done('stripe.connect.oauth_authorize', 'connect', 'Connect OAuth authorize: request errors, the connect page, Skip this form creates a prefilled Standard account and redirects with code/scope/state; Deny redirects access_denied', 'api', 'common', () =>
|
|
4202
|
+
withOAuth(async (w) => {
|
|
4203
|
+
const err = async (query: string) => { const r = await w.authorize(query); return { status: r.status, ...((await r.json()) as Body) } as Body; };
|
|
4204
|
+
const noClient = await err(authorizeQuery().replace('client_id=ca_twin_self&', ''));
|
|
4205
|
+
const unknown = await err(authorizeQuery().replace('ca_twin_self', 'ca_nope'));
|
|
4206
|
+
const noType = await err(authorizeQuery().replace('&response_type=code', ''));
|
|
4207
|
+
const tokenType = await err(authorizeQuery().replace('response_type=code', 'response_type=token'));
|
|
4208
|
+
const badScope = await err(authorizeQuery().replace('scope=read_write', 'scope=admin'));
|
|
4209
|
+
const errorsRight = noClient.status === 400 && noClient.error === 'invalid_request' && unknown.error === 'invalid_client' && unknown.error_description === 'No application matches the supplied client identifier'
|
|
4210
|
+
&& noType.error === 'invalid_request' && tokenType.error === 'unsupported_response_type' && badScope.error === 'invalid_scope' && [noClient, unknown, noType, tokenType, badScope].every((e) => e.state === 'st8' && e.status === 400);
|
|
4211
|
+
const prefillQuery = '&stripe_user%5Bemail%5D=org%40cal.test&stripe_user%5Bfirst_name%5D=Ada&stripe_user%5Bbusiness_name%5D=Ada%20Consulting&stripe_user%5Bcountry%5D=CA&stripe_user%5Burl%5D=not-a-url&stripe_user%5Bbusiness_type%5D=llc&stripe_user%5Bproduct_description%5D=Consulting%20sessions&stripe_user%5Bphone_number%5D=6045550123';
|
|
4212
|
+
const shown = await (await w.authorize(authorizeQuery(prefillQuery))).text();
|
|
4213
|
+
const pageRight = shown.includes('Skip this form') && shown.includes('Deny access') && shown.includes('Read and write access') && shown.includes('org@cal.test') && shown.includes('Twin Inc.');
|
|
4214
|
+
// Deny: back to the platform with access_denied and the state, and no account made
|
|
4215
|
+
const denied = (await w.authorize(authorizeQuery(prefillQuery), 'deny')).headers.get('location');
|
|
4216
|
+
const noneMade = ((await w.api('GET', '/v1/accounts')).body.data as Body[]).length === 0;
|
|
4217
|
+
const { location, code, token } = await w.connect(prefillQuery);
|
|
4218
|
+
const acct = (await w.api('GET', `/v1/accounts/${String(token.stripe_user_id)}`)).body;
|
|
4219
|
+
const profile = acct.business_profile as Body;
|
|
4220
|
+
// scope defaults to read_only
|
|
4221
|
+
const readOnly = new URL((await w.authorize(authorizeQuery().replace('scope=read_write&', ''), 'skip')).headers.get('location') ?? 'about:blank').searchParams.get('scope');
|
|
4222
|
+
// an empty scope is no scope: the default, not invalid_scope
|
|
4223
|
+
const emptyScope = new URL((await w.authorize(authorizeQuery().replace('scope=read_write', 'scope='), 'skip')).headers.get('location') ?? 'about:blank').searchParams.get('scope');
|
|
4224
|
+
// two people connecting at once get two codes, each for its own account (a regression guard: in one process a
|
|
4225
|
+
// count and its write never interleave; the atomic mint is for Worlds whose store two processes write)
|
|
4226
|
+
const both = await Promise.all([w.authorize(authorizeQuery(), 'skip'), w.authorize(authorizeQuery(), 'skip')]);
|
|
4227
|
+
const codes = both.map((r) => new URL(r.headers.get('location') ?? 'about:blank').searchParams.get('code'));
|
|
4228
|
+
const bothTokens = await Promise.all(codes.map(async (c) => (await w.endpoint('token', `grant_type=authorization_code&code=${c}`)).body));
|
|
4229
|
+
return errorsRight && pageRight && noneMade
|
|
4230
|
+
&& denied === `${OAUTH_CALLBACK}?error=access_denied&error_description=The%20user%20denied%20your%20request&state=st8`
|
|
4231
|
+
&& `${location.origin}${location.pathname}` === OAUTH_CALLBACK && [...location.searchParams.keys()].join(',') === 'scope,code,state' && location.searchParams.get('scope') === 'read_write' && location.searchParams.get('state') === 'st8' && /^ac_/.test(code)
|
|
4232
|
+
&& acct.id === token.stripe_user_id && acct.type === 'standard' && (acct.controller as Body)?.type === 'account' && acct.email === 'org@cal.test' && acct.country === 'CA' && acct.default_currency === 'cad'
|
|
4233
|
+
&& profile?.name === 'Ada Consulting' && profile?.url === null && profile?.product_description === 'Consulting sessions' && profile?.support_phone === '6045550123'
|
|
4234
|
+
&& acct.business_type === 'company' && ((acct.settings as Body)?.dashboard as Body)?.display_name === 'Ada Consulting'
|
|
4235
|
+
&& ((acct.requirements as Body)?.currently_due as unknown[])?.length === 0 && acct.details_submitted === true && acct.payouts_enabled === true
|
|
4236
|
+
&& acct.charges_enabled === true && (acct.capabilities as Body)?.card_payments === 'active'
|
|
4237
|
+
&& readOnly === 'read_only' && emptyScope === 'read_only'
|
|
4238
|
+
&& codes[0] !== codes[1] && bothTokens.every((t) => /^acct_/.test(String(t.stripe_user_id))) && bothTokens[0]!.stripe_user_id !== bothTokens[1]!.stripe_user_id && bothTokens[0]!.refresh_token !== bothTokens[1]!.refresh_token;
|
|
4239
|
+
}),
|
|
4240
|
+
),
|
|
4241
|
+
// POST connect.stripe.com/oauth/token with grant_type=authorization_code, as stripe-node's oauth.token sends it (the
|
|
4242
|
+
// platform's secret key as Bearer; curl's -u as Basic): the reference's response, the connected account usable with the
|
|
4243
|
+
// Stripe-Account header at once; a code "can only be used once and expires in 5 minutes", and "Consuming an
|
|
4244
|
+
// authorization code more than once revokes the account connection"; the reference's error codes.
|
|
4245
|
+
done('stripe.connect.oauth_token', 'connect', 'Connect OAuth token: an authorization code becomes the connection (stripe_user_id, tokens), once, within 5 minutes; reuse revokes; invalid_request / invalid_grant / unsupported_grant_type', 'api', 'common', () =>
|
|
4246
|
+
withOAuth(async (w) => {
|
|
4247
|
+
const { code, token } = await w.connect();
|
|
4248
|
+
const account = String(token.stripe_user_id);
|
|
4249
|
+
const shape = Object.keys(token).sort().join(',') === 'access_token,livemode,refresh_token,scope,stripe_publishable_key,stripe_user_id,token_type'
|
|
4250
|
+
&& token.token_type === 'bearer' && token.livemode === false && token.scope === 'read_write' && /^sk_test_/.test(String(token.access_token))
|
|
4251
|
+
&& /^pk_test_/.test(String(token.stripe_publishable_key)) && /^rt_/.test(String(token.refresh_token)) && /^acct_/.test(account);
|
|
4252
|
+
// the platform acts as the account (Cal.com: a PaymentIntent with stripeAccount), and reads its default currency
|
|
4253
|
+
const pi = await w.api('POST', '/v1/payment_intents', 'amount=2500¤cy=usd', account);
|
|
4254
|
+
const retrieved = (await w.api('GET', `/v1/accounts/${account}`)).body;
|
|
4255
|
+
const e = async (body: string, auth?: string) => { const r = await w.endpoint('token', body, auth); return { status: r.status, ...r.body } as Body; };
|
|
4256
|
+
const noKey = await e(`grant_type=authorization_code&code=${code}`, '');
|
|
4257
|
+
const publishable = await e(`grant_type=authorization_code&code=${code}`, 'Bearer pk_test_platform');
|
|
4258
|
+
const noGrant = await e(`code=${code}`);
|
|
4259
|
+
const password = await e('grant_type=password');
|
|
4260
|
+
const noCode = await e('grant_type=authorization_code');
|
|
4261
|
+
const missing = await e('grant_type=authorization_code&code=ac_nope');
|
|
4262
|
+
// curl -u sk_...: (Basic) works as Bearer does
|
|
4263
|
+
const second = await w.authorize(authorizeQuery(), 'skip');
|
|
4264
|
+
const basic = await e(`grant_type=authorization_code&code=${new URL(second.headers.get('location')!).searchParams.get('code')}`, `Basic ${btoa('sk_test_platform:')}`);
|
|
4265
|
+
// five minutes: a code 299 s old is exchanged, one 300 s old is not
|
|
4266
|
+
const fresh = new URL((await w.authorize(authorizeQuery(), 'skip')).headers.get('location')!).searchParams.get('code');
|
|
4267
|
+
const stale = new URL((await w.authorize(authorizeQuery(), 'skip')).headers.get('location')!).searchParams.get('code');
|
|
4268
|
+
w.at('2026-03-01T00:04:59.000Z');
|
|
4269
|
+
const inTime = await e(`grant_type=authorization_code&code=${fresh}`);
|
|
4270
|
+
w.at('2026-03-01T00:05:00.000Z');
|
|
4271
|
+
const late = await e(`grant_type=authorization_code&code=${stale}`);
|
|
4272
|
+
const liveCode = new URL((await w.authorize(authorizeQuery(), 'skip')).headers.get('location')!).searchParams.get('code');
|
|
4273
|
+
const liveKey = await e(`grant_type=authorization_code&code=${liveCode}`, 'Bearer sk_live_platform');
|
|
4274
|
+
// a JSON body is read as the form is
|
|
4275
|
+
const jsonCode = new URL((await w.authorize(authorizeQuery(), 'skip')).headers.get('location')!).searchParams.get('code');
|
|
4276
|
+
const asJson = await w.f(new Request('https://connect.stripe.com/oauth/token', { method: 'POST', headers: { authorization: 'Bearer sk_test_platform', 'content-type': 'application/json' }, body: JSON.stringify({ grant_type: 'authorization_code', code: jsonCode }) }));
|
|
4277
|
+
const asJsonBody = (await asJson.json()) as Body;
|
|
4278
|
+
// the first code again: refused, and the connection it made is revoked
|
|
4279
|
+
const reused = await e(`grant_type=authorization_code&code=${code}`);
|
|
4280
|
+
const after = await w.api('POST', '/v1/payment_intents', 'amount=2500¤cy=usd', account);
|
|
4281
|
+
const others = await w.api('POST', '/v1/payment_intents', 'amount=2500¤cy=usd', String(basic.stripe_user_id));
|
|
4282
|
+
// the same code exchanged twice at once (a callback run twice): one exchange wins, and still the connection is revoked
|
|
4283
|
+
// (the order the loser marks first is pinned below, `midway`)
|
|
4284
|
+
const ids = async () => ((await w.api('GET', '/v1/accounts?limit=100')).body.data as Body[]).map((a) => String(a.id));
|
|
4285
|
+
const known = await ids();
|
|
4286
|
+
const raced = new URL((await w.authorize(authorizeQuery(), 'skip')).headers.get('location')!).searchParams.get('code');
|
|
4287
|
+
const racedAccount = (await ids()).find((a) => !known.includes(a)) ?? '';
|
|
4288
|
+
const pair = await Promise.all([e(`grant_type=authorization_code&code=${raced}`), e(`grant_type=authorization_code&code=${raced}`)]);
|
|
4289
|
+
const racedAfter = racedAccount ? await w.api('GET', '/v1/customers', undefined, racedAccount) : { status: 0, body: {} };
|
|
4290
|
+
// the order a race can take the other way: the second use marks the code while the first exchange is still writing
|
|
4291
|
+
// its connection (the state that second use leaves, seeded as it is written), so the first exchange, finding the
|
|
4292
|
+
// mark after its write, revokes the connection itself
|
|
4293
|
+
const midway = new URL((await w.authorize(authorizeQuery(), 'skip')).headers.get('location')!).searchParams.get('code')!;
|
|
4294
|
+
const midAccount = (await ids()).find((a) => !known.includes(a) && a !== racedAccount) ?? '';
|
|
4295
|
+
await applyTwinWrite('stripe', { operation: 'oauth_code.reuse', subjectType: '_oauth_code', subjectId: midway, fields: { reused: true }, occurredAt: '2026-03-01T00:05:00.000Z', actor: { kind: 'agent' } }, w.root);
|
|
4296
|
+
const midExchange = await e(`grant_type=authorization_code&code=${midway}`);
|
|
4297
|
+
const midAfter = midAccount ? await w.api('GET', '/v1/customers', undefined, midAccount) : { status: 0, body: {} };
|
|
4298
|
+
return shape && pi.status === 200 && pi.body.object === 'payment_intent' && retrieved.default_currency === 'usd'
|
|
4299
|
+
&& midExchange.status === 400 && midExchange.error === 'invalid_grant' && midAfter.status === 403
|
|
4300
|
+
&& noKey.status === 401 && publishable.status === 401 && noGrant.status === 400 && noGrant.error === 'invalid_request' && password.error === 'unsupported_grant_type'
|
|
4301
|
+
&& noCode.error === 'invalid_request' && missing.status === 400 && missing.error === 'invalid_grant' && missing.error_description === 'Authorization code does not exist: ac_nope'
|
|
4302
|
+
&& basic.status === 200 && basic.token_type === 'bearer' && basic.stripe_user_id !== account
|
|
4303
|
+
&& inTime.status === 200 && late.status === 400 && late.error === 'invalid_grant' && String(late.error_description).includes('expired')
|
|
4304
|
+
&& liveKey.error === 'invalid_grant' && asJson.status === 200 && /^acct_/.test(String(asJsonBody.stripe_user_id))
|
|
4305
|
+
&& reused.status === 400 && reused.error === 'invalid_grant' && String(reused.error_description).includes('already been used')
|
|
4306
|
+
&& after.status === 403 && (after.body.error as Body)?.code === 'account_invalid' && others.status === 200
|
|
4307
|
+
&& pair.filter((p) => p.status === 200).length <= 1 && pair.some((p) => p.error === 'invalid_grant') && racedAfter.status === 403;
|
|
4308
|
+
}),
|
|
4309
|
+
),
|
|
4310
|
+
// grant_type=refresh_token: "a new access token of an equal or lesser scope"; "Any existing access token with the same
|
|
4311
|
+
// scope and mode ... is revoked"; a refresh token of a revoked connection is refused.
|
|
4312
|
+
done('stripe.connect.oauth_refresh', 'connect', 'Connect OAuth refresh_token grant: a new access token of equal or lesser scope; invalid_scope / invalid_grant', 'api', 'niche', () =>
|
|
4313
|
+
withOAuth(async (w) => {
|
|
4314
|
+
const { token } = await w.connect();
|
|
4315
|
+
const e = async (body: string) => { const r = await w.endpoint('token', body); return { status: r.status, ...r.body } as Body; };
|
|
4316
|
+
const same = await e(`grant_type=refresh_token&refresh_token=${String(token.refresh_token)}`);
|
|
4317
|
+
const lesser = await e(`grant_type=refresh_token&refresh_token=${String(token.refresh_token)}&scope=read_only`);
|
|
4318
|
+
const bogus = await e(`grant_type=refresh_token&refresh_token=${String(token.refresh_token)}&scope=admin`);
|
|
4319
|
+
const liveRefresh = await w.endpoint('token', `grant_type=refresh_token&refresh_token=${String(token.refresh_token)}`, 'Bearer sk_live_platform');
|
|
4320
|
+
const noToken = await e('grant_type=refresh_token');
|
|
4321
|
+
const unknown = await e('grant_type=refresh_token&refresh_token=rt_nope');
|
|
4322
|
+
// a read_only connection cannot refresh into read_write
|
|
4323
|
+
const ro = (await w.connect('').then(async () => {
|
|
4324
|
+
const loc = new URL((await w.authorize(authorizeQuery().replace('scope=read_write', 'scope=read_only'), 'skip')).headers.get('location')!);
|
|
4325
|
+
return (await w.endpoint('token', `grant_type=authorization_code&code=${loc.searchParams.get('code')}`)).body;
|
|
4326
|
+
}));
|
|
4327
|
+
const greater = await e(`grant_type=refresh_token&refresh_token=${String(ro.refresh_token)}&scope=read_write`);
|
|
4328
|
+
await w.endpoint('deauthorize', `client_id=ca_twin_self&stripe_user_id=${String(token.stripe_user_id)}`);
|
|
4329
|
+
const revoked = await e(`grant_type=refresh_token&refresh_token=${String(token.refresh_token)}`);
|
|
4330
|
+
return same.status === 200 && same.scope === 'read_write' && same.refresh_token === token.refresh_token && same.stripe_user_id === token.stripe_user_id
|
|
4331
|
+
&& same.access_token !== token.access_token && /^sk_test_/.test(String(same.access_token))
|
|
4332
|
+
&& lesser.status === 200 && lesser.scope === 'read_only' && lesser.access_token !== same.access_token
|
|
4333
|
+
&& bogus.error === 'invalid_scope' && liveRefresh.body.error === 'invalid_grant' && noToken.error === 'invalid_request' && unknown.status === 400 && unknown.error === 'invalid_grant'
|
|
4334
|
+
&& ro.scope === 'read_only' && greater.error === 'invalid_scope' && revoked.error === 'invalid_grant';
|
|
4335
|
+
}),
|
|
4336
|
+
),
|
|
4337
|
+
// POST connect.stripe.com/oauth/deauthorize: `{ stripe_user_id }`, and then "the account can't be accessed by your
|
|
4338
|
+
// platform ... through the API" (the Stripe-Account header, /v1/accounts/{account}); invalid_request and invalid_client
|
|
4339
|
+
// as the reference lists them. "You can only revoke a Standard account's access": an account the platform made itself
|
|
4340
|
+
// was never connected by OAuth, so it is not connected to the application.
|
|
4341
|
+
done('stripe.connect.oauth_deauthorize', 'connect', 'Connect OAuth deauthorize: revokes the connection; the account is then refused with the Stripe-Account header; invalid_request / invalid_client', 'api', 'common', () =>
|
|
4342
|
+
withOAuth(async (w) => {
|
|
4343
|
+
const { token } = await w.connect();
|
|
4344
|
+
const other = (await w.connect()).token;
|
|
4345
|
+
const account = String(token.stripe_user_id);
|
|
4346
|
+
const e = async (body: string, auth?: string) => { const r = await w.endpoint('deauthorize', body, auth); return { status: r.status, ...r.body } as Body; };
|
|
4347
|
+
const made = String((await w.api('POST', '/v1/accounts', 'type=standard&country=US')).body.id);
|
|
4348
|
+
const noClient = await e(`stripe_user_id=${account}`);
|
|
4349
|
+
const noKey = await e(`client_id=ca_twin_self&stripe_user_id=${account}`, '');
|
|
4350
|
+
const noAccount = await e('client_id=ca_twin_self');
|
|
4351
|
+
const wrongClient = await e(`client_id=ca_nope&stripe_user_id=${account}`);
|
|
4352
|
+
const liveKey = await e(`client_id=ca_twin_self&stripe_user_id=${account}`, 'Bearer sk_live_platform');
|
|
4353
|
+
const notConnected = await e(`client_id=ca_twin_self&stripe_user_id=${made}`);
|
|
4354
|
+
const before = await w.api('GET', '/v1/customers', undefined, account);
|
|
4355
|
+
const revoked = await e(`client_id=ca_twin_self&stripe_user_id=${account}`);
|
|
4356
|
+
const asIt = await w.api('GET', '/v1/customers', undefined, account);
|
|
4357
|
+
const named = await w.api('GET', `/v1/accounts/${account}`);
|
|
4358
|
+
const again = await e(`client_id=ca_twin_self&stripe_user_id=${account}`);
|
|
4359
|
+
const listed = ((await w.api('GET', '/v1/accounts?limit=100')).body.data as Body[]).map((a) => a.id);
|
|
4360
|
+
const stillOthers = await w.api('GET', '/v1/customers', undefined, String(other.stripe_user_id));
|
|
4361
|
+
const stillMade = await w.api('GET', '/v1/customers', undefined, made);
|
|
4362
|
+
return before.status === 200 && revoked.status === 200 && Object.keys(revoked).sort().join(',') === 'status,stripe_user_id' && revoked.stripe_user_id === account
|
|
4363
|
+
&& asIt.status === 403 && (asIt.body.error as Body)?.code === 'account_invalid' && String((asIt.body.error as Body)?.message).includes(account)
|
|
4364
|
+
&& named.status === 403 && again.status === 400 && again.error === 'invalid_client'
|
|
4365
|
+
&& noKey.status === 401 && noClient.error === 'invalid_request' && noAccount.error === 'invalid_request' && wrongClient.error === 'invalid_client' && liveKey.error === 'invalid_client'
|
|
4366
|
+
&& notConnected.error === 'invalid_client' && stillOthers.status === 200 && stillMade.status === 200
|
|
4367
|
+
&& !listed.includes(account) && listed.includes(other.stripe_user_id) && listed.includes(made);
|
|
4368
|
+
}),
|
|
4369
|
+
),
|
|
4370
|
+
// account.application.authorized "Occurs whenever a user authorizes an application" and account.application.deauthorized
|
|
4371
|
+
// "whenever a user deauthorizes an application" (docs.stripe.com/api/events/types), each the connected account's event
|
|
4372
|
+
// (top-level `account`, Connect endpoints: docs.stripe.com/connect/webhooks) carrying the application.
|
|
4373
|
+
done('stripe.connect.oauth_events', 'connect', 'Connect OAuth events: account.application.authorized and .deauthorized reach Connect endpoints with the account and the application', 'api', 'common', async () => {
|
|
4374
|
+
const rx = await webhookReceiver();
|
|
4375
|
+
try {
|
|
4376
|
+
return await withOAuth(async (w) => {
|
|
4377
|
+
await w.api('POST', '/v1/webhook_endpoints', `url=${rx.url('/platform')}&enabled_events[]=*`);
|
|
4378
|
+
const connectEndpoint = await w.api('POST', '/v1/webhook_endpoints', `url=${rx.url('/connect')}&enabled_events[]=*&connect=true`);
|
|
4379
|
+
const { token } = await w.connect();
|
|
4380
|
+
const account = String(token.stripe_user_id);
|
|
4381
|
+
// a charge on the account, whose funds come due after the connection is revoked
|
|
4382
|
+
await w.api('POST', '/v1/charges', 'amount=5000¤cy=usd&source=tok_visa', account);
|
|
4383
|
+
const before = rx.got('/connect').filter((e) => e.account === account && e.type === 'charge.succeeded').length;
|
|
4384
|
+
const authorized = rx.got('/connect').filter((e) => e.type === 'account.application.authorized');
|
|
4385
|
+
// two deauthorizations at once: one revokes (and one event is sent), the other finds it no longer connected (a
|
|
4386
|
+
// regression guard: in one process the check and the revoke never interleave)
|
|
4387
|
+
const twice = await Promise.all([1, 2].map(() => w.endpoint('deauthorize', `client_id=ca_twin_self&stripe_user_id=${account}`)));
|
|
4388
|
+
const deauthorized = rx.got('/connect').filter((e) => e.type === 'account.application.deauthorized');
|
|
4389
|
+
const seen = rx.got('/connect').length;
|
|
4390
|
+
// days later its funds are available: the account's own balance.available no longer reaches the platform
|
|
4391
|
+
w.at('2026-03-06T00:00:00.000Z');
|
|
4392
|
+
const drained = await w.f(new Request('http://stripe.test/_twin/drain', { method: 'POST' }));
|
|
4393
|
+
const drainedBody = (await drained.json()) as { delivered: Array<{ type: string; account?: string }> };
|
|
4394
|
+
const leaked = rx.got('/connect').slice(seen).filter((e) => e.account === account);
|
|
4395
|
+
const app = authorized[0]?.data.object as Body | undefined;
|
|
4396
|
+
return connectEndpoint.status === 200 && connectEndpoint.body.connect === true
|
|
4397
|
+
&& authorized.length === 1 && authorized[0]!.account === account && app?.object === 'application' && app?.id === 'ca_twin_self' && typeof app?.name === 'string'
|
|
4398
|
+
&& before === 1 && drainedBody.delivered.some((d) => d.account === account && d.type === 'balance.available') && leaked.length === 0
|
|
4399
|
+
&& twice.filter((r) => r.status === 200).length === 1 && twice.some((r) => r.body.error === 'invalid_client')
|
|
4400
|
+
&& deauthorized.length === 1 && deauthorized[0]!.account === account && (deauthorized[0]!.data.object as Body).id === 'ca_twin_self'
|
|
4401
|
+
&& !rx.got('/platform').some((e) => e.type.startsWith('account.application.'));
|
|
4402
|
+
});
|
|
4403
|
+
} finally {
|
|
4404
|
+
rx.close();
|
|
4405
|
+
}
|
|
4406
|
+
}),
|
|
4407
|
+
// The token answer's access_token and stripe_publishable_key act as the connected account ("Use the Stripe-Account
|
|
4408
|
+
// header with your platform's secret key", the reference, for the deprecated keys): a request made with one is the
|
|
4409
|
+
// account's (its events are its own, GET /v1/account answers it), and it is not the platform's key at the OAuth
|
|
4410
|
+
// endpoints; a key no live connection holds, after a refresh
|
|
4411
|
+
// ("Any existing access token with the same scope and mode ... is revoked") or a deauthorize, is an invalid API key.
|
|
4412
|
+
done('stripe.connect.oauth_access_token_auth', 'connect', 'Connect OAuth keys: the access_token and stripe_publishable_key act as the connected account; a refreshed-away or revoked token is refused 401', 'api', 'niche', () =>
|
|
4413
|
+
withOAuth(async (w) => {
|
|
4414
|
+
const { token } = await w.connect();
|
|
4415
|
+
const account = String(token.stripe_user_id);
|
|
4416
|
+
const as = async (m: string, p: string, key: unknown, b?: string, acct?: string) => {
|
|
4417
|
+
const r = await w.f(new Request(`http://stripe.test${p}`, { method: m, headers: { authorization: `Bearer ${String(key)}`, 'content-type': 'application/x-www-form-urlencoded', ...(acct ? { 'stripe-account': acct } : {}) }, ...(b !== undefined ? { body: b } : {}) }));
|
|
4418
|
+
return { status: r.status, body: (await r.json()) as Body };
|
|
4419
|
+
};
|
|
4420
|
+
const me = await as('GET', '/v1/account', token.access_token);
|
|
4421
|
+
// curl -u sk_…: (Basic) is the same key
|
|
4422
|
+
const basicMe = await w.f(new Request('http://stripe.test/v1/account', { headers: { authorization: `Basic ${btoa(`${String(token.access_token)}:`)}` } }));
|
|
4423
|
+
const basicBody = (await basicMe.json()) as Body;
|
|
4424
|
+
const unknownAccount = await w.api('GET', '/v1/account', undefined, 'acct_twin_nope');
|
|
4425
|
+
// the account's key is not the platform's: it cannot exchange, refresh or deauthorize
|
|
4426
|
+
const asPlatform = await w.endpoint('token', `grant_type=refresh_token&refresh_token=${String(token.refresh_token)}`, `Bearer ${String(token.access_token)}`);
|
|
4427
|
+
const deauthAsIt = await w.endpoint('deauthorize', `client_id=ca_twin_self&stripe_user_id=${account}`, `Bearer ${String(token.access_token)}`);
|
|
4428
|
+
const platformSelf = await w.api('GET', '/v1/account');
|
|
4429
|
+
const viaHeader = await w.api('GET', '/v1/account', undefined, account);
|
|
4430
|
+
const made = await as('POST', '/v1/customers', token.access_token, 'email=buyer@connected.test');
|
|
4431
|
+
const theirs = ((await w.api('GET', '/v1/events?type=customer.created', undefined, account)).body.data as Body[]).map((e) => ((e.data as Body).object as Body).id);
|
|
4432
|
+
const ours = ((await w.api('GET', '/v1/events?type=customer.created')).body.data as Body[]).map((e) => ((e.data as Body).object as Body).id);
|
|
4433
|
+
// the publishable key acts as the account and is still only a publishable key
|
|
4434
|
+
const pkRead = await as('GET', '/v1/customers', token.stripe_publishable_key);
|
|
4435
|
+
const elsewhere = await as('GET', '/v1/customers', token.access_token, undefined, 'acct_twin_other');
|
|
4436
|
+
const refreshed = (await w.endpoint('token', `grant_type=refresh_token&refresh_token=${String(token.refresh_token)}`)).body;
|
|
4437
|
+
const oldToken = await as('GET', '/v1/customers', token.access_token);
|
|
4438
|
+
const newToken = await as('GET', '/v1/customers', refreshed.access_token);
|
|
4439
|
+
const forged = await as('GET', '/v1/customers', `sk_test_oauth_${account}_9`);
|
|
4440
|
+
await w.endpoint('deauthorize', `client_id=ca_twin_self&stripe_user_id=${account}`);
|
|
4441
|
+
const afterRevoke = await as('GET', '/v1/customers', refreshed.access_token);
|
|
4442
|
+
return me.status === 200 && me.body.id === account && me.body.type === 'standard' && platformSelf.body.id === 'acct_twin_self' && viaHeader.body.id === account
|
|
4443
|
+
&& basicMe.status === 200 && basicBody.id === account && unknownAccount.status === 403 && (unknownAccount.body.error as Body)?.code === 'account_invalid'
|
|
4444
|
+
&& asPlatform.status === 401 && deauthAsIt.status === 401
|
|
4445
|
+
&& made.status === 200 && theirs.includes(made.body.id) && !ours.includes(made.body.id)
|
|
4446
|
+
&& pkRead.status === 403 && (pkRead.body.error as Body)?.code === 'secret_key_required'
|
|
4447
|
+
&& elsewhere.status === 403 && (elsewhere.body.error as Body)?.code === 'account_invalid'
|
|
4448
|
+
&& oldToken.status === 401 && newToken.status === 200 && ((newToken.body.data as Body[]) ?? []).some((c) => c.id === made.body.id)
|
|
4449
|
+
&& forged.status === 401 && afterRevoke.status === 401;
|
|
4450
|
+
}),
|
|
4451
|
+
),
|
|
4452
|
+
todo('stripe.connect.oauth_scope_enforcement', 'connect', 'Connect OAuth: a read_only connection (or access token) is refused writes as the account', 'api', 'niche'),
|
|
4453
|
+
todo('stripe.connect.oauth_existing_account', 'connect', 'Connect OAuth: sign in and connect an existing Stripe account instead of creating one', 'api', 'niche'),
|
|
4454
|
+
todo('stripe.connect.oauth_account_application', 'connect', 'Connect OAuth: the full account application form (the page offers only the test-mode Skip this form)', 'ui', 'niche'),
|
|
4455
|
+
|
|
3836
4456
|
// ── Connect: account links (hosted onboarding) ──
|
|
3837
4457
|
done('stripe.connect.account_links', 'connect', 'Connect: account_links (onboarding URL)', 'api', 'niche', () =>
|
|
3838
4458
|
withRoot(async (h) => {
|
|
@@ -3892,7 +4512,12 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
3892
4512
|
const g = await h({ m: 'GET', p: `/v1/accounts/${id(acct)}/capabilities/card_payments` });
|
|
3893
4513
|
const req = await h({ m: 'POST', p: `/v1/accounts/${id(acct)}/capabilities/card_payments`, b: 'requested=true' });
|
|
3894
4514
|
const miss = await h({ m: 'GET', p: `/v1/accounts/${id(acct)}/capabilities/bogus` });
|
|
3895
|
-
|
|
4515
|
+
// requested on an account that still owes its information, Stripe's review leaves it inactive with what is due
|
|
4516
|
+
// (the capability's `requirements`; "inactive ... Check requirements", docs.stripe.com/api/capabilities/object;
|
|
4517
|
+
// semantics/connect.ts review, 44dea3cb3), where it used to answer a pending that nothing ever moved
|
|
4518
|
+
const due = ((field(req, 'requirements') as Body | undefined)?.currently_due as string[] | undefined) ?? [];
|
|
4519
|
+
return ok(g) && field(g, 'object') === 'capability' && ok(req) && field(req, 'requested') === true && field(req, 'status') === 'inactive'
|
|
4520
|
+
&& due.includes('business_profile.mcc') && miss.status === 404;
|
|
3896
4521
|
}),
|
|
3897
4522
|
),
|
|
3898
4523
|
|
|
@@ -3969,13 +4594,17 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
3969
4594
|
// scoped overwrite + find/delete are produced ONLY by this feature.)
|
|
3970
4595
|
done('stripe.connect.secrets', 'connect', 'Connect: apps secret store', 'api', 'niche', () =>
|
|
3971
4596
|
withRoot(async (h) => {
|
|
4597
|
+
// a secret's value is "nullable string Includable": answered only when the request expands it
|
|
4598
|
+
// (docs.stripe.com/api/secret_management; stripe-version.ts INCLUDABLE, 1d786078c), never on a plain answer
|
|
3972
4599
|
const set = await h({ m: 'POST', p: '/v1/apps/secrets', b: 'name=api_key&scope[type]=account&payload=sk_secret_1' });
|
|
3973
|
-
if (!ok(set) || field(set, 'object') !== 'apps.secret' || field(set, 'payload') !==
|
|
3974
|
-
const
|
|
4600
|
+
if (!ok(set) || field(set, 'object') !== 'apps.secret' || field(set, 'payload') !== undefined) return false;
|
|
4601
|
+
const plainFind = await h({ m: 'GET', p: '/v1/apps/secrets/find?name=api_key&scope[type]=account' });
|
|
4602
|
+
if (!ok(plainFind) || field(plainFind, 'payload') !== undefined) return false;
|
|
4603
|
+
const find = await h({ m: 'GET', p: '/v1/apps/secrets/find?name=api_key&scope[type]=account&expand[]=payload' });
|
|
3975
4604
|
if (!ok(find) || field(find, 'payload') !== 'sk_secret_1') return false;
|
|
3976
4605
|
// overwrite: same name+scope updates in place (no duplicate in the list).
|
|
3977
4606
|
const set2 = await h({ m: 'POST', p: '/v1/apps/secrets', b: 'name=api_key&scope[type]=account&payload=sk_secret_2' });
|
|
3978
|
-
const find2 = await h({ m: 'GET', p: '/v1/apps/secrets/find?name=api_key&scope[type]=account' });
|
|
4607
|
+
const find2 = await h({ m: 'GET', p: '/v1/apps/secrets/find?name=api_key&scope[type]=account&expand[]=payload' });
|
|
3979
4608
|
const list = await h({ m: 'GET', p: '/v1/apps/secrets?scope[type]=account' });
|
|
3980
4609
|
if (!ok(set2) || field(find2, 'payload') !== 'sk_secret_2' || ((list.body as Body).data as Body[]).length !== 1) return false;
|
|
3981
4610
|
// delete → find 404s.
|
|
@@ -3998,8 +4627,9 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
3998
4627
|
done('stripe.payment_intents.apply_customer_balance', 'payment_intents', 'PaymentIntents: apply_customer_balance', 'api', 'niche', () =>
|
|
3999
4628
|
withRoot(async (h) => {
|
|
4000
4629
|
const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=acb@twin.test' });
|
|
4001
|
-
// partial funds first
|
|
4002
|
-
|
|
4630
|
+
// partial funds first, through the test helper ("Create an incoming testmode bank transfer",
|
|
4631
|
+
// docs.stripe.com/api/cash_balance/fund_cash_balance): the twin-invented POST .../cash_balance_transactions is gone (5decb1d77)
|
|
4632
|
+
await h({ m: 'POST', p: `/v1/test_helpers/customers/${id(cust)}/fund_cash_balance`, b: 'amount=600¤cy=usd' });
|
|
4003
4633
|
const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: `amount=1000¤cy=usd&customer=${id(cust)}&allowed_payment_method_types[]=customer_balance` });
|
|
4004
4634
|
if (!ok(pi)) return false;
|
|
4005
4635
|
const partial = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/apply_customer_balance` });
|
|
@@ -4007,7 +4637,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
4007
4637
|
const na = field(partial, 'next_action') as Body;
|
|
4008
4638
|
if (na?.type !== 'display_bank_transfer_instructions' || (na.display_bank_transfer_instructions as Body).amount_remaining !== 400) return false;
|
|
4009
4639
|
// top up the rest → succeeds.
|
|
4010
|
-
await h({ m: 'POST', p: `/v1/customers/${id(cust)}/
|
|
4640
|
+
await h({ m: 'POST', p: `/v1/test_helpers/customers/${id(cust)}/fund_cash_balance`, b: 'amount=400¤cy=usd' });
|
|
4011
4641
|
const full = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/apply_customer_balance` });
|
|
4012
4642
|
if (!ok(full) || field(full, 'status') !== 'succeeded' || field(full, 'amount_received') !== 1000) return false;
|
|
4013
4643
|
// a succeeded PI 400s; unknown id 404.
|
|
@@ -4034,10 +4664,14 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
4034
4664
|
const both = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/verify_microdeposits`, b: 'amounts[]=32&amounts[]=45&descriptor_code=SM11AA' });
|
|
4035
4665
|
const none = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/verify_microdeposits` });
|
|
4036
4666
|
if (both.status !== 400 || none.status !== 400) return false;
|
|
4037
|
-
// correct amounts →
|
|
4667
|
+
// correct amounts → processing, next_action cleared: "When the bank account is successfully verified, Stripe returns
|
|
4668
|
+
// the PaymentIntent object with a status of `processing`" (docs.stripe.com/payments/ach-direct-debit/accept-a-payment;
|
|
4669
|
+
// 242035d1c); a test-mode debit then settles ("Test transactions settle instantly"), so the next read has succeeded
|
|
4038
4670
|
const good = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/verify_microdeposits`, b: 'amounts[]=32&amounts[]=45' });
|
|
4671
|
+
const settled = await h({ m: 'GET', p: `/v1/payment_intents/${id(pi)}` });
|
|
4039
4672
|
const missing = await h({ m: 'POST', p: '/v1/payment_intents/pi_nope/verify_microdeposits', b: 'amounts[]=32&amounts[]=45' });
|
|
4040
|
-
return ok(good) && field(good, 'status') === '
|
|
4673
|
+
return ok(good) && field(good, 'status') === 'processing' && field(good, 'next_action') === null
|
|
4674
|
+
&& field(settled, 'status') === 'succeeded' && missing.status === 404;
|
|
4041
4675
|
}),
|
|
4042
4676
|
),
|
|
4043
4677
|
// SetupIntent micro-deposit verification: same flow for saving an ACH bank account — confirm
|
|
@@ -4254,28 +4888,35 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
4254
4888
|
// summary are produced ONLY by this feature.)
|
|
4255
4889
|
done('stripe.billing.credit_grants', 'billing', 'Billing credit grants (prepaid credits)', 'api', 'niche', () =>
|
|
4256
4890
|
withRoot(async (h) => {
|
|
4891
|
+
// the served spec requires what a grant applies to (applicability_config) and what a balance summary filters on
|
|
4892
|
+
// (filter), which the parameter check enforces (e6bfff516: "a missing required one [answers] 400
|
|
4893
|
+
// parameter_missing", docs.stripe.com/error-codes#parameter-missing); every request gives them, so each refusal
|
|
4894
|
+
// below is still the one it names
|
|
4895
|
+
const APPLIES = 'applicability_config[scope][price_type]=metered';
|
|
4896
|
+
const METERED = 'filter[type]=applicability_scope&filter[applicability_scope][price_type]=metered';
|
|
4257
4897
|
const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=credgr@twin.test' });
|
|
4258
|
-
const g = await h({ m: 'POST', p: '/v1/billing/credit_grants', b: `customer=${id(cust)}&category=promotional&name=Welcome&amount[type]=monetary&amount[monetary][value]=1000&amount[monetary][currency]=usd` });
|
|
4898
|
+
const g = await h({ m: 'POST', p: '/v1/billing/credit_grants', b: `customer=${id(cust)}&category=promotional&name=Welcome&amount[type]=monetary&amount[monetary][value]=1000&amount[monetary][currency]=usd&${APPLIES}` });
|
|
4259
4899
|
if (!ok(g) || field(g, 'object') !== 'billing.credit_grant' || field(g, 'category') !== 'promotional') return false;
|
|
4260
4900
|
if (((field(g, 'amount') as Body)?.monetary as Body)?.value !== 1000) return false;
|
|
4261
4901
|
const get = await h({ m: 'GET', p: `/v1/billing/credit_grants/${id(g)}` });
|
|
4262
4902
|
const list = await h({ m: 'GET', p: `/v1/billing/credit_grants?customer=${id(cust)}` });
|
|
4263
4903
|
if (!ok(get) || id(get) !== id(g) || ((list.body as Body).data as Body[]).length !== 1) return false;
|
|
4264
4904
|
// balance summary reflects the active grant.
|
|
4265
|
-
const sum = await h({ m: 'GET', p: `/v1/billing/credit_balance_summary?customer=${id(cust)}` });
|
|
4905
|
+
const sum = await h({ m: 'GET', p: `/v1/billing/credit_balance_summary?customer=${id(cust)}&${METERED}` });
|
|
4266
4906
|
const balances = (sum.body as Body).balances as Body[];
|
|
4267
4907
|
if (!ok(sum) || (((balances[0]!.available_balance as Body).monetary as Body).value) !== 1000) return false;
|
|
4268
4908
|
// void clears it from the balance.
|
|
4269
4909
|
const voided = await h({ m: 'POST', p: `/v1/billing/credit_grants/${id(g)}/void` });
|
|
4270
4910
|
if (!ok(voided) || field(voided, 'voided_at') === null) return false;
|
|
4271
|
-
const sum2 = await h({ m: 'GET', p: `/v1/billing/credit_balance_summary?customer=${id(cust)}` });
|
|
4911
|
+
const sum2 = await h({ m: 'GET', p: `/v1/billing/credit_balance_summary?customer=${id(cust)}&${METERED}` });
|
|
4272
4912
|
if (((sum2.body as Body).balances as Body[]).length !== 0) return false;
|
|
4273
4913
|
// vendor errors.
|
|
4274
|
-
const noCust = await h({ m: 'POST', p: '/v1/billing/credit_grants', b: 'customer=cus_nope&category=paid&amount[type]=monetary&amount[monetary][value]=10&amount[monetary][currency]=usd' });
|
|
4275
|
-
const badCat = await h({ m: 'POST', p: '/v1/billing/credit_grants', b: `customer=${id(cust)}&category=bogus&amount[type]=monetary&amount[monetary][value]=10&amount[monetary][currency]=usd` });
|
|
4276
|
-
const noAmt = await h({ m: 'POST', p: '/v1/billing/credit_grants', b: `customer=${id(cust)}&category=paid` });
|
|
4914
|
+
const noCust = await h({ m: 'POST', p: '/v1/billing/credit_grants', b: 'customer=cus_nope&category=paid&amount[type]=monetary&amount[monetary][value]=10&amount[monetary][currency]=usd&' + APPLIES });
|
|
4915
|
+
const badCat = await h({ m: 'POST', p: '/v1/billing/credit_grants', b: `customer=${id(cust)}&category=bogus&amount[type]=monetary&amount[monetary][value]=10&amount[monetary][currency]=usd&${APPLIES}` });
|
|
4916
|
+
const noAmt = await h({ m: 'POST', p: '/v1/billing/credit_grants', b: `customer=${id(cust)}&category=paid&${APPLIES}` });
|
|
4277
4917
|
const missing = await h({ m: 'GET', p: '/v1/billing/credit_grants/credgr_nope' });
|
|
4278
|
-
return noCust.status === 404 && badCat.status === 400 &&
|
|
4918
|
+
return noCust.status === 404 && badCat.status === 400 && ((badCat.body as Body).error as Body)?.code === 'parameter_invalid_string_enum'
|
|
4919
|
+
&& noAmt.status === 400 && ((noAmt.body as Body).error as Body)?.param === 'amount' && missing.status === 404;
|
|
4279
4920
|
}),
|
|
4280
4921
|
),
|
|
4281
4922
|
// Billing meters lifecycle: create→list (status filter)→deactivate (active→inactive)→
|
|
@@ -4407,7 +5048,9 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
4407
5048
|
withRoot(async (h) => {
|
|
4408
5049
|
const calc = await h({ m: 'POST', p: '/v1/tax/calculations', b: 'currency=usd&line_items[0][amount]=1000&line_items[0][reference]=sku_1&customer_details[address][country]=US' });
|
|
4409
5050
|
if (!ok(calc)) return false;
|
|
4410
|
-
|
|
5051
|
+
// a transaction's line_items is "nullable object Includable" (docs.stripe.com/api/tax/transactions/object; 107591e5f):
|
|
5052
|
+
// absent unless the request expands it, as it is here and on the reversal
|
|
5053
|
+
const txn = await h({ m: 'POST', p: '/v1/tax/transactions/create_from_calculation', b: `calculation=${id(calc)}&reference=order_123&expand[]=line_items` });
|
|
4411
5054
|
if (!ok(txn) || field(txn, 'object') !== 'tax.transaction' || field(txn, 'type') !== 'transaction' || field(txn, 'reference') !== 'order_123') return false;
|
|
4412
5055
|
const txnLines = ((field(txn, 'line_items') as Body).data as Body[]) ?? [];
|
|
4413
5056
|
if (txnLines.length !== 1 || txnLines[0]!.amount_tax !== 100) return false;
|
|
@@ -4415,7 +5058,8 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
4415
5058
|
const li = await h({ m: 'GET', p: `/v1/tax/transactions/${id(txn)}/line_items` });
|
|
4416
5059
|
if (!ok(g) || id(g) !== id(txn) || ((li.body as Body).data as Body[]).length !== 1) return false;
|
|
4417
5060
|
// a reversal NEGATES the amounts.
|
|
4418
|
-
|
|
5061
|
+
if (field(g, 'line_items') !== undefined) return false;
|
|
5062
|
+
const rev = await h({ m: 'POST', p: '/v1/tax/transactions/create_reversal', b: `original_transaction=${id(txn)}&reference=refund_123&mode=full&expand[]=line_items` });
|
|
4419
5063
|
if (!ok(rev) || field(rev, 'type') !== 'reversal') return false;
|
|
4420
5064
|
const revLines = ((field(rev, 'line_items') as Body).data as Body[]) ?? [];
|
|
4421
5065
|
if (revLines[0]!.amount_tax !== -100 || (field(rev, 'reversal') as Body)?.original_transaction !== id(txn)) return false;
|
|
@@ -4552,15 +5196,18 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
4552
5196
|
// retrieve returned nothing, if a status update didn't persist, or if filtering were broken.
|
|
4553
5197
|
done('stripe.issuing.tokens', 'issuing', 'Issuing: network tokens', 'api', 'niche', () =>
|
|
4554
5198
|
withRoot(async (h) => {
|
|
4555
|
-
|
|
4556
|
-
const
|
|
5199
|
+
const ch = await h({ m: 'POST', p: '/v1/issuing/cardholders', b: 'name=Jane&type=individual&billing[address][country]=US' });
|
|
5200
|
+
const card = await h({ m: 'POST', p: '/v1/issuing/cards', b: `cardholder=${id(ch)}¤cy=usd&type=virtual` });
|
|
5201
|
+
// a card with no wallet yet lists none; the list is always of one card's tokens (the served spec requires `card`,
|
|
5202
|
+
// which the parameter check enforces, e6bfff516), so the bare list is refused; unknown ids 404 on retrieve + update.
|
|
5203
|
+
const empty = await h({ m: 'GET', p: `/v1/issuing/tokens?card=${id(card)}` });
|
|
4557
5204
|
if (!ok(empty) || field(empty, 'object') !== 'list' || ((empty.body as Body).data as Body[]).length !== 0) return false;
|
|
5205
|
+
const unscoped = await h({ m: 'GET', p: '/v1/issuing/tokens' });
|
|
5206
|
+
if (unscoped.status !== 400 || ((unscoped.body as Body).error as Body)?.param !== 'card') return false;
|
|
4558
5207
|
const missing = await h({ m: 'GET', p: '/v1/issuing/tokens/iss_tok_nope' });
|
|
4559
5208
|
const badUpd = await h({ m: 'POST', p: '/v1/issuing/tokens/iss_tok_nope', b: 'status=active' });
|
|
4560
5209
|
if (missing.status !== 404 || badUpd.status !== 404) return false;
|
|
4561
5210
|
// a token is network-minted when the cardholder adds a real card to a phone's wallet (the twin's stand-in).
|
|
4562
|
-
const ch = await h({ m: 'POST', p: '/v1/issuing/cardholders', b: 'name=Jane&type=individual&billing[address][country]=US' });
|
|
4563
|
-
const card = await h({ m: 'POST', p: '/v1/issuing/cards', b: `cardholder=${id(ch)}¤cy=usd&type=virtual` });
|
|
4564
5211
|
const seeded = await h({ m: 'POST', p: `/_twin/issuing/cards/${id(card)}/wallets`, b: 'wallet_provider=apple_pay' });
|
|
4565
5212
|
if (!ok(seeded) || field(seeded, 'object') !== 'issuing.token' || field(seeded, 'status') !== 'active' || field(seeded, 'card') !== id(card)) return false;
|
|
4566
5213
|
// minting requires a real card.
|
|
@@ -4611,7 +5258,8 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
4611
5258
|
if (!ok(act) || field(act, 'status') !== 'active') return false;
|
|
4612
5259
|
const list = await h({ m: 'GET', p: '/v1/issuing/personalization_designs?status=active' });
|
|
4613
5260
|
if (((list.body as Body).data as Body[]).length !== 1) return false;
|
|
4614
|
-
|
|
5261
|
+
// a rejection says why: the served spec requires rejection_reasons (enforced by the parameter check, e6bfff516)
|
|
5262
|
+
const rej = await h({ m: 'POST', p: `/v1/test_helpers/issuing/personalization_designs/${id(d)}/reject`, b: 'rejection_reasons[card_logo][]=inappropriate' });
|
|
4615
5263
|
const noBundle = await h({ m: 'POST', p: '/v1/issuing/personalization_designs', b: 'name=x' });
|
|
4616
5264
|
const missing = await h({ m: 'GET', p: '/v1/issuing/personalization_designs/pd_nope' });
|
|
4617
5265
|
return ok(rej) && field(rej, 'status') === 'rejected' && noBundle.status === 400 && missing.status === 404;
|
|
@@ -4652,12 +5300,17 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
4652
5300
|
// produced ONLY by this feature.)
|
|
4653
5301
|
done('stripe.treasury.financial_accounts', 'treasury', 'Treasury: financial accounts', 'api', 'niche', () =>
|
|
4654
5302
|
withRoot(async (h) => {
|
|
4655
|
-
|
|
5303
|
+
// features as the served spec's treasury.financial_account_features gives them: a toggle (card_issuing) or one per
|
|
5304
|
+
// network (outbound_payments.ach); a requested one is active at once in test mode and one never requested is left
|
|
5305
|
+
// out, each being optional in the spec (semantics/treasury.ts featuresOf, dd5d4344f)
|
|
5306
|
+
const fa = await h({ m: 'POST', p: '/v1/treasury/financial_accounts', b: 'supported_currencies[]=usd&features[card_issuing][requested]=true&features[outbound_payments][ach][requested]=true' });
|
|
4656
5307
|
if (!ok(fa) || field(fa, 'object') !== 'treasury.financial_account' || field(fa, 'status') !== 'open') return false;
|
|
4657
5308
|
const bal = field(fa, 'balance') as Body;
|
|
4658
5309
|
if ((bal.cash as Body).usd !== 0) return false;
|
|
4659
|
-
|
|
4660
|
-
if ((
|
|
5310
|
+
const features = field(fa, 'features') as Body;
|
|
5311
|
+
if ((features.card_issuing as Body).status !== 'active' || ((features.outbound_payments as Body).ach as Body)?.status !== 'active') return false;
|
|
5312
|
+
if (features.deposit_insurance !== undefined) return false;
|
|
5313
|
+
if ((field(fa, 'active_features') as string[]).join(',') !== 'card_issuing,outbound_payments.ach') return false;
|
|
4661
5314
|
const get = await h({ m: 'GET', p: `/v1/treasury/financial_accounts/${id(fa)}` });
|
|
4662
5315
|
const list = await h({ m: 'GET', p: '/v1/treasury/financial_accounts?status=open' });
|
|
4663
5316
|
const feats = await h({ m: 'GET', p: `/v1/treasury/financial_accounts/${id(fa)}/features` });
|
|
@@ -4823,19 +5476,10 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
4823
5476
|
return noPm.status === 400 && noUrl.status === 400 && nope.status === 404;
|
|
4824
5477
|
}),
|
|
4825
5478
|
),
|
|
4826
|
-
// Crypto onramp sessions
|
|
4827
|
-
//
|
|
4828
|
-
|
|
4829
|
-
|
|
4830
|
-
const cos = await h({ m: 'POST', p: '/v1/crypto/onramp_sessions', b: 'transaction_details[destination_currency]=eth&transaction_details[destination_network]=ethereum&transaction_details[source_amount]=100' });
|
|
4831
|
-
if (!ok(cos) || field(cos, 'object') !== 'crypto.onramp_session' || field(cos, 'status') !== 'initialized' || !id(cos).startsWith('cos_')) return false;
|
|
4832
|
-
if (typeof field(cos, 'client_secret') !== 'string') return false;
|
|
4833
|
-
if ((field(cos, 'transaction_details') as Body)?.destination_currency !== 'eth') return false;
|
|
4834
|
-
const get = await h({ m: 'GET', p: `/v1/crypto/onramp_sessions/${id(cos)}` });
|
|
4835
|
-
const nope = await h({ m: 'GET', p: '/v1/crypto/onramp_sessions/cos_nope' });
|
|
4836
|
-
return ok(get) && field(get, 'id') === id(cos) && nope.status === 404;
|
|
4837
|
-
}),
|
|
4838
|
-
),
|
|
5479
|
+
// Crypto onramp sessions (POST/GET /v1/crypto/onramp_sessions, docs.stripe.com/crypto/onramp/api-reference) are Stripe
|
|
5480
|
+
// API, but not in the spec this pack vendors and serves; the twin's hand route for them was removed with the other
|
|
5481
|
+
// routes outside the served spec (5decb1d77), so the capability is a todo again until the lane models it.
|
|
5482
|
+
todo('stripe.crypto.onramp', 'core', 'Crypto onramp sessions', 'api', 'niche'),
|
|
4839
5483
|
|
|
4840
5484
|
// ── HONEST DENOMINATOR GROWTH (real Stripe surfaces NOT yet modeled) ────────────────
|
|
4841
5485
|
// The gaps above were closed this cycle; these enumerate genuine remaining surface so the
|