@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.
Files changed (67) hide show
  1. package/README.md +33 -1
  2. package/dist/src/index.js +6 -4
  3. package/dist/src/manifest.js +8 -3
  4. package/dist/src/screens/checkout.js +20 -6
  5. package/dist/src/screens/connect-oauth.d.ts +27 -0
  6. package/dist/src/screens/connect-oauth.js +414 -0
  7. package/dist/src/screens/connect-settings.d.ts +22 -0
  8. package/dist/src/screens/connect-settings.js +103 -0
  9. package/dist/src/screens/portal.js +2 -0
  10. package/dist/src/semantics/after-payment.d.ts +1 -1
  11. package/dist/src/semantics/after-payment.js +6 -0
  12. package/dist/src/semantics/charges.js +10 -2
  13. package/dist/src/semantics/checkout.js +19 -6
  14. package/dist/src/semantics/connect.js +20 -3
  15. package/dist/src/semantics/invoices.js +4 -0
  16. package/dist/src/semantics/issuing.js +7 -2
  17. package/dist/src/semantics/ledger.d.ts +11 -6
  18. package/dist/src/semantics/ledger.js +40 -21
  19. package/dist/src/semantics/payment-methods.js +2 -0
  20. package/dist/src/semantics/shared.d.ts +5 -1
  21. package/dist/src/semantics/shared.js +14 -3
  22. package/dist/src/semantics/test-cards.d.ts +4 -0
  23. package/dist/src/semantics/test-cards.js +7 -0
  24. package/dist/src/semantics/transfers.js +1 -1
  25. package/dist/src/stripe-capabilities.js +829 -186
  26. package/dist/src/stripe-conformance.d.ts +2 -0
  27. package/dist/src/stripe-conformance.js +11 -2
  28. package/dist/src/stripe-emit.js +2 -2
  29. package/dist/src/stripe-events.js +14 -10
  30. package/dist/src/stripe-mirror-ui.js +3 -3
  31. package/dist/src/stripe-server.js +97 -24
  32. package/dist/src/stripe-shared.d.ts +3 -0
  33. package/dist/src/stripe-shared.js +3 -0
  34. package/dist/src/stripe-twin.js +7 -1
  35. package/dist/src/stripe-version.d.ts +2 -0
  36. package/dist/src/stripe-version.js +2 -0
  37. package/dist/test-fixtures/stripe-known-deviations.json +6 -1
  38. package/dist/test-fixtures/stripe-schemas.json +85 -12
  39. package/package.json +4 -4
  40. package/src/index.ts +6 -4
  41. package/src/manifest.ts +8 -3
  42. package/src/screens/checkout.tsx +21 -6
  43. package/src/screens/connect-oauth.tsx +400 -0
  44. package/src/screens/connect-settings.tsx +121 -0
  45. package/src/screens/portal.tsx +2 -0
  46. package/src/semantics/after-payment.ts +6 -1
  47. package/src/semantics/charges.ts +11 -2
  48. package/src/semantics/checkout.ts +19 -6
  49. package/src/semantics/connect.ts +19 -3
  50. package/src/semantics/invoices.ts +4 -0
  51. package/src/semantics/issuing.ts +7 -2
  52. package/src/semantics/ledger.ts +60 -23
  53. package/src/semantics/payment-methods.ts +2 -0
  54. package/src/semantics/shared.ts +14 -3
  55. package/src/semantics/test-cards.ts +7 -0
  56. package/src/semantics/transfers.ts +1 -1
  57. package/src/stripe-capabilities.ts +826 -182
  58. package/src/stripe-conformance.ts +13 -2
  59. package/src/stripe-emit.ts +2 -2
  60. package/src/stripe-events.ts +14 -10
  61. package/src/stripe-mirror-ui.ts +3 -3
  62. package/src/stripe-server.ts +85 -24
  63. package/src/stripe-shared.ts +3 -0
  64. package/src/stripe-twin.ts +6 -1
  65. package/src/stripe-version.ts +3 -0
  66. package/test-fixtures/stripe-known-deviations.json +6 -1
  67. 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>) => Promise<void>;
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. Answers the account id. */
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&currency=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}&currency=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
- // .../cash_balance_transactions accrues into `available`; POST updates reconciliation_mode.
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)}/cash_balance_transactions`, b: 'amount=5000&currency=usd' });
324
+ const fund = await h({ m: 'POST', p: `/v1/test_helpers/customers/${id(cust)}/fund_cash_balance`, b: 'amount=5000&currency=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)}/cash_balance_transactions`, b: 'amount=2500&currency=usd' });
326
+ const more = await h({ m: 'POST', p: `/v1/test_helpers/customers/${id(cust)}/fund_cash_balance`, b: 'amount=2500&currency=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
- return gone.status === 404 && afterN === beforeN - 1 && again.status === 404;
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&currency=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
- const partial = await h({ m: 'GET', p: `/v1/charges/${id(c)}` });
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
- 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')}T00:00:00Z`, ...(s.acct ? { stripeAccount: s.acct } : {}) });
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&currency=usd&destination=${seller}` });
799
865
  const p = await h({ m: 'POST', p: '/v1/payouts', b: 'amount=4000&currency=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&currency=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
- if (!ok(rev) || field(rev, 'original_payout') !== id(p) || field(rev, 'amount') !== 4000) return false;
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&currency=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&currency=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&currency=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&currency=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&currency=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&currency=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&currency=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&currency=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&currency=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&currency=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&currency=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&currency=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&currency=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&currency=usd&payment_method=pm_card_visa&confirm=true');
926
1086
  await h('POST', '/v1/transfers', `amount=1500&currency=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
- if (!ok(pc) || field(pc, 'object') !== 'promotion_code' || field(pc, 'code') !== 'SAVE10' || field(pc, 'active') !== true || field(pc, 'coupon') !== id(coupon)) return false;
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
- if (!ok(up) || field(up, 'object') !== 'invoice' || field(up, 'id') !== null) return false;
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 prorations = lines.filter((l) => l.proration === true);
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
- 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')}-10T00:00:00Z` });
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&currency=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
- 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')}-10T00:00:00Z` });
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&currency=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
- // via meter_events (and the legacy subscription_items usage_records). The twin requires
1533
- // display_name/event_name/default_aggregation[formula], lists/retrieves meters, deactivate
1534
- // transitions active→inactive, and usage record summaries total the reported quantity.
1535
- done('stripe.billing.metered_usage', 'subscriptions', 'Usage / metered billing (meters + usage records)', 'api', 'common', () =>
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 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' });
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
- const evt = await h({ m: 'POST', p: '/v1/billing/meter_events', b: 'event_name=api_request&payload[value]=10&payload[stripe_customer_id]=cus_x' });
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
- // legacy per-item usage records SUM in the summary — post two (7 + 3) so the assertion
1546
- // proves real aggregation (a single-value / canned impl would not total to 10).
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&currency=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&currency=usd` });
1654
- const inv = await h({ m: 'POST', p: '/v1/invoices', b: `customer=${id(cust)}` });
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
- if (!ok(oob) || field(oob, 'status') !== 'paid' || field(oob, 'paid_out_of_band') !== true) return false;
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
- if (!ok(up) || field(up, 'object') !== 'invoice' || field(up, 'id') !== null) return false;
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&currency=usd` });
1697
- const inv = await h({ m: 'POST', p: '/v1/invoices', b: `customer=${id(cust)}` });
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
- 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][quantity]=1&subscription_data[metadata][attemptId]=att_cap_1&subscription_data[trial_period_days]=7` });
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') === field(sub, 'current_period_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
- const pi = await h({ m: 'GET', p: `/v1/payment_intents/${String(field(inv, 'payment_intent'))}` });
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&currency=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&currency=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(`https://api.stripe.com${path}`, { method, headers: { 'content-type': 'application/x-www-form-urlencoded' }, ...(body ? { body } : {}) }))).json() as Promise<Body>;
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(`https://api.stripe.com${path}`, { method, headers: { 'content-type': 'application/x-www-form-urlencoded' }, ...(body ? { body } : {}) }))).json() as Promise<Body>;
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&currency=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('https://api.stripe.com/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}` }));
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(`https://api.stripe.com${path}`))).json() as Promise<Body>;
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(`https://api.stripe.com${path}`, { method, headers: { 'content-type': 'application/x-www-form-urlencoded', ...(account ? { 'stripe-account': account } : {}) }, ...(body ? { body } : {}) }))).json() as Promise<Body>;
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('https://api.stripe.com/_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' }));
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('https://api.stripe.com/_twin/account', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'business_profile[url]=https://dub.test' }));
2066
- const doorAccount = await (await door(new Request('https://api.stripe.com/v1/account'))).json() as Body;
2067
- const doorEvents = (((await (await door(new Request('https://api.stripe.com/v1/events?type=account.updated'))).json()) as Body).data as Body[] | undefined) ?? [];
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
- 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')}-10T00:00:00Z` });
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&currency=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&currency=usd&source=tok_bypassPending');
2319
2611
  await h('POST', '/v1/transfers', `amount=5000&currency=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
- done('stripe.connect.top_ups', 'connect', 'Top-ups (fund platform balance)', 'api', 'niche', () =>
2339
- withRoot(async (h) => {
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&currency=usd&statement_descriptor=Top up' });
2341
- if (!ok(tu) || field(tu, 'object') !== 'topup' || field(tu, 'status') !== 'succeeded') return false;
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 l = await h({ m: 'GET', p: '/v1/topups?status=succeeded' });
2345
- // a succeeded top-up cannot be canceled.
2346
- const cancel = await h({ m: 'POST', p: `/v1/topups/${id(tu)}/cancel` });
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&currency=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) && ((l.body as Body).data as Body[]).length === 1 &&
2350
- cancel.status === 400 && noMoney.status === 400 && nope.status === 404;
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
- if (!ok(v) || field(v, 'object') !== 'verification_session') return false;
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 twin default.
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&currency=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 === '2024-06-20' : false;
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
- const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=clock@twin.test' });
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
- done('stripe.ui.tax', 'ui-dashboard', 'Dashboard: Tax (Tax Rates + Tax Calculations) screens (data-coupled)', 'ui', 'common', uiDataCoupled({
2779
- markers: ['Tax Rates', 'Tax Calculations', 'list-row'],
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
- const calcs = ((await get('/v1/tax/calculations')).data as Body[]) as unknown as StripeRow[];
2787
- if (!rates.length || !calcs.length) return false;
3098
+ if (!rates.length) return false;
2788
3099
  const ratesMarkup = renderSectionList('tax_rates', rates);
2789
- const calcsMarkup = renderSectionList('tax/calculations', calcs);
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)}&currency=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)}&currency=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
- return !consulted && field(auth2, 'status') === 'pending' && field(auth2, 'approved') === false;
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 rv = await h({ m: 'POST', p: '/v1/radar/reviews', b: 'charge=ch_twin&payment_intent=pi_twin' });
3201
- if (!ok(rv) || field(rv, 'object') !== 'review' || field(rv, 'open') !== true) return false;
3527
+ const risky = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=2000&currency=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 Rules: create a custom rule (requires action ∈ {block,review,allow} + predicate),
3239
- // retrieve, list (filterable by action), delete. Missing action/predicate 400; bad action 400.
3240
- done('stripe.radar.rules', 'radar', 'Radar rules', 'api', 'niche', () =>
3241
- withRoot(async (h) => {
3242
- const rule = await h({ m: 'POST', p: '/v1/radar/rules', b: 'action=block&predicate=:risk_level: = "highest"' });
3243
- if (!ok(rule) || field(rule, 'object') !== 'radar.rule' || field(rule, 'action') !== 'block') return false;
3244
- const g = await h({ m: 'GET', p: `/v1/radar/rules/${id(rule)}` });
3245
- const l = await h({ m: 'GET', p: '/v1/radar/rules?action=block' });
3246
- if (!ok(g) || id(g) !== id(rule) || ((l.body as Body).data as Body[]).length !== 1) return false;
3247
- const del = await h({ m: 'DELETE', p: `/v1/radar/rules/${id(rule)}` });
3248
- const gone = await h({ m: 'GET', p: `/v1/radar/rules/${id(rule)}` });
3249
- const noAction = await h({ m: 'POST', p: '/v1/radar/rules', b: 'predicate=x' });
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
- // a flagged payment opens a Review; a value list + one item; one custom rule.
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&currency=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
- const rules = ((await get('/v1/radar/rules')).data as Body[]) ?? [];
3277
- // the seeded objects must be present in the projections the screen reads
3278
- const review = reviews.find((r) => r.charge === 'ch_radar_ui');
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
- const rule = rules.find((r) => r.predicate === ':risk_level: = "highest"');
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
- const rulesMarkup = renderSectionList('radar/rules', rules as StripeRow[]);
3287
- return (
3288
- // reviews + rules render their id in the row subtitle; the value-list row renders its
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
- return summaryMarkup.includes('balance-summary') && summaryMarkup.includes(formatStripeAmount(424200, 'usd')) &&
3564
- listRows(txnsMarkup) === txns.length && txns.some((t) => t.amount === 424200);
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
- seed: async (h) => { await h({ m: 'POST', p: '/v1/reporting/report_runs', b: 'report_type=balance.summary.1' }); },
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&parameters[interval_start]=0&parameters[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') !== 'requires_confirmation') return false;
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
- await h({ m: 'POST', p: `/v1/setup_intents/${id(conf)}/confirm` });
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
- const m = await h({ m: 'POST', p: '/v1/mandates', b: 'payment_method=pm_card_visa' });
3787
- if (!ok(m) || field(m, 'object') !== 'mandate') return false;
3788
- const g = await h({ m: 'GET', p: `/v1/mandates/${id(m)}` });
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) && id(g) === id(m) && field(g, 'status') === 'active' && miss.status === 404;
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(/&amp;/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&currency=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&currency=usd', account);
4281
+ const others = await w.api('POST', '/v1/payment_intents', 'amount=2500&currency=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&currency=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
- return ok(g) && field(g, 'object') === 'capability' && ok(req) && field(req, 'status') === 'pending' && miss.status === 404;
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') !== 'sk_secret_1') return false;
3974
- const find = await h({ m: 'GET', p: '/v1/apps/secrets/find?name=api_key&scope[type]=account' });
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
- await h({ m: 'POST', p: `/v1/customers/${id(cust)}/cash_balance_transactions`, b: 'amount=600&currency=usd' });
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&currency=usd' });
4003
4633
  const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: `amount=1000&currency=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)}/cash_balance_transactions`, b: 'amount=400&currency=usd' });
4640
+ await h({ m: 'POST', p: `/v1/test_helpers/customers/${id(cust)}/fund_cash_balance`, b: 'amount=400&currency=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 → succeeded, next_action cleared.
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') === 'succeeded' && field(good, 'next_action') === null && missing.status === 404;
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 && noAmt.status === 400 && missing.status === 404;
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
- const txn = await h({ m: 'POST', p: '/v1/tax/transactions/create_from_calculation', b: `calculation=${id(calc)}&reference=order_123` });
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
- const rev = await h({ m: 'POST', p: '/v1/tax/transactions/create_reversal', b: `original_transaction=${id(txn)}&reference=refund_123&mode=full` });
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
- // empty account: lists none, unknown ids 404 on retrieve + update.
4556
- const empty = await h({ m: 'GET', p: '/v1/issuing/tokens' });
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)}&currency=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)}&currency=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
- const rej = await h({ m: 'POST', p: `/v1/test_helpers/issuing/personalization_designs/${id(d)}/reject` });
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
- const fa = await h({ m: 'POST', p: '/v1/treasury/financial_accounts', b: 'supported_currencies[]=usd&features[card_issuing][requested]=true&features[outbound_payments][requested]=true' });
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
- if ((((field(fa, 'features') as Body).card_issuing) as Body).status !== 'active') return false;
4660
- if ((((field(fa, 'features') as Body).deposit_insurance) as Body).status !== 'restricted') return false;
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: create an initialized session with a client_secret + transaction
4827
- // details, then retrieve. id cos_. Unknown id 404. (The hosted purchase widget is OOS.)
4828
- done('stripe.crypto.onramp', 'core', 'Crypto onramp sessions', 'api', 'niche', () =>
4829
- withRoot(async (h) => {
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