@volter/twin-stripe 2.0.0 → 2.0.1

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 (65) 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 +13 -9
  30. package/dist/src/stripe-server.js +72 -5
  31. package/dist/src/stripe-shared.d.ts +3 -0
  32. package/dist/src/stripe-shared.js +3 -0
  33. package/dist/src/stripe-twin.js +7 -1
  34. package/dist/src/stripe-version.d.ts +2 -0
  35. package/dist/src/stripe-version.js +2 -0
  36. package/dist/test-fixtures/stripe-known-deviations.json +6 -1
  37. package/dist/test-fixtures/stripe-schemas.json +85 -12
  38. package/package.json +4 -4
  39. package/src/index.ts +6 -4
  40. package/src/manifest.ts +8 -3
  41. package/src/screens/checkout.tsx +21 -6
  42. package/src/screens/connect-oauth.tsx +400 -0
  43. package/src/screens/connect-settings.tsx +121 -0
  44. package/src/screens/portal.tsx +2 -0
  45. package/src/semantics/after-payment.ts +6 -1
  46. package/src/semantics/charges.ts +11 -2
  47. package/src/semantics/checkout.ts +19 -6
  48. package/src/semantics/connect.ts +19 -3
  49. package/src/semantics/invoices.ts +4 -0
  50. package/src/semantics/issuing.ts +7 -2
  51. package/src/semantics/ledger.ts +60 -23
  52. package/src/semantics/payment-methods.ts +2 -0
  53. package/src/semantics/shared.ts +14 -3
  54. package/src/semantics/test-cards.ts +7 -0
  55. package/src/semantics/transfers.ts +1 -1
  56. package/src/stripe-capabilities.ts +826 -182
  57. package/src/stripe-conformance.ts +13 -2
  58. package/src/stripe-emit.ts +2 -2
  59. package/src/stripe-events.ts +13 -9
  60. package/src/stripe-server.ts +60 -5
  61. package/src/stripe-shared.ts +3 -0
  62. package/src/stripe-twin.ts +6 -1
  63. package/src/stripe-version.ts +3 -0
  64. package/test-fixtures/stripe-known-deviations.json +6 -1
  65. package/test-fixtures/stripe-schemas.json +85 -12
@@ -7,7 +7,7 @@
7
7
  // hidden gap, and a high % against a thin list is a misleading metric (the bug this fixes).
8
8
  import { mkdtempSync, rmSync } from 'node:fs';
9
9
  import { createServer } from 'node:http';
10
- import { emitTwinEvent } from '@volter/world-core';
10
+ import { applyTwinWrite, emitTwinEvent } from '@volter/world-core';
11
11
  import { stripeEmitter } from "./stripe-emit.js";
12
12
  import { tmpdir } from 'node:os';
13
13
  import { join } from 'node:path';
@@ -21,6 +21,7 @@ import { fullSyncStripe } from "./stripe-connector.js";
21
21
  import { buildStripeMirrorClient, createStripeMirrorServer, formatStripeAmount } from "./stripe-mirror-ui.js";
22
22
  import { handleStripeTwinRequest } from "./stripe-twin.js";
23
23
  import { createStripeTwinFetch } from "./stripe-server.js";
24
+ import { SERVED_VERSION } from "./stripe-version.js";
24
25
  import { pack } from "./index.js";
25
26
  import { stripeIdentityFlow } from "./screens/identity.js";
26
27
  import { stripeFinancialConnectionsFlow } from "./screens/financial-connections.js";
@@ -38,7 +39,7 @@ function uiDataCoupled(opts) {
38
39
  const server = await createStripeMirrorServer({ root, port: 0 });
39
40
  try {
40
41
  const h = (s) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root });
41
- await opts.seed(h);
42
+ await opts.seed(h, root);
42
43
  const base = `http://127.0.0.1:${server.port}`;
43
44
  const get = (path) => fetch(`${base}${path}`).then((r) => r.json());
44
45
  const post = (path, body) => fetch(`${base}${path}`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: body ?? '' }).then((r) => r.json());
@@ -93,9 +94,11 @@ async function fundIssuing(root, amount, at) {
93
94
  }
94
95
  const ok = (r) => r.status >= 200 && r.status < 300;
95
96
  /** A seller the platform can pay: a Custom account onboarded through the API (its business profile, the owner's
96
- * acceptance of the terms, a bank account), and funds in the platform's balance to send it. Answers the account id. */
97
+ * acceptance of the terms, the individual's name, a bank account), and funds in the platform's balance to send it.
98
+ * Answers the account id. The name is owed at once for an individual with transfers (e0e3b88c6: what Stripe's
99
+ * requirements endpoint lists for the business type and capabilities), so without it transfers stay inactive. */
97
100
  async function onboardedSeller(h) {
98
- 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' });
101
+ 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' });
99
102
  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' });
100
103
  await h({ m: 'POST', p: '/v1/charges', b: 'amount=100000&currency=usd&source=tok_bypassPending' });
101
104
  return id(a);
@@ -119,6 +122,47 @@ function fetcher(f) {
119
122
  method: m, headers: { authorization: 'Bearer sk_test_twin', 'content-type': 'application/x-www-form-urlencoded', ...(acct ? { 'stripe-account': acct } : {}) }, ...(b !== undefined ? { body: b } : {}),
120
123
  }))).json());
121
124
  }
125
+ // ── Connect OAuth (screens/connect-oauth.tsx, screens/connect-settings.tsx) ──
126
+ /** Cal.com's callback, the redirect URI its Stripe app registers. */
127
+ const OAUTH_CALLBACK = 'http://localhost:3000/api/integrations/stripepayment/callback';
128
+ /** An authorize query as Cal.com's Stripe app builds it (qs-stringify: nested stripe_user[...] keys), with `extra` added. */
129
+ const authorizeQuery = (extra = '', redirect = OAUTH_CALLBACK) => `client_id=ca_twin_self&scope=read_write&response_type=code${redirect === null ? '' : `&redirect_uri=${encodeURIComponent(redirect)}`}&state=st8${extra}`;
130
+ /** A fresh World with a Connect platform whose OAuth is on and whose callback is registered (unless `configure` is false). */
131
+ async function withOAuth(run, configure = true) {
132
+ const root = mkdtempSync(join(tmpdir(), 'stp-oauth-'));
133
+ let now = '2026-03-01T00:00:00.000Z';
134
+ const f = createStripeTwinFetch({ root, clock: () => now });
135
+ const form = { 'content-type': 'application/x-www-form-urlencoded' };
136
+ const w = {
137
+ f,
138
+ root,
139
+ api: async (m, p, b, acct) => {
140
+ 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 } : {}) }));
141
+ return { status: r.status, body: (await r.json()) };
142
+ },
143
+ at: (iso) => { now = iso; },
144
+ 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() })),
145
+ authorize: (query, decision) => f(new Request(`https://connect.stripe.com/oauth/authorize?${query}`, decision ? { method: 'POST', headers: form, body: `decision=${decision}` } : {})),
146
+ endpoint: async (path, body, auth = 'Bearer sk_test_platform') => {
147
+ const r = await f(new Request(`https://connect.stripe.com/oauth/${path}`, { method: 'POST', headers: { ...form, ...(auth ? { authorization: auth } : {}) }, body }));
148
+ return { status: r.status, body: (await r.json()) };
149
+ },
150
+ connect: async (extra = '') => {
151
+ const location = new URL((await w.authorize(authorizeQuery(extra), 'skip')).headers.get('location') ?? 'about:blank');
152
+ const code = location.searchParams.get('code') ?? '';
153
+ const token = (await w.endpoint('token', `grant_type=authorization_code&code=${code}`)).body;
154
+ return { location, code, token };
155
+ },
156
+ };
157
+ try {
158
+ if (configure)
159
+ await w.settings(true, OAUTH_CALLBACK);
160
+ return await verifyBoundary('stripe.withOAuth', () => run(w));
161
+ }
162
+ finally {
163
+ rmSync(root, { recursive: true, force: true });
164
+ }
165
+ }
122
166
  /** A dispute as Stripe opens one: a charge whose test card the bank disputes (docs.stripe.com/testing#disputes). */
123
167
  async function disputed(h, amount = 1000) {
124
168
  await h({ m: 'POST', p: '/v1/charges', b: `amount=${amount}&currency=usd&source=tok_createDispute` });
@@ -222,8 +266,10 @@ export const STRIPE_CAPABILITIES = [
222
266
  l2.body.data.length === 0 && noType.status === 400 && missingCust.status === 404;
223
267
  })),
224
268
  // Cash balance: GET /v1/customers/:id/cash_balance returns the cash_balance summary
225
- // (available currency→amount map, derived from the cash-balance ledger). Funding via
226
- // .../cash_balance_transactions accrues into `available`; POST updates reconciliation_mode.
269
+ // (available currency→amount map, derived from the cash-balance ledger). Funding via the test helper
270
+ // POST /v1/test_helpers/customers/:id/fund_cash_balance ("Create an incoming testmode bank transfer",
271
+ // docs.stripe.com/api/cash_balance/fund_cash_balance) accrues into `available`; POST updates reconciliation_mode.
272
+ // (The twin-invented POST .../cash_balance_transactions left with 5decb1d77: Stripe's API has no such write.)
227
273
  // An unknown customer 404s; an invalid reconciliation_mode 400s. (Only this feature produces
228
274
  // the available map keyed by the funded currency.)
229
275
  done('stripe.customers.cash_balance', 'customers', 'Customer cash balance', 'api', 'niche', () => withRoot(async (h) => {
@@ -231,10 +277,10 @@ export const STRIPE_CAPABILITIES = [
231
277
  const empty = await h({ m: 'GET', p: `/v1/customers/${id(cust)}/cash_balance` });
232
278
  if (!ok(empty) || field(empty, 'object') !== 'cash_balance' || field(empty, 'available') !== null)
233
279
  return false;
234
- const fund = await h({ m: 'POST', p: `/v1/customers/${id(cust)}/cash_balance_transactions`, b: 'amount=5000&currency=usd' });
280
+ const fund = await h({ m: 'POST', p: `/v1/test_helpers/customers/${id(cust)}/fund_cash_balance`, b: 'amount=5000&currency=usd' });
235
281
  if (!ok(fund) || field(fund, 'type') !== 'funded' || field(fund, 'ending_balance') !== 5000)
236
282
  return false;
237
- const more = await h({ m: 'POST', p: `/v1/customers/${id(cust)}/cash_balance_transactions`, b: 'amount=2500&currency=usd' });
283
+ const more = await h({ m: 'POST', p: `/v1/test_helpers/customers/${id(cust)}/fund_cash_balance`, b: 'amount=2500&currency=usd' });
238
284
  if (field(more, 'ending_balance') !== 7500)
239
285
  return false;
240
286
  const bal = await h({ m: 'GET', p: `/v1/customers/${id(cust)}/cash_balance` });
@@ -310,7 +356,11 @@ export const STRIPE_CAPABILITIES = [
310
356
  const beforeN = before.body.data.length;
311
357
  const afterN = after.body.data.length;
312
358
  const again = await h({ m: 'DELETE', p: `/v1/customers/${id(c)}` });
313
- return gone.status === 404 && afterN === beforeN - 1 && again.status === 404;
359
+ // a deleted customer still RETRIEVES, as the stub: "If it's for a deleted Customer, a subset of the customer's
360
+ // information is returned, including a `deleted` property that's set to true" (docs.stripe.com/api/customers/retrieve;
361
+ // 8154bb647). It leaves the list, and a second delete finds nothing to delete.
362
+ return gone.status === 200 && field(gone, 'deleted') === true && field(gone, 'id') === id(c) && field(gone, 'email') === undefined
363
+ && afterN === beforeN - 1 && again.status === 404;
314
364
  })),
315
365
  // ── PaymentIntents ───────────────────────────────────────────────────────────────
316
366
  done('stripe.payment_intents.create', 'payment_intents', 'PaymentIntents: create + retrieve + list', 'api', 'core', () => withRoot(async (h) => {
@@ -593,7 +643,12 @@ export const STRIPE_CAPABILITIES = [
593
643
  const r1 = await h({ m: 'POST', p: '/v1/refunds', b: `charge=${id(c)}&amount=500` });
594
644
  if (!ok(r1))
595
645
  return false;
596
- const partial = await h({ m: 'GET', p: `/v1/charges/${id(c)}` });
646
+ // `refunds` is includable on a charge, "not returned by default" (docs.stripe.com/api/charges/object; 4388f04e9):
647
+ // the plain read leaves it out, and the read that expands it carries the refund
648
+ const plain = await h({ m: 'GET', p: `/v1/charges/${id(c)}` });
649
+ if (field(plain, 'refunds') !== undefined)
650
+ return false;
651
+ const partial = await h({ m: 'GET', p: `/v1/charges/${id(c)}?expand[]=refunds` });
597
652
  if (field(partial, 'amount_refunded') !== 500 || field(partial, 'refunded') !== false)
598
653
  return false;
599
654
  const listed = field(partial, 'refunds')?.data;
@@ -746,19 +801,26 @@ export const STRIPE_CAPABILITIES = [
746
801
  done('stripe.payouts.reverse', 'payouts', 'Payout reverse (a connected account\'s paid payout)', 'api', 'niche', async () => {
747
802
  const root = mkdtempSync(join(tmpdir(), 'stp-rev-'));
748
803
  try {
749
- const h = (s) => 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 } : {}) });
804
+ // at noon: accounts are paid out automatically at midnight UTC on their schedule's days (5ab14297c), so a charge
805
+ // made at the stroke of midnight is swept into that same instant's automatic payout and nothing is left to send
806
+ const h = (s) => 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 } : {}) });
750
807
  const seller = await onboardedSeller(h);
751
808
  await h({ m: 'POST', p: '/v1/transfers', b: `amount=5000&currency=usd&destination=${seller}` });
752
809
  const p = await h({ m: 'POST', p: '/v1/payouts', b: 'amount=4000&currency=usd', acct: seller });
753
810
  if (!ok(p) || (await h({ m: 'POST', p: `/v1/payouts/${id(p)}/reverse`, acct: seller })).status !== 400)
754
811
  return false;
812
+ // the platform's own payout, made the same day, before its automatic payout sweeps what is left (5ab14297c)
813
+ const plat = await h({ m: 'POST', p: '/v1/payouts', b: 'amount=1000&currency=usd' });
814
+ if (!ok(plat))
815
+ return false;
755
816
  const rev = await h({ m: 'POST', p: `/v1/payouts/${id(p)}/reverse`, acct: seller, day: 5 });
756
- if (!ok(rev) || field(rev, 'original_payout') !== id(p) || field(rev, 'amount') !== 4000)
817
+ // the reversal is a payout the other way, "a negative amount" as the reverse page's example answers it
818
+ // (docs.stripe.com/api/payouts/reverse; 8848ebfaa)
819
+ if (!ok(rev) || field(rev, 'original_payout') !== id(p) || field(rev, 'amount') !== -4000)
757
820
  return false;
758
821
  const orig = await h({ m: 'GET', p: `/v1/payouts/${id(p)}`, acct: seller, day: 5 });
759
822
  if (field(orig, 'status') !== 'paid' || field(orig, 'reversed_by') !== id(rev))
760
823
  return false;
761
- const plat = await h({ m: 'POST', p: '/v1/payouts', b: 'amount=1000&currency=usd' });
762
824
  const platRev = await h({ m: 'POST', p: `/v1/payouts/${id(plat)}/reverse`, day: 5 });
763
825
  const twice = await h({ m: 'POST', p: `/v1/payouts/${id(p)}/reverse`, acct: seller, day: 5 });
764
826
  const nope = await h({ m: 'POST', p: '/v1/payouts/po_nope/reverse' });
@@ -832,7 +894,7 @@ export const STRIPE_CAPABILITIES = [
832
894
  const h = fetcher(createStripeTwinFetch({ root, clock: () => now }));
833
895
  await h('POST', '/v1/webhook_endpoints', `url=${rx.url('/platform')}&enabled_events[]=balance.available&enabled_events[]=payout.paid`);
834
896
  await h('POST', '/v1/webhook_endpoints', `url=${rx.url('/connect')}&enabled_events[]=balance.available&enabled_events[]=payout.paid&connect=true`);
835
- 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);
897
+ 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);
836
898
  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');
837
899
  await h('POST', '/_twin/account', 'settings[payouts][schedule][interval]=manual');
838
900
  const pi = await h('POST', '/v1/payment_intents', 'amount=2592&currency=usd&payment_method=pm_card_visa&confirm=true');
@@ -866,6 +928,98 @@ export const STRIPE_CAPABILITIES = [
866
928
  rmSync(root, { recursive: true, force: true });
867
929
  }
868
930
  }),
931
+ // Test mode's clock, as Stripe documents it (docs.stripe.com/testing#available-balance; semantics/ledger.ts): a card
932
+ // that bypasses the pending balance (4000000000000077, 4000003720000278, their pm_card_/tok_ names) puts its funds in
933
+ // the available balance at once, a PaymentMethod saved from it too when a later charge names it by id, and a
934
+ // source_transaction transfer from that charge is available on the connected account at once ("takes on the pending
935
+ // status of the associated charge", docs.stripe.com/connect/separate-charges-and-transfers); the drain in that same
936
+ // instant sends each account balance.available. Every other card keeps the delay (4242 pending), and a test payout
937
+ // keeps live timing ("Test payouts simulate a live payout", docs.stripe.com/payouts#test-payouts): paid at its
938
+ // arrival_date, not before. Dub's partner payout reaches Completed this way without World time for the funds. A US
939
+ // bank account debit settles at once too ("Test transactions settle instantly and are added to your available test
940
+ // balance", docs.stripe.com/testing), and every credit available at once is sent as balance.available.
941
+ 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 () => {
942
+ const root = mkdtempSync(join(tmpdir(), 'stp-bypass-'));
943
+ const rx = await webhookReceiver();
944
+ try {
945
+ let now = '2026-06-01T12:00:00.000Z';
946
+ const h = fetcher(createStripeTwinFetch({ root, clock: () => now }));
947
+ await h('POST', '/v1/webhook_endpoints', `url=${rx.url('/connect')}&enabled_events[]=balance.available&enabled_events[]=payout.paid&connect=true`);
948
+ 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);
949
+ 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');
950
+ await h('POST', '/_twin/account', 'settings[payouts][schedule][interval]=manual');
951
+ const btOf = async (charge) => h('GET', `/v1/balance_transactions/${String((await h('GET', `/v1/charges/${String(charge)}`)).balance_transaction)}`);
952
+ const amounts = async (acct) => {
953
+ const b = await h('GET', '/v1/balance', undefined, acct);
954
+ return { available: b.available[0]?.amount ?? 0, pending: b.pending[0]?.amount ?? 0 };
955
+ };
956
+ // 4242: the delay stands in test mode
957
+ const visa = await h('POST', '/v1/payment_intents', 'amount=1000&currency=usd&payment_method=pm_card_visa&confirm=true');
958
+ if ((await btOf(visa.latest_charge)).status !== 'pending')
959
+ return false;
960
+ const before = await amounts();
961
+ // a card saved from 4000000000000077, charged later by its id
962
+ const customer = String((await h('POST', '/v1/customers', 'email=maya@acme.test')).id);
963
+ 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);
964
+ await h('POST', `/v1/payment_methods/${pm}/attach`, `customer=${customer}`);
965
+ 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`);
966
+ const charge = String(pi.latest_charge?.id);
967
+ const bt = await btOf(charge);
968
+ const after = await amounts();
969
+ // the entry reads available however it is read: its own retrieve, the balance, an expansion
970
+ const embedded = pi.latest_charge?.balance_transaction?.status;
971
+ const platformAtOnce = bt.status === 'available' && embedded === 'available' && after.available - before.available === Number(bt.net) && after.pending === before.pending;
972
+ // its source_transaction transfer is available on the connected account at once, and is sent as such
973
+ await h('POST', '/v1/transfers', `amount=2400&currency=usd&destination=${seller}&source_transaction=${charge}`);
974
+ const theirs = await amounts(seller);
975
+ const sent = async () => (await h('POST', '/_twin/drain')).delivered.map((d) => `${String(d.type)}@${String(d.account ?? 'platform')}`);
976
+ const drained = await sent();
977
+ const again = await sent();
978
+ const hook = rx.got('/connect').find((e) => e.type === 'balance.available');
979
+ const announced = drained.includes(`balance.available@${seller}`) && drained.includes('balance.available@platform') && again.length === 0
980
+ && hook?.account === seller && hook.data.object.available?.[0]?.amount === 2400;
981
+ // a test payout to Stripe's succeeding test bank account keeps live timing: pending until its arrival_date
982
+ const po = await h('POST', '/v1/payouts', 'amount=2400&currency=usd', seller);
983
+ if (!(platformAtOnce && theirs.available === 2400 && theirs.pending === 0 && announced && typeof po.id === 'string'))
984
+ return false;
985
+ const early = await sent();
986
+ now = new Date(Number(po.arrival_date) * 1000 + 1000).toISOString();
987
+ const arrived = await sent();
988
+ const paid = (await h('GET', `/v1/payouts/${String(po.id)}`, undefined, seller)).status;
989
+ // the international bypass card, raw and by test name
990
+ const intl = await h('POST', '/v1/charges', 'amount=500&currency=usd&card[number]=4000003720000278&card[exp_month]=4&card[exp_year]=2030');
991
+ const named = String((await h('POST', `/v1/payment_methods/pm_card_bypassPendingInternational/attach`, `customer=${customer}`)).id);
992
+ const byName = await h('POST', '/v1/payment_intents', `amount=500&currency=usd&customer=${customer}&payment_method=${named}&confirm=true&off_session=true`);
993
+ const intlAtOnce = (await btOf(intl.id)).status === 'available' && (await btOf(byName.latest_charge)).status === 'available';
994
+ // a US bank account debit settles into the available balance at once ("Test transactions settle instantly")
995
+ const ach = await h('POST', '/v1/payment_intents', 'amount=700&currency=usd&payment_method=pm_usBankAccount_success&confirm=true');
996
+ const achCharge = (await h('GET', `/v1/payment_intents/${String(ach.id)}`)).latest_charge;
997
+ // and a stored us_bank_account PaymentMethod's, as an application saves one
998
+ 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);
999
+ const stored = await h('POST', '/v1/payment_intents', `amount=800&currency=usd&payment_method=${bank}&confirm=true`);
1000
+ const storedCharge = (await h('GET', `/v1/payment_intents/${String(stored.id)}`)).latest_charge;
1001
+ const achAtOnce = typeof achCharge === 'string' && (await btOf(achCharge)).status === 'available'
1002
+ && typeof storedCharge === 'string' && (await btOf(storedCharge)).status === 'available';
1003
+ // a plain transfer's credit is sent as the connected account's balance.available too
1004
+ await h('POST', '/v1/transfers', `amount=100&currency=usd&destination=${seller}`);
1005
+ const plain = (await sent()).includes(`balance.available@${seller}`);
1006
+ // a card made from payment_method_data carries what follows its success (a dispute, docs.stripe.com/testing#disputes)
1007
+ 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');
1008
+ const disputed = (await h('GET', `/v1/charges/${String(fromData.latest_charge)}`)).disputed === true;
1009
+ // a 4242 charge, three days on and before any drain records its move, reads available however it is read, an
1010
+ // expansion included
1011
+ const later = await h('POST', '/v1/payment_intents', 'amount=400&currency=usd&payment_method=pm_card_visa&confirm=true');
1012
+ now = new Date(Date.parse(now) + 3 * 86_400_000).toISOString();
1013
+ const laterRead = await h('GET', `/v1/charges/${String(later.latest_charge)}?expand[]=balance_transaction`);
1014
+ const visaAvailable = laterRead.balance_transaction?.status === 'available';
1015
+ return po.status === 'pending' && Number(po.arrival_date) > Number(po.created) && !early.includes(`payout.paid@${seller}`)
1016
+ && arrived.includes(`payout.paid@${seller}`) && paid === 'paid' && intlAtOnce && achAtOnce && plain && visaAvailable && disputed;
1017
+ }
1018
+ finally {
1019
+ rx.close();
1020
+ rmSync(root, { recursive: true, force: true });
1021
+ }
1022
+ }),
869
1023
  // One event, one id: the webhook a consumer receives and the event the Events API stores carry the same id, and no two
870
1024
  // events share one — Dub's queue deduplicates by event.id, and a Balance has no id of its own to tell two accounts'
871
1025
  // balance.available apart. Two partners' funds coming due in one drain are two events, each retrievable by its id as
@@ -880,7 +1034,7 @@ export const STRIPE_CAPABILITIES = [
880
1034
  await h('POST', '/v1/webhook_endpoints', `url=${rx.url('/connect')}&enabled_events[]=balance.available&connect=true`);
881
1035
  await h('POST', '/_twin/account', 'settings[payouts][schedule][interval]=manual');
882
1036
  const partner = async () => {
883
- 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);
1037
+ 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);
884
1038
  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');
885
1039
  const pi = await h('POST', '/v1/payment_intents', 'amount=2000&currency=usd&payment_method=pm_card_visa&confirm=true');
886
1040
  await h('POST', '/v1/transfers', `amount=1500&currency=usd&destination=${a}&source_transaction=${String(pi.latest_charge)}`);
@@ -1061,7 +1215,11 @@ export const STRIPE_CAPABILITIES = [
1061
1215
  done('stripe.promotion_codes.create', 'discounts', 'PromotionCode create + activate/deactivate', 'api', 'common', () => withRoot(async (h) => {
1062
1216
  const coupon = await h({ m: 'POST', p: '/v1/coupons', b: 'percent_off=10&duration=once' });
1063
1217
  const pc = await h({ m: 'POST', p: '/v1/promotion_codes', b: `promotion[type]=coupon&promotion[coupon]=${id(coupon)}&code=SAVE10` });
1064
- if (!ok(pc) || field(pc, 'object') !== 'promotion_code' || field(pc, 'code') !== 'SAVE10' || field(pc, 'active') !== true || field(pc, 'coupon') !== id(coupon))
1218
+ // what it promotes is `promotion: {type: 'coupon', coupon}` in the served version, with no top-level coupon
1219
+ // (2025-09-30.clover, docs.stripe.com/changelog/clover/2025-09-30/polymorphic-coupon; stripe-version.ts renders it)
1220
+ if (!ok(pc) || field(pc, 'object') !== 'promotion_code' || field(pc, 'code') !== 'SAVE10' || field(pc, 'active') !== true)
1221
+ return false;
1222
+ if (field(pc, 'promotion')?.type !== 'coupon' || field(pc, 'promotion')?.coupon !== id(coupon) || field(pc, 'coupon') !== undefined)
1065
1223
  return false;
1066
1224
  const g = await h({ m: 'GET', p: `/v1/promotion_codes/${id(pc)}` });
1067
1225
  const off = await h({ m: 'POST', p: `/v1/promotion_codes/${id(pc)}`, b: 'active=false' });
@@ -1253,10 +1411,16 @@ export const STRIPE_CAPABILITIES = [
1253
1411
  return false;
1254
1412
  // upcoming invoice WITH a proration_date splits the period (credit + charge proration lines).
1255
1413
  const up = await h({ m: 'POST', p: '/v1/invoices/create_preview', b: `subscription=${id(sub)}&subscription_details[proration_date]=100` });
1256
- if (!ok(up) || field(up, 'object') !== 'invoice' || field(up, 'id') !== null)
1414
+ // a preview's id is prefixed `upcoming_in` ("For preview invoices created using the create preview endpoint, this id
1415
+ // will be prefixed with `upcoming_in`", served spec invoice.id), and a line's proration flag is on its parent's
1416
+ // details in the served version (basil, docs.stripe.com/changelog/basil; stripe-version.ts line_item, 24e757f60)
1417
+ if (!ok(up) || field(up, 'object') !== 'invoice' || !String(field(up, 'id')).startsWith('upcoming_in_'))
1257
1418
  return false;
1258
1419
  const lines = field(up, 'lines').data;
1259
- const prorations = lines.filter((l) => l.proration === true);
1420
+ const prorationOf = (l) => l.parent?.subscription_item_details?.proration ?? l.parent?.invoice_item_details?.proration;
1421
+ if (lines.some((l) => 'proration' in l))
1422
+ return false;
1423
+ const prorations = lines.filter((l) => prorationOf(l) === true);
1260
1424
  return prorations.length === 2 && prorations.some((l) => Number(l.amount) < 0) && prorations.some((l) => Number(l.amount) > 0);
1261
1425
  })),
1262
1426
  // Renewal over time: at the period's end a subscription_cycle invoice is drafted and, an hour on, charged to the
@@ -1265,7 +1429,10 @@ export const STRIPE_CAPABILITIES = [
1265
1429
  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 () => {
1266
1430
  const root = mkdtempSync(join(tmpdir(), 'stp-renew-'));
1267
1431
  try {
1268
- const h = (s) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: `2026-${String(s.month ?? 1).padStart(2, '0')}-10T00:00:00Z` });
1432
+ // February's reads come two hours into the renewed period: its invoice is drafted at the period's end and charged
1433
+ // "an hour on" (above; cab4389eb stamps each catch-up write at that moment), so a read at the period's very end
1434
+ // sees the draft, not the paid invoice this claim is about
1435
+ const h = (s) => 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` });
1269
1436
  const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Box' });
1270
1437
  const price = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=3500&currency=usd&recurring[interval]=month&product=${id(prod)}` });
1271
1438
  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' });
@@ -1454,7 +1621,10 @@ export const STRIPE_CAPABILITIES = [
1454
1621
  done('stripe.subscriptions.cancel_at_period_end', 'subscriptions', 'cancel_at_period_end cancels the subscription when its period ends', 'api', 'common', async () => {
1455
1622
  const root = mkdtempSync(join(tmpdir(), 'stp-cape-'));
1456
1623
  try {
1457
- const h = (s) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: `2026-${String(s.month ?? 1).padStart(2, '0')}-10T00:00:00Z` });
1624
+ // February's reads come two hours into the renewed period: its invoice is drafted at the period's end and charged
1625
+ // "an hour on" (above; cab4389eb stamps each catch-up write at that moment), so a read at the period's very end
1626
+ // sees the draft, not the paid invoice this claim is about
1627
+ const h = (s) => 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` });
1458
1628
  const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Box' });
1459
1629
  const price = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=3500&currency=usd&recurring[interval]=month&product=${id(prod)}` });
1460
1630
  const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=leaving@twin.test' });
@@ -1514,31 +1684,32 @@ export const STRIPE_CAPABILITIES = [
1514
1684
  return ok(g) && id(g) === id(sched) && l.body.data.length === 1 &&
1515
1685
  ok(rel) && field(rel, 'status') === 'released' && relAgain.status === 400 && noTarget.status === 400;
1516
1686
  })),
1517
- // Metered/usage billing: a Billing Meter defines event_name + aggregation; usage is reported
1518
- // via meter_events (and the legacy subscription_items usage_records). The twin requires
1519
- // display_name/event_name/default_aggregation[formula], lists/retrieves meters, deactivate
1520
- // transitions active→inactive, and usage record summaries total the reported quantity.
1521
- done('stripe.billing.metered_usage', 'subscriptions', 'Usage / metered billing (meters + usage records)', 'api', 'common', () => withRoot(async (h) => {
1522
- 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' });
1687
+ // Metered/usage billing: a Billing Meter defines event_name + aggregation; usage is reported as meter events and read
1688
+ // back as the meter's event summaries (GET /v1/billing/meters/:id/event_summaries, docs.stripe.com/api/billing/meter-event-summary).
1689
+ // The twin requires display_name/event_name/default_aggregation, lists/retrieves meters, deactivate transitions
1690
+ // active→inactive, and a summary totals the customer's reported values. The legacy per-item usage records this claim
1691
+ // used to drive "left the API with basil" and were removed from the twin (5decb1d77; docs.stripe.com/changelog/basil).
1692
+ done('stripe.billing.metered_usage', 'subscriptions', 'Usage / metered billing (meters + meter events and their summaries)', 'api', 'common', () => withRoot(async (h) => {
1693
+ const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=metered@twin.test' });
1694
+ 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' });
1523
1695
  if (!ok(meter) || field(meter, 'object') !== 'billing.meter' || field(meter, 'status') !== 'active' || field(meter, 'event_name') !== 'api_request')
1524
1696
  return false;
1525
1697
  const noAgg = await h({ m: 'POST', p: '/v1/billing/meters', b: 'display_name=X&event_name=y' });
1526
1698
  if (noAgg.status !== 400)
1527
1699
  return false;
1528
- 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' });
1700
+ // two events (7 + 3) so the summary proves real aggregation (a single-value / canned impl would not total 10)
1701
+ 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)}` });
1529
1702
  if (!ok(evt) || field(evt, 'object') !== 'billing.meter_event' || field(evt, 'event_name') !== 'api_request')
1530
1703
  return false;
1704
+ 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)}` });
1705
+ // a window on whole days, as Stripe asks (its bounds are refused off minute boundaries), around the events just sent
1706
+ const now = Math.floor(Date.now() / 86_400_000) * 86_400;
1707
+ 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}` });
1708
+ const sd = summ.body.data;
1531
1709
  const g = await h({ m: 'GET', p: `/v1/billing/meters/${id(meter)}` });
1532
1710
  const deact = await h({ m: 'POST', p: `/v1/billing/meters/${id(meter)}/deactivate` });
1533
- // legacy per-item usage records SUM in the summary — post two (7 + 3) so the assertion
1534
- // proves real aggregation (a single-value / canned impl would not total to 10).
1535
- const ur = await h({ m: 'POST', p: '/v1/subscription_items/si_twin/usage_records', b: 'quantity=7' });
1536
- const ur2 = await h({ m: 'POST', p: '/v1/subscription_items/si_twin/usage_records', b: 'quantity=3' });
1537
- const noQty = await h({ m: 'POST', p: '/v1/subscription_items/si_twin/usage_records' });
1538
- const summ = await h({ m: 'GET', p: '/v1/subscription_items/si_twin/usage_record_summaries' });
1539
- const sd = summ.body.data;
1540
- return ok(g) && ok(deact) && field(deact, 'status') === 'inactive' && ok(ur) && ok(ur2) && noQty.status === 400 &&
1541
- sd.length === 1 && sd[0].total_usage === 10;
1711
+ return ok(evt2) && ok(summ) && sd.length === 1 && sd[0].object === 'billing.meter_event_summary' && sd[0].aggregated_value === 10
1712
+ && ok(g) && ok(deact) && field(deact, 'status') === 'inactive';
1542
1713
  })),
1543
1714
  // Subscription discounts: a `coupon` on create attaches a discount (validated to exist),
1544
1715
  // materialized as the canonical discount object on the sub; update can attach/replace, and
@@ -1614,11 +1785,12 @@ export const STRIPE_CAPABILITIES = [
1614
1785
  const miss = await h({ m: 'GET', p: '/v1/invoiceitems/ii_nope' });
1615
1786
  if (!ok(g) || id(g) !== id(ii) || miss.status !== 404)
1616
1787
  return false;
1617
- // The earlier pending 400 is swept onto the invoice at creation.
1788
+ // The earlier pending 400 is swept onto the invoice at creation, when the create asks: pending_invoice_items_behavior
1789
+ // "Defaults to `exclude` if the parameter is omitted" (docs.stripe.com/api/invoices/create; e0e3b88c6).
1618
1790
  // An item on a DRAFT invoice moves the invoice's total when it changes, and again when
1619
1791
  // it is deleted — an invoice billing for a line that no longer says that is money in
1620
1792
  // two places at once.
1621
- const inv = await h({ m: 'POST', p: '/v1/invoices', b: `customer=${id(cust)}` });
1793
+ const inv = await h({ m: 'POST', p: '/v1/invoices', b: `customer=${id(cust)}&pending_invoice_items_behavior=include` });
1622
1794
  const on = await h({ m: 'POST', p: '/v1/invoiceitems', b: `customer=${id(cust)}&invoice=${id(inv)}&amount=900&currency=usd` });
1623
1795
  const u = await h({ m: 'POST', p: `/v1/invoiceitems/${id(on)}`, b: 'amount=1500' });
1624
1796
  const afterUpdate = await h({ m: 'GET', p: `/v1/invoices/${id(inv)}` });
@@ -1642,12 +1814,17 @@ export const STRIPE_CAPABILITIES = [
1642
1814
  done('stripe.invoices.send', 'invoices', 'Invoice send / mark_uncollectible / pay out-of-band', 'api', 'common', () => withRoot(async (h) => {
1643
1815
  const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=send@twin.test' });
1644
1816
  await h({ m: 'POST', p: '/v1/invoiceitems', b: `customer=${id(cust)}&amount=4000&currency=usd` });
1645
- const inv = await h({ m: 'POST', p: '/v1/invoices', b: `customer=${id(cust)}` });
1817
+ // the pending item is taken only when the create asks ("Defaults to `exclude`", docs.stripe.com/api/invoices/create)
1818
+ const inv = await h({ m: 'POST', p: '/v1/invoices', b: `customer=${id(cust)}&pending_invoice_items_behavior=include` });
1819
+ if (field(inv, 'amount_due') !== 4000)
1820
+ return false;
1646
1821
  const sent = await h({ m: 'POST', p: `/v1/invoices/${id(inv)}/send` });
1647
1822
  if (!ok(sent) || field(sent, 'status') !== 'open')
1648
1823
  return false;
1649
1824
  const oob = await h({ m: 'POST', p: `/v1/invoices/${id(inv)}/pay`, b: 'paid_out_of_band=true' });
1650
- if (!ok(oob) || field(oob, 'status') !== 'paid' || field(oob, 'paid_out_of_band') !== true)
1825
+ // out of band is `amount_paid_off_stripe` in the served version ("Amount ... paid on the invoice outside of
1826
+ // Stripe", served spec invoice); basil answers no `paid_out_of_band` (docs.stripe.com/changelog/basil; 24e757f60)
1827
+ 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)
1651
1828
  return false;
1652
1829
  // a second invoice can be marked uncollectible
1653
1830
  const inv2 = await h({ m: 'POST', p: '/v1/invoices', b: `customer=${id(cust)}` });
@@ -1667,7 +1844,11 @@ export const STRIPE_CAPABILITIES = [
1667
1844
  const coupon = await h({ m: 'POST', p: '/v1/coupons', b: 'percent_off=10&duration=forever' });
1668
1845
  const sub = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&items[0][price]=${id(price)}&discounts[0][coupon]=${id(coupon)}` });
1669
1846
  const up = await h({ m: 'POST', p: '/v1/invoices/create_preview', b: `subscription=${id(sub)}` });
1670
- if (!ok(up) || field(up, 'object') !== 'invoice' || field(up, 'id') !== null)
1847
+ // not persisted, and named as a preview: "For preview invoices created using the create preview endpoint, this id
1848
+ // will be prefixed with `upcoming_in`" (served spec, invoice.id; b75e48888)
1849
+ if (!ok(up) || field(up, 'object') !== 'invoice' || !String(field(up, 'id')).startsWith('upcoming_in_'))
1850
+ return false;
1851
+ if ((await h({ m: 'GET', p: `/v1/invoices/${String(field(up, 'id'))}` })).status !== 404)
1671
1852
  return false;
1672
1853
  // subtotal 3000, 10% off → total 2700.
1673
1854
  if (field(up, 'subtotal') !== 3000 || field(up, 'total') !== 2700)
@@ -1686,14 +1867,21 @@ export const STRIPE_CAPABILITIES = [
1686
1867
  done('stripe.credit_notes', 'invoices', 'CreditNotes (create/preview/void/list)', 'api', 'common', () => withRoot(async (h) => {
1687
1868
  const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=cn@twin.test' });
1688
1869
  await h({ m: 'POST', p: '/v1/invoiceitems', b: `customer=${id(cust)}&amount=5000&currency=usd` });
1689
- const inv = await h({ m: 'POST', p: '/v1/invoices', b: `customer=${id(cust)}` });
1870
+ // the pending item is taken only when the create asks ("Defaults to `exclude`", docs.stripe.com/api/invoices/create; e0e3b88c6)
1871
+ const inv = await h({ m: 'POST', p: '/v1/invoices', b: `customer=${id(cust)}&pending_invoice_items_behavior=include` });
1690
1872
  await h({ m: 'POST', p: `/v1/invoices/${id(inv)}/finalize` });
1691
1873
  await h({ m: 'POST', p: `/v1/invoices/${id(inv)}/pay`, b: 'payment_method=pm_card_visa' });
1874
+ // a paid invoice's credit says how it is paid back: "The sum of refunds, customer balance credits, and outside of
1875
+ // Stripe credits must equal the post_payment_amount" (docs.stripe.com/api/credit_notes/create; 1159092c7), so the
1876
+ // 2000 goes to the customer's balance (credit_amount), and a split that does not add up is refused
1877
+ const unsplit = await h({ m: 'GET', p: `/v1/credit_notes/preview?invoice=${id(inv)}&amount=2000` });
1878
+ if (unsplit.status !== 400)
1879
+ return false;
1692
1880
  // preview computes the object WITHOUT persisting it
1693
- const prev = await h({ m: 'GET', p: `/v1/credit_notes/preview?invoice=${id(inv)}&amount=2000` });
1881
+ const prev = await h({ m: 'GET', p: `/v1/credit_notes/preview?invoice=${id(inv)}&amount=2000&credit_amount=2000` });
1694
1882
  if (!ok(prev) || field(prev, 'object') !== 'credit_note' || field(prev, 'amount') !== 2000)
1695
1883
  return false;
1696
- const cn = await h({ m: 'POST', p: '/v1/credit_notes', b: `invoice=${id(inv)}&amount=2000&reason=order_change` });
1884
+ const cn = await h({ m: 'POST', p: '/v1/credit_notes', b: `invoice=${id(inv)}&amount=2000&credit_amount=2000&reason=order_change` });
1697
1885
  if (!ok(cn) || field(cn, 'object') !== 'credit_note' || field(cn, 'status') !== 'issued' || field(cn, 'type') !== 'post_payment' || field(cn, 'amount') !== 2000)
1698
1886
  return false;
1699
1887
  const g = await h({ m: 'GET', p: `/v1/credit_notes/${id(cn)}` });
@@ -1739,7 +1927,9 @@ export const STRIPE_CAPABILITIES = [
1739
1927
  // becomes a real trialing window) — and the Session itself never echoes the param.
1740
1928
  done('stripe.checkout.subscription_data', 'checkout', 'Checkout completion copies subscription_data (metadata + trial) onto the created subscription', 'api', 'core', () => withRoot(async (h) => {
1741
1929
  const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=subdata@twin.test' });
1742
- 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` });
1930
+ // the inline price is a subscription's: a product and a recurring interval (docs.stripe.com/api/checkout/sessions/create,
1931
+ // line_items.price_data), so the subscription has an item to carry its period (c3a378808 bills inline prices)
1932
+ 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` });
1743
1933
  if (!ok(cs) || field(cs, 'subscription_data') !== undefined || field(cs, '_subscription_data') !== undefined)
1744
1934
  return false;
1745
1935
  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' });
@@ -1748,8 +1938,12 @@ export const STRIPE_CAPABILITIES = [
1748
1938
  return false;
1749
1939
  const sub = await h({ m: 'GET', p: `/v1/subscriptions/${field(completed, 'subscription')}` });
1750
1940
  const md = field(sub, 'metadata');
1941
+ // a subscription's billing period is its items' in the served version ("current_period_start and current_period_end
1942
+ // ... moved to the subscription item", docs.stripe.com/changelog/basil/2025-03-31/deprecate-subscription-current-period-start-and-end; 24e757f60)
1943
+ const item = (field(sub, 'items')?.data ?? [])[0];
1751
1944
  return ok(sub) && md?.attemptId === 'att_cap_1' && field(sub, 'status') === 'trialing'
1752
- && typeof field(sub, 'trial_end') === 'number' && field(sub, 'trial_end') === field(sub, 'current_period_end');
1945
+ && typeof field(sub, 'trial_end') === 'number' && field(sub, 'trial_end') === item?.current_period_end
1946
+ && field(sub, 'current_period_end') === undefined;
1753
1947
  })),
1754
1948
  // A subscription session's customer pays its first invoice on the page: the invoice is paid through a succeeded
1755
1949
  // PaymentIntent and its charge, and the card is the subscription's default payment method
@@ -1762,11 +1956,100 @@ export const STRIPE_CAPABILITIES = [
1762
1956
  const done = await h({ m: 'GET', p: `/v1/checkout/sessions/${id(cs)}` });
1763
1957
  const sub = await h({ m: 'GET', p: `/v1/subscriptions/${String(field(done, 'subscription'))}` });
1764
1958
  const inv = await h({ m: 'GET', p: `/v1/invoices/${String(field(done, 'invoice'))}` });
1765
- const pi = await h({ m: 'GET', p: `/v1/payment_intents/${String(field(inv, 'payment_intent'))}` });
1959
+ // the invoice's PaymentIntent is its payment in the served version: basil answers no `invoice.payment_intent` and
1960
+ // lists the invoice's payments (docs.stripe.com/changelog/basil/2025-03-31/add-support-for-multiple-partial-payments-on-invoices; 24e757f60)
1961
+ const payments = (await h({ m: 'GET', p: `/v1/invoice_payments?invoice=${String(field(done, 'invoice'))}` })).body.data ?? [];
1962
+ const pi = await h({ m: 'GET', p: `/v1/payment_intents/${String(payments[0]?.payment?.payment_intent)}` });
1963
+ if (payments.length !== 1 || payments[0].status !== 'paid' || payments[0].amount_paid !== 3500)
1964
+ return false;
1766
1965
  return field(sub, 'status') === 'active' && typeof field(sub, 'default_payment_method') === 'string'
1767
1966
  && field(inv, 'status') === 'paid' && field(inv, 'billing_reason') === 'subscription_create' && field(inv, 'amount_paid') === 3500
1768
1967
  && field(pi, 'status') === 'succeeded' && typeof field(pi, 'latest_charge') === 'string';
1769
1968
  })),
1969
+ // Checkout attaches the card it saves to the customer and says so: payment_method.attached ("Occurs whenever a new payment
1970
+ // method is attached to a customer", docs.stripe.com/api/events/types) for a subscription's card, which is also the
1971
+ // subscription's default payment method (docs.stripe.com/payments/checkout/how-checkout-works), for a setup session's, and
1972
+ // for a payment's only under payment_intent_data.setup_future_usage (docs.stripe.com/api/checkout/sessions/create
1973
+ // #create_checkout_session-customer); the event comes before the subscription it pays for (the twin's order; Stripe's is
1974
+ // not guaranteed, docs.stripe.com/webhooks#event-ordering). Rallly walk 4: its billing page read no saved card.
1975
+ 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', () => withRoot(async (h) => {
1976
+ const card = 'cardNumber=4242424242424242&cardExpiry=12%2F34&cardCvc=123&billingName=Twin&billingCountry=US&billingPostalCode=94105';
1977
+ const attachedEvents = async () => (await h({ m: 'GET', p: '/v1/events?type=payment_method.attached&limit=100' })).body.data ?? [];
1978
+ const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=saved@twin.test' });
1979
+ const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Pro' });
1980
+ const price = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=1200&currency=usd&product=${id(prod)}&recurring[interval]=month` });
1981
+ 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` });
1982
+ await h({ m: 'POST', p: `/c/pay/${id(sub)}`, b: card });
1983
+ const done = await h({ m: 'GET', p: `/v1/checkout/sessions/${id(sub)}` });
1984
+ const subscription = await h({ m: 'GET', p: `/v1/subscriptions/${String(field(done, 'subscription'))}` });
1985
+ const pmId = field(subscription, 'default_payment_method');
1986
+ const pm = await h({ m: 'GET', p: `/v1/payment_methods/${String(pmId)}` });
1987
+ const attached = await attachedEvents();
1988
+ const all = (await h({ m: 'GET', p: '/v1/events?limit=100' })).body.data ?? [];
1989
+ // the list is newest first: the attach is older than the subscription's creation and the session's completion
1990
+ const at = (type) => all.findIndex((e) => e.type === type);
1991
+ const subscriptionOk = field(done, 'status') === 'complete' && typeof pmId === 'string' && field(pm, 'customer') === id(cust)
1992
+ && attached.length === 1 && attached[0].data.object.id === pmId && attached[0].data.object.customer === id(cust)
1993
+ && at('payment_method.attached') > at('customer.subscription.created') && at('customer.subscription.created') > at('checkout.session.completed');
1994
+ // a payment without setup_future_usage saves nothing on the customer; with it, the card is attached
1995
+ const pay = (extra) => 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}` });
1996
+ const once = await pay('');
1997
+ await h({ m: 'POST', p: `/c/pay/${id(once)}`, b: card });
1998
+ const oncePi = await h({ m: 'GET', p: `/v1/payment_intents/${String(field(await h({ m: 'GET', p: `/v1/checkout/sessions/${id(once)}` }), 'payment_intent'))}` });
1999
+ const oncePm = await h({ m: 'GET', p: `/v1/payment_methods/${String(field(oncePi, 'payment_method'))}` });
2000
+ const afterOnce = (await attachedEvents()).length;
2001
+ const keep = await pay('&payment_intent_data[setup_future_usage]=off_session');
2002
+ await h({ m: 'POST', p: `/c/pay/${id(keep)}`, b: card });
2003
+ const keepPi = await h({ m: 'GET', p: `/v1/payment_intents/${String(field(await h({ m: 'GET', p: `/v1/checkout/sessions/${id(keep)}` }), 'payment_intent'))}` });
2004
+ const keepPm = await h({ m: 'GET', p: `/v1/payment_methods/${String(field(keepPi, 'payment_method'))}` });
2005
+ const afterKeep = await attachedEvents();
2006
+ const paymentOk = ok(oncePm) && field(oncePm, 'customer') === null && afterOnce === 1 && field(keepPi, 'setup_future_usage') === 'off_session'
2007
+ && field(keepPm, 'customer') === id(cust) && afterKeep.length === 2 && afterKeep[0].data.object.id === id(keepPm);
2008
+ // a setup session saves the card: its SetupIntent names it, and it is attached
2009
+ const setup = await h({ m: 'POST', p: '/v1/checkout/sessions', b: `mode=setup&currency=usd&success_url=https://x.test&customer=${id(cust)}` });
2010
+ await h({ m: 'POST', p: `/c/pay/${id(setup)}`, b: card });
2011
+ const si = await h({ m: 'GET', p: `/v1/setup_intents/${String(field(await h({ m: 'GET', p: `/v1/checkout/sessions/${id(setup)}` }), 'setup_intent'))}` });
2012
+ const setupPm = await h({ m: 'GET', p: `/v1/payment_methods/${String(field(si, 'payment_method'))}` });
2013
+ const afterSetup = await attachedEvents();
2014
+ const setupOk = field(si, 'status') === 'succeeded' && field(setupPm, 'customer') === id(cust) && afterSetup.length === 3 && afterSetup[0].data.object.id === id(setupPm);
2015
+ return subscriptionOk && paymentOk && setupOk;
2016
+ })),
2017
+ // A session made for a customer with an email shows that email on the page, prefilled and not editable, and the session
2018
+ // is the customer's email whatever is posted; a customer with none types one, which Checkout sets on the customer ("If the
2019
+ // Customer already has a valid email set, the email will be prefilled and not editable in Checkout. If the Customer does
2020
+ // not have a valid email, Checkout will set the email entered during the session on the Customer",
2021
+ // docs.stripe.com/api/checkout/sessions/create#create_checkout_session-customer). Rallly walk 4: the field was empty.
2022
+ 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 () => {
2023
+ const root = mkdtempSync(join(tmpdir(), 'stp-cs-email-'));
2024
+ try {
2025
+ const f = createStripeTwinFetch({ root });
2026
+ const api = async (method, path, body) => (await f(new Request(`http://stripe.test${path}`, { method, headers: { 'content-type': 'application/x-www-form-urlencoded' }, ...(body ? { body } : {}) }))).json();
2027
+ const post = (url, email) => 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() }));
2028
+ const session = (customer) => 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`);
2029
+ const known = await api('POST', '/v1/customers', 'email=known@twin.test');
2030
+ const cs = await session(String(known.id));
2031
+ const html = await (await f(new Request(String(cs.url)))).text();
2032
+ const input = /<input[^>]*id="email"[^>]*>/.exec(html)?.[0] ?? '';
2033
+ const paid = await post(String(cs.url), 'other@twin.test');
2034
+ const done = await api('GET', `/v1/checkout/sessions/${String(cs.id)}`);
2035
+ const knownAfter = await api('GET', `/v1/customers/${String(known.id)}`);
2036
+ const prefilled = input.includes('value="known@twin.test"') && /\sreadonly(=""|\s|\/|>)/i.test(input)
2037
+ && paid.status === 303 && done.customer_details?.email === 'known@twin.test' && knownAfter.email === 'known@twin.test';
2038
+ // a customer with no email: the field is empty and editable, and the typed email becomes the customer's
2039
+ const bare = await api('POST', '/v1/customers', 'name=Bare');
2040
+ const cs2 = await session(String(bare.id));
2041
+ const bareInput = /<input[^>]*id="email"[^>]*>/.exec(await (await f(new Request(String(cs2.url)))).text())?.[0] ?? '';
2042
+ const paid2 = await post(String(cs2.url), 'typed@twin.test');
2043
+ const bareAfter = await api('GET', `/v1/customers/${String(bare.id)}`);
2044
+ const updated = (await api('GET', '/v1/events?type=customer.updated')).data ?? [];
2045
+ const typed = bareInput !== '' && !/readonly/i.test(bareInput) && paid2.status === 303 && bareAfter.email === 'typed@twin.test'
2046
+ && updated.length === 1 && updated[0].data.object.id === bare.id && updated[0].data.object.email === 'typed@twin.test';
2047
+ return prefilled && typed;
2048
+ }
2049
+ finally {
2050
+ rmSync(root, { recursive: true, force: true });
2051
+ }
2052
+ }),
1770
2053
  // Checkout's expiry field takes the digits typed straight through, as Stripe's formats `1230` into `12 / 30`: the page
1771
2054
  // marks the field for its formatter, and the posted digits pay and are the card's expiry (screens/checkout.tsx
1772
2055
  // parseExpiry); two digits are still incomplete.
@@ -1774,7 +2057,7 @@ export const STRIPE_CAPABILITIES = [
1774
2057
  const root = mkdtempSync(join(tmpdir(), 'stp-expiry-'));
1775
2058
  try {
1776
2059
  const f = createStripeTwinFetch({ root, clock: () => '2026-03-01T00:00:00Z' });
1777
- const api = async (method, path, body) => (await f(new Request(`https://api.stripe.com${path}`, { method, headers: { 'content-type': 'application/x-www-form-urlencoded' }, ...(body ? { body } : {}) }))).json();
2060
+ const api = async (method, path, body) => (await f(new Request(`http://stripe.test${path}`, { method, headers: { 'content-type': 'application/x-www-form-urlencoded' }, ...(body ? { body } : {}) }))).json();
1778
2061
  const pay = (cs, expiry) => 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() }));
1779
2062
  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');
1780
2063
  const cs = await session();
@@ -1811,7 +2094,7 @@ export const STRIPE_CAPABILITIES = [
1811
2094
  const root = mkdtempSync(join(tmpdir(), 'stp-trialpage-'));
1812
2095
  try {
1813
2096
  const f = createStripeTwinFetch({ root, clock: () => '2026-03-01T00:00:00Z' });
1814
- const api = async (method, path, body) => (await f(new Request(`https://api.stripe.com${path}`, { method, headers: { 'content-type': 'application/x-www-form-urlencoded' }, ...(body ? { body } : {}) }))).json();
2097
+ const api = async (method, path, body) => (await f(new Request(`http://stripe.test${path}`, { method, headers: { 'content-type': 'application/x-www-form-urlencoded' }, ...(body ? { body } : {}) }))).json();
1815
2098
  const text = async (cs) => (await (await f(new Request(String(cs.url)))).text()).replace(/<script[\s\S]*?<\/script>/g, '').replace(/<[^>]+>/g, '|');
1816
2099
  const prod = await api('POST', '/v1/products', 'name=Business');
1817
2100
  const price = await api('POST', '/v1/prices', `unit_amount=9000&currency=usd&recurring[interval]=month&product=${String(prod.id)}`);
@@ -1829,14 +2112,14 @@ export const STRIPE_CAPABILITIES = [
1829
2112
  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`));
1830
2113
  const kept = await text(await api('POST', '/v1/checkout/sessions', `${line(price)}&subscription_data[trial_period_days]=14&discounts[0][coupon]=${String(forever.id)}`));
1831
2114
  // Checkout refuses a trial_end under 48 hours away
1832
- 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}` }));
2115
+ 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}` }));
1833
2116
  if (!spent.includes('|Then $90.00 per month|') || !kept.includes('|Then $81.00 per month|') || soon.status !== 400)
1834
2117
  return false;
1835
2118
  // and the twin bills what the page says: the one-time price on the first invoice, then $90.00 a period
1836
2119
  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`);
1837
2120
  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' }));
1838
2121
  const later = createStripeTwinFetch({ root, clock: () => '2026-03-20T00:00:00Z' });
1839
- const read = async (path) => (await later(new Request(`https://api.stripe.com${path}`))).json();
2122
+ const read = async (path) => (await later(new Request(`http://stripe.test${path}`))).json();
1840
2123
  const done = await read(`/v1/checkout/sessions/${String(paid.id)}`);
1841
2124
  const first = await read(`/v1/invoices/${String(done.invoice)}`);
1842
2125
  const bills = (await read(`/v1/invoices?subscription=${String(done.subscription)}`)).data ?? [];
@@ -2013,7 +2296,7 @@ export const STRIPE_CAPABILITIES = [
2013
2296
  const root = mkdtempSync(join(tmpdir(), 'stp-public-'));
2014
2297
  try {
2015
2298
  const f = createStripeTwinFetch({ root });
2016
- const api = async (method, path, body, account) => (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();
2299
+ const api = async (method, path, body, account) => (await f(new Request(`http://stripe.test${path}`, { method, headers: { 'content-type': 'application/x-www-form-urlencoded', ...(account ? { 'stripe-account': account } : {}) }, ...(body ? { body } : {}) }))).json();
2017
2300
  const page = async (url) => (await f(new Request(url))).text();
2018
2301
  const save = (name) => 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() }));
2019
2302
  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');
@@ -2052,12 +2335,12 @@ export const STRIPE_CAPABILITIES = [
2052
2335
  const landed = new URL(slashSave.headers.get('location') ?? '', 'https://dashboard.stripe.com/settings/public/').pathname;
2053
2336
  // the /_twin/account door, written first, starts the platform's record from the same default and is an update too
2054
2337
  const door = createStripeTwinFetch({ root: join(root, 'door') });
2055
- 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' }));
2338
+ 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' }));
2056
2339
  // and a save on the page keeps the public details it does not show, as the door keeps the name the page saved
2057
2340
  await door(new Request('https://dashboard.stripe.com/settings/public', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'business_name=Dub' }));
2058
- 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' }));
2059
- const doorAccount = await (await door(new Request('https://api.stripe.com/v1/account'))).json();
2060
- const doorEvents = (await (await door(new Request('https://api.stripe.com/v1/events?type=account.updated'))).json()).data ?? [];
2341
+ 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' }));
2342
+ const doorAccount = await (await door(new Request('http://stripe.test/v1/account'))).json();
2343
+ const doorEvents = (await (await door(new Request('http://stripe.test/v1/events?type=account.updated'))).json()).data ?? [];
2061
2344
  const long = await save('x'.repeat(5001));
2062
2345
  return before.includes('Twin Inc.') && !before.includes('Dub') && empty.status === 400 && saved.status === 303 && long.status === 400
2063
2346
  && 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')
@@ -2086,7 +2369,10 @@ export const STRIPE_CAPABILITIES = [
2086
2369
  done('stripe.billing_portal.payment_method_and_pay', 'checkout', 'Billing Portal: update the payment method and pay an open invoice', 'api', 'common', async () => {
2087
2370
  const root = mkdtempSync(join(tmpdir(), 'stp-portal-'));
2088
2371
  try {
2089
- const h = (s) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: `2026-${String(s.month ?? 1).padStart(2, '0')}-10T00:00:00Z` });
2372
+ // February's reads come two hours into the renewed period: its invoice is drafted at the period's end and charged
2373
+ // "an hour on" (above; cab4389eb stamps each catch-up write at that moment), so a read at the period's very end
2374
+ // sees the draft, not the paid invoice this claim is about
2375
+ const h = (s) => 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` });
2090
2376
  const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Box' });
2091
2377
  const price = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=3500&currency=usd&recurring[interval]=month&product=${id(prod)}` });
2092
2378
  const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=portal-pay@twin.test' });
@@ -2200,7 +2486,7 @@ export const STRIPE_CAPABILITIES = [
2200
2486
  const root = mkdtempSync(join(tmpdir(), 'stp-src-'));
2201
2487
  try {
2202
2488
  const h = (s) => 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 } : {}) });
2203
- 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' });
2489
+ 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' });
2204
2490
  const seller = id(a);
2205
2491
  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' });
2206
2492
  await h({ m: 'POST', p: '/_twin/account', b: 'settings[payouts][schedule][interval]=manual' });
@@ -2291,6 +2577,11 @@ export const STRIPE_CAPABILITIES = [
2291
2577
  await h('POST', '/v1/webhook_endpoints', `url=${rx.url('/platform')}&enabled_events[]=*`);
2292
2578
  await h('POST', '/v1/webhook_endpoints', `url=${rx.url('/connect')}&enabled_events[]=*&connect=true`);
2293
2579
  const seller = String((await h('POST', '/v1/accounts', 'type=express&country=US')).id);
2580
+ // the account's own event: an update "Occurs whenever an account status or property has changed"
2581
+ // (account.updated, docs.stripe.com/api/events/types). Its creation was asserted to send one when this claim was
2582
+ // written (788199389), which no version of the twin did; an Express account's creation changes nothing Stripe
2583
+ // reviews here (semantics/connect.ts review: an Express account is onboarded on Stripe's pages).
2584
+ await h('POST', `/v1/accounts/${seller}`, 'metadata[tier]=gold');
2294
2585
  const theirs = String((await h('POST', '/v1/customers', 'email=buyer@seller.test', seller)).id);
2295
2586
  const ours = String((await h('POST', '/v1/customers', 'email=buyer@platform.test')).id);
2296
2587
  const at = (path, type, object) => rx.got(path).filter((e) => e.type === type && e.data.object.id === object);
@@ -2317,7 +2608,7 @@ export const STRIPE_CAPABILITIES = [
2317
2608
  const h = fetcher(createStripeTwinFetch({ root, clock: () => now }));
2318
2609
  await h('POST', '/v1/webhook_endpoints', `url=${rx.url('/platform')}&enabled_events[]=*`);
2319
2610
  await h('POST', '/v1/webhook_endpoints', `url=${rx.url('/connect')}&enabled_events[]=*&connect=true`);
2320
- 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);
2611
+ 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);
2321
2612
  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');
2322
2613
  await h('POST', '/v1/charges', 'amount=100000&currency=usd&source=tok_bypassPending');
2323
2614
  await h('POST', '/v1/transfers', `amount=5000&currency=usd&destination=${seller}`);
@@ -2342,24 +2633,42 @@ export const STRIPE_CAPABILITIES = [
2342
2633
  rmSync(root, { recursive: true, force: true });
2343
2634
  }
2344
2635
  }),
2345
- done('stripe.connect.top_ups', 'connect', 'Top-ups (fund platform balance)', 'api', 'niche', () => withRoot(async (h) => {
2346
- const tu = await h({ m: 'POST', p: '/v1/topups', b: 'amount=50000&currency=usd&statement_descriptor=Top up' });
2347
- if (!ok(tu) || field(tu, 'object') !== 'topup' || field(tu, 'status') !== 'succeeded')
2348
- return false;
2349
- const g = await h({ m: 'GET', p: `/v1/topups/${id(tu)}` });
2350
- const u = await h({ m: 'POST', p: `/v1/topups/${id(tu)}`, b: 'metadata[ref]=q3' });
2351
- const l = await h({ m: 'GET', p: '/v1/topups?status=succeeded' });
2352
- // a succeeded top-up cannot be canceled.
2353
- const cancel = await h({ m: 'POST', p: `/v1/topups/${id(tu)}/cancel` });
2354
- const noMoney = await h({ m: 'POST', p: '/v1/topups', b: 'currency=usd' });
2355
- const nope = await h({ m: 'GET', p: '/v1/topups/tu_nope' });
2356
- return ok(g) && id(g) === id(tu) && ok(u) && l.body.data.length === 1 &&
2357
- cancel.status === 400 && noMoney.status === 400 && nope.status === 404;
2358
- })),
2636
+ // A top-up is pending until its funds arrive five days on ("USA (USD) ACH Debit Transfer 5 days",
2637
+ // docs.stripe.com/connect/top-ups; semantics/terminal.ts, c3519a064), and only a pending one can be canceled; a
2638
+ // succeeded one cannot. The verify runs on a World clock it moves, so the arrival is chosen, not waited for.
2639
+ done('stripe.connect.top_ups', 'connect', 'Top-ups (fund platform balance)', 'api', 'niche', async () => {
2640
+ const root = mkdtempSync(join(tmpdir(), 'stp-topup-'));
2641
+ try {
2642
+ const h = (s) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: `2026-06-${String(s.day ?? 1).padStart(2, '0')}T12:00:00Z` });
2643
+ const tu = await h({ m: 'POST', p: '/v1/topups', b: 'amount=50000&currency=usd&statement_descriptor=Top up' });
2644
+ if (!ok(tu) || field(tu, 'object') !== 'topup' || field(tu, 'status') !== 'pending' || field(tu, 'expected_availability_date') !== Number(field(tu, 'created')) + 5 * 86_400)
2645
+ return false;
2646
+ const g = await h({ m: 'GET', p: `/v1/topups/${id(tu)}` });
2647
+ const u = await h({ m: 'POST', p: `/v1/topups/${id(tu)}`, b: 'metadata[ref]=q3' });
2648
+ const pendingList = await h({ m: 'GET', p: '/v1/topups?status=pending' });
2649
+ // a second one, canceled while it is still pending
2650
+ const other = await h({ m: 'POST', p: '/v1/topups', b: 'amount=100&currency=usd' });
2651
+ const canceled = await h({ m: 'POST', p: `/v1/topups/${id(other)}/cancel` });
2652
+ // six days on the first has arrived: succeeded, and past canceling
2653
+ const arrived = await h({ m: 'GET', p: `/v1/topups/${id(tu)}`, day: 7 });
2654
+ const l = await h({ m: 'GET', p: '/v1/topups?status=succeeded', day: 7 });
2655
+ const cancel = await h({ m: 'POST', p: `/v1/topups/${id(tu)}/cancel`, day: 7 });
2656
+ const noMoney = await h({ m: 'POST', p: '/v1/topups', b: 'currency=usd' });
2657
+ const nope = await h({ m: 'GET', p: '/v1/topups/tu_nope' });
2658
+ return ok(g) && id(g) === id(tu) && ok(u) && pendingList.body.data.length === 1
2659
+ && ok(canceled) && field(canceled, 'status') === 'canceled'
2660
+ && field(arrived, 'status') === 'succeeded' && typeof field(arrived, 'balance_transaction') === 'string'
2661
+ && l.body.data.length === 1 && cancel.status === 400 && noMoney.status === 400 && nope.status === 404;
2662
+ }
2663
+ finally {
2664
+ rmSync(root, { recursive: true, force: true });
2665
+ }
2666
+ }),
2359
2667
  // ── Identity / Files ──────────────────────────────────────────────────────────────
2360
2668
  done('stripe.identity.verification_sessions', 'identity', 'Identity VerificationSessions: create + retrieve', 'api', 'niche', () => withRoot(async (h) => {
2361
2669
  const v = await h({ m: 'POST', p: '/v1/identity/verification_sessions', b: 'type=document' });
2362
- if (!ok(v) || field(v, 'object') !== 'verification_session')
2670
+ // the object is named by its namespace: "object": "identity.verification_session" (docs.stripe.com/api/identity/verification_sessions/object)
2671
+ if (!ok(v) || field(v, 'object') !== 'identity.verification_session' || field(v, 'status') !== 'requires_input')
2363
2672
  return false;
2364
2673
  const g = await h({ m: 'GET', p: `/v1/identity/verification_sessions/${id(v)}` });
2365
2674
  return ok(g) && id(g) === id(v);
@@ -2724,11 +3033,12 @@ export const STRIPE_CAPABILITIES = [
2724
3033
  const ev = events.body.data.find((e) => e.type === 'payment_intent.created');
2725
3034
  if (!ev || ev.api_version !== '2022-11-15')
2726
3035
  return false;
2727
- // no version → the twin default.
3036
+ // no version → the version the pack serves, its vendored spec's (stripe-version.ts SERVED_VERSION; "answers
3037
+ // render in the served API version", 24e757f60), as a request that pins none gets the account's
2728
3038
  const pi2 = await handleStripeTwinRequest({ method: 'POST', path: '/v1/payment_intents', body: 'amount=500&currency=usd', root });
2729
3039
  const ev2 = (await handleStripeTwinRequest({ method: 'GET', path: '/v1/events', root })).body.data
2730
3040
  .find((e) => e.type === 'payment_intent.created' && e.data?.object?.id === id(pi2));
2731
- return ev2 ? ev2.api_version === '2024-06-20' : false;
3041
+ return ev2 ? ev2.api_version === SERVED_VERSION && SERVED_VERSION > '2025-03-31' : false;
2732
3042
  }
2733
3043
  catch (err) {
2734
3044
  if (isInfrastructureError(err))
@@ -2748,8 +3058,11 @@ export const STRIPE_CAPABILITIES = [
2748
3058
  const clock = await h({ m: 'POST', p: '/v1/test_helpers/test_clocks', b: 'frozen_time=1000&name=T' });
2749
3059
  if (!ok(clock) || field(clock, 'object') !== 'test_helpers.test_clock' || field(clock, 'frozen_time') !== 1000)
2750
3060
  return false;
2751
- // a trialing subscription whose trial_end is 5000.
2752
- const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=clock@twin.test' });
3061
+ // a trialing subscription whose trial_end is 5000, for a customer ON the clock: a clock moves only what is attached
3062
+ // to it ("create a customer with the test_clock parameter", docs.stripe.com/billing/testing/test-clocks/api-advanced-usage),
3063
+ // and a trial_end is refused unless it is in the future (subscriptions.ts trialEndRefused) — for a clock's customer,
3064
+ // the clock's future. The customer was left off the clock when this was written, which its 1970 trial_end cannot survive.
3065
+ const cust = await h({ m: 'POST', p: '/v1/customers', b: `email=clock@twin.test&test_clock=${id(clock)}` });
2753
3066
  const sub = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&trial_end=5000` });
2754
3067
  if (field(sub, 'status') !== 'trialing')
2755
3068
  return false;
@@ -2838,21 +3151,19 @@ export const STRIPE_CAPABILITIES = [
2838
3151
  // the running mirror server → render the mirror's OWN ListPane over both → assert one
2839
3152
  // list-row per seeded object carrying the seeded name. Failable: an empty workspace yields
2840
3153
  // zero rows → zero list-rows.
2841
- done('stripe.ui.tax', 'ui-dashboard', 'Dashboard: Tax (Tax Rates + Tax Calculations) screens (data-coupled)', 'ui', 'common', uiDataCoupled({
2842
- markers: ['Tax Rates', 'Tax Calculations', 'list-row'],
3154
+ // Tax Rates only: Stripe publishes no list of tax calculations (a calculation is retrieved by id), so the mirror's
3155
+ // Tax Calculations screen, which read the invented GET /v1/tax/calculations, left with it (5decb1d77).
3156
+ done('stripe.ui.tax', 'ui-dashboard', 'Dashboard: Tax (Tax Rates) screen (data-coupled)', 'ui', 'common', uiDataCoupled({
3157
+ markers: ['Tax Rates', 'list-row'],
2843
3158
  seed: async (h) => {
2844
3159
  await h({ m: 'POST', p: '/v1/tax_rates', b: 'display_name=UI Check Tax&percentage=12.5&inclusive=false&jurisdiction=US' });
2845
- 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' });
2846
3160
  },
2847
3161
  check: async ({ get }) => {
2848
3162
  const rates = (await get('/v1/tax_rates')).data;
2849
- const calcs = (await get('/v1/tax/calculations')).data;
2850
- if (!rates.length || !calcs.length)
3163
+ if (!rates.length)
2851
3164
  return false;
2852
3165
  const ratesMarkup = renderSectionList('tax_rates', rates);
2853
- const calcsMarkup = renderSectionList('tax/calculations', calcs);
2854
- return listRows(ratesMarkup) === rates.length && ratesMarkup.includes('UI Check Tax') &&
2855
- listRows(calcsMarkup) === calcs.length;
3166
+ return listRows(ratesMarkup) === rates.length && ratesMarkup.includes('UI Check Tax');
2856
3167
  },
2857
3168
  })),
2858
3169
  // ── Issuing ──────────────────────────────────────────────────────────────────────
@@ -2941,10 +3252,18 @@ export const STRIPE_CAPABILITIES = [
2941
3252
  await fundIssuing(root, 10000000); // the Issuing balance authorizations are paid from
2942
3253
  const ch = await h({ m: 'POST', p: '/v1/issuing/cardholders', b: 'name=Jane&type=individual&billing[address][country]=US' });
2943
3254
  const card = await h({ m: 'POST', p: `/v1/issuing/cards`, b: `cardholder=${id(ch)}&currency=usd&type=virtual` });
3255
+ // with no real-time webhook the authorization is approved at once ("If you don't have a real-time authorization
3256
+ // webhook, we approve the authorization without sending the issuing_authorization.request",
3257
+ // docs.stripe.com/issuing/purchases/authorizations), so the approve API refuses it as already decided; the merchant's
3258
+ // capture (the test helper) is what makes the transaction
2944
3259
  const auth = await h({ m: 'POST', p: '/v1/test_helpers/issuing/authorizations', b: `card=${id(card)}&amount=4200` });
2945
- await h({ m: 'POST', p: `/v1/issuing/authorizations/${id(auth)}/approve` });
3260
+ if (field(auth, 'approved') !== true || (await h({ m: 'POST', p: `/v1/issuing/authorizations/${id(auth)}/approve` })).status !== 400)
3261
+ return false;
3262
+ await h({ m: 'POST', p: `/v1/test_helpers/issuing/authorizations/${id(auth)}/capture` });
2946
3263
  const txns = await h({ m: 'GET', p: '/v1/issuing/transactions' });
2947
3264
  const txn = txns.body.data[0];
3265
+ if (txns.body.data.length !== 1 || txn.amount !== -4200 || txn.authorization !== id(auth))
3266
+ return false;
2948
3267
  const g = await h({ m: 'GET', p: `/v1/issuing/transactions/${txn.id}` });
2949
3268
  if (!ok(g) || field(g, 'object') !== 'issuing.transaction')
2950
3269
  return false;
@@ -3012,6 +3331,9 @@ export const STRIPE_CAPABILITIES = [
3012
3331
  const root2 = mkdtempSync(join(tmpdir(), 'stp-cap-'));
3013
3332
  try {
3014
3333
  const h2 = (s) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root: root2, occurredAt: '2026-06-14T00:00:00Z' });
3334
+ // funded as the first World is: an authorization the Issuing balance cannot cover is declined insufficient_funds
3335
+ // (docs.stripe.com/issuing/funding/balance), which would decide it before any endpoint could be consulted
3336
+ await fundIssuing(root2, 10000000, '2026-06-14T00:00:00Z');
3015
3337
  let consulted = false;
3016
3338
  setStripeEventDelivery((_url, event) => {
3017
3339
  if (event.type === 'issuing_authorization.request')
@@ -3021,8 +3343,14 @@ export const STRIPE_CAPABILITIES = [
3021
3343
  const ch2 = await h2({ m: 'POST', p: '/v1/issuing/cardholders', b: 'name=RT2&type=individual&billing[address][country]=US' });
3022
3344
  const card2 = await h2({ m: 'POST', p: '/v1/issuing/cards', b: `cardholder=${id(ch2)}&currency=usd&type=virtual` });
3023
3345
  await h2({ m: 'POST', p: '/v1/webhook_endpoints', b: `url=${encodeURIComponent('https://other.twin.test/hook')}&enabled_events[]=invoice.paid` });
3346
+ // ...and, not enrolled, it is approved at once by the account's default settings: "If you don't have a real-time
3347
+ // authorization webhook, we approve the authorization without sending the issuing_authorization.request"
3348
+ // (docs.stripe.com/issuing/purchases/authorizations; semantics/issuing.ts, reason card_active). It used to stay
3349
+ // pending for the approve API, which is the deprecated path.
3024
3350
  const auth2 = await h2({ m: 'POST', p: '/v1/test_helpers/issuing/authorizations', b: `card=${id(card2)}&amount=100` });
3025
- return !consulted && field(auth2, 'status') === 'pending' && field(auth2, 'approved') === false;
3351
+ const history2 = field(auth2, 'request_history') ?? [];
3352
+ return !consulted && field(auth2, 'status') === 'pending' && field(auth2, 'approved') === true
3353
+ && history2.length === 1 && history2[0].reason === 'card_active';
3026
3354
  }
3027
3355
  finally {
3028
3356
  rmSync(root2, { recursive: true, force: true });
@@ -3310,9 +3638,18 @@ export const STRIPE_CAPABILITIES = [
3310
3638
  // not a public create). open is the only approvable state → /approve closes it (open:false,
3311
3639
  // closed_reason 'approved'); a second approve 400s. retrieve + list round-trip; unknown id 404.
3312
3640
  // (the open→closed approve transition is produced ONLY by this feature.)
3641
+ // 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
3642
+ // removed with the invented endpoints, 5decb1d77): an elevated-risk card's charge is placed in review
3643
+ // (docs.stripe.com/radar/reviews; the test card 4000000000009235, pm_card_riskLevelElevated, docs.stripe.com/testing).
3313
3644
  done('stripe.radar.reviews', 'radar', 'Radar Reviews (approve/list)', 'api', 'niche', () => withRoot(async (h) => {
3314
- const rv = await h({ m: 'POST', p: '/v1/radar/reviews', b: 'charge=ch_twin&payment_intent=pi_twin' });
3315
- if (!ok(rv) || field(rv, 'object') !== 'review' || field(rv, 'open') !== true)
3645
+ const risky = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=2000&currency=usd&payment_method=pm_card_riskLevelElevated&confirm=true' });
3646
+ if (!ok(risky) || field(risky, 'status') !== 'succeeded')
3647
+ return false;
3648
+ const opened = (await h({ m: 'GET', p: '/v1/reviews' })).body.data ?? [];
3649
+ if (opened.length !== 1)
3650
+ return false;
3651
+ const rv = await h({ m: 'GET', p: `/v1/reviews/${String(opened[0].id)}` });
3652
+ if (!ok(rv) || field(rv, 'object') !== 'review' || field(rv, 'open') !== true || field(rv, 'payment_intent') !== id(risky))
3316
3653
  return false;
3317
3654
  const g = await h({ m: 'GET', p: `/v1/reviews/${id(rv)}` });
3318
3655
  const l = await h({ m: 'GET', p: '/v1/reviews' });
@@ -3352,62 +3689,37 @@ export const STRIPE_CAPABILITIES = [
3352
3689
  ok(delList) && field(delList, 'deleted') === true && gone.status === 404 &&
3353
3690
  noAlias.status === 400 && badList.status === 400 && noValue.status === 400;
3354
3691
  })),
3355
- // Radar Rules: create a custom rule (requires action ∈ {block,review,allow} + predicate),
3356
- // retrieve, list (filterable by action), delete. Missing action/predicate 400; bad action 400.
3357
- done('stripe.radar.rules', 'radar', 'Radar rules', 'api', 'niche', () => withRoot(async (h) => {
3358
- const rule = await h({ m: 'POST', p: '/v1/radar/rules', b: 'action=block&predicate=:risk_level: = "highest"' });
3359
- if (!ok(rule) || field(rule, 'object') !== 'radar.rule' || field(rule, 'action') !== 'block')
3360
- return false;
3361
- const g = await h({ m: 'GET', p: `/v1/radar/rules/${id(rule)}` });
3362
- const l = await h({ m: 'GET', p: '/v1/radar/rules?action=block' });
3363
- if (!ok(g) || id(g) !== id(rule) || l.body.data.length !== 1)
3364
- return false;
3365
- const del = await h({ m: 'DELETE', p: `/v1/radar/rules/${id(rule)}` });
3366
- const gone = await h({ m: 'GET', p: `/v1/radar/rules/${id(rule)}` });
3367
- const noAction = await h({ m: 'POST', p: '/v1/radar/rules', b: 'predicate=x' });
3368
- const badAction = await h({ m: 'POST', p: '/v1/radar/rules', b: 'action=nuke&predicate=x' });
3369
- const noPred = await h({ m: 'POST', p: '/v1/radar/rules', b: 'action=block' });
3370
- return ok(del) && field(del, 'deleted') === true && gone.status === 404 &&
3371
- noAction.status === 400 && badAction.status === 400 && noPred.status === 400;
3372
- })),
3373
- // Dashboard: Radar (Reviews + Value Lists + Rules) screens — rendered surfaces in the mirror.
3374
- // Radar (Reviews + Value Lists + Rules) — RUNG-5 data-coupled: the three section markers are
3375
- // bundled (the screens are wired in) AND, for each of the three Radar collections, we seed
3376
- // real twin state (a review, a value-list + item, a rule), fetch the SAME /v1 endpoint the
3377
- // mirror's React client reads off the running mirror server, render the mirror's OWN ListPane
3378
- // component over those rows, and assert the rendered DOM emits exactly one `list-row` landmark
3379
- // per seeded object carrying that object's id. Failable: an empty workspace yields zero rows →
3380
- // zero list-rows → false; and a marker-only/hardcoded render could not surface the seeded ids.
3381
- done('stripe.ui.radar', 'ui-dashboard', 'Dashboard: Radar (Reviews + Value Lists + Rules) screens', 'ui', 'niche', uiDataCoupled({
3382
- markers: ['Radar Reviews', 'Radar Value Lists', 'Radar Rules', 'list-row'],
3692
+ // Radar rules are not in Stripe's API: rules are written in the Dashboard (docs.stripe.com/radar/rules), and the
3693
+ // published spec has no /v1/radar/rules. The twin's routes for them were invented and are gone (5decb1d77), so the
3694
+ // `stripe.radar.rules` claim they carried is withdrawn rather than kept as a todo: it is not vendor API surface.
3695
+ // Dashboard: Radar (Reviews + Value Lists) screens — RUNG-5 data-coupled: the section markers are bundled (the screens
3696
+ // are wired in) AND, for each Radar collection, we seed real twin state (a review Radar opens on an elevated-risk
3697
+ // charge, a value-list + item), fetch the SAME /v1 endpoint the mirror's React client reads off the running mirror
3698
+ // server, render the mirror's OWN ListPane component over those rows, and assert the rendered DOM emits exactly one
3699
+ // `list-row` landmark per seeded object carrying it. Failable: an empty workspace yields zero rows → false. The Rules
3700
+ // screen left the mirror with the invented /v1/radar/rules it read (5decb1d77: "The Dashboard mirror no longer lists
3701
+ // ... radar rules"), and the review is no longer seeded through the invented POST /v1/radar/reviews.
3702
+ done('stripe.ui.radar', 'ui-dashboard', 'Dashboard: Radar (Reviews + Value Lists) screens', 'ui', 'niche', uiDataCoupled({
3703
+ markers: ['Radar Reviews', 'Radar Value Lists', 'list-row'],
3383
3704
  seed: async (h) => {
3384
- // a flagged payment opens a Review; a value list + one item; one custom rule.
3385
- await h({ m: 'POST', p: '/v1/radar/reviews', b: 'charge=ch_radar_ui&payment_intent=pi_radar_ui' });
3705
+ await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=2000&currency=usd&payment_method=pm_card_riskLevelElevated&confirm=true' });
3386
3706
  const vl = await h({ m: 'POST', p: '/v1/radar/value_lists', b: 'alias=ui_block_ips&name=UI Blocked IPs&item_type=ip_address' });
3387
3707
  await h({ m: 'POST', p: '/v1/radar/value_list_items', b: `value_list=${vl.body.id}&value=9.9.9.9` });
3388
- await h({ m: 'POST', p: '/v1/radar/rules', b: 'action=block&predicate=:risk_level: = "highest"' });
3389
3708
  },
3390
3709
  check: async ({ get }) => {
3391
3710
  const reviews = (await get('/v1/reviews')).data ?? [];
3392
3711
  const valueLists = (await get('/v1/radar/value_lists')).data ?? [];
3393
- const rules = (await get('/v1/radar/rules')).data ?? [];
3394
- // the seeded objects must be present in the projections the screen reads
3395
- const review = reviews.find((r) => r.charge === 'ch_radar_ui');
3712
+ // the review is the one Radar opened on the seeded elevated-risk payment
3713
+ const risky = ((await get('/v1/payment_intents')).data ?? [])[0];
3714
+ const review = reviews.find((r) => r.open === true && r.payment_intent === risky?.id && typeof r.charge === 'string' && r.charge === risky?.latest_charge);
3396
3715
  const vl = valueLists.find((l) => l.alias === 'ui_block_ips');
3397
- const rule = rules.find((r) => r.predicate === ':risk_level: = "highest"');
3398
- if (!review || !vl || !rule)
3716
+ if (!review || !vl)
3399
3717
  return false;
3400
- // RENDER the mirror's own ListPane over the fetched rows and assert the DOM emits one
3401
- // list-row per object, each carrying the seeded object's id (subtitle renders r.id).
3402
3718
  const reviewsMarkup = renderSectionList('reviews', reviews);
3403
3719
  const valueListsMarkup = renderSectionList('radar/value_lists', valueLists);
3404
- const rulesMarkup = renderSectionList('radar/rules', rules);
3405
- return (
3406
- // reviews + rules render their id in the row subtitle; the value-list row renders its
3407
- // name + alias (the id is not surfaced in that section's row), so assert those instead.
3408
- listRows(reviewsMarkup) === reviews.length && reviewsMarkup.includes(String(review.id)) &&
3409
- listRows(valueListsMarkup) === valueLists.length && valueListsMarkup.includes('UI Blocked IPs') && valueListsMarkup.includes('ui_block_ips') &&
3410
- listRows(rulesMarkup) === rules.length && rulesMarkup.includes(String(rule.id)) && rulesMarkup.includes('block'));
3720
+ // a review's row names its charge; the value-list row renders its name + alias
3721
+ return listRows(reviewsMarkup) === reviews.length && reviewsMarkup.includes(String(review.charge))
3722
+ && listRows(valueListsMarkup) === valueLists.length && valueListsMarkup.includes('UI Blocked IPs') && valueListsMarkup.includes('ui_block_ips');
3411
3723
  },
3412
3724
  })),
3413
3725
  // ── Reporting ────────────────────────────────────────────────────────────────────
@@ -3700,8 +4012,10 @@ export const STRIPE_CAPABILITIES = [
3700
4012
  return false;
3701
4013
  const summaryMarkup = renderToStaticMarkup(createElement(BalanceSummary, { row: { id: 'balance', ...balance } }));
3702
4014
  const txnsMarkup = renderSectionList('balance_transactions', txns);
3703
- return summaryMarkup.includes('balance-summary') && summaryMarkup.includes(formatStripeAmount(424200, 'usd')) &&
3704
- listRows(txnsMarkup) === txns.length && txns.some((t) => t.amount === 424200);
4015
+ // the balance is the charge less Stripe's fee, 2.9% + 30¢ (stripe.com/pricing; the ledger, d5c8f9a70):
4016
+ // 424200 - (12302 + 30) = 411868 available, while the transaction keeps the gross amount
4017
+ return summaryMarkup.includes('balance-summary') && summaryMarkup.includes(formatStripeAmount(411868, 'usd')) &&
4018
+ listRows(txnsMarkup) === txns.length && txns.some((t) => t.amount === 424200 && t.fee === 12332 && t.net === 411868);
3705
4019
  },
3706
4020
  })),
3707
4021
  // TWIN-14 (B4) migration — was a marker-grep over the literals 'Connected Accounts',
@@ -3821,7 +4135,12 @@ export const STRIPE_CAPABILITIES = [
3821
4135
  // per seeded run carrying its report_type (fails on an empty workspace).
3822
4136
  done('stripe.ui.reports', 'ui-dashboard', 'Dashboard: Reports / analytics screen', 'ui', 'niche', uiDataCoupled({
3823
4137
  markers: ['Reports', 'reporting/report_runs', 'list-row'],
3824
- seed: async (h) => { await h({ m: 'POST', p: '/v1/reporting/report_runs', b: 'report_type=balance.summary.1' }); },
4138
+ // made ten minutes ago on the World's clock: a run starts pending and succeeds a minute on ("Most runs complete within a
4139
+ // few minutes", docs.stripe.com/reports/api; stripe.reporting.report_runs), so one made at the instant the screen reads
4140
+ // it is still pending
4141
+ seed: async (_h, root) => {
4142
+ 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() });
4143
+ },
3825
4144
  check: async ({ get }) => {
3826
4145
  const rows = (await get('/v1/reporting/report_runs')).data;
3827
4146
  if (!rows || rows.length < 1)
@@ -3894,15 +4213,22 @@ export const STRIPE_CAPABILITIES = [
3894
4213
  // ════════════════════════════════════════════════════════════════════════════════════
3895
4214
  // ── SetupIntents full lifecycle (only confirm was tracked before) ──
3896
4215
  done('stripe.setup_intents.lifecycle', 'payment_methods', 'SetupIntents: create / retrieve / list / cancel', 'api', 'common', () => withRoot(async (h) => {
4216
+ // made without a payment method it waits for one: "When the SetupIntent is created, it has a status of
4217
+ // requires_payment_method until a payment method is attached" (docs.stripe.com/payments/setupintents/lifecycle;
4218
+ // 45d8b30d1); with one it waits for confirmation
3897
4219
  const c = await h({ m: 'POST', p: '/v1/setup_intents', b: 'usage=off_session' });
3898
- if (!ok(c) || field(c, 'status') !== 'requires_confirmation')
4220
+ if (!ok(c) || field(c, 'status') !== 'requires_payment_method')
3899
4221
  return false;
3900
4222
  const g = await h({ m: 'GET', p: `/v1/setup_intents/${id(c)}` });
3901
4223
  const l = await h({ m: 'GET', p: '/v1/setup_intents' });
3902
4224
  const x = await h({ m: 'POST', p: `/v1/setup_intents/${id(c)}/cancel` });
3903
4225
  // cancelling a succeeded SI is rejected (negative path)
3904
- const conf = await h({ m: 'POST', p: '/v1/setup_intents', b: 'usage=off_session' });
3905
- await h({ m: 'POST', p: `/v1/setup_intents/${id(conf)}/confirm` });
4226
+ const conf = await h({ m: 'POST', p: '/v1/setup_intents', b: 'usage=off_session&payment_method=pm_card_visa' });
4227
+ if (field(conf, 'status') !== 'requires_confirmation')
4228
+ return false;
4229
+ const confirmed = await h({ m: 'POST', p: `/v1/setup_intents/${id(conf)}/confirm` });
4230
+ if (field(confirmed, 'status') !== 'succeeded')
4231
+ return false;
3906
4232
  const badCancel = await h({ m: 'POST', p: `/v1/setup_intents/${id(conf)}/cancel` });
3907
4233
  return ok(g) && id(g) === id(c) && field(l, 'object') === 'list'
3908
4234
  && ok(x) && field(x, 'status') === 'canceled' && badCancel.status === 400;
@@ -3925,12 +4251,16 @@ export const STRIPE_CAPABILITIES = [
3925
4251
  })),
3926
4252
  // ── Mandates retrieve ──
3927
4253
  done('stripe.mandates.retrieve', 'payment_methods', 'Mandates: retrieve (404 unknown)', 'api', 'niche', () => withRoot(async (h) => {
3928
- const m = await h({ m: 'POST', p: '/v1/mandates', b: 'payment_method=pm_card_visa' });
3929
- if (!ok(m) || field(m, 'object') !== 'mandate')
3930
- return false;
3931
- const g = await h({ m: 'GET', p: `/v1/mandates/${id(m)}` });
4254
+ // Stripe's API makes no mandate on request (the twin's seeding POST /v1/mandates was invented and is gone,
4255
+ // 5decb1d77): a mandate is the customer's acceptance a verified bank account carries, and a SetupIntent verified by
4256
+ // micro-deposits names its multi-use one (docs.stripe.com/api/mandates, docs.stripe.com/api/setup_intents/object mandate)
4257
+ const si = await h({ m: 'POST', p: '/v1/setup_intents', b: 'allowed_payment_method_types[]=us_bank_account' });
4258
+ await h({ m: 'POST', p: `/v1/setup_intents/${id(si)}/confirm`, b: 'payment_method=pm_us_bank_account' });
4259
+ const verified = await h({ m: 'POST', p: `/v1/setup_intents/${id(si)}/verify_microdeposits`, b: 'descriptor_code=SM11AA' });
4260
+ const mandateId = String(field(verified, 'mandate'));
4261
+ const g = await h({ m: 'GET', p: `/v1/mandates/${mandateId}` });
3932
4262
  const miss = await h({ m: 'GET', p: '/v1/mandates/mandate_nope' });
3933
- return ok(g) && id(g) === id(m) && field(g, 'status') === 'active' && miss.status === 404;
4263
+ return ok(g) && field(g, 'object') === 'mandate' && id(g) === mandateId && field(g, 'status') === 'active' && field(g, 'type') === 'multi_use' && miss.status === 404;
3934
4264
  })),
3935
4265
  // ── Refund cancel ──
3936
4266
  // A bank-transfer payment's refund waits in requires_action for the customer's bank details (Stripe emails them), and
@@ -3970,6 +4300,292 @@ export const STRIPE_CAPABILITIES = [
3970
4300
  return ok(inc) && field(inc, 'amount') === 1500 && field(inc, 'amount_capturable') === 1500 && field(inc, 'status') === 'requires_capture' && bad.status === 400
3971
4301
  && field(held, 'amount') === 1500 && field(taken, 'amount_captured') === 1500 && field(taken, 'amount_refunded') === 0 && field(taken, 'refunded') === false;
3972
4302
  })),
4303
+ // ── Connect OAuth for Standard accounts (screens/connect-oauth.tsx; docs.stripe.com/connect/oauth-reference) ──
4304
+ // The platform's OAuth settings: "Enable onboarding accounts with OAuth", "Copy your client_id", "Set your redirect_uri
4305
+ // ... If you don't include the redirect_uri parameter in your request, Stripe defaults to using the first address you've
4306
+ // configured" (docs.stripe.com/connect/oauth-standard-accounts). The page shows the client_id and what was saved, and
4307
+ // authorize holds the platform to it.
4308
+ 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', () => withOAuth(async (w) => {
4309
+ const read = async (path) => (await w.f(new Request(`https://dashboard.stripe.com${path}`))).text();
4310
+ const first = await read('/settings/connect');
4311
+ const shownId = /data-testid="connect-client-id">(ca_[^<]+)</.exec(first)?.[1];
4312
+ // a new platform has OAuth off: authorize refuses it, in JSON, with the state
4313
+ const off = await w.authorize(authorizeQuery());
4314
+ const offBody = (await off.json());
4315
+ // a redirect URI that is not an http(s) URL is refused on the page, and nothing is saved
4316
+ const bad = await w.settings(true, 'ftp://files.test/cb');
4317
+ const fragment = await w.settings(true, 'https://app.test/cb#tab');
4318
+ const stillOff = (await (await w.authorize(authorizeQuery())).json()).error === 'invalid_client';
4319
+ // OAuth on with no redirect URI: a request naming none has nowhere to go
4320
+ await w.settings(true, '');
4321
+ const nowhere = (await (await w.authorize(authorizeQuery('', null))).json());
4322
+ const saved = await w.settings(true, `https://app.test/first\n${OAUTH_CALLBACK}?tab=stripe`);
4323
+ const listed = await read('/settings/connect/onboarding-options/oauth');
4324
+ const uris = [...listed.matchAll(/data-testid="connect-redirect-uri">([^<]+)</g)].map((m) => m[1].replace(/&amp;/g, '&'));
4325
+ // no redirect_uri: the first registered; a registered one with its own query keeps it; an unregistered one is refused
4326
+ const defaulted = (await w.authorize(authorizeQuery('', null), 'skip')).headers.get('location') ?? '';
4327
+ const named = (await w.authorize(authorizeQuery('', `${OAUTH_CALLBACK}?tab=stripe`), 'skip')).headers.get('location') ?? '';
4328
+ const unregistered = await w.authorize(authorizeQuery('', OAUTH_CALLBACK));
4329
+ const unregisteredBody = (await unregistered.json());
4330
+ // turned off again, authorize refuses again
4331
+ await w.settings(false, OAUTH_CALLBACK);
4332
+ const offAgain = (await (await w.authorize(authorizeQuery())).json()).error === 'invalid_client';
4333
+ const testLink = (await read('/test/settings/connect/onboarding-options/oauth')).includes('OAuth is disabled.');
4334
+ return shownId === 'ca_twin_self' && first.includes('OAuth is disabled.') && off.status === 400 && offBody.error === 'invalid_client' && offBody.state === 'st8'
4335
+ && 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`
4336
+ && 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_`)
4337
+ && unregistered.status === 400 && unregisteredBody.error === 'invalid_redirect_uri' && String(unregisteredBody.error_description).includes(OAUTH_CALLBACK)
4338
+ && offAgain && testLink;
4339
+ }, false)),
4340
+ // GET connect.stripe.com/oauth/authorize: the request checked as the reference lists its errors (a JSON dictionary with
4341
+ // error, error_description and state, never a redirect), the page the person answers, "Skip this form" in test mode
4342
+ // creating a new Standard account from the prefill ("Any parameters with invalid values are silently ignored") and
4343
+ // redirecting with code, scope and state; a denial redirects with access_denied and creates nothing.
4344
+ 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', () => withOAuth(async (w) => {
4345
+ const err = async (query) => { const r = await w.authorize(query); return { status: r.status, ...(await r.json()) }; };
4346
+ const noClient = await err(authorizeQuery().replace('client_id=ca_twin_self&', ''));
4347
+ const unknown = await err(authorizeQuery().replace('ca_twin_self', 'ca_nope'));
4348
+ const noType = await err(authorizeQuery().replace('&response_type=code', ''));
4349
+ const tokenType = await err(authorizeQuery().replace('response_type=code', 'response_type=token'));
4350
+ const badScope = await err(authorizeQuery().replace('scope=read_write', 'scope=admin'));
4351
+ const errorsRight = noClient.status === 400 && noClient.error === 'invalid_request' && unknown.error === 'invalid_client' && unknown.error_description === 'No application matches the supplied client identifier'
4352
+ && 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);
4353
+ 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';
4354
+ const shown = await (await w.authorize(authorizeQuery(prefillQuery))).text();
4355
+ 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.');
4356
+ // Deny: back to the platform with access_denied and the state, and no account made
4357
+ const denied = (await w.authorize(authorizeQuery(prefillQuery), 'deny')).headers.get('location');
4358
+ const noneMade = (await w.api('GET', '/v1/accounts')).body.data.length === 0;
4359
+ const { location, code, token } = await w.connect(prefillQuery);
4360
+ const acct = (await w.api('GET', `/v1/accounts/${String(token.stripe_user_id)}`)).body;
4361
+ const profile = acct.business_profile;
4362
+ // scope defaults to read_only
4363
+ const readOnly = new URL((await w.authorize(authorizeQuery().replace('scope=read_write&', ''), 'skip')).headers.get('location') ?? 'about:blank').searchParams.get('scope');
4364
+ // an empty scope is no scope: the default, not invalid_scope
4365
+ const emptyScope = new URL((await w.authorize(authorizeQuery().replace('scope=read_write', 'scope='), 'skip')).headers.get('location') ?? 'about:blank').searchParams.get('scope');
4366
+ // two people connecting at once get two codes, each for its own account (a regression guard: in one process a
4367
+ // count and its write never interleave; the atomic mint is for Worlds whose store two processes write)
4368
+ const both = await Promise.all([w.authorize(authorizeQuery(), 'skip'), w.authorize(authorizeQuery(), 'skip')]);
4369
+ const codes = both.map((r) => new URL(r.headers.get('location') ?? 'about:blank').searchParams.get('code'));
4370
+ const bothTokens = await Promise.all(codes.map(async (c) => (await w.endpoint('token', `grant_type=authorization_code&code=${c}`)).body));
4371
+ return errorsRight && pageRight && noneMade
4372
+ && denied === `${OAUTH_CALLBACK}?error=access_denied&error_description=The%20user%20denied%20your%20request&state=st8`
4373
+ && `${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)
4374
+ && acct.id === token.stripe_user_id && acct.type === 'standard' && acct.controller?.type === 'account' && acct.email === 'org@cal.test' && acct.country === 'CA' && acct.default_currency === 'cad'
4375
+ && profile?.name === 'Ada Consulting' && profile?.url === null && profile?.product_description === 'Consulting sessions' && profile?.support_phone === '6045550123'
4376
+ && acct.business_type === 'company' && acct.settings?.dashboard?.display_name === 'Ada Consulting'
4377
+ && acct.requirements?.currently_due?.length === 0 && acct.details_submitted === true && acct.payouts_enabled === true
4378
+ && acct.charges_enabled === true && acct.capabilities?.card_payments === 'active'
4379
+ && readOnly === 'read_only' && emptyScope === 'read_only'
4380
+ && 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;
4381
+ })),
4382
+ // POST connect.stripe.com/oauth/token with grant_type=authorization_code, as stripe-node's oauth.token sends it (the
4383
+ // platform's secret key as Bearer; curl's -u as Basic): the reference's response, the connected account usable with the
4384
+ // Stripe-Account header at once; a code "can only be used once and expires in 5 minutes", and "Consuming an
4385
+ // authorization code more than once revokes the account connection"; the reference's error codes.
4386
+ 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', () => withOAuth(async (w) => {
4387
+ const { code, token } = await w.connect();
4388
+ const account = String(token.stripe_user_id);
4389
+ const shape = Object.keys(token).sort().join(',') === 'access_token,livemode,refresh_token,scope,stripe_publishable_key,stripe_user_id,token_type'
4390
+ && token.token_type === 'bearer' && token.livemode === false && token.scope === 'read_write' && /^sk_test_/.test(String(token.access_token))
4391
+ && /^pk_test_/.test(String(token.stripe_publishable_key)) && /^rt_/.test(String(token.refresh_token)) && /^acct_/.test(account);
4392
+ // the platform acts as the account (Cal.com: a PaymentIntent with stripeAccount), and reads its default currency
4393
+ const pi = await w.api('POST', '/v1/payment_intents', 'amount=2500&currency=usd', account);
4394
+ const retrieved = (await w.api('GET', `/v1/accounts/${account}`)).body;
4395
+ const e = async (body, auth) => { const r = await w.endpoint('token', body, auth); return { status: r.status, ...r.body }; };
4396
+ const noKey = await e(`grant_type=authorization_code&code=${code}`, '');
4397
+ const publishable = await e(`grant_type=authorization_code&code=${code}`, 'Bearer pk_test_platform');
4398
+ const noGrant = await e(`code=${code}`);
4399
+ const password = await e('grant_type=password');
4400
+ const noCode = await e('grant_type=authorization_code');
4401
+ const missing = await e('grant_type=authorization_code&code=ac_nope');
4402
+ // curl -u sk_...: (Basic) works as Bearer does
4403
+ const second = await w.authorize(authorizeQuery(), 'skip');
4404
+ const basic = await e(`grant_type=authorization_code&code=${new URL(second.headers.get('location')).searchParams.get('code')}`, `Basic ${btoa('sk_test_platform:')}`);
4405
+ // five minutes: a code 299 s old is exchanged, one 300 s old is not
4406
+ const fresh = new URL((await w.authorize(authorizeQuery(), 'skip')).headers.get('location')).searchParams.get('code');
4407
+ const stale = new URL((await w.authorize(authorizeQuery(), 'skip')).headers.get('location')).searchParams.get('code');
4408
+ w.at('2026-03-01T00:04:59.000Z');
4409
+ const inTime = await e(`grant_type=authorization_code&code=${fresh}`);
4410
+ w.at('2026-03-01T00:05:00.000Z');
4411
+ const late = await e(`grant_type=authorization_code&code=${stale}`);
4412
+ const liveCode = new URL((await w.authorize(authorizeQuery(), 'skip')).headers.get('location')).searchParams.get('code');
4413
+ const liveKey = await e(`grant_type=authorization_code&code=${liveCode}`, 'Bearer sk_live_platform');
4414
+ // a JSON body is read as the form is
4415
+ const jsonCode = new URL((await w.authorize(authorizeQuery(), 'skip')).headers.get('location')).searchParams.get('code');
4416
+ 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 }) }));
4417
+ const asJsonBody = (await asJson.json());
4418
+ // the first code again: refused, and the connection it made is revoked
4419
+ const reused = await e(`grant_type=authorization_code&code=${code}`);
4420
+ const after = await w.api('POST', '/v1/payment_intents', 'amount=2500&currency=usd', account);
4421
+ const others = await w.api('POST', '/v1/payment_intents', 'amount=2500&currency=usd', String(basic.stripe_user_id));
4422
+ // the same code exchanged twice at once (a callback run twice): one exchange wins, and still the connection is revoked
4423
+ // (the order the loser marks first is pinned below, `midway`)
4424
+ const ids = async () => (await w.api('GET', '/v1/accounts?limit=100')).body.data.map((a) => String(a.id));
4425
+ const known = await ids();
4426
+ const raced = new URL((await w.authorize(authorizeQuery(), 'skip')).headers.get('location')).searchParams.get('code');
4427
+ const racedAccount = (await ids()).find((a) => !known.includes(a)) ?? '';
4428
+ const pair = await Promise.all([e(`grant_type=authorization_code&code=${raced}`), e(`grant_type=authorization_code&code=${raced}`)]);
4429
+ const racedAfter = racedAccount ? await w.api('GET', '/v1/customers', undefined, racedAccount) : { status: 0, body: {} };
4430
+ // the order a race can take the other way: the second use marks the code while the first exchange is still writing
4431
+ // its connection (the state that second use leaves, seeded as it is written), so the first exchange, finding the
4432
+ // mark after its write, revokes the connection itself
4433
+ const midway = new URL((await w.authorize(authorizeQuery(), 'skip')).headers.get('location')).searchParams.get('code');
4434
+ const midAccount = (await ids()).find((a) => !known.includes(a) && a !== racedAccount) ?? '';
4435
+ 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);
4436
+ const midExchange = await e(`grant_type=authorization_code&code=${midway}`);
4437
+ const midAfter = midAccount ? await w.api('GET', '/v1/customers', undefined, midAccount) : { status: 0, body: {} };
4438
+ return shape && pi.status === 200 && pi.body.object === 'payment_intent' && retrieved.default_currency === 'usd'
4439
+ && midExchange.status === 400 && midExchange.error === 'invalid_grant' && midAfter.status === 403
4440
+ && noKey.status === 401 && publishable.status === 401 && noGrant.status === 400 && noGrant.error === 'invalid_request' && password.error === 'unsupported_grant_type'
4441
+ && noCode.error === 'invalid_request' && missing.status === 400 && missing.error === 'invalid_grant' && missing.error_description === 'Authorization code does not exist: ac_nope'
4442
+ && basic.status === 200 && basic.token_type === 'bearer' && basic.stripe_user_id !== account
4443
+ && inTime.status === 200 && late.status === 400 && late.error === 'invalid_grant' && String(late.error_description).includes('expired')
4444
+ && liveKey.error === 'invalid_grant' && asJson.status === 200 && /^acct_/.test(String(asJsonBody.stripe_user_id))
4445
+ && reused.status === 400 && reused.error === 'invalid_grant' && String(reused.error_description).includes('already been used')
4446
+ && after.status === 403 && after.body.error?.code === 'account_invalid' && others.status === 200
4447
+ && pair.filter((p) => p.status === 200).length <= 1 && pair.some((p) => p.error === 'invalid_grant') && racedAfter.status === 403;
4448
+ })),
4449
+ // grant_type=refresh_token: "a new access token of an equal or lesser scope"; "Any existing access token with the same
4450
+ // scope and mode ... is revoked"; a refresh token of a revoked connection is refused.
4451
+ 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', () => withOAuth(async (w) => {
4452
+ const { token } = await w.connect();
4453
+ const e = async (body) => { const r = await w.endpoint('token', body); return { status: r.status, ...r.body }; };
4454
+ const same = await e(`grant_type=refresh_token&refresh_token=${String(token.refresh_token)}`);
4455
+ const lesser = await e(`grant_type=refresh_token&refresh_token=${String(token.refresh_token)}&scope=read_only`);
4456
+ const bogus = await e(`grant_type=refresh_token&refresh_token=${String(token.refresh_token)}&scope=admin`);
4457
+ const liveRefresh = await w.endpoint('token', `grant_type=refresh_token&refresh_token=${String(token.refresh_token)}`, 'Bearer sk_live_platform');
4458
+ const noToken = await e('grant_type=refresh_token');
4459
+ const unknown = await e('grant_type=refresh_token&refresh_token=rt_nope');
4460
+ // a read_only connection cannot refresh into read_write
4461
+ const ro = (await w.connect('').then(async () => {
4462
+ const loc = new URL((await w.authorize(authorizeQuery().replace('scope=read_write', 'scope=read_only'), 'skip')).headers.get('location'));
4463
+ return (await w.endpoint('token', `grant_type=authorization_code&code=${loc.searchParams.get('code')}`)).body;
4464
+ }));
4465
+ const greater = await e(`grant_type=refresh_token&refresh_token=${String(ro.refresh_token)}&scope=read_write`);
4466
+ await w.endpoint('deauthorize', `client_id=ca_twin_self&stripe_user_id=${String(token.stripe_user_id)}`);
4467
+ const revoked = await e(`grant_type=refresh_token&refresh_token=${String(token.refresh_token)}`);
4468
+ return same.status === 200 && same.scope === 'read_write' && same.refresh_token === token.refresh_token && same.stripe_user_id === token.stripe_user_id
4469
+ && same.access_token !== token.access_token && /^sk_test_/.test(String(same.access_token))
4470
+ && lesser.status === 200 && lesser.scope === 'read_only' && lesser.access_token !== same.access_token
4471
+ && bogus.error === 'invalid_scope' && liveRefresh.body.error === 'invalid_grant' && noToken.error === 'invalid_request' && unknown.status === 400 && unknown.error === 'invalid_grant'
4472
+ && ro.scope === 'read_only' && greater.error === 'invalid_scope' && revoked.error === 'invalid_grant';
4473
+ })),
4474
+ // POST connect.stripe.com/oauth/deauthorize: `{ stripe_user_id }`, and then "the account can't be accessed by your
4475
+ // platform ... through the API" (the Stripe-Account header, /v1/accounts/{account}); invalid_request and invalid_client
4476
+ // as the reference lists them. "You can only revoke a Standard account's access": an account the platform made itself
4477
+ // was never connected by OAuth, so it is not connected to the application.
4478
+ 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', () => withOAuth(async (w) => {
4479
+ const { token } = await w.connect();
4480
+ const other = (await w.connect()).token;
4481
+ const account = String(token.stripe_user_id);
4482
+ const e = async (body, auth) => { const r = await w.endpoint('deauthorize', body, auth); return { status: r.status, ...r.body }; };
4483
+ const made = String((await w.api('POST', '/v1/accounts', 'type=standard&country=US')).body.id);
4484
+ const noClient = await e(`stripe_user_id=${account}`);
4485
+ const noKey = await e(`client_id=ca_twin_self&stripe_user_id=${account}`, '');
4486
+ const noAccount = await e('client_id=ca_twin_self');
4487
+ const wrongClient = await e(`client_id=ca_nope&stripe_user_id=${account}`);
4488
+ const liveKey = await e(`client_id=ca_twin_self&stripe_user_id=${account}`, 'Bearer sk_live_platform');
4489
+ const notConnected = await e(`client_id=ca_twin_self&stripe_user_id=${made}`);
4490
+ const before = await w.api('GET', '/v1/customers', undefined, account);
4491
+ const revoked = await e(`client_id=ca_twin_self&stripe_user_id=${account}`);
4492
+ const asIt = await w.api('GET', '/v1/customers', undefined, account);
4493
+ const named = await w.api('GET', `/v1/accounts/${account}`);
4494
+ const again = await e(`client_id=ca_twin_self&stripe_user_id=${account}`);
4495
+ const listed = (await w.api('GET', '/v1/accounts?limit=100')).body.data.map((a) => a.id);
4496
+ const stillOthers = await w.api('GET', '/v1/customers', undefined, String(other.stripe_user_id));
4497
+ const stillMade = await w.api('GET', '/v1/customers', undefined, made);
4498
+ return before.status === 200 && revoked.status === 200 && Object.keys(revoked).sort().join(',') === 'status,stripe_user_id' && revoked.stripe_user_id === account
4499
+ && asIt.status === 403 && asIt.body.error?.code === 'account_invalid' && String(asIt.body.error?.message).includes(account)
4500
+ && named.status === 403 && again.status === 400 && again.error === 'invalid_client'
4501
+ && noKey.status === 401 && noClient.error === 'invalid_request' && noAccount.error === 'invalid_request' && wrongClient.error === 'invalid_client' && liveKey.error === 'invalid_client'
4502
+ && notConnected.error === 'invalid_client' && stillOthers.status === 200 && stillMade.status === 200
4503
+ && !listed.includes(account) && listed.includes(other.stripe_user_id) && listed.includes(made);
4504
+ })),
4505
+ // account.application.authorized "Occurs whenever a user authorizes an application" and account.application.deauthorized
4506
+ // "whenever a user deauthorizes an application" (docs.stripe.com/api/events/types), each the connected account's event
4507
+ // (top-level `account`, Connect endpoints: docs.stripe.com/connect/webhooks) carrying the application.
4508
+ 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 () => {
4509
+ const rx = await webhookReceiver();
4510
+ try {
4511
+ return await withOAuth(async (w) => {
4512
+ await w.api('POST', '/v1/webhook_endpoints', `url=${rx.url('/platform')}&enabled_events[]=*`);
4513
+ const connectEndpoint = await w.api('POST', '/v1/webhook_endpoints', `url=${rx.url('/connect')}&enabled_events[]=*&connect=true`);
4514
+ const { token } = await w.connect();
4515
+ const account = String(token.stripe_user_id);
4516
+ // a charge on the account, whose funds come due after the connection is revoked
4517
+ await w.api('POST', '/v1/charges', 'amount=5000&currency=usd&source=tok_visa', account);
4518
+ const before = rx.got('/connect').filter((e) => e.account === account && e.type === 'charge.succeeded').length;
4519
+ const authorized = rx.got('/connect').filter((e) => e.type === 'account.application.authorized');
4520
+ // two deauthorizations at once: one revokes (and one event is sent), the other finds it no longer connected (a
4521
+ // regression guard: in one process the check and the revoke never interleave)
4522
+ const twice = await Promise.all([1, 2].map(() => w.endpoint('deauthorize', `client_id=ca_twin_self&stripe_user_id=${account}`)));
4523
+ const deauthorized = rx.got('/connect').filter((e) => e.type === 'account.application.deauthorized');
4524
+ const seen = rx.got('/connect').length;
4525
+ // days later its funds are available: the account's own balance.available no longer reaches the platform
4526
+ w.at('2026-03-06T00:00:00.000Z');
4527
+ const drained = await w.f(new Request('http://stripe.test/_twin/drain', { method: 'POST' }));
4528
+ const drainedBody = (await drained.json());
4529
+ const leaked = rx.got('/connect').slice(seen).filter((e) => e.account === account);
4530
+ const app = authorized[0]?.data.object;
4531
+ return connectEndpoint.status === 200 && connectEndpoint.body.connect === true
4532
+ && authorized.length === 1 && authorized[0].account === account && app?.object === 'application' && app?.id === 'ca_twin_self' && typeof app?.name === 'string'
4533
+ && before === 1 && drainedBody.delivered.some((d) => d.account === account && d.type === 'balance.available') && leaked.length === 0
4534
+ && twice.filter((r) => r.status === 200).length === 1 && twice.some((r) => r.body.error === 'invalid_client')
4535
+ && deauthorized.length === 1 && deauthorized[0].account === account && deauthorized[0].data.object.id === 'ca_twin_self'
4536
+ && !rx.got('/platform').some((e) => e.type.startsWith('account.application.'));
4537
+ });
4538
+ }
4539
+ finally {
4540
+ rx.close();
4541
+ }
4542
+ }),
4543
+ // The token answer's access_token and stripe_publishable_key act as the connected account ("Use the Stripe-Account
4544
+ // header with your platform's secret key", the reference, for the deprecated keys): a request made with one is the
4545
+ // account's (its events are its own, GET /v1/account answers it), and it is not the platform's key at the OAuth
4546
+ // endpoints; a key no live connection holds, after a refresh
4547
+ // ("Any existing access token with the same scope and mode ... is revoked") or a deauthorize, is an invalid API key.
4548
+ 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', () => withOAuth(async (w) => {
4549
+ const { token } = await w.connect();
4550
+ const account = String(token.stripe_user_id);
4551
+ const as = async (m, p, key, b, acct) => {
4552
+ 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 } : {}) }));
4553
+ return { status: r.status, body: (await r.json()) };
4554
+ };
4555
+ const me = await as('GET', '/v1/account', token.access_token);
4556
+ // curl -u sk_…: (Basic) is the same key
4557
+ const basicMe = await w.f(new Request('http://stripe.test/v1/account', { headers: { authorization: `Basic ${btoa(`${String(token.access_token)}:`)}` } }));
4558
+ const basicBody = (await basicMe.json());
4559
+ const unknownAccount = await w.api('GET', '/v1/account', undefined, 'acct_twin_nope');
4560
+ // the account's key is not the platform's: it cannot exchange, refresh or deauthorize
4561
+ const asPlatform = await w.endpoint('token', `grant_type=refresh_token&refresh_token=${String(token.refresh_token)}`, `Bearer ${String(token.access_token)}`);
4562
+ const deauthAsIt = await w.endpoint('deauthorize', `client_id=ca_twin_self&stripe_user_id=${account}`, `Bearer ${String(token.access_token)}`);
4563
+ const platformSelf = await w.api('GET', '/v1/account');
4564
+ const viaHeader = await w.api('GET', '/v1/account', undefined, account);
4565
+ const made = await as('POST', '/v1/customers', token.access_token, 'email=buyer@connected.test');
4566
+ const theirs = (await w.api('GET', '/v1/events?type=customer.created', undefined, account)).body.data.map((e) => e.data.object.id);
4567
+ const ours = (await w.api('GET', '/v1/events?type=customer.created')).body.data.map((e) => e.data.object.id);
4568
+ // the publishable key acts as the account and is still only a publishable key
4569
+ const pkRead = await as('GET', '/v1/customers', token.stripe_publishable_key);
4570
+ const elsewhere = await as('GET', '/v1/customers', token.access_token, undefined, 'acct_twin_other');
4571
+ const refreshed = (await w.endpoint('token', `grant_type=refresh_token&refresh_token=${String(token.refresh_token)}`)).body;
4572
+ const oldToken = await as('GET', '/v1/customers', token.access_token);
4573
+ const newToken = await as('GET', '/v1/customers', refreshed.access_token);
4574
+ const forged = await as('GET', '/v1/customers', `sk_test_oauth_${account}_9`);
4575
+ await w.endpoint('deauthorize', `client_id=ca_twin_self&stripe_user_id=${account}`);
4576
+ const afterRevoke = await as('GET', '/v1/customers', refreshed.access_token);
4577
+ return me.status === 200 && me.body.id === account && me.body.type === 'standard' && platformSelf.body.id === 'acct_twin_self' && viaHeader.body.id === account
4578
+ && basicMe.status === 200 && basicBody.id === account && unknownAccount.status === 403 && unknownAccount.body.error?.code === 'account_invalid'
4579
+ && asPlatform.status === 401 && deauthAsIt.status === 401
4580
+ && made.status === 200 && theirs.includes(made.body.id) && !ours.includes(made.body.id)
4581
+ && pkRead.status === 403 && pkRead.body.error?.code === 'secret_key_required'
4582
+ && elsewhere.status === 403 && elsewhere.body.error?.code === 'account_invalid'
4583
+ && oldToken.status === 401 && newToken.status === 200 && (newToken.body.data ?? []).some((c) => c.id === made.body.id)
4584
+ && forged.status === 401 && afterRevoke.status === 401;
4585
+ })),
4586
+ todo('stripe.connect.oauth_scope_enforcement', 'connect', 'Connect OAuth: a read_only connection (or access token) is refused writes as the account', 'api', 'niche'),
4587
+ todo('stripe.connect.oauth_existing_account', 'connect', 'Connect OAuth: sign in and connect an existing Stripe account instead of creating one', 'api', 'niche'),
4588
+ 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'),
3973
4589
  // ── Connect: account links (hosted onboarding) ──
3974
4590
  done('stripe.connect.account_links', 'connect', 'Connect: account_links (onboarding URL)', 'api', 'niche', () => withRoot(async (h) => {
3975
4591
  const acct = await h({ m: 'POST', p: '/v1/accounts', b: 'type=express' });
@@ -4026,7 +4642,12 @@ export const STRIPE_CAPABILITIES = [
4026
4642
  const g = await h({ m: 'GET', p: `/v1/accounts/${id(acct)}/capabilities/card_payments` });
4027
4643
  const req = await h({ m: 'POST', p: `/v1/accounts/${id(acct)}/capabilities/card_payments`, b: 'requested=true' });
4028
4644
  const miss = await h({ m: 'GET', p: `/v1/accounts/${id(acct)}/capabilities/bogus` });
4029
- return ok(g) && field(g, 'object') === 'capability' && ok(req) && field(req, 'status') === 'pending' && miss.status === 404;
4645
+ // requested on an account that still owes its information, Stripe's review leaves it inactive with what is due
4646
+ // (the capability's `requirements`; "inactive ... Check requirements", docs.stripe.com/api/capabilities/object;
4647
+ // semantics/connect.ts review, 44dea3cb3), where it used to answer a pending that nothing ever moved
4648
+ const due = field(req, 'requirements')?.currently_due ?? [];
4649
+ return ok(g) && field(g, 'object') === 'capability' && ok(req) && field(req, 'requested') === true && field(req, 'status') === 'inactive'
4650
+ && due.includes('business_profile.mcc') && miss.status === 404;
4030
4651
  })),
4031
4652
  // ── Connect: transfer reversals ──
4032
4653
  done('stripe.connect.transfer_reversals', 'connect', 'Connect: transfer reversals (partial + full)', 'api', 'niche', () => withRoot(async (h) => {
@@ -4097,15 +4718,20 @@ export const STRIPE_CAPABILITIES = [
4097
4718
  // duplicate); /delete deletes it. A user-scope secret requires scope[user]. (the
4098
4719
  // scoped overwrite + find/delete are produced ONLY by this feature.)
4099
4720
  done('stripe.connect.secrets', 'connect', 'Connect: apps secret store', 'api', 'niche', () => withRoot(async (h) => {
4721
+ // a secret's value is "nullable string Includable": answered only when the request expands it
4722
+ // (docs.stripe.com/api/secret_management; stripe-version.ts INCLUDABLE, 1d786078c), never on a plain answer
4100
4723
  const set = await h({ m: 'POST', p: '/v1/apps/secrets', b: 'name=api_key&scope[type]=account&payload=sk_secret_1' });
4101
- if (!ok(set) || field(set, 'object') !== 'apps.secret' || field(set, 'payload') !== 'sk_secret_1')
4724
+ if (!ok(set) || field(set, 'object') !== 'apps.secret' || field(set, 'payload') !== undefined)
4102
4725
  return false;
4103
- const find = await h({ m: 'GET', p: '/v1/apps/secrets/find?name=api_key&scope[type]=account' });
4726
+ const plainFind = await h({ m: 'GET', p: '/v1/apps/secrets/find?name=api_key&scope[type]=account' });
4727
+ if (!ok(plainFind) || field(plainFind, 'payload') !== undefined)
4728
+ return false;
4729
+ const find = await h({ m: 'GET', p: '/v1/apps/secrets/find?name=api_key&scope[type]=account&expand[]=payload' });
4104
4730
  if (!ok(find) || field(find, 'payload') !== 'sk_secret_1')
4105
4731
  return false;
4106
4732
  // overwrite: same name+scope updates in place (no duplicate in the list).
4107
4733
  const set2 = await h({ m: 'POST', p: '/v1/apps/secrets', b: 'name=api_key&scope[type]=account&payload=sk_secret_2' });
4108
- const find2 = await h({ m: 'GET', p: '/v1/apps/secrets/find?name=api_key&scope[type]=account' });
4734
+ const find2 = await h({ m: 'GET', p: '/v1/apps/secrets/find?name=api_key&scope[type]=account&expand[]=payload' });
4109
4735
  const list = await h({ m: 'GET', p: '/v1/apps/secrets?scope[type]=account' });
4110
4736
  if (!ok(set2) || field(find2, 'payload') !== 'sk_secret_2' || list.body.data.length !== 1)
4111
4737
  return false;
@@ -4128,8 +4754,9 @@ export const STRIPE_CAPABILITIES = [
4128
4754
  // 400s; unknown id 404. (the balance-reconciliation transition is produced ONLY here.)
4129
4755
  done('stripe.payment_intents.apply_customer_balance', 'payment_intents', 'PaymentIntents: apply_customer_balance', 'api', 'niche', () => withRoot(async (h) => {
4130
4756
  const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=acb@twin.test' });
4131
- // partial funds first.
4132
- await h({ m: 'POST', p: `/v1/customers/${id(cust)}/cash_balance_transactions`, b: 'amount=600&currency=usd' });
4757
+ // partial funds first, through the test helper ("Create an incoming testmode bank transfer",
4758
+ // docs.stripe.com/api/cash_balance/fund_cash_balance): the twin-invented POST .../cash_balance_transactions is gone (5decb1d77)
4759
+ await h({ m: 'POST', p: `/v1/test_helpers/customers/${id(cust)}/fund_cash_balance`, b: 'amount=600&currency=usd' });
4133
4760
  const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: `amount=1000&currency=usd&customer=${id(cust)}&allowed_payment_method_types[]=customer_balance` });
4134
4761
  if (!ok(pi))
4135
4762
  return false;
@@ -4140,7 +4767,7 @@ export const STRIPE_CAPABILITIES = [
4140
4767
  if (na?.type !== 'display_bank_transfer_instructions' || na.display_bank_transfer_instructions.amount_remaining !== 400)
4141
4768
  return false;
4142
4769
  // top up the rest → succeeds.
4143
- await h({ m: 'POST', p: `/v1/customers/${id(cust)}/cash_balance_transactions`, b: 'amount=400&currency=usd' });
4770
+ await h({ m: 'POST', p: `/v1/test_helpers/customers/${id(cust)}/fund_cash_balance`, b: 'amount=400&currency=usd' });
4144
4771
  const full = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/apply_customer_balance` });
4145
4772
  if (!ok(full) || field(full, 'status') !== 'succeeded' || field(full, 'amount_received') !== 1000)
4146
4773
  return false;
@@ -4171,10 +4798,14 @@ export const STRIPE_CAPABILITIES = [
4171
4798
  const none = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/verify_microdeposits` });
4172
4799
  if (both.status !== 400 || none.status !== 400)
4173
4800
  return false;
4174
- // correct amounts → succeeded, next_action cleared.
4801
+ // correct amounts → processing, next_action cleared: "When the bank account is successfully verified, Stripe returns
4802
+ // the PaymentIntent object with a status of `processing`" (docs.stripe.com/payments/ach-direct-debit/accept-a-payment;
4803
+ // 242035d1c); a test-mode debit then settles ("Test transactions settle instantly"), so the next read has succeeded
4175
4804
  const good = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/verify_microdeposits`, b: 'amounts[]=32&amounts[]=45' });
4805
+ const settled = await h({ m: 'GET', p: `/v1/payment_intents/${id(pi)}` });
4176
4806
  const missing = await h({ m: 'POST', p: '/v1/payment_intents/pi_nope/verify_microdeposits', b: 'amounts[]=32&amounts[]=45' });
4177
- return ok(good) && field(good, 'status') === 'succeeded' && field(good, 'next_action') === null && missing.status === 404;
4807
+ return ok(good) && field(good, 'status') === 'processing' && field(good, 'next_action') === null
4808
+ && field(settled, 'status') === 'succeeded' && missing.status === 404;
4178
4809
  })),
4179
4810
  // SetupIntent micro-deposit verification: same flow for saving an ACH bank account — confirm
4180
4811
  // with the verification PM → requires_action; descriptor_code SM11AA verifies → succeeded; a
@@ -4398,8 +5029,14 @@ export const STRIPE_CAPABILITIES = [
4398
5029
  // active (non-voided, non-expired) grants per currency. (the prepaid balance + draw-down
4399
5030
  // summary are produced ONLY by this feature.)
4400
5031
  done('stripe.billing.credit_grants', 'billing', 'Billing credit grants (prepaid credits)', 'api', 'niche', () => withRoot(async (h) => {
5032
+ // the served spec requires what a grant applies to (applicability_config) and what a balance summary filters on
5033
+ // (filter), which the parameter check enforces (e6bfff516: "a missing required one [answers] 400
5034
+ // parameter_missing", docs.stripe.com/error-codes#parameter-missing); every request gives them, so each refusal
5035
+ // below is still the one it names
5036
+ const APPLIES = 'applicability_config[scope][price_type]=metered';
5037
+ const METERED = 'filter[type]=applicability_scope&filter[applicability_scope][price_type]=metered';
4401
5038
  const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=credgr@twin.test' });
4402
- 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` });
5039
+ 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}` });
4403
5040
  if (!ok(g) || field(g, 'object') !== 'billing.credit_grant' || field(g, 'category') !== 'promotional')
4404
5041
  return false;
4405
5042
  if (field(g, 'amount')?.monetary?.value !== 1000)
@@ -4409,7 +5046,7 @@ export const STRIPE_CAPABILITIES = [
4409
5046
  if (!ok(get) || id(get) !== id(g) || list.body.data.length !== 1)
4410
5047
  return false;
4411
5048
  // balance summary reflects the active grant.
4412
- const sum = await h({ m: 'GET', p: `/v1/billing/credit_balance_summary?customer=${id(cust)}` });
5049
+ const sum = await h({ m: 'GET', p: `/v1/billing/credit_balance_summary?customer=${id(cust)}&${METERED}` });
4413
5050
  const balances = sum.body.balances;
4414
5051
  if (!ok(sum) || (balances[0].available_balance.monetary.value) !== 1000)
4415
5052
  return false;
@@ -4417,15 +5054,16 @@ export const STRIPE_CAPABILITIES = [
4417
5054
  const voided = await h({ m: 'POST', p: `/v1/billing/credit_grants/${id(g)}/void` });
4418
5055
  if (!ok(voided) || field(voided, 'voided_at') === null)
4419
5056
  return false;
4420
- const sum2 = await h({ m: 'GET', p: `/v1/billing/credit_balance_summary?customer=${id(cust)}` });
5057
+ const sum2 = await h({ m: 'GET', p: `/v1/billing/credit_balance_summary?customer=${id(cust)}&${METERED}` });
4421
5058
  if (sum2.body.balances.length !== 0)
4422
5059
  return false;
4423
5060
  // vendor errors.
4424
- 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' });
4425
- 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` });
4426
- const noAmt = await h({ m: 'POST', p: '/v1/billing/credit_grants', b: `customer=${id(cust)}&category=paid` });
5061
+ 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 });
5062
+ 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}` });
5063
+ const noAmt = await h({ m: 'POST', p: '/v1/billing/credit_grants', b: `customer=${id(cust)}&category=paid&${APPLIES}` });
4427
5064
  const missing = await h({ m: 'GET', p: '/v1/billing/credit_grants/credgr_nope' });
4428
- return noCust.status === 404 && badCat.status === 400 && noAmt.status === 400 && missing.status === 404;
5065
+ return noCust.status === 404 && badCat.status === 400 && badCat.body.error?.code === 'parameter_invalid_string_enum'
5066
+ && noAmt.status === 400 && noAmt.body.error?.param === 'amount' && missing.status === 404;
4429
5067
  })),
4430
5068
  // Billing meters lifecycle: create→list (status filter)→deactivate (active→inactive)→
4431
5069
  // reactivate, plus meter event_summaries that aggregate the meter's events for a customer
@@ -4567,7 +5205,9 @@ export const STRIPE_CAPABILITIES = [
4567
5205
  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' });
4568
5206
  if (!ok(calc))
4569
5207
  return false;
4570
- const txn = await h({ m: 'POST', p: '/v1/tax/transactions/create_from_calculation', b: `calculation=${id(calc)}&reference=order_123` });
5208
+ // a transaction's line_items is "nullable object Includable" (docs.stripe.com/api/tax/transactions/object; 107591e5f):
5209
+ // absent unless the request expands it, as it is here and on the reversal
5210
+ const txn = await h({ m: 'POST', p: '/v1/tax/transactions/create_from_calculation', b: `calculation=${id(calc)}&reference=order_123&expand[]=line_items` });
4571
5211
  if (!ok(txn) || field(txn, 'object') !== 'tax.transaction' || field(txn, 'type') !== 'transaction' || field(txn, 'reference') !== 'order_123')
4572
5212
  return false;
4573
5213
  const txnLines = field(txn, 'line_items').data ?? [];
@@ -4578,7 +5218,9 @@ export const STRIPE_CAPABILITIES = [
4578
5218
  if (!ok(g) || id(g) !== id(txn) || li.body.data.length !== 1)
4579
5219
  return false;
4580
5220
  // a reversal NEGATES the amounts.
4581
- const rev = await h({ m: 'POST', p: '/v1/tax/transactions/create_reversal', b: `original_transaction=${id(txn)}&reference=refund_123&mode=full` });
5221
+ if (field(g, 'line_items') !== undefined)
5222
+ return false;
5223
+ 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` });
4582
5224
  if (!ok(rev) || field(rev, 'type') !== 'reversal')
4583
5225
  return false;
4584
5226
  const revLines = field(rev, 'line_items').data ?? [];
@@ -4719,17 +5361,21 @@ export const STRIPE_CAPABILITIES = [
4719
5361
  // / list+filter. An empty account still lists none; unknown ids still 404. This fails if
4720
5362
  // retrieve returned nothing, if a status update didn't persist, or if filtering were broken.
4721
5363
  done('stripe.issuing.tokens', 'issuing', 'Issuing: network tokens', 'api', 'niche', () => withRoot(async (h) => {
4722
- // empty account: lists none, unknown ids 404 on retrieve + update.
4723
- const empty = await h({ m: 'GET', p: '/v1/issuing/tokens' });
5364
+ const ch = await h({ m: 'POST', p: '/v1/issuing/cardholders', b: 'name=Jane&type=individual&billing[address][country]=US' });
5365
+ const card = await h({ m: 'POST', p: '/v1/issuing/cards', b: `cardholder=${id(ch)}&currency=usd&type=virtual` });
5366
+ // a card with no wallet yet lists none; the list is always of one card's tokens (the served spec requires `card`,
5367
+ // which the parameter check enforces, e6bfff516), so the bare list is refused; unknown ids 404 on retrieve + update.
5368
+ const empty = await h({ m: 'GET', p: `/v1/issuing/tokens?card=${id(card)}` });
4724
5369
  if (!ok(empty) || field(empty, 'object') !== 'list' || empty.body.data.length !== 0)
4725
5370
  return false;
5371
+ const unscoped = await h({ m: 'GET', p: '/v1/issuing/tokens' });
5372
+ if (unscoped.status !== 400 || unscoped.body.error?.param !== 'card')
5373
+ return false;
4726
5374
  const missing = await h({ m: 'GET', p: '/v1/issuing/tokens/iss_tok_nope' });
4727
5375
  const badUpd = await h({ m: 'POST', p: '/v1/issuing/tokens/iss_tok_nope', b: 'status=active' });
4728
5376
  if (missing.status !== 404 || badUpd.status !== 404)
4729
5377
  return false;
4730
5378
  // a token is network-minted when the cardholder adds a real card to a phone's wallet (the twin's stand-in).
4731
- const ch = await h({ m: 'POST', p: '/v1/issuing/cardholders', b: 'name=Jane&type=individual&billing[address][country]=US' });
4732
- const card = await h({ m: 'POST', p: '/v1/issuing/cards', b: `cardholder=${id(ch)}&currency=usd&type=virtual` });
4733
5379
  const seeded = await h({ m: 'POST', p: `/_twin/issuing/cards/${id(card)}/wallets`, b: 'wallet_provider=apple_pay' });
4734
5380
  if (!ok(seeded) || field(seeded, 'object') !== 'issuing.token' || field(seeded, 'status') !== 'active' || field(seeded, 'card') !== id(card))
4735
5381
  return false;
@@ -4793,7 +5439,8 @@ export const STRIPE_CAPABILITIES = [
4793
5439
  const list = await h({ m: 'GET', p: '/v1/issuing/personalization_designs?status=active' });
4794
5440
  if (list.body.data.length !== 1)
4795
5441
  return false;
4796
- const rej = await h({ m: 'POST', p: `/v1/test_helpers/issuing/personalization_designs/${id(d)}/reject` });
5442
+ // a rejection says why: the served spec requires rejection_reasons (enforced by the parameter check, e6bfff516)
5443
+ const rej = await h({ m: 'POST', p: `/v1/test_helpers/issuing/personalization_designs/${id(d)}/reject`, b: 'rejection_reasons[card_logo][]=inappropriate' });
4797
5444
  const noBundle = await h({ m: 'POST', p: '/v1/issuing/personalization_designs', b: 'name=x' });
4798
5445
  const missing = await h({ m: 'GET', p: '/v1/issuing/personalization_designs/pd_nope' });
4799
5446
  return ok(rej) && field(rej, 'status') === 'rejected' && noBundle.status === 400 && missing.status === 404;
@@ -4830,15 +5477,21 @@ export const STRIPE_CAPABILITIES = [
4830
5477
  // Unknown id 404. (the opened account + per-currency zero balance + feature map are
4831
5478
  // produced ONLY by this feature.)
4832
5479
  done('stripe.treasury.financial_accounts', 'treasury', 'Treasury: financial accounts', 'api', 'niche', () => withRoot(async (h) => {
4833
- 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' });
5480
+ // features as the served spec's treasury.financial_account_features gives them: a toggle (card_issuing) or one per
5481
+ // network (outbound_payments.ach); a requested one is active at once in test mode and one never requested is left
5482
+ // out, each being optional in the spec (semantics/treasury.ts featuresOf, dd5d4344f)
5483
+ 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' });
4834
5484
  if (!ok(fa) || field(fa, 'object') !== 'treasury.financial_account' || field(fa, 'status') !== 'open')
4835
5485
  return false;
4836
5486
  const bal = field(fa, 'balance');
4837
5487
  if (bal.cash.usd !== 0)
4838
5488
  return false;
4839
- if ((field(fa, 'features').card_issuing).status !== 'active')
5489
+ const features = field(fa, 'features');
5490
+ if (features.card_issuing.status !== 'active' || features.outbound_payments.ach?.status !== 'active')
5491
+ return false;
5492
+ if (features.deposit_insurance !== undefined)
4840
5493
  return false;
4841
- if ((field(fa, 'features').deposit_insurance).status !== 'restricted')
5494
+ if (field(fa, 'active_features').join(',') !== 'card_issuing,outbound_payments.ach')
4842
5495
  return false;
4843
5496
  const get = await h({ m: 'GET', p: `/v1/treasury/financial_accounts/${id(fa)}` });
4844
5497
  const list = await h({ m: 'GET', p: '/v1/treasury/financial_accounts?status=open' });
@@ -5021,20 +5674,10 @@ export const STRIPE_CAPABILITIES = [
5021
5674
  const nope = await h({ m: 'GET', p: '/v1/forwarding/requests/fwdr_nope' });
5022
5675
  return noPm.status === 400 && noUrl.status === 400 && nope.status === 404;
5023
5676
  })),
5024
- // Crypto onramp sessions: create an initialized session with a client_secret + transaction
5025
- // details, then retrieve. id cos_. Unknown id 404. (The hosted purchase widget is OOS.)
5026
- done('stripe.crypto.onramp', 'core', 'Crypto onramp sessions', 'api', 'niche', () => withRoot(async (h) => {
5027
- 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' });
5028
- if (!ok(cos) || field(cos, 'object') !== 'crypto.onramp_session' || field(cos, 'status') !== 'initialized' || !id(cos).startsWith('cos_'))
5029
- return false;
5030
- if (typeof field(cos, 'client_secret') !== 'string')
5031
- return false;
5032
- if (field(cos, 'transaction_details')?.destination_currency !== 'eth')
5033
- return false;
5034
- const get = await h({ m: 'GET', p: `/v1/crypto/onramp_sessions/${id(cos)}` });
5035
- const nope = await h({ m: 'GET', p: '/v1/crypto/onramp_sessions/cos_nope' });
5036
- return ok(get) && field(get, 'id') === id(cos) && nope.status === 404;
5037
- })),
5677
+ // Crypto onramp sessions (POST/GET /v1/crypto/onramp_sessions, docs.stripe.com/crypto/onramp/api-reference) are Stripe
5678
+ // API, but not in the spec this pack vendors and serves; the twin's hand route for them was removed with the other
5679
+ // routes outside the served spec (5decb1d77), so the capability is a todo again until the lane models it.
5680
+ todo('stripe.crypto.onramp', 'core', 'Crypto onramp sessions', 'api', 'niche'),
5038
5681
  // ── HONEST DENOMINATOR GROWTH (real Stripe surfaces NOT yet modeled) ────────────────
5039
5682
  // The gaps above were closed this cycle; these enumerate genuine remaining surface so the
5040
5683
  // % stays honest (a missing entry is a hidden gap). Each is a real Stripe API.