@volter/twin-stripe 0.1.2 → 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 (229) hide show
  1. package/README.md +96 -27
  2. package/client/dashboard-api.ts +286 -0
  3. package/client/stripe-mirror.css +272 -159
  4. package/client/stripe-mirror.tsx +1384 -541
  5. package/dist/client/dashboard-api.d.ts +107 -0
  6. package/dist/client/dashboard-api.js +238 -0
  7. package/dist/client/dashboard-api.ts +286 -0
  8. package/dist/client/stripe-mirror.bundle.js +236 -0
  9. package/dist/client/stripe-mirror.css +275 -0
  10. package/dist/client/stripe-mirror.d.ts +134 -0
  11. package/dist/client/stripe-mirror.js +823 -0
  12. package/dist/client/stripe-mirror.tsx +1534 -0
  13. package/dist/src/cli.d.ts +2 -0
  14. package/dist/src/cli.js +39 -0
  15. package/dist/src/generated/events.gen.json +1 -0
  16. package/dist/src/generated/surface.gen.json +1 -0
  17. package/dist/src/generated/ui.gen.json +1 -0
  18. package/dist/src/index.d.ts +14 -0
  19. package/dist/src/index.js +75 -0
  20. package/dist/src/manifest.d.ts +2 -0
  21. package/dist/src/manifest.js +1070 -0
  22. package/dist/src/screens/checkout.d.ts +31 -0
  23. package/dist/src/screens/checkout.js +255 -0
  24. package/dist/src/screens/connect-oauth.d.ts +27 -0
  25. package/dist/src/screens/connect-oauth.js +414 -0
  26. package/dist/src/screens/connect-settings.d.ts +22 -0
  27. package/dist/src/screens/connect-settings.js +103 -0
  28. package/dist/src/screens/consent-skin.d.ts +4 -0
  29. package/dist/src/screens/consent-skin.js +18 -0
  30. package/dist/src/screens/financial-connections.d.ts +5 -0
  31. package/dist/src/screens/financial-connections.js +90 -0
  32. package/dist/src/screens/identity.d.ts +5 -0
  33. package/dist/src/screens/identity.js +86 -0
  34. package/dist/src/screens/industries.d.ts +1 -0
  35. package/dist/src/screens/industries.js +267 -0
  36. package/dist/src/screens/onboarding.d.ts +13 -0
  37. package/dist/src/screens/onboarding.js +225 -0
  38. package/dist/src/screens/portal.d.ts +5 -0
  39. package/dist/src/screens/portal.js +216 -0
  40. package/dist/src/screens/public-details.d.ts +5 -0
  41. package/dist/src/screens/public-details.js +90 -0
  42. package/dist/src/semantics/after-payment.d.ts +22 -0
  43. package/dist/src/semantics/after-payment.js +99 -0
  44. package/dist/src/semantics/apps-secrets.d.ts +2 -0
  45. package/dist/src/semantics/apps-secrets.js +54 -0
  46. package/dist/src/semantics/balance.d.ts +11 -0
  47. package/dist/src/semantics/balance.js +195 -0
  48. package/dist/src/semantics/billing.d.ts +2 -0
  49. package/dist/src/semantics/billing.js +220 -0
  50. package/dist/src/semantics/charges.d.ts +28 -0
  51. package/dist/src/semantics/charges.js +209 -0
  52. package/dist/src/semantics/checkout.d.ts +15 -0
  53. package/dist/src/semantics/checkout.js +316 -0
  54. package/dist/src/semantics/connect.d.ts +5 -0
  55. package/dist/src/semantics/connect.js +493 -0
  56. package/dist/src/semantics/coupons.d.ts +6 -0
  57. package/dist/src/semantics/coupons.js +92 -0
  58. package/dist/src/semantics/credit-notes.d.ts +2 -0
  59. package/dist/src/semantics/credit-notes.js +172 -0
  60. package/dist/src/semantics/customers.d.ts +6 -0
  61. package/dist/src/semantics/customers.js +429 -0
  62. package/dist/src/semantics/disputes.d.ts +2 -0
  63. package/dist/src/semantics/disputes.js +51 -0
  64. package/dist/src/semantics/entitlements.d.ts +2 -0
  65. package/dist/src/semantics/entitlements.js +95 -0
  66. package/dist/src/semantics/ephemeral-keys.d.ts +2 -0
  67. package/dist/src/semantics/ephemeral-keys.js +34 -0
  68. package/dist/src/semantics/files.d.ts +2 -0
  69. package/dist/src/semantics/files.js +125 -0
  70. package/dist/src/semantics/invoices.d.ts +18 -0
  71. package/dist/src/semantics/invoices.js +545 -0
  72. package/dist/src/semantics/issuing.d.ts +13 -0
  73. package/dist/src/semantics/issuing.js +575 -0
  74. package/dist/src/semantics/ledger.d.ts +59 -0
  75. package/dist/src/semantics/ledger.js +200 -0
  76. package/dist/src/semantics/payment-intents.d.ts +18 -0
  77. package/dist/src/semantics/payment-intents.js +404 -0
  78. package/dist/src/semantics/payment-links.d.ts +2 -0
  79. package/dist/src/semantics/payment-links.js +133 -0
  80. package/dist/src/semantics/payment-methods.d.ts +20 -0
  81. package/dist/src/semantics/payment-methods.js +140 -0
  82. package/dist/src/semantics/plans.d.ts +5 -0
  83. package/dist/src/semantics/plans.js +121 -0
  84. package/dist/src/semantics/platform.d.ts +9 -0
  85. package/dist/src/semantics/platform.js +206 -0
  86. package/dist/src/semantics/products.d.ts +2 -0
  87. package/dist/src/semantics/products.js +140 -0
  88. package/dist/src/semantics/radar.d.ts +2 -0
  89. package/dist/src/semantics/radar.js +83 -0
  90. package/dist/src/semantics/refunds.d.ts +9 -0
  91. package/dist/src/semantics/refunds.js +195 -0
  92. package/dist/src/semantics/renewals.d.ts +47 -0
  93. package/dist/src/semantics/renewals.js +251 -0
  94. package/dist/src/semantics/setup-intents.d.ts +2 -0
  95. package/dist/src/semantics/setup-intents.js +84 -0
  96. package/dist/src/semantics/shared.d.ts +82 -0
  97. package/dist/src/semantics/shared.js +203 -0
  98. package/dist/src/semantics/subscription-schedules.d.ts +2 -0
  99. package/dist/src/semantics/subscription-schedules.js +119 -0
  100. package/dist/src/semantics/subscriptions.d.ts +11 -0
  101. package/dist/src/semantics/subscriptions.js +605 -0
  102. package/dist/src/semantics/tax.d.ts +2 -0
  103. package/dist/src/semantics/tax.js +197 -0
  104. package/dist/src/semantics/terminal.d.ts +5 -0
  105. package/dist/src/semantics/terminal.js +182 -0
  106. package/dist/src/semantics/test-cards.d.ts +4 -0
  107. package/dist/src/semantics/test-cards.js +7 -0
  108. package/dist/src/semantics/test-clocks.d.ts +6 -0
  109. package/dist/src/semantics/test-clocks.js +73 -0
  110. package/dist/src/semantics/tokens.d.ts +4 -0
  111. package/dist/src/semantics/tokens.js +44 -0
  112. package/dist/src/semantics/transfers.d.ts +2 -0
  113. package/dist/src/semantics/transfers.js +154 -0
  114. package/dist/src/semantics/treasury.d.ts +2 -0
  115. package/dist/src/semantics/treasury.js +377 -0
  116. package/dist/src/semantics/webhook-endpoints.d.ts +3 -0
  117. package/dist/src/semantics/webhook-endpoints.js +85 -0
  118. package/dist/src/stripe-budget.d.ts +55 -0
  119. package/dist/src/stripe-budget.js +155 -0
  120. package/dist/src/stripe-capabilities.d.ts +3 -0
  121. package/dist/src/stripe-capabilities.js +5695 -0
  122. package/dist/src/stripe-conformance.d.ts +43 -0
  123. package/dist/src/stripe-conformance.js +105 -0
  124. package/dist/src/stripe-connector.d.ts +161 -0
  125. package/dist/src/stripe-connector.js +414 -0
  126. package/dist/src/stripe-emit.d.ts +2 -0
  127. package/dist/src/stripe-emit.js +145 -0
  128. package/dist/src/stripe-events.d.ts +93 -0
  129. package/dist/src/stripe-events.js +392 -0
  130. package/dist/src/stripe-js.d.ts +4 -0
  131. package/dist/src/stripe-js.js +70 -0
  132. package/dist/src/stripe-mirror-ui.d.ts +15 -0
  133. package/dist/src/stripe-mirror-ui.js +87 -0
  134. package/dist/src/stripe-params.d.ts +3 -0
  135. package/dist/src/stripe-params.js +43 -0
  136. package/dist/src/stripe-perform-harness.d.ts +9 -0
  137. package/dist/src/stripe-perform-harness.js +26 -0
  138. package/dist/src/stripe-server.d.ts +33 -0
  139. package/dist/src/stripe-server.js +393 -0
  140. package/dist/src/stripe-shared.d.ts +109 -0
  141. package/dist/src/stripe-shared.js +276 -0
  142. package/dist/src/stripe-twin.d.ts +155 -0
  143. package/dist/src/stripe-twin.js +1232 -0
  144. package/dist/src/stripe-ui-conformance.d.ts +5 -0
  145. package/dist/src/stripe-ui-conformance.js +79 -0
  146. package/dist/src/stripe-ui-structure.d.ts +3 -0
  147. package/dist/src/stripe-ui-structure.js +168 -0
  148. package/dist/src/stripe-version.d.ts +12 -0
  149. package/dist/src/stripe-version.js +287 -0
  150. package/dist/test-fixtures/stripe-known-deviations.json +110 -0
  151. package/dist/test-fixtures/stripe-openapi-operations.SOURCE.md +14 -0
  152. package/dist/test-fixtures/stripe-openapi-operations.json +4717 -0
  153. package/dist/test-fixtures/stripe-schemas.SOURCE.md +35 -0
  154. package/dist/test-fixtures/stripe-schemas.json +3813 -0
  155. package/package.json +18 -10
  156. package/src/cli.ts +7 -7
  157. package/src/generated/events.gen.json +1 -0
  158. package/src/generated/surface.gen.json +1 -0
  159. package/src/generated/ui.gen.json +1 -0
  160. package/src/index.ts +34 -10
  161. package/src/manifest.ts +1102 -0
  162. package/src/screens/checkout.tsx +267 -0
  163. package/src/screens/connect-oauth.tsx +400 -0
  164. package/src/screens/connect-settings.tsx +121 -0
  165. package/src/screens/consent-skin.ts +20 -0
  166. package/src/screens/financial-connections.tsx +101 -0
  167. package/src/screens/identity.tsx +96 -0
  168. package/src/screens/industries.ts +267 -0
  169. package/src/screens/onboarding.tsx +243 -0
  170. package/src/screens/portal.tsx +220 -0
  171. package/src/screens/public-details.tsx +105 -0
  172. package/src/semantics/after-payment.ts +118 -0
  173. package/src/semantics/apps-secrets.ts +58 -0
  174. package/src/semantics/balance.ts +209 -0
  175. package/src/semantics/billing.ts +216 -0
  176. package/src/semantics/charges.ts +220 -0
  177. package/src/semantics/checkout.ts +310 -0
  178. package/src/semantics/connect.ts +487 -0
  179. package/src/semantics/coupons.ts +97 -0
  180. package/src/semantics/credit-notes.ts +168 -0
  181. package/src/semantics/customers.ts +432 -0
  182. package/src/semantics/disputes.ts +62 -0
  183. package/src/semantics/entitlements.ts +94 -0
  184. package/src/semantics/ephemeral-keys.ts +34 -0
  185. package/src/semantics/files.ts +143 -0
  186. package/src/semantics/invoices.ts +545 -0
  187. package/src/semantics/issuing.ts +590 -0
  188. package/src/semantics/ledger.ts +253 -0
  189. package/src/semantics/payment-intents.ts +420 -0
  190. package/src/semantics/payment-links.ts +148 -0
  191. package/src/semantics/payment-methods.ts +145 -0
  192. package/src/semantics/plans.ts +131 -0
  193. package/src/semantics/platform.ts +220 -0
  194. package/src/semantics/products.ts +154 -0
  195. package/src/semantics/radar.ts +85 -0
  196. package/src/semantics/refunds.ts +218 -0
  197. package/src/semantics/renewals.ts +274 -0
  198. package/src/semantics/setup-intents.ts +87 -0
  199. package/src/semantics/shared.ts +226 -0
  200. package/src/semantics/subscription-schedules.ts +129 -0
  201. package/src/semantics/subscriptions.ts +610 -0
  202. package/src/semantics/tax.ts +220 -0
  203. package/src/semantics/terminal.ts +195 -0
  204. package/src/semantics/test-cards.ts +7 -0
  205. package/src/semantics/test-clocks.ts +77 -0
  206. package/src/semantics/tokens.ts +52 -0
  207. package/src/semantics/transfers.ts +174 -0
  208. package/src/semantics/treasury.ts +383 -0
  209. package/src/semantics/webhook-endpoints.ts +87 -0
  210. package/src/stripe-budget.ts +4 -4
  211. package/src/stripe-capabilities.ts +2258 -380
  212. package/src/stripe-conformance.ts +19 -7
  213. package/src/stripe-connector.ts +68 -40
  214. package/src/stripe-emit.ts +15 -8
  215. package/src/stripe-events.ts +102 -40
  216. package/src/stripe-js.ts +70 -0
  217. package/src/stripe-mirror-ui.ts +28 -298
  218. package/src/stripe-params.ts +44 -0
  219. package/src/stripe-perform-harness.ts +29 -0
  220. package/src/stripe-server.ts +318 -38
  221. package/src/stripe-shared.ts +297 -0
  222. package/src/stripe-twin.ts +434 -5325
  223. package/src/stripe-ui-conformance.ts +70 -107
  224. package/src/stripe-ui-structure.ts +124 -348
  225. package/src/stripe-version.ts +281 -0
  226. package/test-fixtures/stripe-known-deviations.json +8 -8
  227. package/test-fixtures/stripe-openapi-operations.json +1188 -2855
  228. package/test-fixtures/stripe-schemas.json +85 -12
  229. package/src/stripe-form.ts +0 -35
@@ -6,13 +6,16 @@
6
6
  // regression. Grow this toward Stripe's *full* surface every cycle — a missing entry is a
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
- import { emitTwinEvent } from '@volter/twin';
9
+ import { createServer } from 'node:http';
10
+ import type { AddressInfo } from 'node:net';
11
+ import { applyTwinWrite, emitTwinEvent } from '@volter/world-core';
10
12
  import { stripeEmitter } from './stripe-emit.ts';
11
13
  import { tmpdir } from 'node:os';
12
14
  import { join } from 'node:path';
13
15
  import { createElement } from 'react';
14
16
  import { renderToStaticMarkup } from 'react-dom/server';
15
- import { checkCapabilities, type CapabilityReport, type CapabilitySpec, verifyBoundary, isInfrastructureError, harnessError } from '@volter/twin-tooling';
17
+ import { FORMAT_SCRIPT } from '@volter/world-ui';
18
+ import { checkCapabilities, type CapabilityReport, type CapabilitySpec, verifyBoundary, isInfrastructureError, harnessError } from '@volter/world-tooling';
16
19
  import { ListPane, SECTIONS, PaymentErrorBanner, ConnectAccountPanel, BalanceSummary, type Section } from '../client/stripe-mirror.tsx';
17
20
  import type { StripeRow } from './stripe-mirror-ui.ts';
18
21
  import {
@@ -27,6 +30,13 @@ import {
27
30
  import { fullSyncStripe } from './stripe-connector.ts';
28
31
  import { buildStripeMirrorClient, createStripeMirrorServer, formatStripeAmount } from './stripe-mirror-ui.ts';
29
32
  import { handleStripeTwinRequest, type StripeResponse } from './stripe-twin.ts';
33
+ import { createStripeTwinFetch } from './stripe-server.ts';
34
+ import { SERVED_VERSION } from './stripe-version.ts';
35
+ import { pack } from './index.ts';
36
+ import { stripeIdentityFlow } from './screens/identity.tsx';
37
+ import { stripeFinancialConnectionsFlow } from './screens/financial-connections.tsx';
38
+ import { currentlyDue, stripeOnboardingFlow } from './screens/onboarding.tsx';
39
+ import { stripePortalFlow } from './screens/portal.tsx';
30
40
 
31
41
  // ── UI verify: the built mirror bundle must contain the screen's load-bearing markers ──
32
42
  let bundle: Promise<string> | null = null;
@@ -45,17 +55,17 @@ type ServerFetch = (path: string) => Promise<Body>;
45
55
  type ServerClient = { get: ServerFetch; post: (path: string, body?: string) => Promise<Body> };
46
56
  function uiDataCoupled(opts: {
47
57
  markers: string[];
48
- seed: (h: (s: Step) => Promise<StripeResponse>) => Promise<void>;
58
+ seed: (h: (s: Step) => Promise<StripeResponse>, root: string) => Promise<void>;
49
59
  check: (client: ServerClient) => Promise<boolean>;
50
60
  }): () => Promise<boolean> {
51
61
  return async () => {
52
62
  const js = await mirrorBundle();
53
63
  if (!opts.markers.every((m) => js.includes(m))) return false;
54
64
  const root = mkdtempSync(join(tmpdir(), 'stp-ui-'));
55
- const server = createStripeMirrorServer({ root, port: 0 });
65
+ const server = await createStripeMirrorServer({ root, port: 0 });
56
66
  try {
57
67
  const h = (s: Step) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root });
58
- await opts.seed(h);
68
+ await opts.seed(h, root);
59
69
  const base = `http://127.0.0.1:${server.port}`;
60
70
  const get: ServerFetch = (path) => fetch(`${base}${path}`).then((r) => r.json() as Promise<Body>);
61
71
  const post = (path: string, body?: string) => fetch(`${base}${path}`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: body ?? '' }).then((r) => r.json() as Promise<Body>);
@@ -95,17 +105,129 @@ type Step = { m: string; p: string; b?: string };
95
105
  type Body = Record<string, unknown>;
96
106
 
97
107
  /** Run a sequence of real Stripe requests against an isolated root; return all responses. */
98
- async function withRoot(steps: (h: (s: Step) => Promise<StripeResponse>) => Promise<boolean>): Promise<boolean> {
108
+ async function withRoot(steps: (h: (s: Step) => Promise<StripeResponse>, root: string) => Promise<boolean>): Promise<boolean> {
99
109
  const root = mkdtempSync(join(tmpdir(), 'stp-cap-'));
100
110
  const h = (s: Step) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root });
101
111
  try {
102
- return await verifyBoundary('stripe.withRoot', () => steps(h));
112
+ return await verifyBoundary('stripe.withRoot', () => steps(h, root));
103
113
  } finally {
104
114
  rmSync(root, { recursive: true, force: true });
105
115
  }
106
116
  }
107
117
 
118
+ /** Funds the Issuing balance with a top-up made a week before `at` (default now): a top-up "can take up to 5 business
119
+ * days to become available. While they're pending, they won't be added to your Issuing balance"
120
+ * (docs.stripe.com/issuing/funding/balance), so the next request at `at` finds it settled. */
121
+ async function fundIssuing(root: string, amount: number, at?: string): Promise<StripeResponse> {
122
+ const t = at ? Date.parse(at) : Date.now();
123
+ return handleStripeTwinRequest({ method: 'POST', path: '/v1/topups', body: `amount=${amount}&currency=usd&destination_balance=issuing`, root, occurredAt: new Date(t - 7 * 86_400_000).toISOString() });
124
+ }
125
+
108
126
  const ok = (r: StripeResponse) => r.status >= 200 && r.status < 300;
127
+ /** A seller the platform can pay: a Custom account onboarded through the API (its business profile, the owner's
128
+ * acceptance of the terms, the individual's name, a bank account), and funds in the platform's balance to send it.
129
+ * Answers the account id. The name is owed at once for an individual with transfers (e0e3b88c6: what Stripe's
130
+ * requirements endpoint lists for the business type and capabilities), so without it transfers stay inactive. */
131
+ async function onboardedSeller(h: (s: Step) => Promise<StripeResponse>): Promise<string> {
132
+ const a = await h({ m: 'POST', p: '/v1/accounts', b: 'type=custom&country=US&capabilities[transfers][requested]=true&business_profile[mcc]=5462&business_profile[url]=https://seller.test&tos_acceptance[date]=1767225600&tos_acceptance[ip]=203.0.113.9&individual[first_name]=Sam&individual[last_name]=Seller' });
133
+ await h({ m: 'POST', p: `/v1/accounts/${id(a)}/external_accounts`, b: 'external_account[object]=bank_account&external_account[country]=US&external_account[currency]=usd&external_account[routing_number]=110000000&external_account[account_number]=000123456789' });
134
+ await h({ m: 'POST', p: '/v1/charges', b: 'amount=100000&currency=usd&source=tok_bypassPending' });
135
+ return id(a);
136
+ }
137
+ /** A local webhook receiver, as an application runs one: a node:http server on 127.0.0.1 that keeps each event POSTed
138
+ * to it by path (`/platform`, `/connect`, …). */
139
+ async function webhookReceiver(): Promise<{ url: (path: string) => string; got: (path: string) => StripeEvent[]; close: () => void }> {
140
+ const got = new Map<string, StripeEvent[]>();
141
+ const server = createServer((req, res) => {
142
+ let body = '';
143
+ req.on('data', (d) => { body += String(d); });
144
+ req.on('end', () => { got.set(String(req.url), [...(got.get(String(req.url)) ?? []), JSON.parse(body) as StripeEvent]); res.end('ok'); });
145
+ });
146
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
147
+ const port = (server.address() as AddressInfo).port;
148
+ return { url: (path) => `http://127.0.0.1:${port}${path}`, got: (path) => got.get(path) ?? [], close: () => server.close() };
149
+ }
150
+ /** A request to the twin's fetch at a World instant, as an account (the Stripe-Account header) when one is named. */
151
+ function fetcher(f: (r: Request) => Promise<Response>): (m: string, p: string, b?: string, acct?: string) => Promise<Body> {
152
+ return async (m, p, b, acct) => (await (await f(new Request(`http://stripe.test${p}`, {
153
+ method: m, headers: { authorization: 'Bearer sk_test_twin', 'content-type': 'application/x-www-form-urlencoded', ...(acct ? { 'stripe-account': acct } : {}) }, ...(b !== undefined ? { body: b } : {}),
154
+ }))).json()) as Body;
155
+ }
156
+ // ── Connect OAuth (screens/connect-oauth.tsx, screens/connect-settings.tsx) ──
157
+ /** Cal.com's callback, the redirect URI its Stripe app registers. */
158
+ const OAUTH_CALLBACK = 'http://localhost:3000/api/integrations/stripepayment/callback';
159
+ /** An authorize query as Cal.com's Stripe app builds it (qs-stringify: nested stripe_user[...] keys), with `extra` added. */
160
+ const authorizeQuery = (extra = '', redirect: string | null = OAUTH_CALLBACK): string =>
161
+ `client_id=ca_twin_self&scope=read_write&response_type=code${redirect === null ? '' : `&redirect_uri=${encodeURIComponent(redirect)}`}&state=st8${extra}`;
162
+ type OAuthWorld = {
163
+ f: (r: Request) => Promise<Response>;
164
+ root: string;
165
+ api: (m: string, p: string, b?: string, acct?: string) => Promise<{ status: number; body: Body }>;
166
+ at: (iso: string) => void;
167
+ settings: (enabled: boolean, uris: string) => Promise<Response>;
168
+ authorize: (query: string, decision?: 'skip' | 'deny') => Promise<Response>;
169
+ endpoint: (path: 'token' | 'deauthorize', body: string, auth?: string) => Promise<{ status: number; body: Body }>;
170
+ /** Connect a new account through the page (skip) and exchange its code: the redirect, the code and the token answer. */
171
+ connect: (extra?: string) => Promise<{ location: URL; code: string; token: Body }>;
172
+ };
173
+ /** A fresh World with a Connect platform whose OAuth is on and whose callback is registered (unless `configure` is false). */
174
+ async function withOAuth(run: (w: OAuthWorld) => Promise<boolean>, configure = true): Promise<boolean> {
175
+ const root = mkdtempSync(join(tmpdir(), 'stp-oauth-'));
176
+ let now = '2026-03-01T00:00:00.000Z';
177
+ const f = createStripeTwinFetch({ root, clock: () => now });
178
+ const form = { 'content-type': 'application/x-www-form-urlencoded' };
179
+ const w: OAuthWorld = {
180
+ f,
181
+ root,
182
+ api: async (m, p, b, acct) => {
183
+ const r = await f(new Request(`http://stripe.test${p}`, { method: m, headers: { authorization: 'Bearer sk_test_platform', ...form, ...(acct ? { 'stripe-account': acct } : {}) }, ...(b !== undefined ? { body: b } : {}) }));
184
+ return { status: r.status, body: (await r.json()) as Body };
185
+ },
186
+ at: (iso) => { now = iso; },
187
+ settings: (enabled, uris) => f(new Request('https://dashboard.stripe.com/settings/connect/onboarding-options/oauth', { method: 'POST', headers: form, body: new URLSearchParams({ ...(enabled ? { oauth_enabled: 'on' } : {}), redirect_uris: uris }).toString() })),
188
+ authorize: (query, decision) => f(new Request(`https://connect.stripe.com/oauth/authorize?${query}`, decision ? { method: 'POST', headers: form, body: `decision=${decision}` } : {})),
189
+ endpoint: async (path, body, auth = 'Bearer sk_test_platform') => {
190
+ const r = await f(new Request(`https://connect.stripe.com/oauth/${path}`, { method: 'POST', headers: { ...form, ...(auth ? { authorization: auth } : {}) }, body }));
191
+ return { status: r.status, body: (await r.json()) as Body };
192
+ },
193
+ connect: async (extra = '') => {
194
+ const location = new URL((await w.authorize(authorizeQuery(extra), 'skip')).headers.get('location') ?? 'about:blank');
195
+ const code = location.searchParams.get('code') ?? '';
196
+ const token = (await w.endpoint('token', `grant_type=authorization_code&code=${code}`)).body;
197
+ return { location, code, token };
198
+ },
199
+ };
200
+ try {
201
+ if (configure) await w.settings(true, OAUTH_CALLBACK);
202
+ return await verifyBoundary('stripe.withOAuth', () => run(w));
203
+ } finally {
204
+ rmSync(root, { recursive: true, force: true });
205
+ }
206
+ }
207
+ /** A dispute as Stripe opens one: a charge whose test card the bank disputes (docs.stripe.com/testing#disputes). */
208
+ async function disputed(h: (s: Step) => Promise<StripeResponse>, amount = 1000): Promise<StripeResponse> {
209
+ await h({ m: 'POST', p: '/v1/charges', b: `amount=${amount}&currency=usd&source=tok_createDispute` });
210
+ const first = (((await h({ m: 'GET', p: '/v1/disputes' })).body as Body).data as Body[])[0]!;
211
+ return h({ m: 'GET', p: `/v1/disputes/${String(first.id)}` });
212
+ }
213
+ /** A field on a hosted page under the page's own formatting script: typing (one character at a time), deleting, or a
214
+ * paste or autofill of a whole value, each answering the field's value after the script has run. */
215
+ function expiryField(script: string): { type: (s: string) => string; back: () => string; fill: (s: string) => string } {
216
+ const listeners: Array<(e: { inputType: string }) => void> = [];
217
+ const el = { value: '', addEventListener: (_: string, f: (e: { inputType: string }) => void) => { listeners.push(f); } };
218
+ new Function('document', script)({ querySelectorAll: () => [el] });
219
+ const fire = (inputType: string) => { for (const f of listeners) f({ inputType }); return el.value; };
220
+ return {
221
+ type: (s) => { for (const c of s) { el.value += c; fire('insertText'); } return el.value; },
222
+ back: () => { el.value = el.value.slice(0, -1); return fire('deleteContentBackward'); },
223
+ fill: (s) => { el.value = s; return fire('insertFromPaste'); },
224
+ };
225
+ }
226
+
227
+ /** A person's submission on one of Stripe's hosted pages (verify.stripe.com, the bank-linking flow Stripe.js opens). */
228
+ async function hostedSubmit(root: string, flow: (scope: { root?: string }) => (request: Request) => Promise<Response | undefined>, url: string, form: Record<string, string>): Promise<Response | undefined> {
229
+ return flow({ root })(new Request(url, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(form).toString() }));
230
+ }
109
231
  const id = (r: StripeResponse) => (r.body as Body)?.id as string;
110
232
  const field = (r: StripeResponse, k: string) => (r.body as Body)?.[k];
111
233
 
@@ -119,7 +241,6 @@ const listOk = (path: string) => () =>
119
241
  // ── shorthands (mirror the linear manifest) ──
120
242
  const done = (id: string, area: string, title: string, dimension: CapabilitySpec['dimension'], tier: CapabilitySpec['tier'], verify: CapabilitySpec['verify']): CapabilitySpec => ({ id, area, title, dimension, tier, expected: 'done', verify });
121
243
  const todo = (id: string, area: string, title: string, dimension: CapabilitySpec['dimension'], tier: CapabilitySpec['tier']): CapabilitySpec => ({ id, area, title, dimension, tier, expected: 'todo' });
122
- const outOfScope = (id: string, area: string, title: string, dimension: CapabilitySpec['dimension'], tier: CapabilitySpec['tier'], reason: string): CapabilitySpec => ({ id, area, title, dimension, tier, expected: 'todo', outOfScope: reason });
123
244
 
124
245
  // The real Stripe surface (the target). Entries with verify() + expected:'done' are what we
125
246
  // currently claim are working against THIS twin; everything else (the majority) is a gap.
@@ -189,8 +310,10 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
189
310
  }),
190
311
  ),
191
312
  // Cash balance: GET /v1/customers/:id/cash_balance returns the cash_balance summary
192
- // (available currency→amount map, derived from the cash-balance ledger). Funding via
193
- // .../cash_balance_transactions accrues into `available`; POST updates reconciliation_mode.
313
+ // (available currency→amount map, derived from the cash-balance ledger). Funding via the test helper
314
+ // POST /v1/test_helpers/customers/:id/fund_cash_balance ("Create an incoming testmode bank transfer",
315
+ // docs.stripe.com/api/cash_balance/fund_cash_balance) accrues into `available`; POST updates reconciliation_mode.
316
+ // (The twin-invented POST .../cash_balance_transactions left with 5decb1d77: Stripe's API has no such write.)
194
317
  // An unknown customer 404s; an invalid reconciliation_mode 400s. (Only this feature produces
195
318
  // the available map keyed by the funded currency.)
196
319
  done('stripe.customers.cash_balance', 'customers', 'Customer cash balance', 'api', 'niche', () =>
@@ -198,9 +321,9 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
198
321
  const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=cash@twin.test' });
199
322
  const empty = await h({ m: 'GET', p: `/v1/customers/${id(cust)}/cash_balance` });
200
323
  if (!ok(empty) || field(empty, 'object') !== 'cash_balance' || field(empty, 'available') !== null) return false;
201
- const fund = await h({ m: 'POST', p: `/v1/customers/${id(cust)}/cash_balance_transactions`, b: 'amount=5000&currency=usd' });
324
+ const fund = await h({ m: 'POST', p: `/v1/test_helpers/customers/${id(cust)}/fund_cash_balance`, b: 'amount=5000&currency=usd' });
202
325
  if (!ok(fund) || field(fund, 'type') !== 'funded' || field(fund, 'ending_balance') !== 5000) return false;
203
- const more = await h({ m: 'POST', p: `/v1/customers/${id(cust)}/cash_balance_transactions`, b: 'amount=2500&currency=usd' });
326
+ const more = await h({ m: 'POST', p: `/v1/test_helpers/customers/${id(cust)}/fund_cash_balance`, b: 'amount=2500&currency=usd' });
204
327
  if (field(more, 'ending_balance') !== 7500) return false;
205
328
  const bal = await h({ m: 'GET', p: `/v1/customers/${id(cust)}/cash_balance` });
206
329
  if ((field(bal, 'available') as Body)?.usd !== 7500) return false;
@@ -269,7 +392,11 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
269
392
  const beforeN = ((before.body as Body).data as Body[]).length;
270
393
  const afterN = ((after.body as Body).data as Body[]).length;
271
394
  const again = await h({ m: 'DELETE', p: `/v1/customers/${id(c)}` });
272
- return gone.status === 404 && afterN === beforeN - 1 && again.status === 404;
395
+ // a deleted customer still RETRIEVES, as the stub: "If it's for a deleted Customer, a subset of the customer's
396
+ // information is returned, including a `deleted` property that's set to true" (docs.stripe.com/api/customers/retrieve;
397
+ // 8154bb647). It leaves the list, and a second delete finds nothing to delete.
398
+ return gone.status === 200 && field(gone, 'deleted') === true && field(gone, 'id') === id(c) && field(gone, 'email') === undefined
399
+ && afterN === beforeN - 1 && again.status === 404;
273
400
  }),
274
401
  ),
275
402
 
@@ -298,16 +425,25 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
298
425
  }),
299
426
  ),
300
427
  // Manual capture: a manual-capture PI confirmed lands in requires_capture (funds authorized,
301
- // amount_capturable=amount), then /capture → succeeded (amount_received set, capturable cleared).
302
- // Capturing an already-captured PI is the vendor 400 payment_intent_unexpected_state; unknown id 404.
428
+ // amount_capturable=amount) with its authorization a charge, succeeded and uncaptured (charge.succeeded), then
429
+ // /capture → succeeded (amount_received set, capturable cleared) capturing that same charge (charge.captured,
430
+ // "Occurs whenever a previously uncaptured charge is captured"). Capturing an already-captured PI is the vendor 400
431
+ // payment_intent_unexpected_state; unknown id 404.
303
432
  done('stripe.payment_intents.capture', 'payment_intents', 'PaymentIntent manual capture (capture_method=manual + /capture)', 'api', 'core', () =>
304
433
  withRoot(async (h) => {
305
434
  const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=2000&currency=usd&capture_method=manual' });
306
435
  if (!ok(pi)) return false;
307
436
  const conf = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/confirm`, b: 'payment_method=pm_card_visa' });
308
437
  if (!ok(conf) || field(conf, 'status') !== 'requires_capture' || field(conf, 'amount_capturable') !== 2000) return false;
438
+ const held = await h({ m: 'GET', p: `/v1/charges/${String(field(conf, 'latest_charge'))}` });
439
+ if (field(held, 'status') !== 'succeeded' || field(held, 'captured') !== false || field(held, 'amount_captured') !== 0 || field(held, 'balance_transaction') !== null) return false;
309
440
  const cap = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/capture` });
310
441
  if (!ok(cap) || field(cap, 'status') !== 'succeeded' || field(cap, 'amount_received') !== 2000 || field(cap, 'amount_capturable') !== 0) return false;
442
+ const taken = await h({ m: 'GET', p: `/v1/charges/${String(field(cap, 'latest_charge'))}` });
443
+ if (id(taken) !== id(held) || field(taken, 'captured') !== true || field(taken, 'amount_captured') !== 2000 || typeof field(taken, 'balance_transaction') !== 'string') return false;
444
+ const events = (((await h({ m: 'GET', p: '/v1/events?limit=100' })).body as Body).data as Body[]) ?? [];
445
+ const on = (type: string) => events.filter((e) => e.type === type && ((e.data as Body).object as Body).id === id(held)).map((e) => (e.data as Body).object as Body);
446
+ if (on('charge.succeeded').length !== 1 || on('charge.succeeded')[0]!.captured !== false || on('charge.captured').length !== 1 || on('charge.captured')[0]!.captured !== true) return false;
311
447
  // re-read persists; double-capture is the vendor unexpected-state 400; unknown id 404
312
448
  const g = await h({ m: 'GET', p: `/v1/payment_intents/${id(pi)}` });
313
449
  const again = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/capture` });
@@ -317,6 +453,51 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
317
453
  missing.status === 404;
318
454
  }),
319
455
  ),
456
+ // A declined confirm records the attempt as a failed charge the intent's latest_charge names, written as the event
457
+ // Stripe sends, charge.failed ("Occurs whenever a failed charge attempt occurs", docs.stripe.com/api/events/types), and
458
+ // carrying what every charge of the intent carries: its transfer_group and description. Dub reads the payout invoice a
459
+ // failed payment belongs to from transfer_group.
460
+ done('stripe.payment_intents.declined_charge', 'payment_intents', 'A declined confirm\'s failed charge fires charge.failed with the intent\'s transfer_group and description', 'api', 'common', () =>
461
+ withRoot(async (h) => {
462
+ const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=2000&currency=usd&transfer_group=inv_77&description=Payout%20invoice%2077' });
463
+ const dec = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/confirm`, b: 'payment_method=pm_card_chargeDeclined' });
464
+ const chargeId = ((dec.body as Body).error as Body | undefined)?.charge;
465
+ const after = await h({ m: 'GET', p: `/v1/payment_intents/${id(pi)}` });
466
+ const charge = await h({ m: 'GET', p: `/v1/charges/${String(chargeId)}` });
467
+ const events = (((await h({ m: 'GET', p: '/v1/events?type=charge.failed' })).body as Body).data as Body[]) ?? [];
468
+ const object = (events[0]?.data as Body | undefined)?.object as Body | undefined;
469
+ return dec.status === 402 && field(after, 'latest_charge') === chargeId && field(charge, 'status') === 'failed'
470
+ && field(charge, 'transfer_group') === 'inv_77' && field(charge, 'description') === 'Payout invoice 77'
471
+ && events.length === 1 && object?.id === chargeId && object?.transfer_group === 'inv_77' && object?.description === 'Payout invoice 77';
472
+ }),
473
+ ),
474
+ // Cancelling a manual-capture intent releases its authorization: "For PaymentIntents with a `status` of
475
+ // `requires_capture`, the remaining `amount_capturable` is automatically refunded" (docs.stripe.com/api/payment_intents/cancel).
476
+ // Since basil the cancellation makes no Refund ("`refunded` will no longer be `true` for payment cancellation flows",
477
+ // the basil changelog), no money moves, and capturing the charge is refused, the intent being canceled.
478
+ done('stripe.payment_intents.cancel_authorization', 'payment_intents', 'Cancelling a requires_capture intent releases its uncaptured charge (no Refund, no balance moved); capturing it is refused', 'api', 'common', () =>
479
+ withRoot(async (h) => {
480
+ // the whole balance, available and pending
481
+ const avail = async (): Promise<number> => {
482
+ const b = (await h({ m: 'GET', p: '/v1/balance' })).body as Body;
483
+ return [...((b.available as Body[]) ?? []), ...((b.pending as Body[]) ?? [])].reduce((n, x) => n + (Number(x.amount) || 0), 0);
484
+ };
485
+ const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=2000&currency=usd&capture_method=manual&payment_method=pm_card_visa&confirm=true' });
486
+ const chargeId = String(field(pi, 'latest_charge'));
487
+ const before = await avail();
488
+ const cancel = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/cancel` });
489
+ const ch = await h({ m: 'GET', p: `/v1/charges/${chargeId}` });
490
+ const refunds = ((field(ch, 'refunds') as Body)?.data as Body[]) ?? [];
491
+ const capture = await h({ m: 'POST', p: `/v1/charges/${chargeId}/capture` });
492
+ const after = await avail();
493
+ // since basil a cancellation makes no Refund: amount_captured 0, amount_refunded and refunded untouched
494
+ return field(cancel, 'status') === 'canceled' && field(cancel, 'amount_capturable') === 0
495
+ && field(ch, 'captured') === false && field(ch, 'refunded') === false && field(ch, 'amount_refunded') === 0 && field(ch, 'amount_captured') === 0
496
+ && refunds.length === 0
497
+ && capture.status === 400 && ((capture.body as Body).error as Body)?.code === 'payment_intent_unexpected_state'
498
+ && before === 0 && after === 0;
499
+ }),
500
+ ),
320
501
  // Cancel: a non-terminal PI cancels → status canceled (+ cancellation_reason). Canceling a
321
502
  // succeeded PI is the vendor 400 payment_intent_unexpected_state; an unknown id is 404.
322
503
  done('stripe.payment_intents.cancel', 'payment_intents', 'PaymentIntent cancel', 'api', 'core', () =>
@@ -402,7 +583,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
402
583
  return ok(g) && id(g) === id(ch) && field(l, 'object') === 'list';
403
584
  }),
404
585
  ),
405
- done('stripe.charges.test_declines', 'charges', 'Test-card declines → 402 typed card_error (no charge persisted)', 'api', 'core', () =>
586
+ done('stripe.charges.test_declines', 'charges', 'Test-card declines → 402 typed card_error naming the failed charge', 'api', 'core', () =>
406
587
  withRoot(async (h) => {
407
588
  const dec = await h({ m: 'POST', p: '/v1/charges', b: 'amount=1000&currency=usd&source[number]=4000000000000002' });
408
589
  const ins = await h({ m: 'POST', p: '/v1/charges', b: 'amount=1000&currency=usd&source[number]=4000000000009995' });
@@ -411,6 +592,26 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
411
592
  return dec.status === 402 && decErr?.code === 'card_declined' && ins.status === 402 && insErr?.decline_code === 'insufficient_funds';
412
593
  }),
413
594
  ),
595
+ // POST /v1/charges writes the event Stripe sends for the charge (docs.stripe.com/api/events/types; Stripe has no
596
+ // charge.created): charge.succeeded, "Occurs whenever a charge is successful", for one made and for an authorization
597
+ // (capture=false), charge.failed, "Occurs whenever a failed charge attempt occurs", for a declined attempt, and
598
+ // charge.captured, "Occurs whenever a previously uncaptured charge is captured", when the authorization is captured.
599
+ done('stripe.charges.events', 'charges', 'Charges fire charge.succeeded (made or authorized), charge.failed (declined) and charge.captured', 'api', 'core', () =>
600
+ withRoot(async (h) => {
601
+ const made = await h({ m: 'POST', p: '/v1/charges', b: 'amount=2000&currency=usd&source=tok_visa' });
602
+ const auth = await h({ m: 'POST', p: '/v1/charges', b: 'amount=3000&currency=usd&source=tok_visa&capture=false' });
603
+ const dec = await h({ m: 'POST', p: '/v1/charges', b: 'amount=1000&currency=usd&source=tok_chargeDeclined' });
604
+ const declined = ((dec.body as Body).error as Body | undefined)?.charge;
605
+ await h({ m: 'POST', p: `/v1/charges/${id(auth)}/capture` });
606
+ const events = (((await h({ m: 'GET', p: '/v1/events?limit=100' })).body as Body).data as Body[]) ?? [];
607
+ const of = (type: string) => events.filter((e) => e.type === type).map((e) => (e.data as Body).object as Body);
608
+ const succeeded = of('charge.succeeded');
609
+ return dec.status === 402 && typeof declined === 'string'
610
+ && succeeded.some((c) => c.id === id(made) && c.captured === true) && succeeded.some((c) => c.id === id(auth) && c.captured === false)
611
+ && of('charge.failed').some((c) => c.id === declined && c.status === 'failed')
612
+ && of('charge.captured').some((c) => c.id === id(auth) && c.captured === true && c.amount_captured === 3000);
613
+ }),
614
+ ),
414
615
  // Charge capture: a charge created with capture=false is an uncaptured auth (captured:false,
415
616
  // amount_captured:0); /capture captures it (captured:true, amount_captured set). Re-capture
416
617
  // is the vendor 400 charge_already_captured; an immediately-captured charge (default) cannot
@@ -422,6 +623,12 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
422
623
  const cap = await h({ m: 'POST', p: `/v1/charges/${id(auth)}/capture` });
423
624
  if (!ok(cap) || field(cap, 'captured') !== true || field(cap, 'amount_captured') !== 3000) return false;
424
625
  const again = await h({ m: 'POST', p: `/v1/charges/${id(auth)}/capture` });
626
+ // A PARTIAL capture releases the remainder with no Refund (the basil change): amount_refunded stays 0
627
+ const part = await h({ m: 'POST', p: '/v1/charges', b: 'amount=3000&currency=usd&capture=false' });
628
+ const capPart = await h({ m: 'POST', p: `/v1/charges/${id(part)}/capture`, b: 'amount=1200' });
629
+ if (!ok(capPart) || field(capPart, 'amount_captured') !== 1200 || field(capPart, 'amount_refunded') !== 0 || field(capPart, 'refunded') !== false) return false;
630
+ const released = ((field(capPart, 'refunds') as Body)?.data as Body[]) ?? [];
631
+ if (released.length !== 0) return false;
425
632
  const def = await h({ m: 'POST', p: '/v1/charges', b: 'amount=1000&currency=usd' });
426
633
  const capDef = await h({ m: 'POST', p: `/v1/charges/${id(def)}/capture` });
427
634
  const missing = await h({ m: 'POST', p: '/v1/charges/ch_nope/capture' });
@@ -464,30 +671,112 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
464
671
  // ── Refunds ─────────────────────────────────────────────────────────────────────
465
672
  done('stripe.refunds.crud', 'refunds', 'Refunds: create + retrieve + list', 'api', 'core', () =>
466
673
  withRoot(async (h) => {
467
- const r = await h({ m: 'POST', p: '/v1/refunds', b: 'charge=ch_twin&amount=500' });
674
+ const c = await h({ m: 'POST', p: '/v1/charges', b: 'amount=2000&currency=usd' });
675
+ const r = await h({ m: 'POST', p: '/v1/refunds', b: `charge=${id(c)}&amount=500` });
468
676
  if (!ok(r) || field(r, 'object') !== 'refund') return false;
469
677
  const g = await h({ m: 'GET', p: `/v1/refunds/${id(r)}` });
470
678
  const l = await h({ m: 'GET', p: '/v1/refunds' });
471
679
  return ok(g) && id(g) === id(r) && field(l, 'object') === 'list';
472
680
  }),
473
681
  ),
682
+ // The money model's central claim: a refund is VISIBLE FROM THE CHARGE it came out of.
683
+ // amount_refunded accumulates, `refunded` flips only at the full amount, and the charge's
684
+ // own refunds sub-list carries the refund objects. A twin that answers a succeeded refund
685
+ // while the charge still reads amount_refunded 0 hands a reconciler the wrong ledger with
686
+ // no error to tell them — which is why the over-refund refusal is part of the same claim.
687
+ done('stripe.refunds.visible_on_charge', 'refunds', 'A refund lands on its charge: amount_refunded / refunded / refunds list, and an over-refund is refused', 'api', 'core', () =>
688
+ withRoot(async (h) => {
689
+ const c = await h({ m: 'POST', p: '/v1/charges', b: 'amount=2000&currency=usd' });
690
+ const r1 = await h({ m: 'POST', p: '/v1/refunds', b: `charge=${id(c)}&amount=500` });
691
+ if (!ok(r1)) return false;
692
+ // `refunds` is includable on a charge, "not returned by default" (docs.stripe.com/api/charges/object; 4388f04e9):
693
+ // the plain read leaves it out, and the read that expands it carries the refund
694
+ const plain = await h({ m: 'GET', p: `/v1/charges/${id(c)}` });
695
+ if (field(plain, 'refunds') !== undefined) return false;
696
+ const partial = await h({ m: 'GET', p: `/v1/charges/${id(c)}?expand[]=refunds` });
697
+ if (field(partial, 'amount_refunded') !== 500 || field(partial, 'refunded') !== false) return false;
698
+ const listed = (field(partial, 'refunds') as Body)?.data as Body[] | undefined;
699
+ if (!Array.isArray(listed) || listed.length !== 1 || listed[0]?.id !== id(r1)) return false;
700
+ // more than the charge has left to give is refused, not silently accepted
701
+ const tooMuch = await h({ m: 'POST', p: '/v1/refunds', b: `charge=${id(c)}&amount=1600` });
702
+ if (tooMuch.status !== 400) return false;
703
+ // an omitted amount refunds the whole remainder, and THEN the charge is fully refunded
704
+ // (taken through the charge's OWN refund endpoint — Stripe serves both spellings)
705
+ const r2 = await h({ m: 'POST', p: `/v1/charges/${id(c)}/refunds` });
706
+ if (!ok(r2) || field(r2, 'amount') !== 1500) return false;
707
+ const full = await h({ m: 'GET', p: `/v1/charges/${id(c)}` });
708
+ // and the charge's own refunds sub-route answers the same two refunds
709
+ const sub = await h({ m: 'GET', p: `/v1/charges/${id(c)}/refunds` });
710
+ const none = await h({ m: 'POST', p: '/v1/refunds', b: 'amount=100' });
711
+ const nosuch = await h({ m: 'POST', p: '/v1/refunds', b: 'charge=ch_nope&amount=100' });
712
+ return field(full, 'amount_refunded') === 2000 && field(full, 'refunded') === true
713
+ && ((field(sub, 'data') as Body[])?.length === 2)
714
+ && none.status === 400 && nosuch.status === 404;
715
+ }),
716
+ ),
717
+ // A refund reached through its PaymentIntent lands on the SAME charge: a succeeded intent
718
+ // has a latest_charge (real Stripe always mints one), and that is what the refund hits.
719
+ done('stripe.refunds.by_payment_intent', 'refunds', 'Refund by payment_intent resolves the intent’s charge', 'api', 'common', () =>
720
+ withRoot(async (h) => {
721
+ const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=3000&currency=usd' });
722
+ const con = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/confirm`, b: 'payment_method=pm_card_visa' });
723
+ const charge = field(con, 'latest_charge') as string | undefined;
724
+ if (!charge) return false;
725
+ const r = await h({ m: 'POST', p: '/v1/refunds', b: `payment_intent=${id(pi)}&amount=1200` });
726
+ if (!ok(r) || field(r, 'charge') !== charge || field(r, 'payment_intent') !== id(pi)) return false;
727
+ const ch = await h({ m: 'GET', p: `/v1/charges/${charge}` });
728
+ const missing = await h({ m: 'POST', p: '/v1/refunds', b: 'payment_intent=pi_nope&amount=1' });
729
+ return field(ch, 'amount_refunded') === 1200 && missing.status === 404;
730
+ }),
731
+ ),
474
732
  // Refund update: POST /v1/refunds/:id persists metadata; retrieve round-trips it. Unknown id 404.
733
+ // An uncaptured charge holds an authorization, not money. A PaymentIntent's "remains uncaptured and can't be refunded
734
+ // directly. You must cancel the PaymentIntent" (docs.stripe.com/refunds), so its refund is refused, through the intent
735
+ // or the charge; a capture=false charge's refund releases it (refunded in full, no balance moved) and its capture is
736
+ // then refused ("unless the charge is already refunded", docs.stripe.com/api/charges/capture).
737
+ done('stripe.refunds.uncaptured', 'refunds', 'Refunding an uncaptured charge: refused for a PaymentIntent\'s (cancel it), a release for a capture=false charge; no balance moved, capture refused after', 'api', 'common', () =>
738
+ withRoot(async (h) => {
739
+ const whole = async (): Promise<number> => {
740
+ const b = (await h({ m: 'GET', p: '/v1/balance' })).body as Body;
741
+ return [...((b.available as Body[]) ?? []), ...((b.pending as Body[]) ?? [])].reduce((n, x) => n + (Number(x.amount) || 0), 0);
742
+ };
743
+ const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=2000&currency=usd&capture_method=manual&payment_method=pm_card_visa&confirm=true' });
744
+ const viaIntent = await h({ m: 'POST', p: '/v1/refunds', b: `payment_intent=${id(pi)}` });
745
+ const viaCharge = await h({ m: 'POST', p: '/v1/refunds', b: `charge=${String(field(pi, 'latest_charge'))}` });
746
+ const still = await h({ m: 'GET', p: `/v1/charges/${String(field(pi, 'latest_charge'))}` });
747
+ if (viaIntent.status !== 400 || viaCharge.status !== 400 || field(still, 'refunded') !== false || field(still, 'amount_refunded') !== 0) return false;
748
+ const auth = await h({ m: 'POST', p: '/v1/charges', b: 'amount=3000&currency=usd&source=tok_visa&capture=false' });
749
+ const refund = await h({ m: 'POST', p: '/v1/refunds', b: `charge=${id(auth)}` });
750
+ const released = await h({ m: 'GET', p: `/v1/charges/${id(auth)}` });
751
+ const capture = await h({ m: 'POST', p: `/v1/charges/${id(auth)}/capture` });
752
+ return ok(refund) && field(refund, 'amount') === 3000 && field(refund, 'balance_transaction') === null
753
+ && field(released, 'refunded') === true && field(released, 'captured') === false && field(released, 'amount_refunded') === 3000
754
+ && capture.status === 400 && ((capture.body as Body).error as Body)?.code === 'charge_already_refunded' && await whole() === 0;
755
+ }),
756
+ ),
475
757
  done('stripe.refunds.update', 'refunds', 'Refund update (metadata)', 'api', 'common', () =>
476
758
  withRoot(async (h) => {
477
- const r = await h({ m: 'POST', p: '/v1/refunds', b: 'charge=ch_twin&amount=500' });
759
+ const c = await h({ m: 'POST', p: '/v1/charges', b: 'amount=2000&currency=usd' });
760
+ const r = await h({ m: 'POST', p: '/v1/refunds', b: `charge=${id(c)}&amount=500` });
478
761
  const u = await h({ m: 'POST', p: `/v1/refunds/${id(r)}`, b: 'metadata[reason]=duplicate' });
479
762
  if (!ok(u) || ((field(u, 'metadata') as Body)?.reason) !== 'duplicate') return false;
480
763
  const g = await h({ m: 'GET', p: `/v1/refunds/${id(r)}` });
481
764
  const missing = await h({ m: 'POST', p: '/v1/refunds/re_nope', b: 'metadata[x]=1' });
482
- return ok(g) && ((field(g, 'metadata') as Body)?.reason) === 'duplicate' && missing.status === 404;
765
+ // the same refund, read and written THROUGH its charge (Stripe's charge-scoped routes)
766
+ const viaCharge = await h({ m: 'GET', p: `/v1/charges/${id(c)}/refunds/${id(r)}` });
767
+ const editedViaCharge = await h({ m: 'POST', p: `/v1/charges/${id(c)}/refunds/${id(r)}`, b: 'metadata[note]=via-charge' });
768
+ const wrongCharge = await h({ m: 'GET', p: `/v1/charges/${id(c)}/refunds/re_nope` });
769
+ return ok(g) && ((field(g, 'metadata') as Body)?.reason) === 'duplicate' && missing.status === 404
770
+ && ok(viaCharge) && id(viaCharge) === id(r) && wrongCharge.status === 404
771
+ && ((field(editedViaCharge, 'metadata') as Body)?.note) === 'via-charge';
483
772
  }),
484
773
  ),
485
774
  // (refunds.cancel upgraded to done() in the AUDIT GROWTH block below.)
486
775
 
487
776
  // ── Disputes ────────────────────────────────────────────────────────────────────
488
- done('stripe.disputes.crud', 'disputes', 'Disputes: create + retrieve + list', 'api', 'common', () =>
777
+ done('stripe.disputes.crud', 'disputes', 'Disputes: a disputed charge opens one; retrieve + list', 'api', 'common', () =>
489
778
  withRoot(async (h) => {
490
- const d = await h({ m: 'POST', p: '/v1/disputes', b: 'charge=ch_twin&amount=1000&currency=usd' });
779
+ const d = await disputed(h);
491
780
  if (!ok(d) || field(d, 'object') !== 'dispute') return false;
492
781
  const g = await h({ m: 'GET', p: `/v1/disputes/${id(d)}` });
493
782
  const l = await h({ m: 'GET', p: '/v1/disputes' });
@@ -496,13 +785,27 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
496
785
  ),
497
786
  done('stripe.disputes.evidence', 'disputes', 'Dispute evidence submission (update) + close', 'api', 'common', () =>
498
787
  withRoot(async (h) => {
499
- const d = await h({ m: 'POST', p: '/v1/disputes', b: 'charge=ch_twin&amount=1000&currency=usd' });
788
+ const d = await disputed(h);
500
789
  const ev = await h({ m: 'POST', p: `/v1/disputes/${id(d)}`, b: 'evidence[uncategorized_text]=we shipped it' });
501
790
  const cl = await h({ m: 'POST', p: `/v1/disputes/${id(d)}/close` });
502
791
  return ok(ev) && ok(cl) && field(cl, 'status') === 'lost';
503
792
  }),
504
793
  ),
505
794
 
795
+ // The dispute test cards open a dispute through Checkout as through the API, and test-mode evidence decides it:
796
+ // `winning_evidence` wins (the amount comes back), `losing_evidence` loses (docs.stripe.com/testing#disputes, #evidence).
797
+ done('stripe.disputes.test_outcomes', 'disputes', 'Dispute test cards through Checkout; winning_evidence / losing_evidence decide the dispute', 'api', 'common', () =>
798
+ withRoot(async (h) => {
799
+ const cs = await h({ m: 'POST', p: '/v1/checkout/sessions', b: 'mode=payment&success_url=https://x.test&line_items[0][price_data][currency]=usd&line_items[0][price_data][unit_amount]=6500&line_items[0][quantity]=1' });
800
+ await h({ m: 'POST', p: `/c/pay/${id(cs)}`, b: 'email=pay%40twin.test&cardNumber=4000000000002685&cardExpiry=12%2F34&cardCvc=123&billingName=Twin&billingCountry=US&billingPostalCode=94105' });
801
+ const opened = ((await h({ m: 'GET', p: '/v1/disputes' })).body as Body).data as Body[];
802
+ const won = await h({ m: 'POST', p: `/v1/disputes/${String(opened[0]?.id)}`, b: 'evidence[uncategorized_text]=winning_evidence' });
803
+ const lostOne = await disputed(h);
804
+ const lost = await h({ m: 'POST', p: `/v1/disputes/${id(lostOne)}`, b: 'evidence[uncategorized_text]=losing_evidence' });
805
+ return opened[0]?.reason === 'product_not_received' && field(won, 'status') === 'won' && field(lost, 'status') === 'lost';
806
+ }),
807
+ ),
808
+
506
809
  // ── Balance / BalanceTransactions / Payouts ───────────────────────────────────────
507
810
  done('stripe.balance.retrieve', 'balance', 'Balance retrieve (available/pending)', 'api', 'core', () =>
508
811
  withRoot(async (h) => {
@@ -510,17 +813,37 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
510
813
  return ok(r) && field(r, 'object') === 'balance';
511
814
  }),
512
815
  ),
513
- done('stripe.balance_transactions.crud', 'balance', 'BalanceTransactions: create + retrieve + list', 'api', 'core', () =>
816
+ done('stripe.balance_transactions.crud', 'balance', 'BalanceTransactions: a charge\'s ledger entry; retrieve + list', 'api', 'core', () =>
514
817
  withRoot(async (h) => {
515
- const t = await h({ m: 'POST', p: '/v1/balance_transactions', b: 'amount=1000&currency=usd' });
516
- if (!ok(t)) return false;
818
+ await h({ m: 'POST', p: '/v1/charges', b: 'amount=1000&currency=usd&source=tok_visa' });
819
+ const t = { status: 200, body: (((await h({ m: 'GET', p: '/v1/balance_transactions' })).body as Body).data as Body[])[0]! } as StripeResponse;
820
+ if (!t.body || field(t, 'amount') !== 1000) return false;
517
821
  const g = await h({ m: 'GET', p: `/v1/balance_transactions/${id(t)}` });
518
822
  const l = await h({ m: 'GET', p: '/v1/balance_transactions' });
519
823
  return ok(g) && id(g) === id(t) && field(l, 'object') === 'list';
520
824
  }),
521
825
  ),
826
+ // Every money movement writes its balance transaction (semantics/ledger.ts): a captured charge credits its
827
+ // amount less Stripe's standard fee, pending until available_on; a refund and a payout debit at once; a
828
+ // platform payout beyond the available balance is balance_insufficient.
829
+ done('stripe.balance_transactions.from_money_movement', 'balance', 'BalanceTransactions written by charges, refunds and payouts; the balance their sum; payouts held to it', 'api', 'common', () =>
830
+ withRoot(async (h) => {
831
+ const ch = await h({ m: 'POST', p: '/v1/charges', b: 'amount=10000&currency=usd&source=tok_visa' });
832
+ const btId = field(ch, 'balance_transaction') as string;
833
+ const bt = await h({ m: 'GET', p: `/v1/balance_transactions/${btId}` });
834
+ if (!(ok(bt) && field(bt, 'type') === 'charge' && field(bt, 'fee') === 320 && field(bt, 'net') === 9680 && field(bt, 'status') === 'pending')) return false;
835
+ // pending funds cannot be paid out; funds that settle at once can
836
+ if ((await h({ m: 'POST', p: '/v1/payouts', b: 'amount=5000&currency=usd' })).status !== 400) return false;
837
+ await h({ m: 'POST', p: '/v1/charges', b: 'amount=10000&currency=usd&source=tok_bypassPending' });
838
+ const po = await h({ m: 'POST', p: '/v1/payouts', b: 'amount=5000&currency=usd' });
839
+ const bal = await h({ m: 'GET', p: '/v1/balance' });
840
+ const available = ((bal.body as { available: Array<{ amount: number }> }).available[0]!).amount;
841
+ return ok(po) && available === 9680 - 5000;
842
+ }),
843
+ ),
522
844
  done('stripe.payouts.crud', 'payouts', 'Payouts: create + retrieve + list + cancel', 'api', 'core', () =>
523
845
  withRoot(async (h) => {
846
+ await h({ m: 'POST', p: '/v1/charges', b: 'amount=2000&currency=usd&source=tok_bypassPending' });
524
847
  const p = await h({ m: 'POST', p: '/v1/payouts', b: 'amount=1000&currency=usd' });
525
848
  if (!ok(p) || field(p, 'object') !== 'payout') return false;
526
849
  const g = await h({ m: 'GET', p: `/v1/payouts/${id(p)}` });
@@ -528,22 +851,39 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
528
851
  return ok(g) && id(g) === id(p) && ok(c);
529
852
  }),
530
853
  ),
531
- // Payout reverse: POST /v1/payouts/:id/reverse materializes the reversal as a NEW payout
532
- // (carrying original_payout) and marks the original canceled + reversed_by. Reversing twice
533
- // 400s; unknown id 404. (Only this feature sets original_payout / reversed_by.)
534
- done('stripe.payouts.reverse', 'payouts', 'Payout reverse', 'api', 'niche', () =>
535
- withRoot(async (h) => {
536
- const p = await h({ m: 'POST', p: '/v1/payouts', b: 'amount=4000&currency=usd' });
537
- if (!ok(p)) return false;
538
- const rev = await h({ m: 'POST', p: `/v1/payouts/${id(p)}/reverse` });
539
- if (!ok(rev) || field(rev, 'original_payout') !== id(p) || field(rev, 'amount') !== 4000) return false;
540
- const orig = await h({ m: 'GET', p: `/v1/payouts/${id(p)}` });
541
- if (field(orig, 'status') !== 'canceled' || field(orig, 'reversed_by') !== id(rev)) return false;
542
- const twice = await h({ m: 'POST', p: `/v1/payouts/${id(p)}/reverse` });
854
+ // Payout reverse: a connected account's payout, once paid, is reversed as a NEW payout carrying original_payout;
855
+ // the original stays paid and names it (reversed_by). A pending payout is canceled instead (400), a platform payout
856
+ // cannot be reversed (400), reversing twice 400s, and an unknown id 404s.
857
+ done('stripe.payouts.reverse', 'payouts', 'Payout reverse (a connected account\'s paid payout)', 'api', 'niche', async () => {
858
+ const root = mkdtempSync(join(tmpdir(), 'stp-rev-'));
859
+ try {
860
+ // at noon: accounts are paid out automatically at midnight UTC on their schedule's days (5ab14297c), so a charge
861
+ // made at the stroke of midnight is swept into that same instant's automatic payout and nothing is left to send
862
+ const h = (s: Step & { acct?: string; day?: number }) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: `2026-06-${String(s.day ?? 1).padStart(2, '0')}T12:00:00Z`, ...(s.acct ? { stripeAccount: s.acct } : {}) });
863
+ const seller = await onboardedSeller(h);
864
+ await h({ m: 'POST', p: '/v1/transfers', b: `amount=5000&currency=usd&destination=${seller}` });
865
+ const p = await h({ m: 'POST', p: '/v1/payouts', b: 'amount=4000&currency=usd', acct: seller });
866
+ if (!ok(p) || (await h({ m: 'POST', p: `/v1/payouts/${id(p)}/reverse`, acct: seller })).status !== 400) return false;
867
+ // the platform's own payout, made the same day, before its automatic payout sweeps what is left (5ab14297c)
868
+ const plat = await h({ m: 'POST', p: '/v1/payouts', b: 'amount=1000&currency=usd' });
869
+ if (!ok(plat)) return false;
870
+ const rev = await h({ m: 'POST', p: `/v1/payouts/${id(p)}/reverse`, acct: seller, day: 5 });
871
+ // the reversal is a payout the other way, "a negative amount" as the reverse page's example answers it
872
+ // (docs.stripe.com/api/payouts/reverse; 8848ebfaa)
873
+ if (!ok(rev) || field(rev, 'original_payout') !== id(p) || field(rev, 'amount') !== -4000) return false;
874
+ const orig = await h({ m: 'GET', p: `/v1/payouts/${id(p)}`, acct: seller, day: 5 });
875
+ if (field(orig, 'status') !== 'paid' || field(orig, 'reversed_by') !== id(rev)) return false;
876
+ const platRev = await h({ m: 'POST', p: `/v1/payouts/${id(plat)}/reverse`, day: 5 });
877
+ const twice = await h({ m: 'POST', p: `/v1/payouts/${id(p)}/reverse`, acct: seller, day: 5 });
543
878
  const nope = await h({ m: 'POST', p: '/v1/payouts/po_nope/reverse' });
544
- return twice.status === 400 && nope.status === 404;
545
- }),
546
- ),
879
+ return platRev.status === 400 && twice.status === 400 && nope.status === 404;
880
+ } catch (err) {
881
+ if (isInfrastructureError(err)) throw harnessError('stripe.payouts.reverse', err);
882
+ return false;
883
+ } finally {
884
+ rmSync(root, { recursive: true, force: true });
885
+ }
886
+ }),
547
887
  // Payout schedule: the automatic-payout cadence lives in account.settings.payouts.schedule.
548
888
  // GET /v1/account returns the platform account with a default daily schedule; POST updates
549
889
  // it to e.g. weekly+anchor (anchors normalized — weekly_anchor set, monthly_anchor null) and
@@ -555,7 +895,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
555
895
  if (!ok(acct)) return false;
556
896
  const sched0 = ((field(acct, 'settings') as Body)?.payouts as Body)?.schedule as Body;
557
897
  if (!sched0 || sched0.interval !== 'daily') return false;
558
- const upd = await h({ m: 'POST', p: '/v1/account', b: 'settings[payouts][schedule][interval]=weekly&settings[payouts][schedule][weekly_anchor]=friday' });
898
+ const upd = await h({ m: 'POST', p: '/_twin/account', b: 'settings[payouts][schedule][interval]=weekly&settings[payouts][schedule][weekly_anchor]=friday' });
559
899
  const sched1 = ((field(upd, 'settings') as Body)?.payouts as Body)?.schedule as Body;
560
900
  if (!ok(upd) || sched1.interval !== 'weekly' || sched1.weekly_anchor !== 'friday' || sched1.monthly_anchor !== null) return false;
561
901
  // persists across a re-read.
@@ -569,6 +909,211 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
569
909
  }),
570
910
  ),
571
911
 
912
+ // Automatic payouts: on its schedule an account is paid out what has become available, and the ledger lists the
913
+ // entries each automatic payout paid (docs.stripe.com/payouts#payout-schedule,
914
+ // docs.stripe.com/api/balance_transactions/list#balance_transaction_list-payout).
915
+ done('stripe.payouts.automatic', 'payouts', 'Automatic payouts on the account\'s schedule, with the entries each paid out', 'api', 'core', async () => {
916
+ const root = mkdtempSync(join(tmpdir(), 'stp-auto-'));
917
+ try {
918
+ const h = (s: Step & { day?: number }) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: `2026-06-${String(s.day ?? 1).padStart(2, '0')}T12:00:00Z` });
919
+ const ch = await h({ m: 'POST', p: '/v1/charges', b: 'amount=2000&currency=usd&source=tok_visa' });
920
+ const early = await h({ m: 'GET', p: '/v1/payouts', day: 2 });
921
+ const later = await h({ m: 'GET', p: '/v1/payouts?created[gte]=1780272000', day: 6 });
922
+ const po = (((later.body as Body).data as Body[]) ?? [])[0];
923
+ const entries = await h({ m: 'GET', p: `/v1/balance_transactions?payout=${String(po?.id)}`, day: 6 });
924
+ const sources = (((entries.body as Body).data as Body[]) ?? []).map((t) => t.source);
925
+ return ((early.body as Body).data as Body[]).length === 0 && po?.automatic === true && po.amount === 2000 - 88
926
+ && sources.includes(id(ch)) && sources.includes(po.id);
927
+ } finally {
928
+ rmSync(root, { recursive: true, force: true });
929
+ }
930
+ }),
931
+
932
+ // Time's events (stripe-server.ts, the drain door): funds coming due send their account balance.available, a payout's
933
+ // arrival sends payout.paid, each once, a connected account's scoped to it (docs.stripe.com/api/events/types;
934
+ // docs.stripe.com/connect/webhooks). Dub pays a partner's connected account out on balance.available and completes
935
+ // the partner's payouts on payout.paid. The webhooks are read where an application reads them, a local receiver behind
936
+ // a platform endpoint and a `connect` one; the stored events are listed as each account (the Stripe-Account header).
937
+ done('stripe.events.time_drain', 'webhooks', 'Events World time produces (balance.available, payout.paid) sent through the drain door, once each, scoped to their account', 'api', 'common', async () => {
938
+ const root = mkdtempSync(join(tmpdir(), 'stp-drain-'));
939
+ const rx = await webhookReceiver();
940
+ try {
941
+ let now = '2026-06-01T12:00:00.000Z';
942
+ const h = fetcher(createStripeTwinFetch({ root, clock: () => now }));
943
+ await h('POST', '/v1/webhook_endpoints', `url=${rx.url('/platform')}&enabled_events[]=balance.available&enabled_events[]=payout.paid`);
944
+ await h('POST', '/v1/webhook_endpoints', `url=${rx.url('/connect')}&enabled_events[]=balance.available&enabled_events[]=payout.paid&connect=true`);
945
+ const seller = String((await h('POST', '/v1/accounts', 'type=custom&country=US&capabilities[transfers][requested]=true&business_profile[mcc]=5462&business_profile[url]=https://seller.test&tos_acceptance[date]=1767225600&tos_acceptance[ip]=203.0.113.9&individual[first_name]=Sam&individual[last_name]=Seller&settings[payouts][schedule][interval]=manual')).id);
946
+ await h('POST', `/v1/accounts/${seller}/external_accounts`, 'external_account[object]=bank_account&external_account[country]=US&external_account[currency]=usd&external_account[routing_number]=110000000&external_account[account_number]=000123456789');
947
+ await h('POST', '/_twin/account', 'settings[payouts][schedule][interval]=manual');
948
+ const pi = await h('POST', '/v1/payment_intents', 'amount=2592&currency=usd&payment_method=pm_card_visa&confirm=true');
949
+ await h('POST', '/v1/transfers', `amount=2400&currency=usd&destination=${seller}&source_transaction=${String(pi.latest_charge)}`);
950
+ const sent = async (): Promise<string[]> => ((await h('POST', '/_twin/drain')).delivered as Body[]).map((d) => `${String(d.type)}@${String(d.account ?? 'platform')}`);
951
+ const early = await sent();
952
+ now = '2026-06-04T12:00:00.000Z';
953
+ const due = await sent();
954
+ const again = await sent();
955
+ const po = await h('POST', '/v1/payouts', 'amount=2400&currency=usd', seller);
956
+ now = '2026-06-07T12:00:00.000Z';
957
+ const arrived = await sent();
958
+ // the connected account's webhooks reach only the connect endpoint, naming it; the platform's only the other
959
+ const theirs = rx.got('/connect');
960
+ const ours = rx.got('/platform');
961
+ const balance = theirs.find((e) => e.type === 'balance.available');
962
+ const paid = theirs.find((e) => e.type === 'payout.paid');
963
+ const hook = balance?.account === seller && ((balance.data.object as Body).available as Body[])?.[0]?.amount === 2400
964
+ && paid?.account === seller && (paid.data.object as Body).id === po.id && theirs.length === 2
965
+ && ours.length === 1 && ours[0]!.type === 'balance.available' && !('account' in ours[0]!);
966
+ const listed = (acct?: string) => h('GET', '/v1/events?limit=50', undefined, acct).then((l) => ((l.data as Body[]) ?? []).map((e) => `${String(e.type)}@${String(e.account ?? 'platform')}`));
967
+ const sellerEvents = await listed(seller);
968
+ const platformEvents = await listed();
969
+ return early.length === 0 && due.includes(`balance.available@${seller}`) && due.includes('balance.available@platform') && again.length === 0
970
+ && po.status === 'pending' && arrived.includes(`payout.paid@${seller}`) && hook
971
+ && sellerEvents.includes(`balance.available@${seller}`) && sellerEvents.includes(`payout.paid@${seller}`)
972
+ && platformEvents.includes('balance.available@platform') && !platformEvents.some((e) => e.endsWith(`@${seller}`));
973
+ } finally {
974
+ rx.close();
975
+ rmSync(root, { recursive: true, force: true });
976
+ }
977
+ }),
978
+
979
+ // Test mode's clock, as Stripe documents it (docs.stripe.com/testing#available-balance; semantics/ledger.ts): a card
980
+ // that bypasses the pending balance (4000000000000077, 4000003720000278, their pm_card_/tok_ names) puts its funds in
981
+ // the available balance at once, a PaymentMethod saved from it too when a later charge names it by id, and a
982
+ // source_transaction transfer from that charge is available on the connected account at once ("takes on the pending
983
+ // status of the associated charge", docs.stripe.com/connect/separate-charges-and-transfers); the drain in that same
984
+ // instant sends each account balance.available. Every other card keeps the delay (4242 pending), and a test payout
985
+ // keeps live timing ("Test payouts simulate a live payout", docs.stripe.com/payouts#test-payouts): paid at its
986
+ // arrival_date, not before. Dub's partner payout reaches Completed this way without World time for the funds. A US
987
+ // bank account debit settles at once too ("Test transactions settle instantly and are added to your available test
988
+ // balance", docs.stripe.com/testing), and every credit available at once is sent as balance.available.
989
+ done('stripe.balance.test_mode_bypass_pending', 'balance', 'Test-mode funds timing: bypass cards (raw, by test name, saved as a PaymentMethod) and bank debits available at once with balance.available sent, source_transaction transfers inheriting it; 4242 and test payouts keep their timing', 'api', 'common', async () => {
990
+ const root = mkdtempSync(join(tmpdir(), 'stp-bypass-'));
991
+ const rx = await webhookReceiver();
992
+ try {
993
+ let now = '2026-06-01T12:00:00.000Z';
994
+ const h = fetcher(createStripeTwinFetch({ root, clock: () => now }));
995
+ await h('POST', '/v1/webhook_endpoints', `url=${rx.url('/connect')}&enabled_events[]=balance.available&enabled_events[]=payout.paid&connect=true`);
996
+ const seller = String((await h('POST', '/v1/accounts', 'type=custom&country=US&capabilities[transfers][requested]=true&business_profile[mcc]=5462&business_profile[url]=https://seller.test&tos_acceptance[date]=1767225600&tos_acceptance[ip]=203.0.113.9&business_type=individual&individual[first_name]=Jordan&individual[last_name]=Lee&settings[payouts][schedule][interval]=manual')).id);
997
+ await h('POST', `/v1/accounts/${seller}/external_accounts`, 'external_account[object]=bank_account&external_account[country]=US&external_account[currency]=usd&external_account[routing_number]=110000000&external_account[account_number]=000123456789');
998
+ await h('POST', '/_twin/account', 'settings[payouts][schedule][interval]=manual');
999
+ const btOf = async (charge: unknown) => h('GET', `/v1/balance_transactions/${String((await h('GET', `/v1/charges/${String(charge)}`)).balance_transaction)}`);
1000
+ const amounts = async (acct?: string) => {
1001
+ const b = await h('GET', '/v1/balance', undefined, acct);
1002
+ return { available: ((b.available as Body[])[0]?.amount as number) ?? 0, pending: ((b.pending as Body[])[0]?.amount as number) ?? 0 };
1003
+ };
1004
+ // 4242: the delay stands in test mode
1005
+ const visa = await h('POST', '/v1/payment_intents', 'amount=1000&currency=usd&payment_method=pm_card_visa&confirm=true');
1006
+ if ((await btOf(visa.latest_charge)).status !== 'pending') return false;
1007
+ const before = await amounts();
1008
+ // a card saved from 4000000000000077, charged later by its id
1009
+ const customer = String((await h('POST', '/v1/customers', 'email=maya@acme.test')).id);
1010
+ const pm = String((await h('POST', '/v1/payment_methods', 'type=card&card[number]=4000000000000077&card[exp_month]=4&card[exp_year]=2030&card[cvc]=314')).id);
1011
+ await h('POST', `/v1/payment_methods/${pm}/attach`, `customer=${customer}`);
1012
+ const pi = await h('POST', '/v1/payment_intents', `amount=2592&currency=usd&customer=${customer}&payment_method=${pm}&confirm=true&off_session=true&expand[]=latest_charge.balance_transaction`);
1013
+ const charge = String((pi.latest_charge as Body | undefined)?.id);
1014
+ const bt = await btOf(charge);
1015
+ const after = await amounts();
1016
+ // the entry reads available however it is read: its own retrieve, the balance, an expansion
1017
+ const embedded = ((pi.latest_charge as Body | undefined)?.balance_transaction as Body | undefined)?.status;
1018
+ const platformAtOnce = bt.status === 'available' && embedded === 'available' && after.available - before.available === Number(bt.net) && after.pending === before.pending;
1019
+ // its source_transaction transfer is available on the connected account at once, and is sent as such
1020
+ await h('POST', '/v1/transfers', `amount=2400&currency=usd&destination=${seller}&source_transaction=${charge}`);
1021
+ const theirs = await amounts(seller);
1022
+ const sent = async (): Promise<string[]> => ((await h('POST', '/_twin/drain')).delivered as Body[]).map((d) => `${String(d.type)}@${String(d.account ?? 'platform')}`);
1023
+ const drained = await sent();
1024
+ const again = await sent();
1025
+ const hook = rx.got('/connect').find((e) => e.type === 'balance.available');
1026
+ const announced = drained.includes(`balance.available@${seller}`) && drained.includes('balance.available@platform') && again.length === 0
1027
+ && hook?.account === seller && ((hook.data.object as Body).available as Body[])?.[0]?.amount === 2400;
1028
+ // a test payout to Stripe's succeeding test bank account keeps live timing: pending until its arrival_date
1029
+ const po = await h('POST', '/v1/payouts', 'amount=2400&currency=usd', seller);
1030
+ if (!(platformAtOnce && theirs.available === 2400 && theirs.pending === 0 && announced && typeof po.id === 'string')) return false;
1031
+ const early = await sent();
1032
+ now = new Date(Number(po.arrival_date) * 1000 + 1000).toISOString();
1033
+ const arrived = await sent();
1034
+ const paid = (await h('GET', `/v1/payouts/${String(po.id)}`, undefined, seller)).status;
1035
+ // the international bypass card, raw and by test name
1036
+ const intl = await h('POST', '/v1/charges', 'amount=500&currency=usd&card[number]=4000003720000278&card[exp_month]=4&card[exp_year]=2030');
1037
+ const named = String((await h('POST', `/v1/payment_methods/pm_card_bypassPendingInternational/attach`, `customer=${customer}`)).id);
1038
+ const byName = await h('POST', '/v1/payment_intents', `amount=500&currency=usd&customer=${customer}&payment_method=${named}&confirm=true&off_session=true`);
1039
+ const intlAtOnce = (await btOf(intl.id)).status === 'available' && (await btOf(byName.latest_charge)).status === 'available';
1040
+ // a US bank account debit settles into the available balance at once ("Test transactions settle instantly")
1041
+ const ach = await h('POST', '/v1/payment_intents', 'amount=700&currency=usd&payment_method=pm_usBankAccount_success&confirm=true');
1042
+ const achCharge = (await h('GET', `/v1/payment_intents/${String(ach.id)}`)).latest_charge;
1043
+ // and a stored us_bank_account PaymentMethod's, as an application saves one
1044
+ const bank = String((await h('POST', '/v1/payment_methods', 'type=us_bank_account&us_bank_account[account_number]=000123456789&us_bank_account[routing_number]=110000000&us_bank_account[account_holder_type]=individual&billing_details[name]=Maya')).id);
1045
+ const stored = await h('POST', '/v1/payment_intents', `amount=800&currency=usd&payment_method=${bank}&confirm=true`);
1046
+ const storedCharge = (await h('GET', `/v1/payment_intents/${String(stored.id)}`)).latest_charge;
1047
+ const achAtOnce = typeof achCharge === 'string' && (await btOf(achCharge)).status === 'available'
1048
+ && typeof storedCharge === 'string' && (await btOf(storedCharge)).status === 'available';
1049
+ // a plain transfer's credit is sent as the connected account's balance.available too
1050
+ await h('POST', '/v1/transfers', `amount=100&currency=usd&destination=${seller}`);
1051
+ const plain = (await sent()).includes(`balance.available@${seller}`);
1052
+ // a card made from payment_method_data carries what follows its success (a dispute, docs.stripe.com/testing#disputes)
1053
+ const fromData = await h('POST', '/v1/payment_intents', 'amount=300&currency=usd&payment_method_data[type]=card&payment_method_data[card][number]=4000000000000259&payment_method_data[card][exp_month]=4&payment_method_data[card][exp_year]=2030&confirm=true');
1054
+ const disputed = (await h('GET', `/v1/charges/${String(fromData.latest_charge)}`)).disputed === true;
1055
+ // a 4242 charge, three days on and before any drain records its move, reads available however it is read, an
1056
+ // expansion included
1057
+ const later = await h('POST', '/v1/payment_intents', 'amount=400&currency=usd&payment_method=pm_card_visa&confirm=true');
1058
+ now = new Date(Date.parse(now) + 3 * 86_400_000).toISOString();
1059
+ const laterRead = await h('GET', `/v1/charges/${String(later.latest_charge)}?expand[]=balance_transaction`);
1060
+ const visaAvailable = (laterRead.balance_transaction as Body | undefined)?.status === 'available';
1061
+ return po.status === 'pending' && Number(po.arrival_date) > Number(po.created) && !early.includes(`payout.paid@${seller}`)
1062
+ && arrived.includes(`payout.paid@${seller}`) && paid === 'paid' && intlAtOnce && achAtOnce && plain && visaAvailable && disputed;
1063
+ } finally {
1064
+ rx.close();
1065
+ rmSync(root, { recursive: true, force: true });
1066
+ }
1067
+ }),
1068
+
1069
+ // One event, one id: the webhook a consumer receives and the event the Events API stores carry the same id, and no two
1070
+ // events share one — Dub's queue deduplicates by event.id, and a Balance has no id of its own to tell two accounts'
1071
+ // balance.available apart. Two partners' funds coming due in one drain are two events, each retrievable by its id as
1072
+ // its account (the Stripe-Account header).
1073
+ done('stripe.events.ids', 'webhooks', 'Event ids: unique per event (two accounts\' balance.available in one drain), and a webhook\'s id is the stored event\'s', 'api', 'common', async () => {
1074
+ const root = mkdtempSync(join(tmpdir(), 'stp-evid-'));
1075
+ const rx = await webhookReceiver();
1076
+ try {
1077
+ let now = '2026-06-01T12:00:00.000Z';
1078
+ const h = fetcher(createStripeTwinFetch({ root, clock: () => now }));
1079
+ await h('POST', '/v1/webhook_endpoints', `url=${rx.url('/platform')}&enabled_events[]=*`);
1080
+ await h('POST', '/v1/webhook_endpoints', `url=${rx.url('/connect')}&enabled_events[]=balance.available&connect=true`);
1081
+ await h('POST', '/_twin/account', 'settings[payouts][schedule][interval]=manual');
1082
+ const partner = async (): Promise<string> => {
1083
+ const a = String((await h('POST', '/v1/accounts', 'type=custom&country=US&capabilities[transfers][requested]=true&business_profile[mcc]=5462&business_profile[url]=https://seller.test&tos_acceptance[date]=1767225600&tos_acceptance[ip]=203.0.113.9&individual[first_name]=Sam&individual[last_name]=Seller&settings[payouts][schedule][interval]=manual')).id);
1084
+ await h('POST', `/v1/accounts/${a}/external_accounts`, 'external_account[object]=bank_account&external_account[country]=US&external_account[currency]=usd&external_account[routing_number]=110000000&external_account[account_number]=000123456789');
1085
+ const pi = await h('POST', '/v1/payment_intents', 'amount=2000&currency=usd&payment_method=pm_card_visa&confirm=true');
1086
+ await h('POST', '/v1/transfers', `amount=1500&currency=usd&destination=${a}&source_transaction=${String(pi.latest_charge)}`);
1087
+ return a;
1088
+ };
1089
+ const [first, second] = [await partner(), await partner()];
1090
+ const customer = await h('POST', '/v1/customers', 'email=ids@twin.test');
1091
+ // two writes at once: each event takes its own number, though both are delivered before either answers
1092
+ const [c1, c2] = await Promise.all([h('POST', '/v1/customers', 'email=ids1@twin.test'), h('POST', '/v1/customers', 'email=ids2@twin.test')]);
1093
+ now = '2026-06-04T12:00:00.000Z';
1094
+ await h('POST', '/_twin/drain');
1095
+ const due = rx.got('/connect').filter((e) => e.type === 'balance.available');
1096
+ const of = (acct: string) => due.find((e) => e.account === acct);
1097
+ const [a, b] = [of(first), of(second)];
1098
+ if (!a || !b || a.id === b.id || due.length !== 2) return false;
1099
+ const [ga, gb] = [await h('GET', `/v1/events/${a.id}`, undefined, first), await h('GET', `/v1/events/${b.id}`, undefined, second)];
1100
+ const hook = rx.got('/platform').find((e) => e.type === 'customer.created');
1101
+ const stored = await h('GET', `/v1/events/${String(hook?.id)}`);
1102
+ const ids = rx.got('/platform').concat(rx.got('/connect')).map((e) => e.id);
1103
+ return ga.id === a.id && ga.account === first && ga.type === 'balance.available' && gb.id === b.id && gb.account === second
1104
+ && stored.id === hook?.id && ((stored.data as Body)?.object as Body)?.id === customer.id && new Set(ids).size === ids.length
1105
+ && await (async () => {
1106
+ const both = rx.got('/platform').filter((e) => e.type === 'customer.created' && [c1.id, c2.id].includes(((e.data as Body)?.object as Body)?.id));
1107
+ const looked = await Promise.all(both.map((e) => h('GET', `/v1/events/${String(e.id)}`)));
1108
+ return both.length === 2 && both[0]!.id !== both[1]!.id
1109
+ && looked.every((g, i) => ((g.data as Body)?.object as Body)?.id === ((both[i]!.data as Body)?.object as Body)?.id);
1110
+ })();
1111
+ } finally {
1112
+ rx.close();
1113
+ rmSync(root, { recursive: true, force: true });
1114
+ }
1115
+ }),
1116
+
572
1117
  // ── Products / Prices ─────────────────────────────────────────────────────────────
573
1118
  done('stripe.products.crud', 'catalog', 'Products: create + retrieve + update + list', 'api', 'core', () =>
574
1119
  withRoot(async (h) => {
@@ -592,6 +1137,53 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
592
1137
  return ok(g) && field(l, 'object') === 'list' && bad.status >= 400;
593
1138
  }),
594
1139
  ),
1140
+ // Plans: the Prices API "replaces the Plans API and is backwards compatible" (docs.stripe.com/api/plans/create), so a
1141
+ // plan made with the id its creator chose ("You can optionally override this ID") is a recurring price under that id:
1142
+ // read as a price, found by a lookup_key set on the price, and listed, updated and deleted as a plan. "Only one of
1143
+ // `amount` and `amount_decimal` can be set"; plan writes send plan.created / plan.updated / plan.deleted carrying the
1144
+ // plan; and "Existing subscribers aren't affected" by a deletion (docs.stripe.com/api/plans/delete).
1145
+ done('stripe.plans.crud', 'catalog', 'Plans: create (with a chosen id) + retrieve + list + update + delete, each plan the recurring price it is', 'api', 'common', () =>
1146
+ withRoot(async (h) => {
1147
+ const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Business' });
1148
+ const plan = await h({ m: 'POST', p: '/v1/plans', b: `id=price_business_monthly&amount=9000&currency=usd&interval=month&product=${id(prod)}` });
1149
+ if (!ok(plan) || field(plan, 'object') !== 'plan' || id(plan) !== 'price_business_monthly' || field(plan, 'amount') !== 9000) return false;
1150
+ const asPrice = await h({ m: 'GET', p: '/v1/prices/price_business_monthly' });
1151
+ const keyed = await h({ m: 'POST', p: '/v1/prices/price_business_monthly', b: 'lookup_key=business_monthly' });
1152
+ const found = await h({ m: 'GET', p: '/v1/prices?lookup_keys[]=business_monthly' });
1153
+ const again = await h({ m: 'POST', p: '/v1/plans', b: `id=price_business_monthly&amount=1&currency=usd&interval=month&product=${id(prod)}` });
1154
+ const renamed = await h({ m: 'POST', p: '/v1/plans/price_business_monthly', b: 'nickname=Business%20monthly' });
1155
+ const listed = await h({ m: 'GET', p: '/v1/plans' });
1156
+ // a subscriber on the plan is not affected by its deletion: the price its item names stays, inactive
1157
+ const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=plan@twin.test' });
1158
+ const sub = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&items[0][price]=price_business_monthly&payment_behavior=default_incomplete` });
1159
+ const gone = await h({ m: 'DELETE', p: '/v1/plans/price_business_monthly' });
1160
+ const after = await h({ m: 'GET', p: '/v1/plans/price_business_monthly' });
1161
+ const twice = await h({ m: 'DELETE', p: '/v1/plans/price_business_monthly' });
1162
+ const kept = await h({ m: 'GET', p: '/v1/prices/price_business_monthly' });
1163
+ const subAfter = await h({ m: 'GET', p: `/v1/subscriptions/${id(sub)}` });
1164
+ const item = ((field(subAfter, 'items') as Body)?.data as Body[] | undefined)?.[0];
1165
+ // amount_decimal is kept as the decimal given, and only one of amount and amount_decimal may be set
1166
+ const decimal = await h({ m: 'POST', p: '/v1/plans', b: `amount_decimal=1250.5&currency=usd&interval=month&product=${id(prod)}` });
1167
+ const both = await h({ m: 'POST', p: '/v1/plans', b: `amount=100&amount_decimal=100&currency=usd&interval=month&product=${id(prod)}` });
1168
+ // a decimal plan bills the decimal times the quantity, rounded after multiplying (docs.stripe.com/products-prices/manage-prices): 1250.5 × 3 = 3751.5 → 3752
1169
+ const onDecimal = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&items[0][price]=${id(decimal)}&items[0][quantity]=3&payment_behavior=default_incomplete` });
1170
+ const decimalInvoice = await h({ m: 'GET', p: `/v1/invoices/${String(field(onDecimal, 'latest_invoice'))}` });
1171
+ // plan writes send plan events carrying the plan
1172
+ const events = (((await h({ m: 'GET', p: '/v1/events?limit=100' })).body as Body).data as Body[]) ?? [];
1173
+ const planEvent = (type: string) => events.find((e) => e.type === type && ((e.data as Body).object as Body).id === 'price_business_monthly');
1174
+ const sent = ['plan.created', 'plan.updated', 'plan.deleted'].every((t) => ((planEvent(t)?.data as Body | undefined)?.object as Body | undefined)?.object === 'plan')
1175
+ && (((planEvent('plan.created')?.data as Body).object as Body).amount === 9000);
1176
+ const data = (field(found, 'data') ?? []) as Array<{ id?: string }>;
1177
+ return ok(asPrice) && field(asPrice, 'object') === 'price' && field(asPrice, 'unit_amount') === 9000
1178
+ && ok(keyed) && data.length === 1 && data[0]!.id === 'price_business_monthly'
1179
+ && again.status === 400
1180
+ && field(renamed, 'nickname') === 'Business monthly'
1181
+ && field(listed, 'object') === 'list' && ok(gone) && field(gone, 'deleted') === true && after.status === 404 && twice.status === 404
1182
+ && ok(kept) && field(kept, 'active') === false && (item?.price as Body | undefined)?.id === 'price_business_monthly'
1183
+ && ok(decimal) && field(decimal, 'amount_decimal') === '1250.5' && field(decimal, 'amount') === null
1184
+ && both.status === 400 && sent && field(decimalInvoice, 'total') === 3752;
1185
+ }),
1186
+ ),
595
1187
  // Tiered pricing: a billing_scheme=tiered price requires tiers_mode (graduated|volume) +
596
1188
  // tiers[] (each up_to + unit_amount/flat_amount); the twin normalizes the tiers, forces
597
1189
  // unit_amount null, and round-trips them. currency_options pass through (a per-currency
@@ -671,8 +1263,11 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
671
1263
  done('stripe.promotion_codes.create', 'discounts', 'PromotionCode create + activate/deactivate', 'api', 'common', () =>
672
1264
  withRoot(async (h) => {
673
1265
  const coupon = await h({ m: 'POST', p: '/v1/coupons', b: 'percent_off=10&duration=once' });
674
- const pc = await h({ m: 'POST', p: '/v1/promotion_codes', b: `coupon=${id(coupon)}&code=SAVE10` });
675
- if (!ok(pc) || field(pc, 'object') !== 'promotion_code' || field(pc, 'code') !== 'SAVE10' || field(pc, 'active') !== true || field(pc, 'coupon') !== id(coupon)) return false;
1266
+ const pc = await h({ m: 'POST', p: '/v1/promotion_codes', b: `promotion[type]=coupon&promotion[coupon]=${id(coupon)}&code=SAVE10` });
1267
+ // what it promotes is `promotion: {type: 'coupon', coupon}` in the served version, with no top-level coupon
1268
+ // (2025-09-30.clover, docs.stripe.com/changelog/clover/2025-09-30/polymorphic-coupon; stripe-version.ts renders it)
1269
+ if (!ok(pc) || field(pc, 'object') !== 'promotion_code' || field(pc, 'code') !== 'SAVE10' || field(pc, 'active') !== true) return false;
1270
+ if ((field(pc, 'promotion') as Body)?.type !== 'coupon' || (field(pc, 'promotion') as Body)?.coupon !== id(coupon) || field(pc, 'coupon') !== undefined) return false;
676
1271
  const g = await h({ m: 'GET', p: `/v1/promotion_codes/${id(pc)}` });
677
1272
  const off = await h({ m: 'POST', p: `/v1/promotion_codes/${id(pc)}`, b: 'active=false' });
678
1273
  const on = await h({ m: 'POST', p: `/v1/promotion_codes/${id(pc)}`, b: 'active=true' });
@@ -680,7 +1275,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
680
1275
  if (!ok(g) || id(g) !== id(pc) || !ok(off) || field(off, 'active') !== false || !ok(on) || field(on, 'active') !== true) return false;
681
1276
  if (((lst.body as Body).data as Body[]).length !== 1) return false;
682
1277
  const noCoupon = await h({ m: 'POST', p: '/v1/promotion_codes', b: 'code=X' });
683
- const badCoupon = await h({ m: 'POST', p: '/v1/promotion_codes', b: 'coupon=coupon_nope&code=Y' });
1278
+ const badCoupon = await h({ m: 'POST', p: '/v1/promotion_codes', b: 'promotion[type]=coupon&promotion[coupon]=coupon_nope&code=Y' });
684
1279
  return noCoupon.status === 400 && badCoupon.status === 400 && ((badCoupon.body as Body).error as Body)?.code === 'resource_missing';
685
1280
  }),
686
1281
  ),
@@ -707,7 +1302,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
707
1302
  if (!card || card.last4 !== '4242') return false;
708
1303
  // last4 FIDELITY (not just presence): a raw test PAN must surface its TRUE last4 —
709
1304
  // the '4242' check above can't prove that, since '4242' equals the synthesized
710
- // fallback. parseStripeForm coerces an all-digit card[number] to a JS number (like
1305
+ // fallback. The form reader coerces an all-digit card[number] to a JS number (like
711
1306
  // any numeric-looking form field), so cover BOTH wire shapes: the numeric-coerced
712
1307
  // bare PAN and a string PAN (spaces keep it a string through the form parser).
713
1308
  const pmNum = await h({ m: 'POST', p: '/v1/payment_methods', b: 'type=card&card[number]=5555555555554444&card[exp_month]=4&card[exp_year]=2030&card[cvc]=314' });
@@ -785,13 +1380,54 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
785
1380
  const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=sub@twin.test' });
786
1381
  const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Plan' });
787
1382
  const price = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=1500&currency=usd&recurring[interval]=month&product=${id(prod)}` });
788
- const sub = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&items[0][price]=${id(price)}` });
1383
+ // the customer's card pays the first invoice, so the subscription starts active
1384
+ const card = await h({ m: 'POST', p: '/v1/payment_methods/pm_card_visa/attach', b: `customer=${id(cust)}` });
1385
+ const sub = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&items[0][price]=${id(price)}&default_payment_method=${id(card)}` });
789
1386
  if (!ok(sub) || field(sub, 'status') !== 'active') return false;
790
1387
  const g = await h({ m: 'GET', p: `/v1/subscriptions/${id(sub)}` });
791
1388
  const cancel = await h({ m: 'DELETE', p: `/v1/subscriptions/${id(sub)}` });
792
1389
  return ok(g) && ok(cancel) && field(cancel, 'status') === 'canceled';
793
1390
  }),
794
1391
  ),
1392
+ // An update's `items` changes the subscription's items (docs.stripe.com/api/subscriptions/update): an entry with an
1393
+ // `id` swaps that item's price ("`quantity` is set to 1 unless a `quantity` parameter is provided") or its quantity, or
1394
+ // with `deleted` removes it; one without adds an item, from a price or from `price_data`, which generates a Price
1395
+ // inline, archived (active=false; docs.stripe.com/products-prices/manage-prices). A refused entry refuses the whole
1396
+ // update, leaving the items as they were. Dub's trial switch to Advanced replaced the price of its one item.
1397
+ done('stripe.subscriptions.update_items', 'subscriptions', 'Subscription update items: swap a price, change a quantity, add (price or price_data), delete; all or nothing', 'api', 'core', () =>
1398
+ withRoot(async (h) => {
1399
+ const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=items@twin.test' });
1400
+ const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Pro' });
1401
+ const pro = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=1000&currency=usd&recurring[interval]=month&product=${id(prod)}` });
1402
+ const advanced = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=3000&currency=usd&recurring[interval]=month&product=${id(prod)}` });
1403
+ const sub = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&items[0][price]=${id(pro)}&items[0][quantity]=3&payment_behavior=default_incomplete` });
1404
+ const items = (r: StripeResponse): Body[] => ((field(r, 'items') as Body)?.data as Body[]) ?? [];
1405
+ const si = String(items(sub)[0]?.id);
1406
+ const upd = (b: string) => h({ m: 'POST', p: `/v1/subscriptions/${id(sub)}`, b });
1407
+ const swapped = await upd(`items[0][id]=${si}&items[0][price]=${id(advanced)}`);
1408
+ const s0 = items(swapped);
1409
+ if (s0.length !== 1 || s0[0]?.id !== si || (s0[0]?.price as Body)?.id !== id(advanced) || s0[0]?.quantity !== 1) return false;
1410
+ const more = items(await upd(`items[0][id]=${si}&items[0][quantity]=5`));
1411
+ if (more.length !== 1 || more[0]?.quantity !== 5 || (more[0]?.price as Body)?.id !== id(advanced)) return false;
1412
+ // naming the item's own price is no change of price: the quantity stays
1413
+ const same = items(await upd(`items[0][id]=${si}&items[0][price]=${id(advanced)}`));
1414
+ if (same[0]?.quantity !== 5) return false;
1415
+ const added = items(await upd(`items[0][price_data][currency]=usd&items[0][price_data][product]=${id(prod)}&items[0][price_data][unit_amount]=250&items[0][price_data][recurring][interval]=month`));
1416
+ const inline = added.find((i) => i.id !== si)?.price as Body | undefined;
1417
+ if (added.length !== 2 || inline?.unit_amount !== 250 || inline?.active !== false || inline?.product !== id(prod)) return false;
1418
+ // one bad entry refuses the update: the delete beside it is not applied
1419
+ const refused = await upd(`items[0][id]=${si}&items[0][deleted]=true&items[1][price]=price_nope`);
1420
+ const kept = items(await h({ m: 'GET', p: `/v1/subscriptions/${id(sub)}` }));
1421
+ if (refused.status !== 400 || kept.length !== 2 || !kept.some((i) => i.id === si)) return false;
1422
+ const removed = items(await upd(`items[0][id]=${si}&items[0][deleted]=true`));
1423
+ const listed = await h({ m: 'GET', p: `/v1/subscription_items?subscription=${id(sub)}` });
1424
+ // a price_data amount that is not a whole number of cents is kept as the decimal, never truncated
1425
+ const decimal = items(await upd(`items[0][price_data][currency]=usd&items[0][price_data][product]=${id(prod)}&items[0][price_data][unit_amount_decimal]=12.5&items[0][price_data][recurring][interval]=month`));
1426
+ const decimalPrice = decimal.map((i) => i.price as Body).find((p) => p?.unit_amount_decimal === '12.5');
1427
+ return removed.length === 1 && removed[0]?.id !== si && ((listed.body as Body).data as Body[]).length === 1
1428
+ && decimalPrice !== undefined && decimalPrice.unit_amount === null;
1429
+ }),
1430
+ ),
795
1431
  done('stripe.subscriptions.list', 'subscriptions', 'Subscription list + filter by customer', 'api', 'core', () =>
796
1432
  withRoot(async (h) => {
797
1433
  const r = await h({ m: 'GET', p: '/v1/subscriptions' });
@@ -810,13 +1446,218 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
810
1446
  const sub = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&items[0][price]=${id(price)}&trial_period_days=14` });
811
1447
  if (!ok(sub) || field(sub, 'status') !== 'trialing' || typeof field(sub, 'trial_end') !== 'number') return false;
812
1448
  // upcoming invoice WITH a proration_date splits the period (credit + charge proration lines).
813
- const up = await h({ m: 'GET', p: `/v1/invoices/upcoming?subscription=${id(sub)}&proration_date=100` });
814
- if (!ok(up) || field(up, 'object') !== 'invoice' || field(up, 'id') !== null) return false;
1449
+ const up = await h({ m: 'POST', p: '/v1/invoices/create_preview', b: `subscription=${id(sub)}&subscription_details[proration_date]=100` });
1450
+ // a preview's id is prefixed `upcoming_in` ("For preview invoices created using the create preview endpoint, this id
1451
+ // will be prefixed with `upcoming_in`", served spec invoice.id), and a line's proration flag is on its parent's
1452
+ // details in the served version (basil, docs.stripe.com/changelog/basil; stripe-version.ts line_item, 24e757f60)
1453
+ if (!ok(up) || field(up, 'object') !== 'invoice' || !String(field(up, 'id')).startsWith('upcoming_in_')) return false;
815
1454
  const lines = (field(up, 'lines') as Body).data as Body[];
816
- const prorations = lines.filter((l) => l.proration === true);
1455
+ const prorationOf = (l: Body): unknown => ((l.parent as Body | undefined)?.subscription_item_details as Body | undefined)?.proration ?? ((l.parent as Body | undefined)?.invoice_item_details as Body | undefined)?.proration;
1456
+ if (lines.some((l) => 'proration' in l)) return false;
1457
+ const prorations = lines.filter((l) => prorationOf(l) === true);
817
1458
  return prorations.length === 2 && prorations.some((l) => Number(l.amount) < 0) && prorations.some((l) => Number(l.amount) > 0);
818
1459
  }),
819
1460
  ),
1461
+ // Renewal over time: at the period's end a subscription_cycle invoice is drafted and, an hour on, charged to the
1462
+ // default payment method; a card that declines when charged leaves it open and the subscription past_due, and
1463
+ // paying it makes the subscription active (docs.stripe.com/billing/subscriptions/overview#payment-status).
1464
+ done('stripe.subscriptions.renewal', 'subscriptions', 'Subscriptions renew at each period end; a declined renewal is past_due until its invoice is paid', 'api', 'core', async () => {
1465
+ const root = mkdtempSync(join(tmpdir(), 'stp-renew-'));
1466
+ try {
1467
+ // February's reads come two hours into the renewed period: its invoice is drafted at the period's end and charged
1468
+ // "an hour on" (above; cab4389eb stamps each catch-up write at that moment), so a read at the period's very end
1469
+ // sees the draft, not the paid invoice this claim is about
1470
+ const h = (s: Step & { month?: number }) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: `2026-${String(s.month ?? 1).padStart(2, '0')}-10T${(s.month ?? 1) > 1 ? '02' : '00'}:00:00Z` });
1471
+ const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Box' });
1472
+ const price = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=3500&currency=usd&recurring[interval]=month&product=${id(prod)}` });
1473
+ const payer = await h({ m: 'POST', p: '/v1/customers', b: 'email=renews@twin.test&payment_method=pm_card_visa&invoice_settings[default_payment_method]=pm_card_visa' });
1474
+ const good = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(payer)}&items[0][price]=${id(price)}&default_payment_method=pm_card_visa` });
1475
+ const failing = await h({ m: 'POST', p: '/v1/customers', b: 'email=declines@twin.test' });
1476
+ const bad = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(failing)}&items[0][price]=${id(price)}&default_payment_method=pm_card_chargeCustomerFail&trial_period_days=7` });
1477
+ const renewed = await h({ m: 'GET', p: `/v1/invoices?subscription=${id(good)}`, month: 2 });
1478
+ const cycle = ((renewed.body as Body).data as Body[]).find((i) => i.billing_reason === 'subscription_cycle');
1479
+ const pastDue = await h({ m: 'GET', p: `/v1/subscriptions/${id(bad)}`, month: 2 });
1480
+ const open = (((await h({ m: 'GET', p: `/v1/invoices?subscription=${id(bad)}&status=open`, month: 2 })).body as Body).data as Body[])[0];
1481
+ const paid = await h({ m: 'POST', p: `/v1/invoices/${String(open?.id)}/pay`, b: 'payment_method=pm_card_visa', month: 2 });
1482
+ const back = await h({ m: 'GET', p: `/v1/subscriptions/${id(bad)}`, month: 2 });
1483
+ // made when Stripe would have made it, not when the February request caught it up
1484
+ const stamped = cycle?.created === cycle?.period_start && (cycle?.status_transitions as Body | undefined)?.paid_at === Number(cycle?.period_start) + 3600;
1485
+ return stamped && cycle?.status === 'paid' && cycle.amount_paid === 3500 && field(pastDue, 'status') === 'past_due'
1486
+ && open?.attempt_count === 1 && field(paid, 'status') === 'paid' && field(back, 'status') === 'active';
1487
+ } finally {
1488
+ rmSync(root, { recursive: true, force: true });
1489
+ }
1490
+ }),
1491
+ // A `once` coupon discounts one invoice: the one the subscription finalizes next after it was applied, which records it,
1492
+ // and is then removed from the subscription's discounts (docs.stripe.com/billing/subscriptions/coupons#coupon-duration),
1493
+ // however that invoice is finalized: by time, or through the Invoices API.
1494
+ done('stripe.subscriptions.once_coupon', 'subscriptions', 'A once coupon discounts the next invoice finalized after it is applied, then is removed from the subscription', 'api', 'common', async () => {
1495
+ const root = mkdtempSync(join(tmpdir(), 'stp-once-'));
1496
+ try {
1497
+ const h = (s: Step & { at?: string }) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: s.at ?? '2026-03-01T00:00:00Z' });
1498
+ const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Box' });
1499
+ const price = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=9000&currency=usd&recurring[interval]=month&product=${id(prod)}` });
1500
+ const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=once%40twin.test&payment_method=pm_card_visa&invoice_settings[default_payment_method]=pm_card_visa' });
1501
+ const coupon = await h({ m: 'POST', p: '/v1/coupons', b: 'id=HALF&percent_off=50&duration=once' });
1502
+ const invoices = async (sub: StripeResponse, at: string) => ((((await h({ m: 'GET', p: `/v1/invoices?subscription=${id(sub)}`, at })).body as Body).data as Body[]) ?? []);
1503
+ const discounts = (r: StripeResponse) => ((field(r, 'discounts') as unknown[] | undefined) ?? []).length;
1504
+ // added after April's renewal was drafted (00:00) and before it is collected (01:00): April is full, May is halved
1505
+ const late = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&items[0][price]=${id(price)}` });
1506
+ await h({ m: 'GET', p: `/v1/subscriptions/${id(late)}`, at: '2026-04-01T00:10:00Z' });
1507
+ await h({ m: 'POST', p: `/v1/subscriptions/${id(late)}`, b: `discounts[0][coupon]=${id(coupon)}`, at: '2026-04-01T00:20:00Z' });
1508
+ const april = (await invoices(late, '2026-04-01T02:00:00Z')).find((i) => i.period_start === Date.parse('2026-04-01T00:00:00Z') / 1000);
1509
+ const stillThere = await h({ m: 'GET', p: `/v1/subscriptions/${id(late)}`, at: '2026-04-01T02:00:00Z' });
1510
+ const may = (await invoices(late, '2026-05-01T02:00:00Z')).find((i) => i.period_start === Date.parse('2026-05-01T00:00:00Z') / 1000);
1511
+ const gone = await h({ m: 'GET', p: `/v1/subscriptions/${id(late)}`, at: '2026-05-01T02:00:00Z' });
1512
+ if (april?.total !== 9000 || discounts(stillThere) !== 1 || may?.total !== 4500 || discounts(gone) !== 0) return false;
1513
+ // the renewal's draft finalized and paid through the Invoices API spends it too: June is full again
1514
+ const other = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&items[0][price]=${id(price)}` });
1515
+ await h({ m: 'POST', p: `/v1/subscriptions/${id(other)}`, b: `discounts[0][coupon]=${id(coupon)}`, at: '2026-03-15T00:00:00Z' });
1516
+ const draft = (await invoices(other, '2026-04-01T00:10:00Z')).find((i) => i.status === 'draft');
1517
+ await h({ m: 'POST', p: `/v1/invoices/${String(draft?.id)}/finalize`, at: '2026-04-01T00:20:00Z' });
1518
+ const afterFinalize = await h({ m: 'GET', p: `/v1/subscriptions/${id(other)}`, at: '2026-04-01T00:30:00Z' });
1519
+ const mayOther = (await invoices(other, '2026-05-01T02:00:00Z')).find((i) => i.period_start === Date.parse('2026-05-01T00:00:00Z') / 1000);
1520
+ return draft?.total === 4500 && discounts(afterFinalize) === 0 && mayOther?.total === 9000;
1521
+ } finally {
1522
+ rmSync(root, { recursive: true, force: true });
1523
+ }
1524
+ }),
1525
+ // Ending a trial early: `trial_end=now` on a trialing subscription starts a paid period now, invoices it and charges it
1526
+ // at once (active; a decline leaves it past_due, error_if_incomplete refuses with 402 and changes nothing); a future
1527
+ // timestamp moves the trial's end and the period with it; a past one is refused (semantics/subscriptions.ts
1528
+ // applyTrialEnd; docs.stripe.com/api/subscriptions/update#update_subscription-trial_end,
1529
+ // docs.stripe.com/billing/subscriptions/upgrade-downgrade#immediate-payment). Read in basil's shape, as Dub reads it.
1530
+ done('stripe.subscriptions.trial_end_now', 'subscriptions', 'Subscription update trial_end: `now` ends the trial and charges a new period at once; a timestamp moves it; a past one is refused', 'api', 'core', async () => {
1531
+ const root = mkdtempSync(join(tmpdir(), 'stp-trialend-'));
1532
+ try {
1533
+ const h = (s: Step & { day?: number }) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: `2026-03-${String(s.day ?? 1).padStart(2, '0')}T00:00:00Z` });
1534
+ const day = (d: number) => Date.parse(`2026-03-${String(d).padStart(2, '0')}T00:00:00Z`) / 1000;
1535
+ const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Business' });
1536
+ const price = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=9000&currency=usd&recurring[interval]=month&product=${id(prod)}` });
1537
+ const trialing = async (email: string, pm: string) => {
1538
+ const c = await h({ m: 'POST', p: '/v1/customers', b: `email=${email}&payment_method=${pm}&invoice_settings[default_payment_method]=${pm}` });
1539
+ return h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(c)}&items[0][price]=${id(price)}&trial_period_days=14` });
1540
+ };
1541
+ const period = (r: StripeResponse) => ((field(r, 'items') as Body | undefined)?.data as Body[] | undefined)?.[0] ?? {};
1542
+ const sub = await trialing('trial%40twin.test', 'pm_card_visa');
1543
+ // a trial's end is its billing anchor
1544
+ if (field(sub, 'billing_cycle_anchor') !== field(sub, 'trial_end')) return false;
1545
+ // refused before anything changes: a past timestamp, a value that is no timestamp
1546
+ const past = await h({ m: 'POST', p: `/v1/subscriptions/${id(sub)}`, b: 'trial_end=1700000000', day: 3 });
1547
+ const junk = await h({ m: 'POST', p: `/v1/subscriptions/${id(sub)}`, b: 'trial_end=tomorrow', day: 3 });
1548
+ const pastError = (past.body as { error?: Body }).error;
1549
+ if (past.status !== 400 || pastError?.param !== 'trial_end' || pastError?.message !== 'Invalid timestamp: must be an integer Unix timestamp in the future.' || junk.status !== 400) return false;
1550
+ // a future timestamp moves the trial's end, the period's end and the billing anchor
1551
+ const moved = await h({ m: 'POST', p: `/v1/subscriptions/${id(sub)}`, b: `trial_end=${day(20)}`, day: 3 });
1552
+ if (field(moved, 'status') !== 'trialing' || field(moved, 'trial_end') !== day(20) || period(moved).current_period_end !== day(20) || field(moved, 'billing_cycle_anchor') !== day(20)) return false;
1553
+ // `now`: the trial ends, a month's period starts now and its invoice is paid by the default payment method
1554
+ const ended = await h({ m: 'POST', p: `/v1/subscriptions/${id(sub)}`, b: 'trial_end=now&cancel_at_period_end=false', day: 5 });
1555
+ const inv = await h({ m: 'GET', p: `/v1/invoices/${String(field(ended, 'latest_invoice'))}`, day: 5 });
1556
+ const invoices = (((await h({ m: 'GET', p: `/v1/invoices?customer=${String(field(sub, 'customer'))}`, day: 5 })).body as Body).data as Body[]) ?? [];
1557
+ const events = ((((await h({ m: 'GET', p: '/v1/events?limit=100', day: 5 })).body as Body).data as Body[]) ?? []).filter((e) => e.created === day(5)).map((e) => e.type);
1558
+ const endedOk = ok(ended) && field(ended, 'status') === 'active' && field(ended, 'trial_end') === day(5) && field(ended, 'billing_cycle_anchor') === day(5)
1559
+ && period(ended).current_period_start === day(5) && period(ended).current_period_end === Date.parse('2026-04-05T00:00:00Z') / 1000;
1560
+ const invoiceOk = field(inv, 'status') === 'paid' && field(inv, 'billing_reason') === 'subscription_update' && field(inv, 'amount_paid') === 9000
1561
+ && field(inv, 'period_start') === day(5) && invoices.some((i) => i.id === id(inv));
1562
+ if (!endedOk || !invoiceOk || !['customer.subscription.updated', 'invoice.created', 'invoice.paid'].every((t) => events.includes(t))) return false;
1563
+ // a cancel scheduled during the trial follows the new period; a `once` coupon added during the trial, or with the
1564
+ // update itself, takes the invoice ending it and is then spent
1565
+ const once = await h({ m: 'POST', p: '/v1/coupons', b: 'id=HALFOFF&percent_off=50&duration=once', day: 2 });
1566
+ const leaving = await trialing('leaving%40twin.test', 'pm_card_visa');
1567
+ await h({ m: 'POST', p: `/v1/subscriptions/${id(leaving)}`, b: `cancel_at_period_end=true&discounts[0][coupon]=${id(once)}`, day: 2 });
1568
+ const early = await h({ m: 'POST', p: `/v1/subscriptions/${id(leaving)}`, b: 'trial_end=now', day: 5 });
1569
+ const halved = await h({ m: 'GET', p: `/v1/invoices/${String(field(early, 'latest_invoice'))}`, day: 5 });
1570
+ const sameCall = await trialing('same%40twin.test', 'pm_card_visa');
1571
+ const withCoupon = await h({ m: 'POST', p: `/v1/subscriptions/${id(sameCall)}`, b: `trial_end=now&discounts[0][coupon]=${id(once)}`, day: 5 });
1572
+ const halvedToo = await h({ m: 'GET', p: `/v1/invoices/${String(field(withCoupon, 'latest_invoice'))}`, day: 5 });
1573
+ const spent = (r: StripeResponse) => ((field(r, 'discounts') as Body[] | undefined) ?? []).length === 0;
1574
+ if (field(early, 'cancel_at') !== period(early).current_period_end || field(early, 'cancel_at_period_end') !== true || field(halved, 'total') !== 4500
1575
+ || !spent(early) || field(halvedToo, 'total') !== 4500 || !spent(withCoupon)) return false;
1576
+ // what the update leaves bills: a quantity of 0 bills nothing (paid, active, with no card); an unknown item is refused as such
1577
+ const nobody = await h({ m: 'POST', p: '/v1/customers', b: 'email=nobody%40twin.test' });
1578
+ const idle = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(nobody)}&items[0][price]=${id(price)}&trial_period_days=14` });
1579
+ const zero = await h({ m: 'POST', p: `/v1/subscriptions/${id(idle)}`, b: `trial_end=now&payment_behavior=error_if_incomplete&items[0][id]=${String(period(idle).id)}&items[0][quantity]=0`, day: 5 });
1580
+ const zeroInvoice = await h({ m: 'GET', p: `/v1/invoices/${String(field(zero, 'latest_invoice'))}`, day: 5 });
1581
+ if (field(zero, 'status') !== 'active' || field(zeroInvoice, 'total') !== 0 || field(zeroInvoice, 'status') !== 'paid') return false;
1582
+ const idle2 = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(nobody)}&items[0][price]=${id(price)}&trial_period_days=14` });
1583
+ const decimal = await h({ m: 'POST', p: `/v1/subscriptions/${id(idle2)}`, b: `trial_end=now&payment_behavior=error_if_incomplete&items[0][id]=${String(period(idle2).id)}&items[0][deleted]=true&items[1][price_data][currency]=usd&items[1][price_data][product]=${id(prod)}&items[1][price_data][unit_amount_decimal]=5000&items[1][price_data][recurring][interval]=month`, day: 5 });
1584
+ const unknown = await h({ m: 'POST', p: `/v1/subscriptions/${id(idle2)}`, b: 'trial_end=now&payment_behavior=error_if_incomplete&items[0][id]=si_nope&items[0][quantity]=2', day: 5 });
1585
+ const idleAfter = await h({ m: 'GET', p: `/v1/subscriptions/${id(idle2)}`, day: 5 });
1586
+ if (decimal.status !== 400 || !String(((unknown.body as { error?: Body }).error)?.message).includes("No such subscription_item: 'si_nope'")
1587
+ || field(idleAfter, 'status') !== 'trialing' || period(idleAfter).id !== period(idle2).id) return false;
1588
+ // a trial made through the API opens its $0 invoice at once, which takes and spends a once coupon, as Checkout's does
1589
+ const withOnce = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(nobody)}&items[0][price]=${id(price)}&trial_period_days=14&discounts[0][coupon]=${id(once)}` });
1590
+ const trialInvoice = await h({ m: 'GET', p: `/v1/invoices/${String(field(withOnce, 'latest_invoice'))}` });
1591
+ if (field(trialInvoice, 'total') !== 0 || field(trialInvoice, 'status') !== 'paid' || field(trialInvoice, 'billing_reason') !== 'subscription_create'
1592
+ || ((field(trialInvoice, 'discounts') as unknown[] | undefined) ?? []).length !== 1 || !spent(withOnce)) return false;
1593
+ // and the subscription names it from its own create: the customer.subscription.created event carries it
1594
+ const createdEvent = ((((await h({ m: 'GET', p: '/v1/events?type=customer.subscription.created&limit=100' })).body as Body).data as Body[]) ?? [])
1595
+ .find((e) => ((e.data as Body).object as Body).id === id(withOnce));
1596
+ if (((createdEvent?.data as Body | undefined)?.object as Body | undefined)?.latest_invoice !== field(withOnce, 'latest_invoice')) return false;
1597
+ // a card that declines when charged: the change stands, the invoice is open and the subscription past_due
1598
+ const declining = await trialing('declines%40twin.test', 'pm_card_chargeCustomerFail');
1599
+ const invoicesOf = async (s: StripeResponse) => ((((await h({ m: 'GET', p: `/v1/invoices?customer=${String(field(s, 'customer'))}`, day: 5 })).body as Body).data as Body[]) ?? []).length;
1600
+ const before = await invoicesOf(declining);
1601
+ const strict = await h({ m: 'POST', p: `/v1/subscriptions/${id(declining)}`, b: 'trial_end=now&payment_behavior=error_if_incomplete', day: 5 });
1602
+ const still = await h({ m: 'GET', p: `/v1/subscriptions/${id(declining)}`, day: 5 });
1603
+ if (await invoicesOf(declining) !== before) return false;
1604
+ // error_if_incomplete judges the period as the update leaves it: $0 items swapped for a paid price with no payment
1605
+ // method to charge is refused as the create refuses it (semantics/subscriptions.ts firstPaymentRefused)
1606
+ const freeProd = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=0&currency=usd&recurring[interval]=month&product=${id(prod)}` });
1607
+ const bare = await h({ m: 'POST', p: '/v1/customers', b: 'email=bare%40twin.test' });
1608
+ const free = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(bare)}&items[0][price]=${id(freeProd)}&trial_period_days=14` });
1609
+ const freeItem = String(period(free).id);
1610
+ const upgrade = await h({ m: 'POST', p: `/v1/subscriptions/${id(free)}`, b: `trial_end=now&payment_behavior=error_if_incomplete&items[0][id]=${freeItem}&items[0][price]=${id(price)}`, day: 5 });
1611
+ const unchanged = await h({ m: 'GET', p: `/v1/subscriptions/${id(free)}`, day: 5 });
1612
+ if (upgrade.status !== 400 || field(unchanged, 'status') !== 'trialing' || (period(unchanged).price as Body | undefined)?.id !== id(freeProd)) return false;
1613
+ // default_incomplete: the period is invoiced and left open, no payment attempted
1614
+ const quiet = await trialing('quiet%40twin.test', 'pm_card_visa');
1615
+ const deferred = await h({ m: 'POST', p: `/v1/subscriptions/${id(quiet)}`, b: 'trial_end=now&payment_behavior=default_incomplete', day: 5 });
1616
+ const unpaid = await h({ m: 'GET', p: `/v1/invoices/${String(field(deferred, 'latest_invoice'))}`, day: 5 });
1617
+ if (field(deferred, 'status') !== 'past_due' || field(unpaid, 'status') !== 'open' || field(unpaid, 'attempted') !== false || field(unpaid, 'amount_paid') !== 0) return false;
1618
+ const lenient = await h({ m: 'POST', p: `/v1/subscriptions/${id(declining)}`, b: 'trial_end=now', day: 5 });
1619
+ const open = await h({ m: 'GET', p: `/v1/invoices/${String(field(lenient, 'latest_invoice'))}`, day: 5 });
1620
+ return strict.status === 402 && field(still, 'status') === 'trialing' && field(still, 'latest_invoice') === field(declining, 'latest_invoice')
1621
+ && field(lenient, 'status') === 'past_due' && field(open, 'status') === 'open' && field(open, 'amount_remaining') === 9000;
1622
+ } finally {
1623
+ rmSync(root, { recursive: true, force: true });
1624
+ }
1625
+ }),
1626
+ // Automatic reconciliation: a bank transfer arriving in the customer's cash balance pays their open invoice that takes
1627
+ // transfers (docs.stripe.com/payments/customer-balance/reconciliation).
1628
+ done('stripe.customers.cash_balance_reconciliation', 'customers', 'Cash balance funding pays an open bank-transfer invoice (automatic reconciliation)', 'api', 'common', () =>
1629
+ withRoot(async (h) => {
1630
+ const cust = await h({ m: 'POST', p: '/v1/customers', b: 'name=Cowork&email=ap@cowork.test' });
1631
+ const inv = await h({ m: 'POST', p: '/v1/invoices', b: `customer=${id(cust)}&collection_method=send_invoice&days_until_due=30&payment_settings[payment_method_types][0]=customer_balance` });
1632
+ await h({ m: 'POST', p: '/v1/invoiceitems', b: `customer=${id(cust)}&invoice=${id(inv)}&amount=24000&currency=usd` });
1633
+ await h({ m: 'POST', p: `/v1/invoices/${id(inv)}/finalize` });
1634
+ await h({ m: 'POST', p: `/v1/test_helpers/customers/${id(cust)}/fund_cash_balance`, b: 'amount=24000&currency=usd' });
1635
+ const paid = await h({ m: 'GET', p: `/v1/invoices/${id(inv)}` });
1636
+ const bal = await h({ m: 'GET', p: `/v1/customers/${id(cust)}/cash_balance` });
1637
+ return field(paid, 'status') === 'paid' && field(paid, 'amount_paid') === 24000 && ((field(bal, 'available') as Body | null)?.usd ?? 0) === 0;
1638
+ }),
1639
+ ),
1640
+ // A subscription set to cancel at its period's end is canceled when the period ends, with no renewal invoice
1641
+ // (docs.stripe.com/billing/subscriptions/cancel#cancel-at-end-of-cycle).
1642
+ done('stripe.subscriptions.cancel_at_period_end', 'subscriptions', 'cancel_at_period_end cancels the subscription when its period ends', 'api', 'common', async () => {
1643
+ const root = mkdtempSync(join(tmpdir(), 'stp-cape-'));
1644
+ try {
1645
+ // February's reads come two hours into the renewed period: its invoice is drafted at the period's end and charged
1646
+ // "an hour on" (above; cab4389eb stamps each catch-up write at that moment), so a read at the period's very end
1647
+ // sees the draft, not the paid invoice this claim is about
1648
+ const h = (s: Step & { month?: number }) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: `2026-${String(s.month ?? 1).padStart(2, '0')}-10T${(s.month ?? 1) > 1 ? '02' : '00'}:00:00Z` });
1649
+ const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Box' });
1650
+ const price = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=3500&currency=usd&recurring[interval]=month&product=${id(prod)}` });
1651
+ const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=leaving@twin.test' });
1652
+ const sub = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&items[0][price]=${id(price)}&default_payment_method=pm_card_visa` });
1653
+ const set = await h({ m: 'POST', p: `/v1/subscriptions/${id(sub)}`, b: 'cancel_at_period_end=true' });
1654
+ const after = await h({ m: 'GET', p: `/v1/subscriptions/${id(sub)}`, month: 3 });
1655
+ const invoices = ((await h({ m: 'GET', p: `/v1/invoices?subscription=${id(sub)}`, month: 3 })).body as Body).data as Body[];
1656
+ return typeof field(set, 'cancel_at') === 'number' && field(after, 'status') === 'canceled' && typeof field(after, 'ended_at') === 'number' && invoices.length === 1;
1657
+ } finally {
1658
+ rmSync(root, { recursive: true, force: true });
1659
+ }
1660
+ }),
820
1661
  // Pause/resume: set pause_collection[behavior]=void → the sub records pause_collection (and
821
1662
  // the customer.subscription.paused event fires); clearing it (pause_collection="") resumes
822
1663
  // (pause_collection null). cancel_at_period_end toggles a scheduled cancel and reactivation.
@@ -861,29 +1702,30 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
861
1702
  ok(rel) && field(rel, 'status') === 'released' && relAgain.status === 400 && noTarget.status === 400;
862
1703
  }),
863
1704
  ),
864
- // Metered/usage billing: a Billing Meter defines event_name + aggregation; usage is reported
865
- // via meter_events (and the legacy subscription_items usage_records). The twin requires
866
- // display_name/event_name/default_aggregation[formula], lists/retrieves meters, deactivate
867
- // transitions active→inactive, and usage record summaries total the reported quantity.
868
- done('stripe.billing.metered_usage', 'subscriptions', 'Usage / metered billing (meters + usage records)', 'api', 'common', () =>
1705
+ // Metered/usage billing: a Billing Meter defines event_name + aggregation; usage is reported as meter events and read
1706
+ // back as the meter's event summaries (GET /v1/billing/meters/:id/event_summaries, docs.stripe.com/api/billing/meter-event-summary).
1707
+ // The twin requires display_name/event_name/default_aggregation, lists/retrieves meters, deactivate transitions
1708
+ // active→inactive, and a summary totals the customer's reported values. The legacy per-item usage records this claim
1709
+ // used to drive "left the API with basil" and were removed from the twin (5decb1d77; docs.stripe.com/changelog/basil).
1710
+ done('stripe.billing.metered_usage', 'subscriptions', 'Usage / metered billing (meters + meter events and their summaries)', 'api', 'common', () =>
869
1711
  withRoot(async (h) => {
870
- const meter = await h({ m: 'POST', p: '/v1/billing/meters', b: 'display_name=API calls&event_name=api_request&default_aggregation[formula]=sum&value_settings[event_payload_key]=value' });
1712
+ const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=metered@twin.test' });
1713
+ const meter = await h({ m: 'POST', p: '/v1/billing/meters', b: 'display_name=API calls&event_name=api_request&default_aggregation[formula]=sum&value_settings[event_payload_key]=value&customer_mapping[event_payload_key]=stripe_customer_id&customer_mapping[type]=by_id' });
871
1714
  if (!ok(meter) || field(meter, 'object') !== 'billing.meter' || field(meter, 'status') !== 'active' || field(meter, 'event_name') !== 'api_request') return false;
872
1715
  const noAgg = await h({ m: 'POST', p: '/v1/billing/meters', b: 'display_name=X&event_name=y' });
873
1716
  if (noAgg.status !== 400) return false;
874
- const evt = await h({ m: 'POST', p: '/v1/billing/meter_events', b: 'event_name=api_request&payload[value]=10&payload[stripe_customer_id]=cus_x' });
1717
+ // two events (7 + 3) so the summary proves real aggregation (a single-value / canned impl would not total 10)
1718
+ const evt = await h({ m: 'POST', p: '/v1/billing/meter_events', b: `event_name=api_request&payload[value]=7&payload[stripe_customer_id]=${id(cust)}` });
875
1719
  if (!ok(evt) || field(evt, 'object') !== 'billing.meter_event' || field(evt, 'event_name') !== 'api_request') return false;
1720
+ const evt2 = await h({ m: 'POST', p: '/v1/billing/meter_events', b: `event_name=api_request&payload[value]=3&payload[stripe_customer_id]=${id(cust)}` });
1721
+ // a window on whole days, as Stripe asks (its bounds are refused off minute boundaries), around the events just sent
1722
+ const now = Math.floor(Date.now() / 86_400_000) * 86_400;
1723
+ const summ = await h({ m: 'GET', p: `/v1/billing/meters/${id(meter)}/event_summaries?customer=${id(cust)}&start_time=${now - 86_400}&end_time=${now + 86_400}` });
1724
+ const sd = (summ.body as Body).data as Body[];
876
1725
  const g = await h({ m: 'GET', p: `/v1/billing/meters/${id(meter)}` });
877
1726
  const deact = await h({ m: 'POST', p: `/v1/billing/meters/${id(meter)}/deactivate` });
878
- // legacy per-item usage records SUM in the summary — post two (7 + 3) so the assertion
879
- // proves real aggregation (a single-value / canned impl would not total to 10).
880
- const ur = await h({ m: 'POST', p: '/v1/subscription_items/si_twin/usage_records', b: 'quantity=7' });
881
- const ur2 = await h({ m: 'POST', p: '/v1/subscription_items/si_twin/usage_records', b: 'quantity=3' });
882
- const noQty = await h({ m: 'POST', p: '/v1/subscription_items/si_twin/usage_records' });
883
- const summ = await h({ m: 'GET', p: '/v1/subscription_items/si_twin/usage_record_summaries' });
884
- const sd = (summ.body as Body).data as Body[];
885
- return ok(g) && ok(deact) && field(deact, 'status') === 'inactive' && ok(ur) && ok(ur2) && noQty.status === 400 &&
886
- sd.length === 1 && sd[0]!.total_usage === 10;
1727
+ return ok(evt2) && ok(summ) && sd.length === 1 && sd[0]!.object === 'billing.meter_event_summary' && sd[0]!.aggregated_value === 10
1728
+ && ok(g) && ok(deact) && field(deact, 'status') === 'inactive';
887
1729
  }),
888
1730
  ),
889
1731
  // Subscription discounts: a `coupon` on create attaches a discount (validated to exist),
@@ -894,12 +1736,12 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
894
1736
  withRoot(async (h) => {
895
1737
  const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=disc@twin.test' });
896
1738
  const coupon = await h({ m: 'POST', p: '/v1/coupons', b: 'percent_off=25&duration=forever' });
897
- const sub = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&coupon=${id(coupon)}` });
1739
+ const sub = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&discounts[0][coupon]=${id(coupon)}` });
898
1740
  if (!ok(sub)) return false;
899
1741
  const discs = (field(sub, 'discounts') as Body[]) ?? [];
900
1742
  if (discs.length !== 1 || discs[0]!.object !== 'discount' || (discs[0]!.coupon as Body)?.id !== id(coupon)) return false;
901
1743
  // unknown coupon on a fresh sub 400s.
902
- const bad = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&coupon=coupon_nope` });
1744
+ const bad = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&discounts[0][coupon]=coupon_nope` });
903
1745
  if (bad.status !== 400) return false;
904
1746
  // remove the discount.
905
1747
  const del = await h({ m: 'DELETE', p: `/v1/subscriptions/${id(sub)}/discount` });
@@ -945,12 +1787,36 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
945
1787
  return ok(payGood) && field(payGood, 'status') === 'paid';
946
1788
  }),
947
1789
  ),
948
- done('stripe.invoiceitems.crud', 'invoices', 'InvoiceItems: create + list', 'api', 'core', () =>
1790
+ done('stripe.invoiceitems.crud', 'invoices', 'InvoiceItems: create + retrieve + update + list + delete', 'api', 'core', () =>
949
1791
  withRoot(async (h) => {
950
1792
  const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=ii@twin.test' });
951
1793
  const ii = await h({ m: 'POST', p: '/v1/invoiceitems', b: `customer=${id(cust)}&amount=400&currency=usd` });
952
1794
  const l = await h({ m: 'GET', p: '/v1/invoiceitems' });
953
- return ok(ii) && field(ii, 'object') === 'invoiceitem' && field(l, 'object') === 'list';
1795
+ if (!ok(ii) || field(ii, 'object') !== 'invoiceitem' || field(l, 'object') !== 'list') return false;
1796
+ // an item is reachable by the id its own create handed back
1797
+ const g = await h({ m: 'GET', p: `/v1/invoiceitems/${id(ii)}` });
1798
+ const miss = await h({ m: 'GET', p: '/v1/invoiceitems/ii_nope' });
1799
+ if (!ok(g) || id(g) !== id(ii) || miss.status !== 404) return false;
1800
+ // The earlier pending 400 is swept onto the invoice at creation, when the create asks: pending_invoice_items_behavior
1801
+ // "Defaults to `exclude` if the parameter is omitted" (docs.stripe.com/api/invoices/create; e0e3b88c6).
1802
+ // An item on a DRAFT invoice moves the invoice's total when it changes, and again when
1803
+ // it is deleted — an invoice billing for a line that no longer says that is money in
1804
+ // two places at once.
1805
+ const inv = await h({ m: 'POST', p: '/v1/invoices', b: `customer=${id(cust)}&pending_invoice_items_behavior=include` });
1806
+ const on = await h({ m: 'POST', p: '/v1/invoiceitems', b: `customer=${id(cust)}&invoice=${id(inv)}&amount=900&currency=usd` });
1807
+ const u = await h({ m: 'POST', p: `/v1/invoiceitems/${id(on)}`, b: 'amount=1500' });
1808
+ const afterUpdate = await h({ m: 'GET', p: `/v1/invoices/${id(inv)}` });
1809
+ if (!ok(u) || field(afterUpdate, 'amount_due') !== 1900) return false;
1810
+ const del = await h({ m: 'DELETE', p: `/v1/invoiceitems/${id(on)}` });
1811
+ const afterDelete = await h({ m: 'GET', p: `/v1/invoices/${id(inv)}` });
1812
+ const gone = await h({ m: 'GET', p: `/v1/invoiceitems/${id(on)}` });
1813
+ // and once the invoice is finalized its items are no longer deletable, like Stripe
1814
+ const inv2 = await h({ m: 'POST', p: '/v1/invoices', b: `customer=${id(cust)}` });
1815
+ const locked = await h({ m: 'POST', p: '/v1/invoiceitems', b: `customer=${id(cust)}&invoice=${id(inv2)}&amount=700&currency=usd` });
1816
+ await h({ m: 'POST', p: `/v1/invoices/${id(inv2)}/finalize` });
1817
+ const refused = await h({ m: 'DELETE', p: `/v1/invoiceitems/${id(locked)}` });
1818
+ return ok(del) && field(del, 'deleted') === true && field(afterDelete, 'amount_due') === 400
1819
+ && gone.status === 404 && refused.status === 400;
954
1820
  }),
955
1821
  ),
956
1822
  // Invoice send / mark_uncollectible / pay-out-of-band: send auto-finalizes a draft (→ open);
@@ -961,11 +1827,15 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
961
1827
  withRoot(async (h) => {
962
1828
  const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=send@twin.test' });
963
1829
  await h({ m: 'POST', p: '/v1/invoiceitems', b: `customer=${id(cust)}&amount=4000&currency=usd` });
964
- const inv = await h({ m: 'POST', p: '/v1/invoices', b: `customer=${id(cust)}` });
1830
+ // the pending item is taken only when the create asks ("Defaults to `exclude`", docs.stripe.com/api/invoices/create)
1831
+ const inv = await h({ m: 'POST', p: '/v1/invoices', b: `customer=${id(cust)}&pending_invoice_items_behavior=include` });
1832
+ if (field(inv, 'amount_due') !== 4000) return false;
965
1833
  const sent = await h({ m: 'POST', p: `/v1/invoices/${id(inv)}/send` });
966
1834
  if (!ok(sent) || field(sent, 'status') !== 'open') return false;
967
1835
  const oob = await h({ m: 'POST', p: `/v1/invoices/${id(inv)}/pay`, b: 'paid_out_of_band=true' });
968
- if (!ok(oob) || field(oob, 'status') !== 'paid' || field(oob, 'paid_out_of_band') !== true) return false;
1836
+ // out of band is `amount_paid_off_stripe` in the served version ("Amount ... paid on the invoice outside of
1837
+ // Stripe", served spec invoice); basil answers no `paid_out_of_band` (docs.stripe.com/changelog/basil; 24e757f60)
1838
+ if (!ok(oob) || field(oob, 'status') !== 'paid' || field(oob, 'amount_paid_off_stripe') !== 4000 || field(oob, 'amount_paid') !== 4000 || 'paid_out_of_band' in (oob.body as Body)) return false;
969
1839
  // a second invoice can be marked uncollectible
970
1840
  const inv2 = await h({ m: 'POST', p: '/v1/invoices', b: `customer=${id(cust)}` });
971
1841
  await h({ m: 'POST', p: `/v1/invoices/${id(inv2)}/finalize` });
@@ -984,14 +1854,17 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
984
1854
  const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Plan' });
985
1855
  const price = await h({ m: 'POST', p: `/v1/prices`, b: `unit_amount=3000&currency=usd&recurring[interval]=month&product=${id(prod)}` });
986
1856
  const coupon = await h({ m: 'POST', p: '/v1/coupons', b: 'percent_off=10&duration=forever' });
987
- const sub = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&items[0][price]=${id(price)}&coupon=${id(coupon)}` });
988
- const up = await h({ m: 'GET', p: `/v1/invoices/upcoming?subscription=${id(sub)}` });
989
- if (!ok(up) || field(up, 'object') !== 'invoice' || field(up, 'id') !== null) return false;
1857
+ const sub = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&items[0][price]=${id(price)}&discounts[0][coupon]=${id(coupon)}` });
1858
+ const up = await h({ m: 'POST', p: '/v1/invoices/create_preview', b: `subscription=${id(sub)}` });
1859
+ // not persisted, and named as a preview: "For preview invoices created using the create preview endpoint, this id
1860
+ // will be prefixed with `upcoming_in`" (served spec, invoice.id; b75e48888)
1861
+ if (!ok(up) || field(up, 'object') !== 'invoice' || !String(field(up, 'id')).startsWith('upcoming_in_')) return false;
1862
+ if ((await h({ m: 'GET', p: `/v1/invoices/${String(field(up, 'id'))}` })).status !== 404) return false;
990
1863
  // subtotal 3000, 10% off → total 2700.
991
1864
  if (field(up, 'subtotal') !== 3000 || field(up, 'total') !== 2700) return false;
992
1865
  const lines = (field(up, 'lines') as Body).data as Body[];
993
1866
  if (lines.length !== 1 || lines[0]!.amount !== 3000) return false;
994
- const noCust = await h({ m: 'GET', p: '/v1/invoices/upcoming?customer=cus_nope' });
1867
+ const noCust = await h({ m: 'POST', p: '/v1/invoices/create_preview', b: 'customer=cus_nope' });
995
1868
  return noCust.status === 404;
996
1869
  }),
997
1870
  ),
@@ -1004,13 +1877,19 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1004
1877
  withRoot(async (h) => {
1005
1878
  const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=cn@twin.test' });
1006
1879
  await h({ m: 'POST', p: '/v1/invoiceitems', b: `customer=${id(cust)}&amount=5000&currency=usd` });
1007
- const inv = await h({ m: 'POST', p: '/v1/invoices', b: `customer=${id(cust)}` });
1880
+ // the pending item is taken only when the create asks ("Defaults to `exclude`", docs.stripe.com/api/invoices/create; e0e3b88c6)
1881
+ const inv = await h({ m: 'POST', p: '/v1/invoices', b: `customer=${id(cust)}&pending_invoice_items_behavior=include` });
1008
1882
  await h({ m: 'POST', p: `/v1/invoices/${id(inv)}/finalize` });
1009
1883
  await h({ m: 'POST', p: `/v1/invoices/${id(inv)}/pay`, b: 'payment_method=pm_card_visa' });
1884
+ // a paid invoice's credit says how it is paid back: "The sum of refunds, customer balance credits, and outside of
1885
+ // Stripe credits must equal the post_payment_amount" (docs.stripe.com/api/credit_notes/create; 1159092c7), so the
1886
+ // 2000 goes to the customer's balance (credit_amount), and a split that does not add up is refused
1887
+ const unsplit = await h({ m: 'GET', p: `/v1/credit_notes/preview?invoice=${id(inv)}&amount=2000` });
1888
+ if (unsplit.status !== 400) return false;
1010
1889
  // preview computes the object WITHOUT persisting it
1011
- const prev = await h({ m: 'GET', p: `/v1/credit_notes/preview?invoice=${id(inv)}&amount=2000` });
1890
+ const prev = await h({ m: 'GET', p: `/v1/credit_notes/preview?invoice=${id(inv)}&amount=2000&credit_amount=2000` });
1012
1891
  if (!ok(prev) || field(prev, 'object') !== 'credit_note' || field(prev, 'amount') !== 2000) return false;
1013
- const cn = await h({ m: 'POST', p: '/v1/credit_notes', b: `invoice=${id(inv)}&amount=2000&reason=order_change` });
1892
+ const cn = await h({ m: 'POST', p: '/v1/credit_notes', b: `invoice=${id(inv)}&amount=2000&credit_amount=2000&reason=order_change` });
1014
1893
  if (!ok(cn) || field(cn, 'object') !== 'credit_note' || field(cn, 'status') !== 'issued' || field(cn, 'type') !== 'post_payment' || field(cn, 'amount') !== 2000) return false;
1015
1894
  const g = await h({ m: 'GET', p: `/v1/credit_notes/${id(cn)}` });
1016
1895
  const lines = await h({ m: 'GET', p: `/v1/credit_notes/${id(cn)}/lines` });
@@ -1040,29 +1919,345 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1040
1919
  return ok(g) && ok(li) && field(li, 'object') === 'list' && ok(exp);
1041
1920
  }),
1042
1921
  ),
1922
+ // A Checkout line item's description: "An arbitrary string attached to the object. Often useful for displaying to users.
1923
+ // Defaults to product name." (spec/openapi.json.gz, the `item` schema). The session create's line_items take no
1924
+ // description of their own (price, price_data, quantity, adjustable_quantity, metadata, tax_rates), so the name is the
1925
+ // price's product's, or the product_data an inline price names. Dub's Checkout showed its Business plan as "Item".
1926
+ done('stripe.checkout.line_item_description', 'checkout', 'Checkout line items are described by their product\'s name (a price\'s product, or price_data.product_data)', 'api', 'common', () =>
1927
+ withRoot(async (h) => {
1928
+ const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Business' });
1929
+ const price = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=9000&currency=usd&product=${id(prod)}` });
1930
+ const cs = await h({ m: 'POST', p: '/v1/checkout/sessions', b: `mode=payment&success_url=https://x.test&line_items[0][price]=${id(price)}&line_items[0][quantity]=2&line_items[1][price_data][currency]=usd&line_items[1][price_data][unit_amount]=100&line_items[1][price_data][product_data][name]=Setup%20fee&line_items[1][quantity]=1` });
1931
+ const items = (((await h({ m: 'GET', p: `/v1/checkout/sessions/${id(cs)}/line_items` })).body as Body).data as Body[]) ?? [];
1932
+ return items.length === 2 && items[0]?.description === 'Business' && items[0]?.amount_total === 18000
1933
+ && items[1]?.description === 'Setup fee' && items[1]?.amount_total === 100;
1934
+ }),
1935
+ ),
1043
1936
  // Completion copies the session's create-only subscription_data onto the created
1044
1937
  // subscription (metadata is how apps bind the checkout attempt → subscription; trial
1045
1938
  // becomes a real trialing window) — and the Session itself never echoes the param.
1046
1939
  done('stripe.checkout.subscription_data', 'checkout', 'Checkout completion copies subscription_data (metadata + trial) onto the created subscription', 'api', 'core', () =>
1047
1940
  withRoot(async (h) => {
1048
1941
  const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=subdata@twin.test' });
1049
- const cs = await h({ m: 'POST', p: '/v1/checkout/sessions', b: `mode=subscription&success_url=https://x.test&customer=${id(cust)}&line_items[0][price_data][currency]=usd&line_items[0][price_data][unit_amount]=999&line_items[0][quantity]=1&subscription_data[metadata][attemptId]=att_cap_1&subscription_data[trial_period_days]=7` });
1942
+ // the inline price is a subscription's: a product and a recurring interval (docs.stripe.com/api/checkout/sessions/create,
1943
+ // line_items.price_data), so the subscription has an item to carry its period (c3a378808 bills inline prices)
1944
+ const cs = await h({ m: 'POST', p: '/v1/checkout/sessions', b: `mode=subscription&success_url=https://x.test&customer=${id(cust)}&line_items[0][price_data][currency]=usd&line_items[0][price_data][unit_amount]=999&line_items[0][price_data][product_data][name]=Plan&line_items[0][price_data][recurring][interval]=month&line_items[0][quantity]=1&subscription_data[metadata][attemptId]=att_cap_1&subscription_data[trial_period_days]=7` });
1050
1945
  if (!ok(cs) || field(cs, 'subscription_data') !== undefined || field(cs, '_subscription_data') !== undefined) return false;
1051
- const completed = await h({ m: 'POST', p: `/v1/checkout/sessions/${id(cs)}`, b: 'status=complete' });
1946
+ await h({ m: 'POST', p: `/c/pay/${id(cs)}`, b: 'email=pay%40twin.test&cardNumber=4242424242424242&cardExpiry=12%2F34&cardCvc=123&billingName=Twin&billingCountry=US&billingPostalCode=94105' });
1947
+ const completed = await h({ m: 'GET', p: `/v1/checkout/sessions/${id(cs)}` });
1052
1948
  if (!ok(completed) || !field(completed, 'subscription')) return false;
1053
1949
  const sub = await h({ m: 'GET', p: `/v1/subscriptions/${field(completed, 'subscription')}` });
1054
1950
  const md = field(sub, 'metadata') as Body | undefined;
1951
+ // a subscription's billing period is its items' in the served version ("current_period_start and current_period_end
1952
+ // ... moved to the subscription item", docs.stripe.com/changelog/basil/2025-03-31/deprecate-subscription-current-period-start-and-end; 24e757f60)
1953
+ const item = (((field(sub, 'items') as Body | undefined)?.data as Body[] | undefined) ?? [])[0];
1055
1954
  return ok(sub) && md?.attemptId === 'att_cap_1' && field(sub, 'status') === 'trialing'
1056
- && typeof field(sub, 'trial_end') === 'number' && field(sub, 'trial_end') === field(sub, 'current_period_end');
1057
- }),
1058
- ),
1955
+ && typeof field(sub, 'trial_end') === 'number' && field(sub, 'trial_end') === item?.current_period_end
1956
+ && field(sub, 'current_period_end') === undefined;
1957
+ }),
1958
+ ),
1959
+ // A subscription session's customer pays its first invoice on the page: the invoice is paid through a succeeded
1960
+ // PaymentIntent and its charge, and the card is the subscription's default payment method
1961
+ // (docs.stripe.com/payments/checkout/how-checkout-works).
1962
+ done('stripe.checkout.subscription_first_invoice', 'checkout', 'Subscription Checkout pays the first invoice with the entered card, the subscription\'s default payment method', 'api', 'core', () =>
1963
+ withRoot(async (h) => {
1964
+ const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Box' });
1965
+ const price = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=3500&currency=usd&product=${id(prod)}&recurring[interval]=month` });
1966
+ const cs = await h({ m: 'POST', p: '/v1/checkout/sessions', b: `mode=subscription&success_url=https://x.test&line_items[0][price]=${id(price)}&line_items[0][quantity]=1` });
1967
+ 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' });
1968
+ const done = await h({ m: 'GET', p: `/v1/checkout/sessions/${id(cs)}` });
1969
+ const sub = await h({ m: 'GET', p: `/v1/subscriptions/${String(field(done, 'subscription'))}` });
1970
+ const inv = await h({ m: 'GET', p: `/v1/invoices/${String(field(done, 'invoice'))}` });
1971
+ // the invoice's PaymentIntent is its payment in the served version: basil answers no `invoice.payment_intent` and
1972
+ // lists the invoice's payments (docs.stripe.com/changelog/basil/2025-03-31/add-support-for-multiple-partial-payments-on-invoices; 24e757f60)
1973
+ const payments = (((await h({ m: 'GET', p: `/v1/invoice_payments?invoice=${String(field(done, 'invoice'))}` })).body as Body).data as Body[]) ?? [];
1974
+ const pi = await h({ m: 'GET', p: `/v1/payment_intents/${String((payments[0]?.payment as Body | undefined)?.payment_intent)}` });
1975
+ if (payments.length !== 1 || payments[0]!.status !== 'paid' || payments[0]!.amount_paid !== 3500) return false;
1976
+ return field(sub, 'status') === 'active' && typeof field(sub, 'default_payment_method') === 'string'
1977
+ && field(inv, 'status') === 'paid' && field(inv, 'billing_reason') === 'subscription_create' && field(inv, 'amount_paid') === 3500
1978
+ && field(pi, 'status') === 'succeeded' && typeof field(pi, 'latest_charge') === 'string';
1979
+ }),
1980
+ ),
1981
+ // Checkout attaches the card it saves to the customer and says so: payment_method.attached ("Occurs whenever a new payment
1982
+ // method is attached to a customer", docs.stripe.com/api/events/types) for a subscription's card, which is also the
1983
+ // subscription's default payment method (docs.stripe.com/payments/checkout/how-checkout-works), for a setup session's, and
1984
+ // for a payment's only under payment_intent_data.setup_future_usage (docs.stripe.com/api/checkout/sessions/create
1985
+ // #create_checkout_session-customer); the event comes before the subscription it pays for (the twin's order; Stripe's is
1986
+ // not guaranteed, docs.stripe.com/webhooks#event-ordering). Rallly walk 4: its billing page read no saved card.
1987
+ done('stripe.checkout.payment_method_attached', 'checkout', 'Checkout attaches the saved card to the customer and emits payment_method.attached (subscription, setup, and payment with setup_future_usage)', 'api', 'core', () =>
1988
+ withRoot(async (h) => {
1989
+ const card = 'cardNumber=4242424242424242&cardExpiry=12%2F34&cardCvc=123&billingName=Twin&billingCountry=US&billingPostalCode=94105';
1990
+ const attachedEvents = async () => (((await h({ m: 'GET', p: '/v1/events?type=payment_method.attached&limit=100' })).body as Body).data as Body[] | undefined) ?? [];
1991
+ const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=saved@twin.test' });
1992
+ const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Pro' });
1993
+ const price = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=1200&currency=usd&product=${id(prod)}&recurring[interval]=month` });
1994
+ const sub = await h({ m: 'POST', p: '/v1/checkout/sessions', b: `mode=subscription&success_url=https://x.test&customer=${id(cust)}&line_items[0][price]=${id(price)}&line_items[0][quantity]=1` });
1995
+ await h({ m: 'POST', p: `/c/pay/${id(sub)}`, b: card });
1996
+ const done = await h({ m: 'GET', p: `/v1/checkout/sessions/${id(sub)}` });
1997
+ const subscription = await h({ m: 'GET', p: `/v1/subscriptions/${String(field(done, 'subscription'))}` });
1998
+ const pmId = field(subscription, 'default_payment_method');
1999
+ const pm = await h({ m: 'GET', p: `/v1/payment_methods/${String(pmId)}` });
2000
+ const attached = await attachedEvents();
2001
+ const all = (((await h({ m: 'GET', p: '/v1/events?limit=100' })).body as Body).data as Body[] | undefined) ?? [];
2002
+ // the list is newest first: the attach is older than the subscription's creation and the session's completion
2003
+ const at = (type: string) => all.findIndex((e) => e.type === type);
2004
+ const subscriptionOk = field(done, 'status') === 'complete' && typeof pmId === 'string' && field(pm, 'customer') === id(cust)
2005
+ && attached.length === 1 && ((attached[0]!.data as Body).object as Body).id === pmId && ((attached[0]!.data as Body).object as Body).customer === id(cust)
2006
+ && at('payment_method.attached') > at('customer.subscription.created') && at('customer.subscription.created') > at('checkout.session.completed');
2007
+ // a payment without setup_future_usage saves nothing on the customer; with it, the card is attached
2008
+ const pay = (extra: string) => h({ m: 'POST', p: '/v1/checkout/sessions', b: `mode=payment&success_url=https://x.test&customer=${id(cust)}&line_items[0][price_data][currency]=usd&line_items[0][price_data][unit_amount]=2500&line_items[0][price_data][product_data][name]=Pot&line_items[0][quantity]=1${extra}` });
2009
+ const once = await pay('');
2010
+ await h({ m: 'POST', p: `/c/pay/${id(once)}`, b: card });
2011
+ const oncePi = await h({ m: 'GET', p: `/v1/payment_intents/${String(field(await h({ m: 'GET', p: `/v1/checkout/sessions/${id(once)}` }), 'payment_intent'))}` });
2012
+ const oncePm = await h({ m: 'GET', p: `/v1/payment_methods/${String(field(oncePi, 'payment_method'))}` });
2013
+ const afterOnce = (await attachedEvents()).length;
2014
+ const keep = await pay('&payment_intent_data[setup_future_usage]=off_session');
2015
+ await h({ m: 'POST', p: `/c/pay/${id(keep)}`, b: card });
2016
+ const keepPi = await h({ m: 'GET', p: `/v1/payment_intents/${String(field(await h({ m: 'GET', p: `/v1/checkout/sessions/${id(keep)}` }), 'payment_intent'))}` });
2017
+ const keepPm = await h({ m: 'GET', p: `/v1/payment_methods/${String(field(keepPi, 'payment_method'))}` });
2018
+ const afterKeep = await attachedEvents();
2019
+ const paymentOk = ok(oncePm) && field(oncePm, 'customer') === null && afterOnce === 1 && field(keepPi, 'setup_future_usage') === 'off_session'
2020
+ && field(keepPm, 'customer') === id(cust) && afterKeep.length === 2 && ((afterKeep[0]!.data as Body).object as Body).id === id(keepPm);
2021
+ // a setup session saves the card: its SetupIntent names it, and it is attached
2022
+ const setup = await h({ m: 'POST', p: '/v1/checkout/sessions', b: `mode=setup&currency=usd&success_url=https://x.test&customer=${id(cust)}` });
2023
+ await h({ m: 'POST', p: `/c/pay/${id(setup)}`, b: card });
2024
+ const si = await h({ m: 'GET', p: `/v1/setup_intents/${String(field(await h({ m: 'GET', p: `/v1/checkout/sessions/${id(setup)}` }), 'setup_intent'))}` });
2025
+ const setupPm = await h({ m: 'GET', p: `/v1/payment_methods/${String(field(si, 'payment_method'))}` });
2026
+ const afterSetup = await attachedEvents();
2027
+ const setupOk = field(si, 'status') === 'succeeded' && field(setupPm, 'customer') === id(cust) && afterSetup.length === 3 && ((afterSetup[0]!.data as Body).object as Body).id === id(setupPm);
2028
+ return subscriptionOk && paymentOk && setupOk;
2029
+ }),
2030
+ ),
2031
+ // A session made for a customer with an email shows that email on the page, prefilled and not editable, and the session
2032
+ // is the customer's email whatever is posted; a customer with none types one, which Checkout sets on the customer ("If the
2033
+ // Customer already has a valid email set, the email will be prefilled and not editable in Checkout. If the Customer does
2034
+ // not have a valid email, Checkout will set the email entered during the session on the Customer",
2035
+ // docs.stripe.com/api/checkout/sessions/create#create_checkout_session-customer). Rallly walk 4: the field was empty.
2036
+ done('stripe.checkout.customer_email', 'checkout', "Checkout for a session's customer shows the customer's email prefilled and not editable; a customer without one gets the typed email", 'ui', 'common', async () => {
2037
+ const root = mkdtempSync(join(tmpdir(), 'stp-cs-email-'));
2038
+ try {
2039
+ const f = createStripeTwinFetch({ root });
2040
+ const api = async (method: string, path: string, body?: string) => (await f(new Request(`http://stripe.test${path}`, { method, headers: { 'content-type': 'application/x-www-form-urlencoded' }, ...(body ? { body } : {}) }))).json() as Promise<Body>;
2041
+ const post = (url: string, email: string) => f(new Request(url, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ email, cardNumber: '4242424242424242', cardExpiry: '12 / 34', cardCvc: '123', billingName: 'Twin', billingCountry: 'US', billingPostalCode: '94105' }).toString() }));
2042
+ const session = (customer: string) => api('POST', '/v1/checkout/sessions', `mode=subscription&success_url=https://x.test&customer=${customer}&line_items[0][price_data][currency]=usd&line_items[0][price_data][unit_amount]=1200&line_items[0][price_data][product_data][name]=Pro&line_items[0][price_data][recurring][interval]=month&line_items[0][quantity]=1`);
2043
+ const known = await api('POST', '/v1/customers', 'email=known@twin.test');
2044
+ const cs = await session(String(known.id));
2045
+ const html = await (await f(new Request(String(cs.url)))).text();
2046
+ const input = /<input[^>]*id="email"[^>]*>/.exec(html)?.[0] ?? '';
2047
+ const paid = await post(String(cs.url), 'other@twin.test');
2048
+ const done = await api('GET', `/v1/checkout/sessions/${String(cs.id)}`);
2049
+ const knownAfter = await api('GET', `/v1/customers/${String(known.id)}`);
2050
+ const prefilled = input.includes('value="known@twin.test"') && /\sreadonly(=""|\s|\/|>)/i.test(input)
2051
+ && paid.status === 303 && (done.customer_details as Body | undefined)?.email === 'known@twin.test' && knownAfter.email === 'known@twin.test';
2052
+ // a customer with no email: the field is empty and editable, and the typed email becomes the customer's
2053
+ const bare = await api('POST', '/v1/customers', 'name=Bare');
2054
+ const cs2 = await session(String(bare.id));
2055
+ const bareInput = /<input[^>]*id="email"[^>]*>/.exec(await (await f(new Request(String(cs2.url)))).text())?.[0] ?? '';
2056
+ const paid2 = await post(String(cs2.url), 'typed@twin.test');
2057
+ const bareAfter = await api('GET', `/v1/customers/${String(bare.id)}`);
2058
+ const updated = ((await api('GET', '/v1/events?type=customer.updated')).data as Body[] | undefined) ?? [];
2059
+ const typed = bareInput !== '' && !/readonly/i.test(bareInput) && paid2.status === 303 && bareAfter.email === 'typed@twin.test'
2060
+ && updated.length === 1 && ((updated[0]!.data as Body).object as Body).id === bare.id && ((updated[0]!.data as Body).object as Body).email === 'typed@twin.test';
2061
+ return prefilled && typed;
2062
+ } finally {
2063
+ rmSync(root, { recursive: true, force: true });
2064
+ }
2065
+ }),
2066
+ // Checkout's expiry field takes the digits typed straight through, as Stripe's formats `1230` into `12 / 30`: the page
2067
+ // marks the field for its formatter, and the posted digits pay and are the card's expiry (screens/checkout.tsx
2068
+ // parseExpiry); two digits are still incomplete.
2069
+ done('stripe.checkout.card_expiry_digits', 'checkout', 'Checkout card expiry accepts digits typed straight through (1230 is 12 / 30), formatted as typed', 'ui', 'common', async () => {
2070
+ const root = mkdtempSync(join(tmpdir(), 'stp-expiry-'));
2071
+ try {
2072
+ const f = createStripeTwinFetch({ root, clock: () => '2026-03-01T00:00:00Z' });
2073
+ const api = async (method: string, path: string, body?: string) => (await f(new Request(`http://stripe.test${path}`, { method, headers: { 'content-type': 'application/x-www-form-urlencoded' }, ...(body ? { body } : {}) }))).json() as Promise<Body>;
2074
+ const pay = (cs: Body, expiry: string) => f(new Request(String(cs.url), { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ email: 'pay@twin.test', cardNumber: '4242424242424242', cardExpiry: expiry, cardCvc: '123', billingName: 'Twin', billingCountry: 'US', billingPostalCode: '94105' }).toString() }));
2075
+ const session = () => api('POST', '/v1/checkout/sessions', 'mode=payment&success_url=https://x.test&line_items[0][price_data][currency]=usd&line_items[0][price_data][unit_amount]=2500&line_items[0][price_data][product_data][name]=Pro&line_items[0][quantity]=1');
2076
+ const cs = await session();
2077
+ const html = await (await f(new Request(String(cs.url)))).text();
2078
+ const script = /<script>([\s\S]*?)<\/script>/.exec(html)?.[1] ?? '';
2079
+ const marked = /<input[^>]*id="cardExpiry"[^>]*data-format="card-expiry"/.test(html) && script === FORMAT_SCRIPT;
2080
+ // the page's own script, run over a field as a person types, deletes, pastes or fills it
2081
+ const typed = expiryField(script);
2082
+ const formats = typed.type('1230') === '12 / 30' && expiryField(script).type('5') === '05 / ' && expiryField(script).type('1/3') === '01 / 3'
2083
+ && expiryField(script).fill('12/2030') === '12 / 30' && expiryField(script).fill('122030') === '12 / 30'
2084
+ && (typed.back(), typed.back()) === '12 / ' && typed.back() === '12' && typed.back() === '1';
2085
+ const short = await pay(cs, '12');
2086
+ const shortText = await short.text();
2087
+ const paid = await pay(cs, '1230');
2088
+ const done = await api('GET', `/v1/checkout/sessions/${String(cs.id)}`);
2089
+ const pi = await api('GET', `/v1/payment_intents/${String(done.payment_intent)}`);
2090
+ const card = (await api('GET', `/v1/payment_methods/${String(pi.payment_method)}`)).card as Body | undefined;
2091
+ // a first digit above 1 is the month on its own: 530 is May 2030
2092
+ const other = await session();
2093
+ const may = await pay(other, '530');
2094
+ const pmMay = (await api('GET', `/v1/payment_methods/${String((await api('GET', `/v1/payment_intents/${String((await api('GET', `/v1/checkout/sessions/${String(other.id)}`)).payment_intent)}`)).payment_method)}`)).card as Body | undefined;
2095
+ return marked && formats && short.status === 402 && shortText.includes('expiration date is incomplete')
2096
+ && paid.status === 303 && done.status === 'complete' && card?.exp_month === 12 && card?.exp_year === 2030
2097
+ && may.status === 303 && pmMay?.exp_month === 5 && pmMay?.exp_year === 2030;
2098
+ } finally {
2099
+ rmSync(root, { recursive: true, force: true });
2100
+ }
2101
+ }),
2102
+ // A subscription session with a trial shows the trial as Checkout does: "14 days free", "Then $90.00 per month", a
2103
+ // "Start trial" button and, under it, when the trial's end charges; one without a trial says Subscribe and its amount
2104
+ // (screens/checkout.tsx trialSummary). The amount and date come from the seeded price and the session's trial.
2105
+ done('stripe.checkout.trial_display', 'checkout', 'Checkout shows a subscription\'s trial: N days free, then the price per interval from the trial\'s end, Start trial', 'ui', 'common', async () => {
2106
+ const root = mkdtempSync(join(tmpdir(), 'stp-trialpage-'));
2107
+ try {
2108
+ const f = createStripeTwinFetch({ root, clock: () => '2026-03-01T00:00:00Z' });
2109
+ const api = async (method: string, path: string, body?: string) => (await f(new Request(`http://stripe.test${path}`, { method, headers: { 'content-type': 'application/x-www-form-urlencoded' }, ...(body ? { body } : {}) }))).json() as Promise<Body>;
2110
+ const text = async (cs: Body) => (await (await f(new Request(String(cs.url)))).text()).replace(/<script[\s\S]*?<\/script>/g, '').replace(/<[^>]+>/g, '|');
2111
+ const prod = await api('POST', '/v1/products', 'name=Business');
2112
+ const price = await api('POST', '/v1/prices', `unit_amount=9000&currency=usd&recurring[interval]=month&product=${String(prod.id)}`);
2113
+ const yearly = await api('POST', '/v1/prices', `unit_amount=90000&currency=usd&recurring[interval]=year&product=${String(prod.id)}`);
2114
+ const line = (p: Body) => `mode=subscription&success_url=https://x.test&customer_email=t%40twin.test&line_items[0][price]=${String(p.id)}&line_items[0][quantity]=1`;
2115
+ const trial = await text(await api('POST', '/v1/checkout/sessions', `${line(price)}&subscription_data[trial_period_days]=14`));
2116
+ // a trial set by its end: 2026-03-08 is a week on
2117
+ const byEnd = await text(await api('POST', '/v1/checkout/sessions', `${line(yearly)}&subscription_data[trial_end]=${Date.parse('2026-03-08T00:00:00Z') / 1000}`));
2118
+ const plain = await text(await api('POST', '/v1/checkout/sessions', line(price)));
2119
+ // after the trial a `once` coupon is spent (the trial's $0 invoice took it) and a one-time item is not billed again;
2120
+ // a `forever` coupon still applies
2121
+ const once = await api('POST', '/v1/coupons', 'id=ONCE50&percent_off=50&duration=once');
2122
+ const forever = await api('POST', '/v1/coupons', 'id=EVER10&percent_off=10&duration=forever');
2123
+ const setup = await api('POST', '/v1/prices', `unit_amount=2500&currency=usd&product=${String(prod.id)}`);
2124
+ const spent = await text(await api('POST', '/v1/checkout/sessions', `${line(price)}&subscription_data[trial_period_days]=14&discounts[0][coupon]=${String(once.id)}&line_items[1][price]=${String(setup.id)}&line_items[1][quantity]=1`));
2125
+ const kept = await text(await api('POST', '/v1/checkout/sessions', `${line(price)}&subscription_data[trial_period_days]=14&discounts[0][coupon]=${String(forever.id)}`));
2126
+ // Checkout refuses a trial_end under 48 hours away
2127
+ const soon = await f(new Request('http://stripe.test/v1/checkout/sessions', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: `${line(price)}&subscription_data[trial_end]=${Date.parse('2026-03-02T12:00:00Z') / 1000}` }));
2128
+ if (!spent.includes('|Then $90.00 per month|') || !kept.includes('|Then $81.00 per month|') || soon.status !== 400) return false;
2129
+ // and the twin bills what the page says: the one-time price on the first invoice, then $90.00 a period
2130
+ const paid = await api('POST', '/v1/checkout/sessions', `${line(price)}&subscription_data[trial_period_days]=14&line_items[1][price]=${String(setup.id)}&line_items[1][quantity]=1`);
2131
+ await f(new Request(String(paid.url), { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'email=t%40twin.test&cardNumber=4242424242424242&cardExpiry=1230&cardCvc=123&billingName=T&billingCountry=US&billingPostalCode=94105' }));
2132
+ const later = createStripeTwinFetch({ root, clock: () => '2026-03-20T00:00:00Z' });
2133
+ const read = async (path: string) => (await later(new Request(`http://stripe.test${path}`))).json() as Promise<Body>;
2134
+ const done = await read(`/v1/checkout/sessions/${String(paid.id)}`);
2135
+ const first = await read(`/v1/invoices/${String(done.invoice)}`);
2136
+ const bills = ((await read(`/v1/invoices?subscription=${String(done.subscription)}`)).data as Body[] | undefined) ?? [];
2137
+ const cycle = bills.find((b) => b.billing_reason === 'subscription_cycle');
2138
+ if (first.total !== 2500 || first.status !== 'paid' || cycle?.total !== 9000) return false;
2139
+ // the same sale in inline price_data: the page and the invoices agree ($25 now, $90 a period)
2140
+ const inlineLines = 'mode=subscription&success_url=https://x.test&customer_email=t%40twin.test&subscription_data[trial_period_days]=14'
2141
+ + `&line_items[0][price_data][currency]=usd&line_items[0][price_data][unit_amount]=9000&line_items[0][price_data][recurring][interval]=month&line_items[0][price_data][product]=${String(prod.id)}&line_items[0][quantity]=1`
2142
+ + '&line_items[1][price_data][currency]=usd&line_items[1][price_data][unit_amount]=2500&line_items[1][price_data][product_data][name]=Setup&line_items[1][quantity]=1';
2143
+ const inlineCs = await api('POST', '/v1/checkout/sessions', inlineLines);
2144
+ const inlinePage = await text(inlineCs);
2145
+ await f(new Request(String(inlineCs.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' }));
2146
+ const inlineDone = await read(`/v1/checkout/sessions/${String(inlineCs.id)}`);
2147
+ const inlineFirst = await read(`/v1/invoices/${String(inlineDone.invoice)}`);
2148
+ const inlineBills = ((await read(`/v1/invoices?subscription=${String(inlineDone.subscription)}`)).data as Body[] | undefined) ?? [];
2149
+ if (!inlinePage.includes('|Then $90.00 per month|') || inlineFirst.total !== 2500 || inlineBills.find((b) => b.billing_reason === 'subscription_cycle')?.total !== 9000) return false;
2150
+ return trial.includes('|14 days free|') && trial.includes('|Then $90.00 per month|') && trial.includes('|Start trial|')
2151
+ && trial.includes('After your trial ends, you will be charged $90.00 per month starting March 15, 2026. You can always cancel before then.')
2152
+ && !trial.includes('|Subscribe|')
2153
+ && byEnd.includes('|7 days free|') && byEnd.includes('|Then $900.00 per year|') && byEnd.includes('starting March 8, 2026')
2154
+ && plain.includes('|Subscribe|') && plain.includes('|$90.00|') && !plain.includes('days free') && !plain.includes('Start trial');
2155
+ } finally {
2156
+ rmSync(root, { recursive: true, force: true });
2157
+ }
2158
+ }),
2159
+ // A session's promotion code discounts it and the subscription it makes; an unknown code and an expired one are
2160
+ // refused at create (docs.stripe.com/api/checkout/sessions/create#create_checkout_session-discounts).
2161
+ done('stripe.checkout.promotion_codes', 'checkout', 'Checkout discounts[].promotion_code: applied, or refused when unknown or expired', 'api', 'common', async () => {
2162
+ const root = mkdtempSync(join(tmpdir(), 'stp-promo-'));
2163
+ try {
2164
+ const h = (s: Step & { day?: number }) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: `2026-05-${String(s.day ?? 1).padStart(2, '0')}T12:00:00Z` });
2165
+ const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Box' });
2166
+ const price = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=3500&currency=usd&recurring[interval]=month&product=${id(prod)}` });
2167
+ const coupon = await h({ m: 'POST', p: '/v1/coupons', b: 'percent_off=50&duration=once' });
2168
+ const code = await h({ m: 'POST', p: '/v1/promotion_codes', b: `promotion[type]=coupon&promotion[coupon]=${id(coupon)}&code=SPRING&expires_at=1780272000` });
2169
+ const make = (day: number, pc: string) => h({ m: 'POST', p: '/v1/checkout/sessions', b: `mode=subscription&success_url=https://x.test&line_items[0][price]=${id(price)}&line_items[0][quantity]=1&discounts[0][promotion_code]=${pc}`, day });
2170
+ const good = await make(2, id(code));
2171
+ const unknown = await make(2, 'promo_nope');
2172
+ const late = await make(30, id(code));
2173
+ const lateOk = late.status === 200;
2174
+ const june = await handleStripeTwinRequest({ method: 'POST', path: '/v1/checkout/sessions', body: `mode=subscription&success_url=https://x.test&line_items[0][price]=${id(price)}&line_items[0][quantity]=1&discounts[0][promotion_code]=${id(code)}`, root, occurredAt: '2026-06-02T12:00:00Z' });
2175
+ return field(good, 'amount_total') === 1750 && unknown.status === 400 && lateOk && june.status === 400 && ((june.body as Body).error as Body)?.code === 'coupon_expired';
2176
+ } finally {
2177
+ rmSync(root, { recursive: true, force: true });
2178
+ }
2179
+ }),
2180
+ // A payment session's payment_intent_data makes a destination charge: the payment transfers to the connected account
2181
+ // less the platform's application fee (docs.stripe.com/connect/destination-charges).
2182
+ done('stripe.checkout.destination_charge', 'checkout', 'Checkout payment_intent_data: destination charge with an application fee', 'api', 'common', () =>
2183
+ withRoot(async (h) => {
2184
+ const seller = await onboardedSeller(h);
2185
+ const cs = await h({ m: 'POST', p: '/v1/checkout/sessions', b: `mode=payment&success_url=https://x.test&line_items[0][price_data][currency]=usd&line_items[0][price_data][unit_amount]=5000&line_items[0][quantity]=1&payment_intent_data[application_fee_amount]=750&payment_intent_data[transfer_data][destination]=${seller}` });
2186
+ 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' });
2187
+ const done = await h({ m: 'GET', p: `/v1/checkout/sessions/${id(cs)}?expand[]=payment_intent.latest_charge` });
2188
+ const charge = ((field(done, 'payment_intent') as Body)?.latest_charge ?? {}) as Body;
2189
+ const transfer = await h({ m: 'GET', p: `/v1/transfers/${String(charge.transfer)}` });
2190
+ return field(transfer, 'destination') === seller && field(transfer, 'amount') === 4250 && charge.application_fee_amount === 750;
2191
+ }),
2192
+ ),
2193
+ // Refunding a destination charge with reverse_transfer and refund_application_fee reverses the transfer and refunds the
2194
+ // application fee (docs.stripe.com/connect/destination-charges#issue-refunds).
2195
+ done('stripe.refunds.reverse_transfer', 'payments', 'Refund reverse_transfer + refund_application_fee on a destination charge', 'api', 'common', () =>
2196
+ withRoot(async (h) => {
2197
+ const seller = await onboardedSeller(h);
2198
+ const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: `amount=5000&currency=usd&payment_method=pm_card_visa&confirm=true&application_fee_amount=750&transfer_data[destination]=${seller}` });
2199
+ const refund = await h({ m: 'POST', p: '/v1/refunds', b: `payment_intent=${id(pi)}&reverse_transfer=true&refund_application_fee=true` });
2200
+ const fees = ((await h({ m: 'GET', p: '/v1/application_fees' })).body as Body).data as Body[];
2201
+ const reversal = typeof field(refund, 'transfer_reversal') === 'string';
2202
+ return ok(refund) && reversal && field(refund, 'reverse_transfer') === undefined && fees[0]?.refunded === true && fees[0]?.amount_refunded === 750;
2203
+ }),
2204
+ ),
2205
+ // Hosted Express onboarding: an Account Link is a single-use entry point to the onboarding session
2206
+ // (docs.stripe.com/connect/hosted-onboarding), which collects what a US account owes before card payments
2207
+ // (docs.stripe.com/connect/required-verification-information: eighteen requirements for an individual), the business, its representative and the payout bank account, refuses a
2208
+ // submit that leaves one out, and stores what it was given on the account (docs.stripe.com/connect/express-accounts).
2209
+ done('stripe.connect.hosted_onboarding', 'connect', 'Hosted onboarding collects and stores the business, representative and bank account', 'api', 'common', async () => {
2210
+ const root = mkdtempSync(join(tmpdir(), 'stp-onb-'));
2211
+ try {
2212
+ const h = (s: Step) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root });
2213
+ const acct = await h({ m: 'POST', p: '/v1/accounts', b: 'type=express&country=US&business_type=individual&email=grower@twin.test' });
2214
+ const owed = (((field(acct, 'requirements') as Body)?.currently_due as string[]) ?? []).length;
2215
+ const link = await h({ m: 'POST', p: '/v1/account_links', b: `account=${id(acct)}&type=account_onboarding&refresh_url=https://x.test/r&return_url=https://x.test/d` });
2216
+ // visiting the link consumes it and opens the onboarding session; a second visit is sent to refresh_url
2217
+ const url = String(field(link, 'url'));
2218
+ const visited = await stripeOnboardingFlow({ root })(new Request(url));
2219
+ const session = visited?.headers.get('location') ?? '';
2220
+ const again = await stripeOnboardingFlow({ root })(new Request(url));
2221
+ // the page offers Stripe's supported industries (a creator or marketer finds Advertising Services) and every state
2222
+ const shown = await (await stripeOnboardingFlow({ root })(new Request(session)))?.text() ?? '';
2223
+ const offers = shown.includes('value="7311"') && shown.includes('Advertising Services') && shown.includes('value="5815"')
2224
+ && shown.includes('value="WY"') && shown.includes('value="DC"') && !shown.includes('value="5967"')
2225
+ && (shown.match(/<option /g) ?? []).length >= 256 + 51;
2226
+ // no industry is chosen for the person, and a padded code is kept as the code
2227
+ const unchosen = shown.includes('<select id="mcc" name="mcc"><option value="" selected="">Select your industry</option>');
2228
+ // an account the platform gave an industry the page offers opens on it; one it does not offer, on no industry
2229
+ const opened = async (mcc: string): Promise<string> => {
2230
+ const a = await h({ m: 'POST', p: '/v1/accounts', b: `type=express&country=US&business_type=individual&business_profile[mcc]=${mcc}` });
2231
+ const l = await h({ m: 'POST', p: '/v1/account_links', b: `account=${id(a)}&type=account_onboarding&refresh_url=https://x.test/r&return_url=https://x.test/d` });
2232
+ const s = (await stripeOnboardingFlow({ root })(new Request(String(field(l, 'url')))))?.headers.get('location') ?? '';
2233
+ return await (await stripeOnboardingFlow({ root })(new Request(s)))?.text() ?? '';
2234
+ };
2235
+ const prefilled = (await opened('7311')).includes('<option value="7311" selected="">') && (await opened('5912')).includes('<option value="" selected="">Select your industry</option>');
2236
+ // an industry the page does not offer (a restricted code) leaves the MCC due
2237
+ const unlisted = currentlyDue('individual', { mcc: '5967' }).includes('business_profile.mcc') && !currentlyDue('individual', { mcc: '7311' }).includes('business_profile.mcc');
2238
+ const partial = await hostedSubmit(root, stripeOnboardingFlow, session, { business_type: 'individual', first_name: 'Ada' });
2239
+ const full = await hostedSubmit(root, stripeOnboardingFlow, session, {
2240
+ mcc: ' 5261 ', url: 'https://grower.test', statement_descriptor: 'GROWER TEST', line1: 'address_full_match', city: 'Portland', state: 'OR', postal_code: '97201',
2241
+ first_name: 'Ada', last_name: 'Grower', email: 'grower@twin.test', phone: '0000000000', dob: '1901-01-01', ssn_last_4: '0000', routing_number: '110000000', account_number: '000123456789', tos: 'accepted',
2242
+ });
2243
+ const after = await h({ m: 'GET', p: `/v1/accounts/${id(acct)}` });
2244
+ const banks = await h({ m: 'GET', p: `/v1/accounts/${id(acct)}/external_accounts` });
2245
+ return owed === 18 && offers && unlisted && unchosen && prefilled && (field(after, 'business_profile') as Body)?.mcc === '5261' && ((field(after, 'requirements') as Body)?.currently_due as string[] | undefined)?.length === 0 && field(after, 'charges_enabled') === true
2246
+ && session.startsWith('https://connect.stripe.com/setup/s/') && again?.headers.get('location') === 'https://x.test/r'
2247
+ && partial?.status === 400 && full?.status === 302 && field(after, 'details_submitted') === true
2248
+ && ((field(after, 'individual') as Body)?.last_name === 'Grower') && ((banks.body as Body).data as Body[])[0]?.last4 === '6789';
2249
+ } finally {
2250
+ rmSync(root, { recursive: true, force: true });
2251
+ }
2252
+ }),
1059
2253
  // Completing a session fires checkout.session.completed (data.object = the completed
1060
2254
  // Session) and stores it in the Events API — the event most billing flows fulfill from.
1061
2255
  done('stripe.checkout.completed_event', 'checkout', 'Checkout completion emits checkout.session.completed with the session payload', 'api', 'core', () =>
1062
2256
  withRoot(async (h) => {
1063
2257
  const cs = await h({ m: 'POST', p: '/v1/checkout/sessions', b: '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][quantity]=1' });
1064
2258
  if (!ok(cs)) return false;
1065
- const completed = await h({ m: 'POST', p: `/v1/checkout/sessions/${id(cs)}`, b: 'status=complete' });
2259
+ 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' });
2260
+ const completed = await h({ m: 'GET', p: `/v1/checkout/sessions/${id(cs)}` });
1066
2261
  if (!ok(completed) || field(completed, 'status') !== 'complete') return false;
1067
2262
  const ev = await h({ m: 'GET', p: '/v1/events?type=checkout.session.completed' });
1068
2263
  const rows = ((ev.body as Body)?.data ?? []) as Body[];
@@ -1071,6 +2266,105 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1071
2266
  && payload?.id === id(cs) && payload?.status === 'complete' && payload?.payment_status === 'paid';
1072
2267
  }),
1073
2268
  ),
2269
+ // Stripe.js (stripe-js.ts): the @stripe/stripe-js loader injects or accepts a script whose src matches
2270
+ // `/^https:\/\/js\.stripe\.com\/v3\/?(\?.*)?$/` or `/^https:\/\/js\.stripe\.com\/(v3|[a-z]+)\/stripe\.js(\?.*)?$/`
2271
+ // (@stripe/stripe-js 7.x dist/index.mjs) and reads `Stripe.version` (3 for v3, else the train's name). Each such path the
2272
+ // pack's js.stripe.com host claims is served a script defining Stripe with that version, and redirectToCheckout sends
2273
+ // the page to the session's hosted page, checkout.stripe.com/c/pay/{id}.
2274
+ done('stripe.js.loader', 'checkout', 'Stripe.js served at /v3, /v3/, /v3/stripe.js and /<train>/stripe.js; redirectToCheckout opens the hosted Checkout page', 'api', 'common', async () => {
2275
+ const root = mkdtempSync(join(tmpdir(), 'stp-js-'));
2276
+ try {
2277
+ const f = createStripeTwinFetch({ root });
2278
+ const host = pack.hosts?.find((x) => 'host' in x && x.host === 'js.stripe.com');
2279
+ const claims = (p: string) => !!host?.pathPattern && new RegExp(host.pathPattern).test(p);
2280
+ const load = async (p: string): Promise<{ version: unknown; opened: string[] } | undefined> => {
2281
+ const res = await f(new Request(`https://js.stripe.com${p}`));
2282
+ if (res.status !== 200 || !claims(p)) return undefined;
2283
+ const opened: string[] = [];
2284
+ const win: Record<string, unknown> = { location: { assign: (u: string) => opened.push(u) } };
2285
+ new Function('window', await res.text())(win);
2286
+ const Stripe = win.Stripe as ((key: string) => { redirectToCheckout: (o: { sessionId: string }) => unknown }) & { version: unknown };
2287
+ void Stripe('pk_test_twin').redirectToCheckout({ sessionId: 'cs_test_1' });
2288
+ return { version: Stripe.version, opened };
2289
+ };
2290
+ const [v3, v3slash, v3file, basil] = [await load('/v3'), await load('/v3/'), await load('/v3/stripe.js'), await load('/basil/stripe.js')];
2291
+ const other = await f(new Request('https://js.stripe.com/v3/other.js'));
2292
+ return [v3, v3slash, v3file].every((x) => x?.version === 3) && basil?.version === 'basil'
2293
+ && [v3, v3slash, v3file, basil].every((x) => x?.opened.length === 1 && x.opened[0] === 'https://checkout.stripe.com/c/pay/cs_test_1')
2294
+ && other.status === 404;
2295
+ } finally {
2296
+ rmSync(root, { recursive: true, force: true });
2297
+ }
2298
+ }),
2299
+ // The name customers see: the operator saves the business name on the Dashboard's Public details page
2300
+ // (dashboard.stripe.com/settings/public), which is the platform account's business_profile.name ("The customer-facing
2301
+ // business name", docs.stripe.com/api/accounts/object), and Checkout and the customer portal show it ("You can change a
2302
+ // Checkout page's name by modifying the Business name field", docs.stripe.com/payments/checkout/customization/appearance).
2303
+ done('stripe.dashboard.public_details', 'checkout', 'Dashboard Public details: the business name saved there is the account\'s business_profile.name, shown on Checkout and the customer portal', 'ui', 'common', async () => {
2304
+ const root = mkdtempSync(join(tmpdir(), 'stp-public-'));
2305
+ try {
2306
+ const f = createStripeTwinFetch({ root });
2307
+ const api = async (method: string, path: string, body?: string, account?: string) => (await f(new Request(`http://stripe.test${path}`, { method, headers: { 'content-type': 'application/x-www-form-urlencoded', ...(account ? { 'stripe-account': account } : {}) }, ...(body ? { body } : {}) }))).json() as Promise<Body>;
2308
+ const page = async (url: string) => (await f(new Request(url))).text();
2309
+ const save = (name: string) => f(new Request('https://dashboard.stripe.com/settings/public', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ business_name: name }).toString() }));
2310
+ const cs = await api('POST', '/v1/checkout/sessions', 'mode=payment&success_url=https://x.test&cancel_url=https://x.test/c&line_items[0][price_data][currency]=usd&line_items[0][price_data][unit_amount]=2500&line_items[0][price_data][product_data][name]=Pro&line_items[0][quantity]=1');
2311
+ const before = await page(`https://checkout.stripe.com/c/pay/${String(cs.id)}`);
2312
+ const empty = await save(' ');
2313
+ const saved = await save('Dub');
2314
+ const form = await page('https://dashboard.stripe.com/settings/public');
2315
+ const account = await api('GET', '/v1/account');
2316
+ const checkout = await page(`https://checkout.stripe.com/c/pay/${String(cs.id)}`);
2317
+ const cust = await api('POST', '/v1/customers', 'email=public@twin.test');
2318
+ const bps = await api('POST', '/v1/billing_portal/sessions', `customer=${String(cust.id)}&return_url=https://x.test/account`);
2319
+ const portal = await page(String(bps.url));
2320
+ // a second save changes the name in place and keeps the account's other public details
2321
+ await save('Dub Technologies');
2322
+ const again = await api('GET', '/v1/account');
2323
+ // each save is the account's update, the first included
2324
+ const updated = ((await api('GET', '/v1/events?type=account.updated')).data as Body[] | undefined) ?? [];
2325
+ // a session's own branding_settings.display_name wins; a direct charge's page shows the connected account's name
2326
+ const line = 'mode=payment&success_url=https://x.test&line_items[0][price_data][currency]=usd&line_items[0][price_data][unit_amount]=900&line_items[0][price_data][product_data][name]=Fern&line_items[0][quantity]=1';
2327
+ const branded = await page(String((await api('POST', '/v1/checkout/sessions', `${line}&branding_settings[display_name]=Dub Links`)).url));
2328
+ const grower = await api('POST', '/v1/accounts', 'type=standard&country=US&business_profile[name]=Grower Co');
2329
+ const directSession = await api('POST', '/v1/checkout/sessions', line, String(grower.id));
2330
+ const direct = await page(String(directSession.url));
2331
+ // the name is the page's only use of the account a session was made on: paying it keeps its events where they were
2332
+ // (the twin makes a hosted page's payment on the platform's books), all of them together
2333
+ await f(new Request(String(directSession.url), { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'email=fern%40x.test&cardNumber=4242424242424242&cardExpiry=12%2F34&cardCvc=123&billingName=Fern&billingCountry=US&billingPostalCode=94105' }));
2334
+ const completedHere = ((((await api('GET', '/v1/events?type=checkout.session.completed')).data as Body[] | undefined) ?? [])).filter((e) => ((e.data as Body)?.object as Body)?.id === directSession.id);
2335
+ const completedThere = ((await api('GET', '/v1/events?type=checkout.session.completed', undefined, String(grower.id))).data as Body[] | undefined) ?? [];
2336
+ const behalf = await page(String((await api('POST', '/v1/billing_portal/sessions', `customer=${String(cust.id)}&return_url=https://x.test/account&on_behalf_of=${String(grower.id)}`)).url));
2337
+ const subBehalf = await page(String((await api('POST', '/v1/checkout/sessions', `mode=subscription&success_url=https://x.test&line_items[0][price_data][currency]=usd&line_items[0][price_data][unit_amount]=900&line_items[0][price_data][recurring][interval]=month&line_items[0][price_data][product_data][name]=Fern&line_items[0][quantity]=1&subscription_data[on_behalf_of]=${String(grower.id)}`)).url));
2338
+ const onBehalf = await page(String((await api('POST', '/v1/checkout/sessions', `${line}&payment_intent_data[on_behalf_of]=${String(grower.id)}&payment_intent_data[transfer_data][destination]=${String(grower.id)}`)).url));
2339
+ const portalAsGrower = await page(String((await api('POST', '/v1/billing_portal/sessions', `customer=${String(cust.id)}&return_url=https://x.test/account`, String(grower.id))).url));
2340
+ // the page's trailing-slash address posts to itself and comes back to itself
2341
+ const slashed = await page('https://dashboard.stripe.com/settings/public/');
2342
+ const slashSave = await f(new Request('https://dashboard.stripe.com/settings/public/', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'business_name=Dub+Technologies' }));
2343
+ const landed = new URL(slashSave.headers.get('location') ?? '', 'https://dashboard.stripe.com/settings/public/').pathname;
2344
+ // the /_twin/account door, written first, starts the platform's record from the same default and is an update too
2345
+ const door = createStripeTwinFetch({ root: join(root, 'door') });
2346
+ await door(new Request('http://stripe.test/_twin/account', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'settings[payouts][schedule][interval]=manual&business_profile[support_email]=help@dub.test' }));
2347
+ // and a save on the page keeps the public details it does not show, as the door keeps the name the page saved
2348
+ await door(new Request('https://dashboard.stripe.com/settings/public', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'business_name=Dub' }));
2349
+ await door(new Request('http://stripe.test/_twin/account', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'business_profile[url]=https://dub.test' }));
2350
+ const doorAccount = await (await door(new Request('http://stripe.test/v1/account'))).json() as Body;
2351
+ const doorEvents = (((await (await door(new Request('http://stripe.test/v1/events?type=account.updated'))).json()) as Body).data as Body[] | undefined) ?? [];
2352
+ const long = await save('x'.repeat(5001));
2353
+ return before.includes('Twin Inc.') && !before.includes('Dub') && empty.status === 400 && saved.status === 303 && long.status === 400
2354
+ && updated.length === 2 && branded.includes('Pay Dub Links') && direct.includes('Pay Grower Co') && completedHere.length === 1 && !completedHere[0]!.account && completedThere.length === 0 && !direct.includes('Dub Technologies') && behalf.includes('Return to Grower Co')
2355
+ && saved.headers.get('location') === '?saved=1' && form.includes('value="Dub"') && !form.includes('action=')
2356
+ && onBehalf.includes('Pay Grower Co') && subBehalf.includes('<div class="pay-merchant">Grower Co</div>') && portalAsGrower.includes('Return to Grower Co')
2357
+ && slashed.includes('name="business_name"') && slashSave.status === 303 && landed === '/settings/public/'
2358
+ && doorAccount.created === 1767225600 && (doorAccount.capabilities as Body)?.card_payments === 'active' && doorEvents.length === 3
2359
+ && (doorAccount.business_profile as Body)?.support_email === 'help@dub.test' && (doorAccount.business_profile as Body)?.name === 'Dub' && (doorAccount.business_profile as Body)?.url === 'https://dub.test'
2360
+ && (((doorAccount.settings as Body)?.payouts as Body)?.schedule as Body)?.interval === 'manual'
2361
+ && (account.business_profile as Body)?.name === 'Dub' && account.id === 'acct_twin_self' && account.charges_enabled === true
2362
+ && checkout.includes('<div class="pay-merchant">Dub</div>') && checkout.includes('Pay Dub') && !checkout.includes('Twin Inc.')
2363
+ && portal.includes('Return to Dub') && portal.includes('<div class="portal-merchant">Dub</div>') && (again.business_profile as Body)?.name === 'Dub Technologies';
2364
+ } finally {
2365
+ rmSync(root, { recursive: true, force: true });
2366
+ }
2367
+ }),
1074
2368
  done('stripe.billing_portal.session', 'checkout', 'Billing Portal Session create (referential to customer)', 'api', 'common', () =>
1075
2369
  withRoot(async (h) => {
1076
2370
  const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=portal@twin.test' });
@@ -1078,6 +2372,34 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1078
2372
  return ok(bps) && field(bps, 'object') === 'billing_portal.session' && !!field(bps, 'url');
1079
2373
  }),
1080
2374
  ),
2375
+ // The portal's payment method and invoices: the customer saves a new card (the default their subscription charges)
2376
+ // and pays the open invoice a declined renewal left, which makes the subscription active
2377
+ // (docs.stripe.com/customer-management/configure-portal).
2378
+ done('stripe.billing_portal.payment_method_and_pay', 'checkout', 'Billing Portal: update the payment method and pay an open invoice', 'api', 'common', async () => {
2379
+ const root = mkdtempSync(join(tmpdir(), 'stp-portal-'));
2380
+ try {
2381
+ // February's reads come two hours into the renewed period: its invoice is drafted at the period's end and charged
2382
+ // "an hour on" (above; cab4389eb stamps each catch-up write at that moment), so a read at the period's very end
2383
+ // sees the draft, not the paid invoice this claim is about
2384
+ const h = (s: Step & { month?: number }) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: `2026-${String(s.month ?? 1).padStart(2, '0')}-10T${(s.month ?? 1) > 1 ? '02' : '00'}:00:00Z` });
2385
+ const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Box' });
2386
+ const price = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=3500&currency=usd&recurring[interval]=month&product=${id(prod)}` });
2387
+ const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=portal-pay@twin.test' });
2388
+ const sub = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&items[0][price]=${id(price)}&default_payment_method=pm_card_chargeCustomerFail&trial_period_days=7` });
2389
+ const open = (((await h({ m: 'GET', p: `/v1/invoices?subscription=${id(sub)}&status=open`, month: 2 })).body as Body).data as Body[])[0];
2390
+ const bps = await h({ m: 'POST', p: '/v1/billing_portal/sessions', b: `customer=${id(cust)}`, month: 2 });
2391
+ const flow = stripePortalFlow({ root, clock: () => '2026-02-10T00:00:00Z' });
2392
+ const post = (to: string, form: Record<string, string>) => flow(new Request(`https://billing.stripe.com/p/session/${id(bps)}/${to}`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(form).toString() }));
2393
+ const saved = await post('payment_method', { cardNumber: '4242424242424242', cardExpiry: '12/34', cardCvc: '123', billingName: 'Twin', billingCountry: 'US', billingPostalCode: '94105' });
2394
+ const paid = await post('pay', { invoice: String(open?.id) });
2395
+ const after = await h({ m: 'GET', p: `/v1/subscriptions/${id(sub)}`, month: 2 });
2396
+ const inv = await h({ m: 'GET', p: `/v1/invoices/${String(open?.id)}`, month: 2 });
2397
+ return saved?.status === 303 && paid?.status === 303 && field(inv, 'status') === 'paid'
2398
+ && field(after, 'status') === 'active' && field(after, 'default_payment_method') !== 'pm_card_chargeCustomerFail';
2399
+ } finally {
2400
+ rmSync(root, { recursive: true, force: true });
2401
+ }
2402
+ }),
1081
2403
  done('stripe.billing_portal.configuration', 'checkout', 'Billing Portal Configurations: create + retrieve + update + list', 'api', 'common', () =>
1082
2404
  withRoot(async (h) => {
1083
2405
  const c = await h({ m: 'POST', p: '/v1/billing_portal/configurations', b: 'business_profile[headline]=Hi&features[customer_update][enabled]=true' });
@@ -1087,8 +2409,6 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1087
2409
  return ok(g) && id(g) === id(c) && field(l, 'object') === 'list';
1088
2410
  }),
1089
2411
  ),
1090
- outOfScope('stripe.checkout.hosted_page', 'checkout', 'Hosted Checkout PAGE pixel rendering', 'ui', 'niche', 'Out of scope: the Session/API is modeled; the hosted PAGE pixels are out of scope.'),
1091
- outOfScope('stripe.billing_portal.hosted_page', 'checkout', 'Hosted Customer Portal PAGE pixel rendering', 'ui', 'niche', 'Out of scope: the Session/API is modeled; the hosted PAGE pixels are out of scope.'),
1092
2412
 
1093
2413
  // ── Payment Links / Quotes ────────────────────────────────────────────────────────
1094
2414
  // Payment Links: create (referential to a Price) → active link with a share url + line_items
@@ -1150,10 +2470,13 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1150
2470
  return ok(g) && ok(u) && ok(ll) && field(ll, 'object') === 'login_link' && ok(del) && field(del, 'deleted') === true;
1151
2471
  }),
1152
2472
  ),
1153
- done('stripe.connect.transfers', 'connect', 'Connect Transfers: create (referential to dest account) + retrieve + list', 'api', 'niche', () =>
2473
+ done('stripe.connect.transfers', 'connect', 'Connect Transfers: to an account whose transfers capability is active, from the platform\'s balance + retrieve + list', 'api', 'niche', () =>
1154
2474
  withRoot(async (h) => {
1155
- const a = await h({ m: 'POST', p: '/v1/accounts', b: 'type=express' });
1156
- const t = await h({ m: 'POST', p: '/v1/transfers', b: `amount=500&currency=usd&destination=${id(a)}` });
2475
+ const seller = await onboardedSeller(h);
2476
+ // an account still onboarding cannot receive transfers
2477
+ const fresh = await h({ m: 'POST', p: '/v1/accounts', b: 'type=express&capabilities[transfers][requested]=true' });
2478
+ if ((await h({ m: 'POST', p: '/v1/transfers', b: `amount=500&currency=usd&destination=${id(fresh)}` })).status !== 400) return false;
2479
+ const t = await h({ m: 'POST', p: '/v1/transfers', b: `amount=500&currency=usd&destination=${seller}` });
1157
2480
  if (!ok(t) || field(t, 'object') !== 'transfer') return false;
1158
2481
  const g = await h({ m: 'GET', p: `/v1/transfers/${id(t)}` });
1159
2482
  const l = await h({ m: 'GET', p: '/v1/transfers' });
@@ -1162,6 +2485,48 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1162
2485
  return ok(g) && field(l, 'object') === 'list' && bad.status >= 400;
1163
2486
  }),
1164
2487
  ),
2488
+ // docs.stripe.com/connect/separate-charges-and-transfers, "Transfer availability": a transfer tied to a charge succeeds
2489
+ // while the charge's funds are pending ("if the related charge hasn't settled yet"; once they have, it is held to the
2490
+ // available balance), never takes more than the charge (less what was reversed of it), takes the charge's
2491
+ // transfer_group over one the request names or, the charge having none, `group_` plus its PaymentIntent id, which the
2492
+ // charge takes too, and pays the destination when the charge's funds become available. (Dub pays its partners this
2493
+ // way: a card charge, then source_transaction.)
2494
+ done('stripe.connect.transfer_source_transaction', 'connect', 'Connect Transfers from a charge\'s pending funds (source_transaction)', 'api', 'common', async () => {
2495
+ const root = mkdtempSync(join(tmpdir(), 'stp-src-'));
2496
+ try {
2497
+ const h = (s: Step & { acct?: string; day?: number }) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: `2026-06-${String(s.day ?? 1).padStart(2, '0')}T12:00:00Z`, ...(s.acct ? { stripeAccount: s.acct } : {}) });
2498
+ const a = await h({ m: 'POST', p: '/v1/accounts', b: 'type=custom&country=US&capabilities[transfers][requested]=true&business_profile[mcc]=5462&business_profile[url]=https://seller.test&tos_acceptance[date]=1767225600&tos_acceptance[ip]=203.0.113.9&individual[first_name]=Sam&individual[last_name]=Seller&settings[payouts][schedule][interval]=manual' });
2499
+ const seller = id(a);
2500
+ await h({ m: 'POST', p: `/v1/accounts/${seller}/external_accounts`, b: 'external_account[object]=bank_account&external_account[country]=US&external_account[currency]=usd&external_account[routing_number]=110000000&external_account[account_number]=000123456789' });
2501
+ await h({ m: 'POST', p: '/_twin/account', b: 'settings[payouts][schedule][interval]=manual' });
2502
+ const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=2592&currency=usd&payment_method=pm_card_visa&confirm=true&transfer_group=inv_7' });
2503
+ const charge = field(pi, 'latest_charge') as string;
2504
+ const t = await h({ m: 'POST', p: '/v1/transfers', b: `amount=2400&currency=usd&destination=${seller}&source_transaction=${charge}&transfer_group=mine` });
2505
+ const over = await h({ m: 'POST', p: '/v1/transfers', b: `amount=500&currency=usd&destination=${seller}&source_transaction=${charge}` });
2506
+ const sellerBalance = await h({ m: 'GET', p: '/v1/balance', acct: seller });
2507
+ const pending = ((sellerBalance.body as Body).pending as Array<{ amount: number }> | undefined)?.[0]?.amount;
2508
+ if (!ok(pi) || !ok(t) || field(t, 'source_transaction') !== charge || field(t, 'transfer_group') !== 'inv_7' || over.status !== 400 || pending !== 2400) return false;
2509
+ // what was reversed of a transfer is room again
2510
+ await h({ m: 'POST', p: `/v1/transfers/${id(t)}/reversals`, b: 'amount=400' });
2511
+ const again = await h({ m: 'POST', p: '/v1/transfers', b: `amount=500&currency=usd&destination=${seller}&source_transaction=${charge}` });
2512
+ // no transfer_group on the charge: group_ plus its PaymentIntent id, on the transfer and on the charge
2513
+ const plain = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=2000&currency=usd&payment_method=pm_card_visa&confirm=true' });
2514
+ const plainCharge = field(plain, 'latest_charge') as string;
2515
+ const grouped = await h({ m: 'POST', p: '/v1/transfers', b: `amount=500&currency=usd&destination=${seller}&source_transaction=${plainCharge}` });
2516
+ const chargeAfter = await h({ m: 'GET', p: `/v1/charges/${plainCharge}` });
2517
+ if (!ok(again) || field(grouped, 'transfer_group') !== `group_${id(plain)}` || field(chargeAfter, 'transfer_group') !== `group_${id(plain)}`) return false;
2518
+ // day 5: both charges have settled, so a transfer from one is held to the platform's available balance
2519
+ // (2487 + 1912 − 2400 + 400 − 500 − 500 = 1399)
2520
+ const tooMuch = await h({ m: 'POST', p: '/v1/transfers', b: `amount=1500&currency=usd&destination=${seller}&source_transaction=${plainCharge}`, day: 5 });
2521
+ const fits = await h({ m: 'POST', p: '/v1/transfers', b: `amount=1000&currency=usd&destination=${seller}&source_transaction=${plainCharge}`, day: 5 });
2522
+ // an authorization is no source of funds until it is captured (the twin's rule: the docs are silent)
2523
+ const held = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=900&currency=usd&capture_method=manual&payment_method=pm_card_visa&confirm=true', day: 5 });
2524
+ const fromHold = await h({ m: 'POST', p: '/v1/transfers', b: `amount=100&currency=usd&destination=${seller}&source_transaction=${String(field(held, 'latest_charge'))}`, day: 5 });
2525
+ const sellerAfter = (((await h({ m: 'GET', p: '/v1/transfers', day: 5 })).body as Body).data as Body[]) ?? [];
2526
+ return tooMuch.status === 400 && ((tooMuch.body as Body).error as Body)?.code === 'balance_insufficient' && ok(fits)
2527
+ && fromHold.status === 400 && !sellerAfter.some((x) => x.source_transaction === field(held, 'latest_charge'));
2528
+ } finally { rmSync(root, { recursive: true, force: true }); }
2529
+ }),
1165
2530
  // (account_links, persons, external_accounts, transfer_reversals, application_fees,
1166
2531
  // capabilities upgraded to done() in the AUDIT GROWTH block below.)
1167
2532
  // Connect payouts (Stripe-Account): a payout created WITH a Stripe-Account header is attributed
@@ -1171,8 +2536,10 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1171
2536
  const root = mkdtempSync(join(tmpdir(), 'stp-cpo-'));
1172
2537
  try {
1173
2538
  const h = (s: Step & { acct?: string }) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, ...(s.acct ? { stripeAccount: s.acct } : {}) });
1174
- const acct = await h({ m: 'POST', p: '/v1/accounts', b: 'type=express&country=US' });
1175
- // a platform payout (no header) and a connected-account payout (with header).
2539
+ const seller = await onboardedSeller(h);
2540
+ const acct = { status: 200, body: { id: seller } } as StripeResponse;
2541
+ await h({ m: 'POST', p: '/v1/transfers', b: `amount=3000&currency=usd&destination=${seller}` });
2542
+ // a platform payout (no header) and a connected-account payout (with header), each from its own balance.
1176
2543
  const plat = await h({ m: 'POST', p: '/v1/payouts', b: 'amount=1000&currency=usd' });
1177
2544
  const conn = await h({ m: 'POST', p: '/v1/payouts', b: 'amount=2000&currency=usd', acct: id(acct) });
1178
2545
  if (!ok(plat) || !ok(conn)) return false;
@@ -1196,27 +2563,106 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1196
2563
  // Top-ups: POST /v1/topups funds the platform balance (amount + currency required); the twin
1197
2564
  // succeeds a test top-up immediately. retrieve/list/update round-trip; a succeeded top-up
1198
2565
  // cannot be canceled (400); missing money 400; unknown id 404.
1199
- done('stripe.connect.top_ups', 'connect', 'Top-ups (fund platform balance)', 'api', 'niche', () =>
1200
- withRoot(async (h) => {
2566
+ // Connect webhooks (docs.stripe.com/connect/webhooks): an endpoint created with `connect` true receives the Connected
2567
+ // accounts scope, one without it Your account; "Each event for a connected account contains a top-level `account`
2568
+ // property that identifies the connected account". A write made as the account (the Stripe-Account header) and the
2569
+ // account's own account.updated are its events; the platform's own writes are the platform's.
2570
+ done('stripe.connect.webhook_scope', 'connect', 'Connect webhooks: a connected account\'s events reach only connect endpoints, with top-level account; the platform\'s only the others', 'api', 'common', async () => {
2571
+ const root = mkdtempSync(join(tmpdir(), 'stp-scope-'));
2572
+ const rx = await webhookReceiver();
2573
+ try {
2574
+ const h = fetcher(createStripeTwinFetch({ root }));
2575
+ await h('POST', '/v1/webhook_endpoints', `url=${rx.url('/platform')}&enabled_events[]=*`);
2576
+ await h('POST', '/v1/webhook_endpoints', `url=${rx.url('/connect')}&enabled_events[]=*&connect=true`);
2577
+ const seller = String((await h('POST', '/v1/accounts', 'type=express&country=US')).id);
2578
+ // the account's own event: an update "Occurs whenever an account status or property has changed"
2579
+ // (account.updated, docs.stripe.com/api/events/types). Its creation was asserted to send one when this claim was
2580
+ // written (788199389), which no version of the twin did; an Express account's creation changes nothing Stripe
2581
+ // reviews here (semantics/connect.ts review: an Express account is onboarded on Stripe's pages).
2582
+ await h('POST', `/v1/accounts/${seller}`, 'metadata[tier]=gold');
2583
+ const theirs = String((await h('POST', '/v1/customers', 'email=buyer@seller.test', seller)).id);
2584
+ const ours = String((await h('POST', '/v1/customers', 'email=buyer@platform.test')).id);
2585
+ const at = (path: string, type: string, object: string) => rx.got(path).filter((e) => e.type === type && (e.data.object as Body).id === object);
2586
+ const theirsC = at('/connect', 'customer.created', theirs);
2587
+ const oursP = at('/platform', 'customer.created', ours);
2588
+ return theirsC.length === 1 && theirsC[0]!.account === seller && at('/platform', 'customer.created', theirs).length === 0
2589
+ && oursP.length === 1 && !('account' in oursP[0]!) && at('/connect', 'customer.created', ours).length === 0
2590
+ && rx.got('/connect').some((e) => e.type === 'account.updated' && e.account === seller)
2591
+ && !rx.got('/platform').some((e) => e.type === 'account.updated');
2592
+ } finally {
2593
+ rx.close();
2594
+ rmSync(root, { recursive: true, force: true });
2595
+ }
2596
+ }),
2597
+ // A connected account's event is its own even when no request acted as it: the automatic payout World time makes for
2598
+ // it (semantics/balance.ts) goes to connect endpoints with `account`. The Events API answers each account its own
2599
+ // events: with the Stripe-Account header that account's, without it the platform's (semantics/webhook-endpoints.ts).
2600
+ done('stripe.connect.event_scope', 'connect', 'A connected account\'s automatic payout event is scoped to it; the Events API lists and retrieves per account', 'api', 'common', async () => {
2601
+ const root = mkdtempSync(join(tmpdir(), 'stp-evscope-'));
2602
+ const rx = await webhookReceiver();
2603
+ try {
2604
+ let now = '2026-06-01T12:00:00.000Z';
2605
+ const h = fetcher(createStripeTwinFetch({ root, clock: () => now }));
2606
+ await h('POST', '/v1/webhook_endpoints', `url=${rx.url('/platform')}&enabled_events[]=*`);
2607
+ await h('POST', '/v1/webhook_endpoints', `url=${rx.url('/connect')}&enabled_events[]=*&connect=true`);
2608
+ const seller = String((await h('POST', '/v1/accounts', 'type=custom&country=US&capabilities[transfers][requested]=true&business_profile[mcc]=5462&business_profile[url]=https://seller.test&tos_acceptance[date]=1767225600&tos_acceptance[ip]=203.0.113.9&individual[first_name]=Sam&individual[last_name]=Seller')).id);
2609
+ await h('POST', `/v1/accounts/${seller}/external_accounts`, 'external_account[object]=bank_account&external_account[country]=US&external_account[currency]=usd&external_account[routing_number]=110000000&external_account[account_number]=000123456789');
2610
+ await h('POST', '/v1/charges', 'amount=100000&currency=usd&source=tok_bypassPending');
2611
+ await h('POST', '/v1/transfers', `amount=5000&currency=usd&destination=${seller}`);
2612
+ now = '2026-06-03T12:00:00.000Z';
2613
+ await h('GET', '/v1/balance');
2614
+ const theirPayout = rx.got('/connect').find((e) => e.type === 'payout.created');
2615
+ if (theirPayout?.account !== seller || (theirPayout.data.object as Body).amount !== 5000) return false;
2616
+ if (rx.got('/platform').some((e) => e.type === 'payout.created' && (e.data.object as Body).id === (theirPayout.data.object as Body).id)) return false;
2617
+ const theirs = ((await h('GET', '/v1/events?limit=100', undefined, seller)).data as Body[]) ?? [];
2618
+ const ours = ((await h('GET', '/v1/events?limit=100')).data as Body[]) ?? [];
2619
+ const listed = theirs.find((e) => e.type === 'payout.created');
2620
+ const hidden = await h('GET', `/v1/events/${String(listed?.id)}`);
2621
+ const shown = await h('GET', `/v1/events/${String(listed?.id)}`, undefined, seller);
2622
+ return theirs.length > 0 && theirs.every((e) => e.account === seller) && listed !== undefined
2623
+ && ours.length > 0 && ours.every((e) => !('account' in e)) && ours.some((e) => e.type === 'transfer.created')
2624
+ && (hidden.error as Body | undefined)?.code === 'resource_missing' && shown.id === listed.id && shown.account === seller;
2625
+ } finally {
2626
+ rx.close();
2627
+ rmSync(root, { recursive: true, force: true });
2628
+ }
2629
+ }),
2630
+ // A top-up is pending until its funds arrive five days on ("USA (USD) ACH Debit Transfer 5 days",
2631
+ // docs.stripe.com/connect/top-ups; semantics/terminal.ts, c3519a064), and only a pending one can be canceled; a
2632
+ // succeeded one cannot. The verify runs on a World clock it moves, so the arrival is chosen, not waited for.
2633
+ done('stripe.connect.top_ups', 'connect', 'Top-ups (fund platform balance)', 'api', 'niche', async () => {
2634
+ const root = mkdtempSync(join(tmpdir(), 'stp-topup-'));
2635
+ try {
2636
+ const h = (s: Step & { day?: number }) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: `2026-06-${String(s.day ?? 1).padStart(2, '0')}T12:00:00Z` });
1201
2637
  const tu = await h({ m: 'POST', p: '/v1/topups', b: 'amount=50000&currency=usd&statement_descriptor=Top up' });
1202
- if (!ok(tu) || field(tu, 'object') !== 'topup' || field(tu, 'status') !== 'succeeded') return false;
2638
+ if (!ok(tu) || field(tu, 'object') !== 'topup' || field(tu, 'status') !== 'pending' || field(tu, 'expected_availability_date') !== Number(field(tu, 'created')) + 5 * 86_400) return false;
1203
2639
  const g = await h({ m: 'GET', p: `/v1/topups/${id(tu)}` });
1204
2640
  const u = await h({ m: 'POST', p: `/v1/topups/${id(tu)}`, b: 'metadata[ref]=q3' });
1205
- const l = await h({ m: 'GET', p: '/v1/topups?status=succeeded' });
1206
- // a succeeded top-up cannot be canceled.
1207
- const cancel = await h({ m: 'POST', p: `/v1/topups/${id(tu)}/cancel` });
2641
+ const pendingList = await h({ m: 'GET', p: '/v1/topups?status=pending' });
2642
+ // a second one, canceled while it is still pending
2643
+ const other = await h({ m: 'POST', p: '/v1/topups', b: 'amount=100&currency=usd' });
2644
+ const canceled = await h({ m: 'POST', p: `/v1/topups/${id(other)}/cancel` });
2645
+ // six days on the first has arrived: succeeded, and past canceling
2646
+ const arrived = await h({ m: 'GET', p: `/v1/topups/${id(tu)}`, day: 7 });
2647
+ const l = await h({ m: 'GET', p: '/v1/topups?status=succeeded', day: 7 });
2648
+ const cancel = await h({ m: 'POST', p: `/v1/topups/${id(tu)}/cancel`, day: 7 });
1208
2649
  const noMoney = await h({ m: 'POST', p: '/v1/topups', b: 'currency=usd' });
1209
2650
  const nope = await h({ m: 'GET', p: '/v1/topups/tu_nope' });
1210
- return ok(g) && id(g) === id(tu) && ok(u) && ((l.body as Body).data as Body[]).length === 1 &&
1211
- cancel.status === 400 && noMoney.status === 400 && nope.status === 404;
1212
- }),
1213
- ),
2651
+ return ok(g) && id(g) === id(tu) && ok(u) && ((pendingList.body as Body).data as Body[]).length === 1
2652
+ && ok(canceled) && field(canceled, 'status') === 'canceled'
2653
+ && field(arrived, 'status') === 'succeeded' && typeof field(arrived, 'balance_transaction') === 'string'
2654
+ && ((l.body as Body).data as Body[]).length === 1 && cancel.status === 400 && noMoney.status === 400 && nope.status === 404;
2655
+ } finally {
2656
+ rmSync(root, { recursive: true, force: true });
2657
+ }
2658
+ }),
1214
2659
 
1215
2660
  // ── Identity / Files ──────────────────────────────────────────────────────────────
1216
2661
  done('stripe.identity.verification_sessions', 'identity', 'Identity VerificationSessions: create + retrieve', 'api', 'niche', () =>
1217
2662
  withRoot(async (h) => {
1218
2663
  const v = await h({ m: 'POST', p: '/v1/identity/verification_sessions', b: 'type=document' });
1219
- if (!ok(v) || field(v, 'object') !== 'verification_session') return false;
2664
+ // the object is named by its namespace: "object": "identity.verification_session" (docs.stripe.com/api/identity/verification_sessions/object)
2665
+ if (!ok(v) || field(v, 'object') !== 'identity.verification_session' || field(v, 'status') !== 'requires_input') return false;
1220
2666
  const g = await h({ m: 'GET', p: `/v1/identity/verification_sessions/${id(v)}` });
1221
2667
  return ok(g) && id(g) === id(v);
1222
2668
  }),
@@ -1226,19 +2672,24 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1226
2672
  // the old fabricating path returned 200 for a missing file and would fail this assertion.
1227
2673
  done('stripe.file_links.create', 'files', 'FileLinks: create (requires file)', 'api', 'niche', () =>
1228
2674
  withRoot(async (h) => {
1229
- const f = await h({ m: 'POST', p: '/v1/file_links', b: 'file=file_twin' });
1230
- if (!ok(f) || field(f, 'object') !== 'file_link' || field(f, 'file') !== 'file_twin') return false;
2675
+ const up = await h({ m: 'POST', p: '/v1/files', b: 'purpose=dispute_evidence&file=evidence.png' });
2676
+ const f = await h({ m: 'POST', p: '/v1/file_links', b: `file=${id(up)}` });
2677
+ if (!ok(f) || field(f, 'object') !== 'file_link' || field(f, 'file') !== id(up)) return false;
2678
+ // a file of a purpose the link's file parameter does not list is refused
2679
+ const logo = await h({ m: 'POST', p: '/v1/files', b: 'purpose=issuing_logo&file=logo.png' });
2680
+ if ((await h({ m: 'POST', p: '/v1/file_links', b: `file=${id(logo)}` })).status !== 400) return false;
1231
2681
  const missing = await h({ m: 'POST', p: '/v1/file_links', b: 'expires_at=0' });
1232
2682
  return missing.status === 400 && ((missing.body as Body).error as Body)?.code === 'parameter_missing';
1233
2683
  }),
1234
2684
  ),
1235
2685
  // (files.upload upgraded to done() in the AUDIT GROWTH block below.)
1236
- // Identity VerificationReports: materialize from a verified session (test helper
1237
- // POST .../verify), then list (filterable by verification_session) + retrieve. Unknown id 404.
2686
+ // Identity VerificationReports: the person verifies on verify.stripe.com, which makes the report; then list
2687
+ // (filterable by verification_session) + retrieve. Unknown id 404.
1238
2688
  done('stripe.identity.verification_reports', 'identity', 'Identity VerificationReports', 'api', 'niche', () =>
1239
- withRoot(async (h) => {
2689
+ withRoot(async (h, root) => {
1240
2690
  const vs = await h({ m: 'POST', p: '/v1/identity/verification_sessions', b: 'type=document' });
1241
- const ver = await h({ m: 'POST', p: `/v1/identity/verification_sessions/${id(vs)}/verify` });
2691
+ await hostedSubmit(root, stripeIdentityFlow, `https://verify.stripe.com/start/${id(vs)}`, { outcome: 'verified' });
2692
+ const ver = await h({ m: 'GET', p: `/v1/identity/verification_sessions/${id(vs)}` });
1242
2693
  if (!ok(ver) || field(ver, 'status') !== 'verified') return false;
1243
2694
  const reportId = field(ver, 'last_verification_report') as string;
1244
2695
  if (!reportId?.startsWith('vr_')) return false;
@@ -1253,10 +2704,12 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1253
2704
  ),
1254
2705
 
1255
2706
  // ── Events / Webhooks / Idempotency ───────────────────────────────────────────────
1256
- done('stripe.events.list', 'events', 'Events: create + retrieve + list', 'api', 'core', () =>
2707
+ done('stripe.events.list', 'events', 'Events: a write records its event; retrieve + list', 'api', 'core', () =>
1257
2708
  withRoot(async (h) => {
1258
- const e = await h({ m: 'POST', p: '/v1/events', b: 'type=twin.synthetic' });
1259
- if (!ok(e) || field(e, 'object') !== 'event') return false;
2709
+ await h({ m: 'POST', p: '/v1/customers', b: 'email=events@twin.test' });
2710
+ const first = (((await h({ m: 'GET', p: '/v1/events' })).body as Body).data as Body[])[0]!;
2711
+ const e = { status: 200, body: first } as StripeResponse;
2712
+ if (field(e, 'object') !== 'event' || field(e, 'type') !== 'customer.created') return false;
1260
2713
  const g = await h({ m: 'GET', p: `/v1/events/${id(e)}` });
1261
2714
  const l = await h({ m: 'GET', p: '/v1/events' });
1262
2715
  return ok(g) && id(g) === id(e) && field(l, 'object') === 'list';
@@ -1284,7 +2737,17 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1284
2737
  if (!report.ok || posts.length !== 1) return false;
1285
2738
  // verified with the same scheme stripe.webhooks.constructEvent implements
1286
2739
  const evt = constructEvent(posts[0]!.body, posts[0]!.headers['stripe-signature']!, secret);
1287
- if (evt.type !== 'invoice.paid' || (evt.data.object as Body).id !== id(inv)) return false;
2740
+ if (evt.type !== 'invoice.paid' || (evt.data.object as Body).id !== id(inv) || 'account' in evt) return false;
2741
+ // a connected account's object re-fires as its event: a payout kept on its books carries the account
2742
+ // (at noon: an automatic payout, made at midnight, would take the funds first)
2743
+ const noon = (m: string, p: string, b?: string, acct?: string) => handleStripeTwinRequest({ method: m, path: p, body: b, root, occurredAt: '2026-08-01T12:00:00Z', ...(acct ? { stripeAccount: acct } : {}) });
2744
+ const seller = await onboardedSeller((s) => noon(s.m, s.p, s.b));
2745
+ await noon('POST', '/v1/transfers', `amount=5000&currency=usd&destination=${seller}`);
2746
+ const po = await noon('POST', '/v1/payouts', 'amount=4000&currency=usd', seller);
2747
+ await H('POST', '/v1/webhook_endpoints', `url=${encodeURIComponent('http://127.0.0.1:1/connect')}&enabled_events[0]=payout.*&connect=true`);
2748
+ const scoped: string[] = [];
2749
+ await emitTwinEvent(stripeEmitter, { type: 'payout.created', subjectId: id(po), root, fetchFn: async (_url, init) => { scoped.push(init.body); return { status: 200 }; } });
2750
+ if (scoped.length === 0 || !scoped.every((b) => (JSON.parse(b) as Body).account === seller)) return false;
1288
2751
  // unknown subject → LOUD, listing what exists
1289
2752
  return await emitTwinEvent(stripeEmitter, { type: 'invoice.paid', subjectId: 'in_twin_999', root }).then(() => false, (e: unknown) => /no invoice "in_twin_999"/.test(String(e)));
1290
2753
  });
@@ -1412,7 +2875,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1412
2875
  withRoot(async (h) => {
1413
2876
  const cus = await h({ m: 'POST', p: '/v1/customers', b: 'email=ev@example.com' });
1414
2877
  if (!ok(cus)) return false;
1415
- const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=2000&currency=usd' });
2878
+ const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=2000&currency=usd&transfer_group=inv_42' });
1416
2879
  const conf = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/confirm`, b: 'payment_method=pm_card_visa' });
1417
2880
  const pm = await h({ m: 'POST', p: '/v1/payment_methods', b: 'type=card&card[number]=4242424242424242&card[exp_month]=4&card[exp_year]=2030' });
1418
2881
  const att = await h({ m: 'POST', p: `/v1/payment_methods/${id(pm)}/attach`, b: `customer=${id(cus)}` });
@@ -1421,8 +2884,11 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1421
2884
  if (!ok(list) || (list.body as Body).object !== 'list') return false;
1422
2885
  const events = (list.body as Body).data as Array<Record<string, unknown>>;
1423
2886
  const types = new Set(events.map((e) => e.type as string));
1424
- const wanted = ['customer.created', 'payment_intent.created', 'payment_intent.succeeded', 'payment_method.attached'];
1425
- if (!wanted.every((t) => types.has(t))) return false;
2887
+ const wanted = ['customer.created', 'payment_intent.created', 'payment_intent.succeeded', 'charge.succeeded', 'payment_method.attached'];
2888
+ if (!wanted.every((t) => types.has(t)) || types.has('charge.created')) return false;
2889
+ // the payment's charge is the event's object and carries the intent's transfer_group (Dub reads its payout invoice from it)
2890
+ const charged = (events.find((e) => e.type === 'charge.succeeded')?.data as Body)?.object as Body | undefined;
2891
+ if (charged?.object !== 'charge' || charged.payment_intent !== id(pi) || charged.transfer_group !== 'inv_42') return false;
1426
2892
  // envelope shape: object 'event', carries the resource snapshot under data.object
1427
2893
  const succeeded = events.find((e) => e.type === 'payment_intent.succeeded');
1428
2894
  if (!succeeded || succeeded.object !== 'event' || ((succeeded.data as Body)?.object as Body)?.status !== 'succeeded') return false;
@@ -1447,8 +2913,8 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1447
2913
  const e1 = await h({ m: 'GET', p: `/v1/payment_intents/${id(pi)}?expand[]=customer` });
1448
2914
  if (!ok(e1) || ((field(e1, 'customer') as Body)?.object) !== 'customer' || (field(e1, 'customer') as Body)?.id !== id(cust)) return false;
1449
2915
  // a charge whose customer + payment_intent chain expands deeply (charge.payment_intent.customer).
1450
- const ch = await h({ m: 'POST', p: `/v1/charges`, b: `amount=1000&currency=usd&customer=${id(cust)}&payment_intent=${id(pi)}` });
1451
- const e2 = await h({ m: 'GET', p: `/v1/charges/${id(ch)}?expand[]=payment_intent.customer` });
2916
+ const ch = await h({ m: 'POST', p: '/v1/payment_intents', b: `amount=1000&currency=usd&customer=${id(cust)}&payment_method=pm_card_visa&confirm=true` });
2917
+ const e2 = await h({ m: 'GET', p: `/v1/charges/${String(field(ch, 'latest_charge'))}?expand[]=payment_intent.customer` });
1452
2918
  const expandedPi = field(e2, 'payment_intent') as Body;
1453
2919
  if (!expandedPi || expandedPi.object !== 'payment_intent' || (expandedPi.customer as Body)?.id !== id(cust)) return false;
1454
2920
  // list-level expand (expand[]=data.customer) expands the field on every row.
@@ -1511,54 +2977,63 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1511
2977
  const events = await handleStripeTwinRequest({ method: 'GET', path: '/v1/events', root });
1512
2978
  const ev = ((events.body as Body).data as Body[]).find((e) => e.type === 'payment_intent.created');
1513
2979
  if (!ev || ev.api_version !== '2022-11-15') return false;
1514
- // no version → the twin default.
2980
+ // no version → the version the pack serves, its vendored spec's (stripe-version.ts SERVED_VERSION; "answers
2981
+ // render in the served API version", 24e757f60), as a request that pins none gets the account's
1515
2982
  const pi2 = await handleStripeTwinRequest({ method: 'POST', path: '/v1/payment_intents', body: 'amount=500&currency=usd', root });
1516
2983
  const ev2 = (((await handleStripeTwinRequest({ method: 'GET', path: '/v1/events', root })).body as Body).data as Body[])
1517
2984
  .find((e) => e.type === 'payment_intent.created' && ((e.data as Body)?.object as Body)?.id === id(pi2));
1518
- return ev2 ? ev2.api_version === '2024-06-20' : false;
2985
+ return ev2 ? ev2.api_version === SERVED_VERSION && SERVED_VERSION > '2025-03-31' : false;
1519
2986
  } catch (err) { if (isInfrastructureError(err)) throw harnessError('stripe.api.versioning', err); return false; } finally { rmSync(root, { recursive: true, force: true }); }
1520
2987
  }),
1521
2988
 
1522
2989
  // ── Test helpers ──────────────────────────────────────────────────────────────────
1523
2990
  // Test clocks: create a clock at a frozen_time, then advance it; advancing past a trialing
1524
2991
  // subscription's trial_end transitions that sub trialing→active (the real clock-tick effect).
1525
- // Advancing backwards 400s; missing frozen_time 400s; delete removes it. (the clock object +
1526
- // the trial→active transition on advance are produced ONLY by this feature.)
2992
+ // Advancing backwards 400s; advancing while advancing 400s test_clock_not_ready; missing frozen_time 400s; delete
2993
+ // removes it.
1527
2994
  done('stripe.test_clocks', 'test-helpers', 'Test clocks (advance time for subscription/invoice)', 'api', 'common', () =>
1528
- withRoot(async (h) => {
2995
+ withRoot(async (h, root) => {
1529
2996
  const clock = await h({ m: 'POST', p: '/v1/test_helpers/test_clocks', b: 'frozen_time=1000&name=T' });
1530
2997
  if (!ok(clock) || field(clock, 'object') !== 'test_helpers.test_clock' || field(clock, 'frozen_time') !== 1000) return false;
1531
- // a trialing subscription whose trial_end is 5000.
1532
- const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=clock@twin.test' });
2998
+ // a trialing subscription whose trial_end is 5000, for a customer ON the clock: a clock moves only what is attached
2999
+ // to it ("create a customer with the test_clock parameter", docs.stripe.com/billing/testing/test-clocks/api-advanced-usage),
3000
+ // and a trial_end is refused unless it is in the future (subscriptions.ts trialEndRefused) — for a clock's customer,
3001
+ // the clock's future. The customer was left off the clock when this was written, which its 1970 trial_end cannot survive.
3002
+ const cust = await h({ m: 'POST', p: '/v1/customers', b: `email=clock@twin.test&test_clock=${id(clock)}` });
1533
3003
  const sub = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&trial_end=5000` });
1534
3004
  if (field(sub, 'status') !== 'trialing') return false;
1535
- // advancing PAST trial_end transitions the sub to active.
3005
+ // advancing PAST trial_end: the clock advances for a few seconds (a second advance meanwhile is refused), then the
3006
+ // trial has ended and the sub is active
1536
3007
  const adv = await h({ m: 'POST', p: `/v1/test_helpers/test_clocks/${id(clock)}/advance`, b: 'frozen_time=6000' });
1537
- if (!ok(adv) || field(adv, 'frozen_time') !== 6000) return false;
1538
- const subAfter = await h({ m: 'GET', p: `/v1/subscriptions/${id(sub)}` });
3008
+ if (!ok(adv) || field(adv, 'status') !== 'advancing') return false;
3009
+ const busy = await h({ m: 'POST', p: `/v1/test_helpers/test_clocks/${id(clock)}/advance`, b: 'frozen_time=7000' });
3010
+ if (busy.status !== 400 || ((busy.body as Body).error as Body)?.code !== 'test_clock_not_ready') return false;
3011
+ const later = new Date(Date.now() + 10_000).toISOString();
3012
+ const subAfter = await handleStripeTwinRequest({ method: 'GET', path: `/v1/subscriptions/${id(sub)}`, root, occurredAt: later });
1539
3013
  if (field(subAfter, 'status') !== 'active') return false;
1540
- const back = await h({ m: 'POST', p: `/v1/test_helpers/test_clocks/${id(clock)}/advance`, b: 'frozen_time=1' });
3014
+ const back = await handleStripeTwinRequest({ method: 'POST', path: `/v1/test_helpers/test_clocks/${id(clock)}/advance`, body: 'frozen_time=1', root, occurredAt: later });
1541
3015
  const noTime = await h({ m: 'POST', p: '/v1/test_helpers/test_clocks' });
1542
3016
  const del = await h({ m: 'DELETE', p: `/v1/test_helpers/test_clocks/${id(clock)}` });
1543
3017
  const gone = await h({ m: 'GET', p: `/v1/test_helpers/test_clocks/${id(clock)}` });
1544
3018
  return back.status === 400 && noTime.status === 400 && ok(del) && field(del, 'deleted') === true && gone.status === 404;
1545
3019
  }),
1546
3020
  ),
1547
- // Test-helper Issuing endpoints: fund_balance accrues the issuing balance; present an
3021
+ // Issuing: a top-up to the Issuing balance funds it; the test-helper endpoints present an
1548
3022
  // authorization (the test-mode way to simulate card usage) → a 'pending' authorization
1549
3023
  // awaiting approve/decline; create_force_capture lands a settled transaction directly.
1550
3024
  // Missing required params 400; unknown card 400. (Exercised together with the issuing family.)
1551
- done('stripe.test_helpers.issuing', 'test-helpers', 'Test-helper endpoints (fund balance, present authorization)', 'api', 'niche', () =>
1552
- withRoot(async (h) => {
1553
- const fund = await h({ m: 'POST', p: '/v1/test_helpers/issuing/fund_balance', b: 'amount=100000&currency=usd' });
1554
- if (!ok(fund) || ((field(fund, 'issuing') as Body)?.available as Body[])[0]?.amount !== 100000) return false;
3025
+ done('stripe.test_helpers.issuing', 'test-helpers', 'Test-helper endpoints (present authorization, force capture), on an Issuing balance a top-up funded', 'api', 'niche', () =>
3026
+ withRoot(async (h, root) => {
3027
+ const fund = await fundIssuing(root, 100000);
3028
+ const bal = await h({ m: 'GET', p: '/v1/balance' });
3029
+ if (!ok(fund) || ((field(bal, 'issuing') as Body)?.available as Body[])[0]?.amount !== 100000) return false;
1555
3030
  const ch = await h({ m: 'POST', p: '/v1/issuing/cardholders', b: 'name=Jane&type=individual&billing[address][line1]=1 Main&billing[address][city]=SF&billing[address][country]=US&billing[address][postal_code]=94105&billing[address][state]=CA' });
1556
3031
  const card = await h({ m: 'POST', p: `/v1/issuing/cards`, b: `cardholder=${id(ch)}&currency=usd&type=virtual` });
1557
3032
  const auth = await h({ m: 'POST', p: '/v1/test_helpers/issuing/authorizations', b: `card=${id(card)}&amount=2500` });
1558
3033
  if (!ok(auth) || field(auth, 'status') !== 'pending' || (field(auth, 'card') as Body)?.id !== id(card) || field(auth, 'cardholder') !== id(ch)) return false;
1559
3034
  const forced = await h({ m: 'POST', p: '/v1/test_helpers/issuing/transactions/create_force_capture', b: `card=${id(card)}&amount=1000` });
1560
3035
  if (!ok(forced) || field(forced, 'type') !== 'capture' || field(forced, 'amount') !== -1000) return false;
1561
- const noAmt = await h({ m: 'POST', p: '/v1/test_helpers/issuing/fund_balance', b: 'currency=usd' });
3036
+ const noAmt = await h({ m: 'POST', p: '/v1/topups', b: 'currency=usd&destination_balance=issuing' });
1562
3037
  const badCard = await h({ m: 'POST', p: '/v1/test_helpers/issuing/authorizations', b: 'card=ic_nope&amount=100' });
1563
3038
  return noAmt.status === 400 && badCard.status === 400;
1564
3039
  }),
@@ -1611,20 +3086,18 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1611
3086
  // the running mirror server → render the mirror's OWN ListPane over both → assert one
1612
3087
  // list-row per seeded object carrying the seeded name. Failable: an empty workspace yields
1613
3088
  // zero rows → zero list-rows.
1614
- done('stripe.ui.tax', 'ui-dashboard', 'Dashboard: Tax (Tax Rates + Tax Calculations) screens (data-coupled)', 'ui', 'common', uiDataCoupled({
1615
- markers: ['Tax Rates', 'Tax Calculations', 'list-row'],
3089
+ // Tax Rates only: Stripe publishes no list of tax calculations (a calculation is retrieved by id), so the mirror's
3090
+ // Tax Calculations screen, which read the invented GET /v1/tax/calculations, left with it (5decb1d77).
3091
+ done('stripe.ui.tax', 'ui-dashboard', 'Dashboard: Tax (Tax Rates) screen (data-coupled)', 'ui', 'common', uiDataCoupled({
3092
+ markers: ['Tax Rates', 'list-row'],
1616
3093
  seed: async (h) => {
1617
3094
  await h({ m: 'POST', p: '/v1/tax_rates', b: 'display_name=UI Check Tax&percentage=12.5&inclusive=false&jurisdiction=US' });
1618
- 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' });
1619
3095
  },
1620
3096
  check: async ({ get }) => {
1621
3097
  const rates = ((await get('/v1/tax_rates')).data as Body[]) as unknown as StripeRow[];
1622
- const calcs = ((await get('/v1/tax/calculations')).data as Body[]) as unknown as StripeRow[];
1623
- if (!rates.length || !calcs.length) return false;
3098
+ if (!rates.length) return false;
1624
3099
  const ratesMarkup = renderSectionList('tax_rates', rates);
1625
- const calcsMarkup = renderSectionList('tax/calculations', calcs);
1626
- return listRows(ratesMarkup) === rates.length && ratesMarkup.includes('UI Check Tax') &&
1627
- listRows(calcsMarkup) === calcs.length;
3100
+ return listRows(ratesMarkup) === rates.length && ratesMarkup.includes('UI Check Tax');
1628
3101
  },
1629
3102
  })),
1630
3103
 
@@ -1665,18 +3138,27 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1665
3138
  ((l.body as Body).data as Body[]).length === 2 && badCh.status === 400 && badStatus.status === 400;
1666
3139
  }),
1667
3140
  ),
1668
- // Authorizations (+approve/decline): an authorization is PRESENTED via the test helper (pending),
1669
- // then /approve closes it (approved:true) AND materializes a captured transaction, or /decline
1670
- // closes it (approved:false). Acting on a finalized authorization 400s; unknown id 404.
3141
+ // Authorizations (+approve/decline): an authorization is PRESENTED via the test helper (pending);
3142
+ // /approve keeps it pending (approved:true, funds held) until the merchant's capture closes it and
3143
+ // makes the transaction; /decline closes it (approved:false). Deciding twice 400s; unknown id 404.
1671
3144
  done('stripe.issuing.authorizations', 'issuing', 'Issuing Authorizations (+approve/decline)', 'api', 'niche', () =>
1672
- withRoot(async (h) => {
3145
+ withRoot(async (h, root) => {
3146
+ await fundIssuing(root, 10000000); // the Issuing balance authorizations are paid from
1673
3147
  const ch = await h({ m: 'POST', p: '/v1/issuing/cardholders', b: 'name=Jane&type=individual&billing[address][country]=US' });
1674
3148
  const card = await h({ m: 'POST', p: `/v1/issuing/cards`, b: `cardholder=${id(ch)}&currency=usd&type=virtual` });
3149
+ // with no real-time endpoint an authorization is approved at once (card_active)
3150
+ const plain = await h({ m: 'POST', p: '/v1/test_helpers/issuing/authorizations', b: `card=${id(card)}&amount=1000` });
3151
+ if (field(plain, 'approved') !== true || ((field(plain, 'request_history') as Body[])?.[0] as Body | undefined)?.reason !== 'card_active') return false;
3152
+ // an endpoint that does not answer leaves it pending for the deprecated approve and decline
3153
+ await h({ m: 'POST', p: '/v1/webhook_endpoints', b: `url=${encodeURIComponent('https://rt.twin.test/auth')}&enabled_events[]=issuing_authorization.request` });
1675
3154
  const auth = await h({ m: 'POST', p: '/v1/test_helpers/issuing/authorizations', b: `card=${id(card)}&amount=3000` });
1676
3155
  if (field(auth, 'status') !== 'pending') return false;
1677
3156
  const appr = await h({ m: 'POST', p: `/v1/issuing/authorizations/${id(auth)}/approve` });
1678
- if (!ok(appr) || field(appr, 'status') !== 'closed' || field(appr, 'approved') !== true) return false;
1679
- // approve materialized a captured transaction.
3157
+ if (!ok(appr) || field(appr, 'status') !== 'pending' || field(appr, 'approved') !== true) return false;
3158
+ // nothing is transacted until the merchant captures
3159
+ if ((((await h({ m: 'GET', p: '/v1/issuing/transactions' })).body as Body).data as Body[]).length !== 0) return false;
3160
+ const cap = await h({ m: 'POST', p: `/v1/test_helpers/issuing/authorizations/${id(auth)}/capture` });
3161
+ if (!ok(cap) || field(cap, 'status') !== 'closed') return false;
1680
3162
  const txns = await h({ m: 'GET', p: '/v1/issuing/transactions' });
1681
3163
  const txnData = (txns.body as Body).data as Body[];
1682
3164
  if (txnData.length !== 1 || txnData[0]!.authorization !== id(auth) || txnData[0]!.amount !== -3000) return false;
@@ -1696,13 +3178,20 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1696
3178
  // retrieved/updated and disputed. A dispute requires transaction + evidence[reason]; /submit
1697
3179
  // transitions unsubmitted→submitted; submitting twice 400s; unknown transaction 400.
1698
3180
  done('stripe.issuing.transactions', 'issuing', 'Issuing Transactions + disputes', 'api', 'niche', () =>
1699
- withRoot(async (h) => {
3181
+ withRoot(async (h, root) => {
3182
+ await fundIssuing(root, 10000000); // the Issuing balance authorizations are paid from
1700
3183
  const ch = await h({ m: 'POST', p: '/v1/issuing/cardholders', b: 'name=Jane&type=individual&billing[address][country]=US' });
1701
3184
  const card = await h({ m: 'POST', p: `/v1/issuing/cards`, b: `cardholder=${id(ch)}&currency=usd&type=virtual` });
3185
+ // with no real-time webhook the authorization is approved at once ("If you don't have a real-time authorization
3186
+ // webhook, we approve the authorization without sending the issuing_authorization.request",
3187
+ // docs.stripe.com/issuing/purchases/authorizations), so the approve API refuses it as already decided; the merchant's
3188
+ // capture (the test helper) is what makes the transaction
1702
3189
  const auth = await h({ m: 'POST', p: '/v1/test_helpers/issuing/authorizations', b: `card=${id(card)}&amount=4200` });
1703
- await h({ m: 'POST', p: `/v1/issuing/authorizations/${id(auth)}/approve` });
3190
+ if (field(auth, 'approved') !== true || (await h({ m: 'POST', p: `/v1/issuing/authorizations/${id(auth)}/approve` })).status !== 400) return false;
3191
+ await h({ m: 'POST', p: `/v1/test_helpers/issuing/authorizations/${id(auth)}/capture` });
1704
3192
  const txns = await h({ m: 'GET', p: '/v1/issuing/transactions' });
1705
3193
  const txn = ((txns.body as Body).data as Body[])[0]!;
3194
+ if (((txns.body as Body).data as Body[]).length !== 1 || txn.amount !== -4200 || txn.authorization !== id(auth)) return false;
1706
3195
  const g = await h({ m: 'GET', p: `/v1/issuing/transactions/${txn.id}` });
1707
3196
  if (!ok(g) || field(g, 'object') !== 'issuing.transaction') return false;
1708
3197
  const u = await h({ m: 'POST', p: `/v1/issuing/transactions/${txn.id}`, b: 'metadata[note]=checked' });
@@ -1729,6 +3218,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1729
3218
  const root = mkdtempSync(join(tmpdir(), 'stp-cap-'));
1730
3219
  try {
1731
3220
  const h = (s: Step) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: '2026-06-14T00:00:00Z' });
3221
+ await fundIssuing(root, 10000000, '2026-06-14T00:00:00Z'); // the Issuing balance authorizations are paid from
1732
3222
  const delivered: StripeEvent[] = [];
1733
3223
  setStripeEventDelivery((_url, event) => {
1734
3224
  delivered.push(event);
@@ -1759,6 +3249,9 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1759
3249
  const root2 = mkdtempSync(join(tmpdir(), 'stp-cap-'));
1760
3250
  try {
1761
3251
  const h2 = (s: Step) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root: root2, occurredAt: '2026-06-14T00:00:00Z' });
3252
+ // funded as the first World is: an authorization the Issuing balance cannot cover is declined insufficient_funds
3253
+ // (docs.stripe.com/issuing/funding/balance), which would decide it before any endpoint could be consulted
3254
+ await fundIssuing(root2, 10000000, '2026-06-14T00:00:00Z');
1762
3255
  let consulted = false;
1763
3256
  setStripeEventDelivery((_url, event) => {
1764
3257
  if (event.type === 'issuing_authorization.request') consulted = true;
@@ -1767,8 +3260,14 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1767
3260
  const ch2 = await h2({ m: 'POST', p: '/v1/issuing/cardholders', b: 'name=RT2&type=individual&billing[address][country]=US' });
1768
3261
  const card2 = await h2({ m: 'POST', p: '/v1/issuing/cards', b: `cardholder=${id(ch2)}&currency=usd&type=virtual` });
1769
3262
  await h2({ m: 'POST', p: '/v1/webhook_endpoints', b: `url=${encodeURIComponent('https://other.twin.test/hook')}&enabled_events[]=invoice.paid` });
3263
+ // ...and, not enrolled, it is approved at once by the account's default settings: "If you don't have a real-time
3264
+ // authorization webhook, we approve the authorization without sending the issuing_authorization.request"
3265
+ // (docs.stripe.com/issuing/purchases/authorizations; semantics/issuing.ts, reason card_active). It used to stay
3266
+ // pending for the approve API, which is the deprecated path.
1770
3267
  const auth2 = await h2({ m: 'POST', p: '/v1/test_helpers/issuing/authorizations', b: `card=${id(card2)}&amount=100` });
1771
- return !consulted && field(auth2, 'status') === 'pending' && field(auth2, 'approved') === false;
3268
+ const history2 = (field(auth2, 'request_history') as Body[] | undefined) ?? [];
3269
+ return !consulted && field(auth2, 'status') === 'pending' && field(auth2, 'approved') === true
3270
+ && history2.length === 1 && history2[0]!.reason === 'card_active';
1772
3271
  } finally {
1773
3272
  rmSync(root2, { recursive: true, force: true });
1774
3273
  }
@@ -1781,15 +3280,16 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1781
3280
  rmSync(root, { recursive: true, force: true });
1782
3281
  }
1783
3282
  }),
1784
- // Real-time authorization fallbacks (the 2s-window posture, fail-closed): a webhook
1785
- // decline closes the authorization (reason webhook_declined); a TIMEOUT declines with
1786
- // reason webhook_timeout; an invalid response (non-2xx, non-JSON, missing `approved`)
3283
+ // Real-time authorization fallbacks (the 2s window): a webhook decline closes the
3284
+ // authorization (reason webhook_declined); a TIMEOUT leaves it pending for the deprecated
3285
+ // approve/decline until the window ends, then declines it with reason webhook_timeout; an invalid response (non-2xx, non-JSON, missing `approved`)
1787
3286
  // declines with reason webhook_error + a reason_message; a partial `amount` in the
1788
3287
  // response is held ONLY when the presentment was is_amount_controllable.
1789
- done('stripe.issuing.realtime_auth_fallbacks', 'issuing', 'Real-time authorization: decline honored; timeout → webhook_timeout; invalid response → webhook_error; partial amount only when controllable', 'api', 'niche', async () => {
3288
+ done('stripe.issuing.realtime_auth_fallbacks', 'issuing', 'Real-time authorization: decline honored; unanswered → pending, approve in the window, webhook_timeout after it; invalid response → webhook_error; partial amount only when controllable', 'api', 'niche', async () => {
1790
3289
  const root = mkdtempSync(join(tmpdir(), 'stp-cap-'));
1791
3290
  try {
1792
3291
  const h = (s: Step) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: '2026-06-14T00:00:00Z' });
3292
+ await fundIssuing(root, 10000000, '2026-06-14T00:00:00Z'); // the Issuing balance authorizations are paid from
1793
3293
  const ch = await h({ m: 'POST', p: '/v1/issuing/cardholders', b: 'name=FB&type=individual&billing[address][country]=US' });
1794
3294
  const card = await h({ m: 'POST', p: '/v1/issuing/cards', b: `cardholder=${id(ch)}&currency=usd&type=virtual` });
1795
3295
  await h({ m: 'POST', p: '/v1/webhook_endpoints', b: `url=${encodeURIComponent('https://rt.twin.test/auth')}&enabled_events[]=issuing_authorization.request` });
@@ -1805,10 +3305,17 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1805
3305
  respond({ status: 200, body: '{"approved":false}' });
1806
3306
  const dec = await present(`card=${id(card)}&amount=900`);
1807
3307
  if (field(dec, 'approved') !== false || field(dec, 'status') !== 'closed' || reasonOf(dec).reason !== 'webhook_declined') return false;
1808
- // timeout → webhook_timeout (the exact TimeoutError AbortSignal.timeout raises)
3308
+ // an unanswered request (the exact TimeoutError AbortSignal.timeout raises) is no decision: pending with its request
1809
3309
  respond(() => { const e = new Error('timed out'); e.name = 'TimeoutError'; throw e; });
1810
3310
  const to = await present(`card=${id(card)}&amount=900`);
1811
- if (field(to, 'approved') !== false || reasonOf(to).reason !== 'webhook_timeout') return false;
3311
+ if (field(to, 'status') !== 'pending' || field(to, 'approved') !== false || !field(to, 'pending_request')) return false;
3312
+ // the deprecated approve inside the window decides it
3313
+ const held = await present(`card=${id(card)}&amount=800`);
3314
+ const approved = await h({ m: 'POST', p: `/v1/issuing/authorizations/${id(held)}/approve` });
3315
+ if (field(approved, 'approved') !== true || reasonOf(approved).reason !== 'webhook_approved') return false;
3316
+ // once the 2-second window has passed, the one left unanswered is declined with reason webhook_timeout
3317
+ const later = await handleStripeTwinRequest({ method: 'GET', path: `/v1/issuing/authorizations/${id(to)}`, root, occurredAt: '2026-06-14T00:00:03Z' });
3318
+ if (field(later, 'status') !== 'closed' || field(later, 'approved') !== false || reasonOf(later).reason !== 'webhook_timeout') return false;
1812
3319
  // invalid responses → webhook_error with a reason_message
1813
3320
  for (const bad of [{ status: 500, body: 'oops' }, { status: 200, body: 'not-json' }, { status: 200, body: '{"ok":true}' }]) {
1814
3321
  respond(bad);
@@ -1838,6 +3345,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1838
3345
  const root = mkdtempSync(join(tmpdir(), 'stp-cap-'));
1839
3346
  try {
1840
3347
  const h = (s: Step) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: '2026-06-14T00:00:00Z' });
3348
+ await fundIssuing(root, 10000000, '2026-06-14T00:00:00Z'); // the Issuing balance authorizations are paid from
1841
3349
  setStripeEventDelivery((_url, event) => {
1842
3350
  if (event.type === 'issuing_authorization.request') return { status: 200, body: '{"approved":true}' };
1843
3351
  });
@@ -1876,7 +3384,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1876
3384
  rmSync(root, { recursive: true, force: true });
1877
3385
  }
1878
3386
  }),
1879
- // Capture debits the issuing balance: fund_balance accrues the float, a webhook-approved
3387
+ // Capture debits the issuing balance: a top-up to Issuing funds the float, a webhook-approved
1880
3388
  // authorization is captured via POST /v1/test_helpers/issuing/authorizations/:id/capture
1881
3389
  // (partial capture_amount honored, refused above the held amount / on a non-approved or
1882
3390
  // closed authorization), the materialized transaction is type 'capture' with a NEGATIVE
@@ -1897,8 +3405,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1897
3405
  const ch = await h({ m: 'POST', p: '/v1/issuing/cardholders', b: 'name=Cap&type=individual&billing[address][country]=US' });
1898
3406
  const card = await h({ m: 'POST', p: '/v1/issuing/cards', b: `cardholder=${id(ch)}&currency=usd&type=virtual` });
1899
3407
  await h({ m: 'POST', p: '/v1/webhook_endpoints', b: `url=${encodeURIComponent('https://rt.twin.test/auth')}&enabled_events[]=issuing_authorization.request` });
1900
- const fund = await h({ m: 'POST', p: '/v1/test_helpers/issuing/fund_balance', b: 'amount=100000&currency=usd' });
1901
- if (((field(fund, 'issuing') as Body).available as Body[])[0]!.amount !== 100000) return false;
3408
+ await fundIssuing(root, 100000, '2026-06-14T00:00:00Z');
1902
3409
  if (await issuingAvailable() !== 100000) return false;
1903
3410
  const auth = await h({ m: 'POST', p: '/v1/test_helpers/issuing/authorizations', b: `card=${id(card)}&amount=30000` });
1904
3411
  if (field(auth, 'approved') !== true) return false;
@@ -1941,6 +3448,29 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1941
3448
  // Readers (+process_payment_intent): a reader registers with a registration_code (status
1942
3449
  // 'online'); handing it a PaymentIntent sets reader.action to process_payment_intent
1943
3450
  // in_progress; cancel_action clears it. Connection tokens mint a secret. Missing code 400.
3451
+ // A simulated reader processing a PaymentIntent is presented a card by the test helper: the intent is paid and the
3452
+ // reader's action succeeds (docs.stripe.com/api/terminal/readers/present_payment_method).
3453
+ done('stripe.terminal.present_payment_method', 'terminal', 'Terminal test helper: present a card to a reader processing a PaymentIntent', 'api', 'common', () =>
3454
+ withRoot(async (h) => {
3455
+ const loc = await h({ m: 'POST', p: '/v1/terminal/locations', b: 'display_name=Market&address[line1]=1 Main&address[city]=Portland&address[state]=OR&address[postal_code]=97201&address[country]=US' });
3456
+ const rdr = await h({ m: 'POST', p: '/v1/terminal/readers', b: `registration_code=simulated-wpe&location=${id(loc)}` });
3457
+ const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=2400&currency=usd&allowed_payment_method_types[0]=card_present' });
3458
+ await h({ m: 'POST', p: `/v1/terminal/readers/${id(rdr)}/process_payment_intent`, b: `payment_intent=${id(pi)}` });
3459
+ const done = await h({ m: 'POST', p: `/v1/test_helpers/terminal/readers/${id(rdr)}/present_payment_method` });
3460
+ const paid = await h({ m: 'GET', p: `/v1/payment_intents/${id(pi)}` });
3461
+ // under manual capture the card holds the amount as an uncaptured charge, which the intent's capture takes
3462
+ const held = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=1800&currency=usd&capture_method=manual&allowed_payment_method_types[0]=card_present' });
3463
+ await h({ m: 'POST', p: `/v1/terminal/readers/${id(rdr)}/process_payment_intent`, b: `payment_intent=${id(held)}` });
3464
+ await h({ m: 'POST', p: `/v1/test_helpers/terminal/readers/${id(rdr)}/present_payment_method` });
3465
+ const hold = await h({ m: 'GET', p: `/v1/payment_intents/${id(held)}` });
3466
+ const auth = await h({ m: 'GET', p: `/v1/charges/${String(field(hold, 'latest_charge'))}` });
3467
+ const cap = await h({ m: 'POST', p: `/v1/payment_intents/${id(held)}/capture` });
3468
+ const taken = await h({ m: 'GET', p: `/v1/charges/${String(field(hold, 'latest_charge'))}` });
3469
+ return ((field(done, 'action') as Body)?.status === 'succeeded') && field(paid, 'status') === 'succeeded' && typeof field(paid, 'latest_charge') === 'string'
3470
+ && field(hold, 'status') === 'requires_capture' && field(auth, 'captured') === false && field(auth, 'amount') === 1800
3471
+ && field(cap, 'latest_charge') === id(auth) && field(taken, 'captured') === true && field(taken, 'amount_captured') === 1800;
3472
+ }),
3473
+ ),
1944
3474
  done('stripe.terminal.readers', 'terminal', 'Terminal Readers (+process_payment_intent)', 'api', 'niche', () =>
1945
3475
  withRoot(async (h) => {
1946
3476
  const loc = await h({ m: 'POST', p: '/v1/terminal/locations', b: 'display_name=HQ&address[country]=US&address[line1]=1 Main&address[city]=SF&address[postal_code]=94105&address[state]=CA' });
@@ -1989,10 +3519,17 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1989
3519
  // not a public create). open is the only approvable state → /approve closes it (open:false,
1990
3520
  // closed_reason 'approved'); a second approve 400s. retrieve + list round-trip; unknown id 404.
1991
3521
  // (the open→closed approve transition is produced ONLY by this feature.)
3522
+ // A review is opened by Radar, never by the API (Stripe's API has no review create; the twin's seeding route for it was
3523
+ // removed with the invented endpoints, 5decb1d77): an elevated-risk card's charge is placed in review
3524
+ // (docs.stripe.com/radar/reviews; the test card 4000000000009235, pm_card_riskLevelElevated, docs.stripe.com/testing).
1992
3525
  done('stripe.radar.reviews', 'radar', 'Radar Reviews (approve/list)', 'api', 'niche', () =>
1993
3526
  withRoot(async (h) => {
1994
- const rv = await h({ m: 'POST', p: '/v1/radar/reviews', b: 'charge=ch_twin&payment_intent=pi_twin' });
1995
- if (!ok(rv) || field(rv, 'object') !== 'review' || field(rv, 'open') !== true) return false;
3527
+ const risky = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=2000&currency=usd&payment_method=pm_card_riskLevelElevated&confirm=true' });
3528
+ if (!ok(risky) || field(risky, 'status') !== 'succeeded') return false;
3529
+ const opened = (((await h({ m: 'GET', p: '/v1/reviews' })).body as Body).data as Body[]) ?? [];
3530
+ if (opened.length !== 1) return false;
3531
+ const rv = await h({ m: 'GET', p: `/v1/reviews/${String(opened[0]!.id)}` });
3532
+ if (!ok(rv) || field(rv, 'object') !== 'review' || field(rv, 'open') !== true || field(rv, 'payment_intent') !== id(risky)) return false;
1996
3533
  const g = await h({ m: 'GET', p: `/v1/reviews/${id(rv)}` });
1997
3534
  const l = await h({ m: 'GET', p: '/v1/reviews' });
1998
3535
  if (!ok(g) || id(g) !== id(rv) || field(l, 'object') !== 'list') return false;
@@ -2029,83 +3566,58 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
2029
3566
  noAlias.status === 400 && badList.status === 400 && noValue.status === 400;
2030
3567
  }),
2031
3568
  ),
2032
- // Radar Rules: create a custom rule (requires action ∈ {block,review,allow} + predicate),
2033
- // retrieve, list (filterable by action), delete. Missing action/predicate 400; bad action 400.
2034
- done('stripe.radar.rules', 'radar', 'Radar rules', 'api', 'niche', () =>
2035
- withRoot(async (h) => {
2036
- const rule = await h({ m: 'POST', p: '/v1/radar/rules', b: 'action=block&predicate=:risk_level: = "highest"' });
2037
- if (!ok(rule) || field(rule, 'object') !== 'radar.rule' || field(rule, 'action') !== 'block') return false;
2038
- const g = await h({ m: 'GET', p: `/v1/radar/rules/${id(rule)}` });
2039
- const l = await h({ m: 'GET', p: '/v1/radar/rules?action=block' });
2040
- if (!ok(g) || id(g) !== id(rule) || ((l.body as Body).data as Body[]).length !== 1) return false;
2041
- const del = await h({ m: 'DELETE', p: `/v1/radar/rules/${id(rule)}` });
2042
- const gone = await h({ m: 'GET', p: `/v1/radar/rules/${id(rule)}` });
2043
- const noAction = await h({ m: 'POST', p: '/v1/radar/rules', b: 'predicate=x' });
2044
- const badAction = await h({ m: 'POST', p: '/v1/radar/rules', b: 'action=nuke&predicate=x' });
2045
- const noPred = await h({ m: 'POST', p: '/v1/radar/rules', b: 'action=block' });
2046
- return ok(del) && field(del, 'deleted') === true && gone.status === 404 &&
2047
- noAction.status === 400 && badAction.status === 400 && noPred.status === 400;
2048
- }),
2049
- ),
2050
- // Dashboard: Radar (Reviews + Value Lists + Rules) screens — rendered surfaces in the mirror.
2051
- // Radar (Reviews + Value Lists + Rules) — RUNG-5 data-coupled: the three section markers are
2052
- // bundled (the screens are wired in) AND, for each of the three Radar collections, we seed
2053
- // real twin state (a review, a value-list + item, a rule), fetch the SAME /v1 endpoint the
2054
- // mirror's React client reads off the running mirror server, render the mirror's OWN ListPane
2055
- // component over those rows, and assert the rendered DOM emits exactly one `list-row` landmark
2056
- // per seeded object carrying that object's id. Failable: an empty workspace yields zero rows →
2057
- // zero list-rows → false; and a marker-only/hardcoded render could not surface the seeded ids.
2058
- done('stripe.ui.radar', 'ui-dashboard', 'Dashboard: Radar (Reviews + Value Lists + Rules) screens', 'ui', 'niche', uiDataCoupled({
2059
- markers: ['Radar Reviews', 'Radar Value Lists', 'Radar Rules', 'list-row'],
3569
+ // Radar rules are not in Stripe's API: rules are written in the Dashboard (docs.stripe.com/radar/rules), and the
3570
+ // published spec has no /v1/radar/rules. The twin's routes for them were invented and are gone (5decb1d77), so the
3571
+ // `stripe.radar.rules` claim they carried is withdrawn rather than kept as a todo: it is not vendor API surface.
3572
+ // Dashboard: Radar (Reviews + Value Lists) screens — RUNG-5 data-coupled: the section markers are bundled (the screens
3573
+ // are wired in) AND, for each Radar collection, we seed real twin state (a review Radar opens on an elevated-risk
3574
+ // charge, a value-list + item), fetch the SAME /v1 endpoint the mirror's React client reads off the running mirror
3575
+ // server, render the mirror's OWN ListPane component over those rows, and assert the rendered DOM emits exactly one
3576
+ // `list-row` landmark per seeded object carrying it. Failable: an empty workspace yields zero rows → false. The Rules
3577
+ // screen left the mirror with the invented /v1/radar/rules it read (5decb1d77: "The Dashboard mirror no longer lists
3578
+ // ... radar rules"), and the review is no longer seeded through the invented POST /v1/radar/reviews.
3579
+ done('stripe.ui.radar', 'ui-dashboard', 'Dashboard: Radar (Reviews + Value Lists) screens', 'ui', 'niche', uiDataCoupled({
3580
+ markers: ['Radar Reviews', 'Radar Value Lists', 'list-row'],
2060
3581
  seed: async (h) => {
2061
- // a flagged payment opens a Review; a value list + one item; one custom rule.
2062
- await h({ m: 'POST', p: '/v1/radar/reviews', b: 'charge=ch_radar_ui&payment_intent=pi_radar_ui' });
3582
+ await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=2000&currency=usd&payment_method=pm_card_riskLevelElevated&confirm=true' });
2063
3583
  const vl = await h({ m: 'POST', p: '/v1/radar/value_lists', b: 'alias=ui_block_ips&name=UI Blocked IPs&item_type=ip_address' });
2064
3584
  await h({ m: 'POST', p: '/v1/radar/value_list_items', b: `value_list=${(vl.body as Body).id}&value=9.9.9.9` });
2065
- await h({ m: 'POST', p: '/v1/radar/rules', b: 'action=block&predicate=:risk_level: = "highest"' });
2066
3585
  },
2067
3586
  check: async ({ get }) => {
2068
3587
  const reviews = ((await get('/v1/reviews')).data as Body[]) ?? [];
2069
3588
  const valueLists = ((await get('/v1/radar/value_lists')).data as Body[]) ?? [];
2070
- const rules = ((await get('/v1/radar/rules')).data as Body[]) ?? [];
2071
- // the seeded objects must be present in the projections the screen reads
2072
- const review = reviews.find((r) => r.charge === 'ch_radar_ui');
3589
+ // the review is the one Radar opened on the seeded elevated-risk payment
3590
+ const risky = (((await get('/v1/payment_intents')).data as Body[]) ?? [])[0];
3591
+ const review = reviews.find((r) => r.open === true && r.payment_intent === risky?.id && typeof r.charge === 'string' && r.charge === risky?.latest_charge);
2073
3592
  const vl = valueLists.find((l) => l.alias === 'ui_block_ips');
2074
- const rule = rules.find((r) => r.predicate === ':risk_level: = "highest"');
2075
- if (!review || !vl || !rule) return false;
2076
- // RENDER the mirror's own ListPane over the fetched rows and assert the DOM emits one
2077
- // list-row per object, each carrying the seeded object's id (subtitle renders r.id).
3593
+ if (!review || !vl) return false;
2078
3594
  const reviewsMarkup = renderSectionList('reviews', reviews as StripeRow[]);
2079
3595
  const valueListsMarkup = renderSectionList('radar/value_lists', valueLists as StripeRow[]);
2080
- const rulesMarkup = renderSectionList('radar/rules', rules as StripeRow[]);
2081
- return (
2082
- // reviews + rules render their id in the row subtitle; the value-list row renders its
2083
- // name + alias (the id is not surfaced in that section's row), so assert those instead.
2084
- listRows(reviewsMarkup) === reviews.length && reviewsMarkup.includes(String(review.id)) &&
2085
- listRows(valueListsMarkup) === valueLists.length && valueListsMarkup.includes('UI Blocked IPs') && valueListsMarkup.includes('ui_block_ips') &&
2086
- listRows(rulesMarkup) === rules.length && rulesMarkup.includes(String(rule.id)) && rulesMarkup.includes('block')
2087
- );
3596
+ // a review's row names its charge; the value-list row renders its name + alias
3597
+ return listRows(reviewsMarkup) === reviews.length && reviewsMarkup.includes(String(review.charge))
3598
+ && listRows(valueListsMarkup) === valueLists.length && valueListsMarkup.includes('UI Blocked IPs') && valueListsMarkup.includes('ui_block_ips');
2088
3599
  },
2089
3600
  })),
2090
3601
 
2091
3602
  // ── Reporting ────────────────────────────────────────────────────────────────────
2092
3603
  // Reporting report_runs + report_types: POST /v1/reporting/report_runs requires
2093
- // parameters[report_type] (a valid type from the catalog); the twin completes the run
2094
- // synchronously (status 'succeeded') with a result File ref. Unknown/missing report_type 400.
3604
+ // report_type (a valid type from the catalog); a run starts pending and completes a
3605
+ // minute later (status 'succeeded') with a result File ref. Unknown/missing report_type 400.
2095
3606
  // The report-type catalog is listable/retrievable.
2096
3607
  done('stripe.reporting.report_runs', 'reporting', 'Reporting (report_runs + report_types)', 'api', 'niche', () =>
2097
- withRoot(async (h) => {
3608
+ withRoot(async (h, root) => {
2098
3609
  const types = await h({ m: 'GET', p: '/v1/reporting/report_types' });
2099
3610
  if (!ok(types) || ((types.body as Body).data as Body[]).length === 0) return false;
2100
3611
  const one = await h({ m: 'GET', p: '/v1/reporting/report_types/balance.summary.1' });
2101
3612
  if (!ok(one) || field(one, 'object') !== 'reporting.report_type') return false;
2102
- const run = await h({ m: 'POST', p: '/v1/reporting/report_runs', b: 'parameters[report_type]=balance.summary.1&parameters[interval_start]=0&parameters[interval_end]=100' });
2103
- if (!ok(run) || field(run, 'object') !== 'reporting.report_run' || field(run, 'status') !== 'succeeded' || field(run, 'report_type') !== 'balance.summary.1') return false;
2104
- if ((field(run, 'result') as Body)?.object !== 'file') return false;
2105
- const g = await h({ m: 'GET', p: `/v1/reporting/report_runs/${id(run)}` });
3613
+ const run = await h({ m: 'POST', p: '/v1/reporting/report_runs', b: 'report_type=balance.summary.1&parameters[interval_start]=0&parameters[interval_end]=100' });
3614
+ if (!ok(run) || field(run, 'object') !== 'reporting.report_run' || field(run, 'status') !== 'pending' || field(run, 'report_type') !== 'balance.summary.1') return false;
3615
+ // "Most runs complete within a few minutes" (docs.stripe.com/reports/api): read two minutes on, it has succeeded
3616
+ const g = await handleStripeTwinRequest({ method: 'GET', path: `/v1/reporting/report_runs/${id(run)}`, root, occurredAt: new Date(Date.now() + 120_000).toISOString() });
3617
+ if (field(g, 'status') !== 'succeeded' || (field(g, 'result') as Body)?.object !== 'file') return false;
2106
3618
  const l = await h({ m: 'GET', p: '/v1/reporting/report_runs' });
2107
3619
  const noType = await h({ m: 'POST', p: '/v1/reporting/report_runs', b: 'parameters[interval_start]=0' });
2108
- const badType = await h({ m: 'POST', p: '/v1/reporting/report_runs', b: 'parameters[report_type]=bogus.report' });
3620
+ const badType = await h({ m: 'POST', p: '/v1/reporting/report_runs', b: 'report_type=bogus.report' });
2109
3621
  const nope = await h({ m: 'GET', p: '/v1/reporting/report_runs/frr_nope' });
2110
3622
  const badTypeRetrieve = await h({ m: 'GET', p: '/v1/reporting/report_types/bogus.report' });
2111
3623
  return ok(g) && id(g) === id(run) && ((l.body as Body).data as Body[]).length === 1 &&
@@ -2113,25 +3625,24 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
2113
3625
  }),
2114
3626
  ),
2115
3627
  // Reporting API (the modellable part of Sigma/Financial reports): report_types catalog +
2116
- // report_runs create→result. A run requires a valid parameters[report_type] (else 400),
2117
- // starts and completes to 'succeeded' with a result File ref. The Sigma SQL warehouse
2118
- // itself is out-of-scope (see the out-of-scope entry below). Unknown type 400.
3628
+ // report_runs create→result. A run requires a valid report_type (else 400),
3629
+ // starts pending and completes to 'succeeded' with a result File ref a minute later. The Sigma scheduled-query
3630
+ // surface is filed separately as a todo. Unknown type 400.
2119
3631
  done('stripe.sigma_financial', 'reporting', 'Financial reports / Sigma (Reporting API)', 'api', 'niche', () =>
2120
- withRoot(async (h) => {
3632
+ withRoot(async (h, root) => {
2121
3633
  const types = await h({ m: 'GET', p: '/v1/reporting/report_types' });
2122
3634
  if (!ok(types) || field(types, 'object') !== 'list' || ((types.body as Body).data as Body[]).length === 0) return false;
2123
- const run = await h({ m: 'POST', p: '/v1/reporting/report_runs', b: 'parameters[report_type]=balance.summary.1' });
2124
- if (!ok(run) || field(run, 'object') !== 'reporting.report_run' || field(run, 'status') !== 'succeeded') return false;
2125
- if ((field(run, 'result') as Body)?.object !== 'file') return false;
2126
- const get = await h({ m: 'GET', p: `/v1/reporting/report_runs/${id(run)}` });
3635
+ const run = await h({ m: 'POST', p: '/v1/reporting/report_runs', b: 'report_type=balance.summary.1' });
3636
+ if (!ok(run) || field(run, 'object') !== 'reporting.report_run' || field(run, 'status') !== 'pending') return false;
3637
+ const get = await handleStripeTwinRequest({ method: 'GET', path: `/v1/reporting/report_runs/${id(run)}`, root, occurredAt: new Date(Date.now() + 120_000).toISOString() });
3638
+ if (field(get, 'status') !== 'succeeded' || (field(get, 'result') as Body)?.object !== 'file') return false;
2127
3639
  const list = await h({ m: 'GET', p: '/v1/reporting/report_runs' });
2128
- const badType = await h({ m: 'POST', p: '/v1/reporting/report_runs', b: 'parameters[report_type]=not.a.type' });
2129
- const missing = await h({ m: 'POST', p: '/v1/reporting/report_runs', b: 'metadata[x]=1' });
3640
+ const badType = await h({ m: 'POST', p: '/v1/reporting/report_runs', b: 'report_type=not.a.type' });
3641
+ const missing = await h({ m: 'POST', p: '/v1/reporting/report_runs', b: 'parameters[interval_start]=0' });
2130
3642
  return ok(get) && field(list, 'object') === 'list' && badType.status === 400 && missing.status === 400;
2131
3643
  }),
2132
3644
  ),
2133
- outOfScope('stripe.sigma_financial.warehouse', 'reporting', 'Sigma SQL query warehouse (scheduled SQL queries)', 'api', 'niche',
2134
- 'Out of scope: Sigma is a hosted SQL analytics warehouse over your account data — running arbitrary SQL against a managed data lake is not locally reproducible. The Reporting API (report_types + report_runs) IS modeled (stripe.sigma_financial).'),
3645
+ todo('stripe.sigma.scheduled_query_runs', 'reporting', 'Sigma scheduled query runs: GET /v1/sigma/scheduled_query_runs list + retrieve, with the run object\'s status/result-file lifecycle (the Reporting API half is already modeled by stripe.sigma_financial)', 'api', 'niche'),
2135
3646
 
2136
3647
  // ── Connector (full pull/push) ────────────────────────────────────────────────────
2137
3648
  done('stripe.connector.read_surface', 'connector', 'Connector read surface (list core collections in one pass)', 'connector', 'core', () =>
@@ -2165,8 +3676,11 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
2165
3676
  pushed.push(`${method} ${path}`);
2166
3677
  return { id: 'ext_pushed', object: 'customer' };
2167
3678
  }) as unknown as Parameters<typeof fullSyncStripe>[0];
3679
+ const { performPending } = await import('./stripe-perform-harness.ts');
3680
+ // protocol 2: the head performs the pending write, the refresh folds the account — two halves, not one
3681
+ const sent = await performPending(execute as never, { root, occurredAt: '2024-01-01T00:00:00.000Z' });
2168
3682
  const res = await fullSyncStripe(execute, { root, occurredAt: '2024-01-01T00:00:00.000Z' });
2169
- if (res.pushed < 1 || pushed.length < 1) return false; // the pending create was pushed
3683
+ if (sent.pushed < 1 || pushed.length < 1) return false; // the pending create was performed
2170
3684
  if (res.observed < 1 || res.collections < 11) return false; // all collections + webhooks pulled
2171
3685
  // the pulled real customer is now in the twin's projection.
2172
3686
  const list = await handleStripeTwinRequest({ method: 'GET', path: '/v1/customers', root });
@@ -2225,12 +3739,15 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
2225
3739
  // screen reads → render the mirror's OWN ListPane → assert the seeded charge id survives.
2226
3740
  done('stripe.ui.refunds', 'ui-dashboard', 'Dashboard: Refunds screen (data-coupled)', 'ui', 'core', uiDataCoupled({
2227
3741
  markers: ['Refunds', 'list-row'],
2228
- seed: async (h) => { await h({ m: 'POST', p: '/v1/refunds', b: 'charge=ch_ui_check_refund&amount=555' }); },
3742
+ seed: async (h) => {
3743
+ const charge = await h({ m: 'POST', p: '/v1/charges', b: 'amount=1000&currency=usd&description=ui_check_refund' });
3744
+ await h({ m: 'POST', p: '/v1/refunds', b: `charge=${id(charge)}&amount=555` });
3745
+ },
2229
3746
  check: async ({ get }) => {
2230
3747
  const rows = ((await get('/v1/refunds')).data as Body[]) as unknown as StripeRow[];
2231
3748
  if (!rows.length) return false;
2232
3749
  const markup = renderSectionList('refunds', rows);
2233
- return listRows(markup) === rows.length && rows.some((r) => r.charge === 'ch_ui_check_refund' && r.amount === 555);
3750
+ return listRows(markup) === rows.length && rows.some((r) => typeof r.charge === 'string' && r.amount === 555);
2234
3751
  },
2235
3752
  })),
2236
3753
  // TWIN-14 (B4) migration — was a marker-grep over the literal 'Subscriptions'; now
@@ -2308,12 +3825,12 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
2308
3825
  // screen reads → render the mirror's OWN ListPane → assert the seeded charge id survives.
2309
3826
  done('stripe.ui.disputes', 'ui-dashboard', 'Dashboard: Disputes screen (data-coupled)', 'ui', 'common', uiDataCoupled({
2310
3827
  markers: ['Disputes', 'list-row'],
2311
- seed: async (h) => { await h({ m: 'POST', p: '/v1/disputes', b: 'charge=ch_ui_check_dispute&amount=1000&currency=usd' }); },
3828
+ seed: async (h) => { await disputed(h, 1234); },
2312
3829
  check: async ({ get }) => {
2313
3830
  const rows = ((await get('/v1/disputes')).data as Body[]) as unknown as StripeRow[];
2314
3831
  if (!rows.length) return false;
2315
3832
  const markup = renderSectionList('disputes', rows);
2316
- return listRows(markup) === rows.length && rows.some((d) => d.charge === 'ch_ui_check_dispute' && d.amount === 1000);
3833
+ return listRows(markup) === rows.length && rows.some((d) => d.amount === 1234);
2317
3834
  },
2318
3835
  })),
2319
3836
  // TWIN-14 (B4) migration — was a marker-grep over the literal 'Payouts'; now data-coupled:
@@ -2321,7 +3838,10 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
2321
3838
  // reads → render the mirror's OWN ListPane → assert the seeded amount survives.
2322
3839
  done('stripe.ui.payouts', 'ui-dashboard', 'Dashboard: Payouts screen (data-coupled)', 'ui', 'core', uiDataCoupled({
2323
3840
  markers: ['Payouts', 'list-row'],
2324
- seed: async (h) => { await h({ m: 'POST', p: '/v1/payouts', b: 'amount=7777&currency=usd' }); },
3841
+ seed: async (h) => {
3842
+ await h({ m: 'POST', p: '/v1/charges', b: 'amount=10000&currency=usd&source=tok_bypassPending' });
3843
+ await h({ m: 'POST', p: '/v1/payouts', b: 'amount=7777&currency=usd' });
3844
+ },
2325
3845
  check: async ({ get }) => {
2326
3846
  const rows = ((await get('/v1/payouts')).data as Body[]) as unknown as StripeRow[];
2327
3847
  if (!rows.length) return false;
@@ -2338,15 +3858,17 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
2338
3858
  // empty workspace's balance has no buckets → `BalanceSummary` renders nothing.
2339
3859
  done('stripe.ui.balance', 'ui-dashboard', 'Dashboard: Balance + Balance Transactions screens (data-coupled)', 'ui', 'core', uiDataCoupled({
2340
3860
  markers: ['Balance', 'Balance Transactions', 'balance-summary'],
2341
- seed: async (h) => { await h({ m: 'POST', p: '/v1/balance_transactions', b: 'amount=424200&currency=usd' }); },
3861
+ seed: async (h) => { await h({ m: 'POST', p: '/v1/charges', b: 'amount=424200&currency=usd&source=tok_bypassPending' }); },
2342
3862
  check: async ({ get }) => {
2343
3863
  const balance = await get('/v1/balance');
2344
3864
  const txns = ((await get('/v1/balance_transactions')).data as Body[]) as unknown as StripeRow[];
2345
3865
  if (!txns.length) return false;
2346
3866
  const summaryMarkup = renderToStaticMarkup(createElement(BalanceSummary, { row: { id: 'balance', ...balance } as StripeRow }));
2347
3867
  const txnsMarkup = renderSectionList('balance_transactions', txns);
2348
- return summaryMarkup.includes('balance-summary') && summaryMarkup.includes(formatStripeAmount(424200, 'usd')) &&
2349
- listRows(txnsMarkup) === txns.length && txns.some((t) => t.amount === 424200);
3868
+ // the balance is the charge less Stripe's fee, 2.9% + 30¢ (stripe.com/pricing; the ledger, d5c8f9a70):
3869
+ // 424200 - (12302 + 30) = 411868 available, while the transaction keeps the gross amount
3870
+ return summaryMarkup.includes('balance-summary') && summaryMarkup.includes(formatStripeAmount(411868, 'usd')) &&
3871
+ listRows(txnsMarkup) === txns.length && txns.some((t) => t.amount === 424200 && t.fee === 12332 && t.net === 411868);
2350
3872
  },
2351
3873
  })),
2352
3874
  // TWIN-14 (B4) migration — was a marker-grep over the literals 'Connected Accounts',
@@ -2358,8 +3880,8 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
2358
3880
  done('stripe.ui.connect', 'ui-dashboard', 'Dashboard: Connect (Connected Accounts + Transfers) screens (data-coupled)', 'ui', 'niche', uiDataCoupled({
2359
3881
  markers: ['Connected Accounts', 'Transfers', 'connect-flag'],
2360
3882
  seed: async (h) => {
2361
- const acct = await h({ m: 'POST', p: '/v1/accounts', b: 'type=express&country=US' });
2362
- await h({ m: 'POST', p: '/v1/transfers', b: `amount=999&currency=usd&destination=${(acct.body as Body).id}` });
3883
+ const seller = await onboardedSeller(h);
3884
+ await h({ m: 'POST', p: '/v1/transfers', b: `amount=999&currency=usd&destination=${seller}` });
2363
3885
  },
2364
3886
  check: async ({ get }) => {
2365
3887
  const accounts = ((await get('/v1/accounts')).data as Body[]) as unknown as StripeRow[];
@@ -2377,40 +3899,34 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
2377
3899
  // screen reads → render the mirror's OWN ListPane → assert the seeded type survives.
2378
3900
  done('stripe.ui.events', 'ui-dashboard', 'Dashboard: Events log screen (data-coupled)', 'ui', 'core', uiDataCoupled({
2379
3901
  markers: ['Events', 'list-row'],
2380
- seed: async (h) => { await h({ m: 'POST', p: '/v1/events', b: 'type=ui.check.event' }); },
3902
+ seed: async (h) => { await h({ m: 'POST', p: '/v1/coupons', b: 'percent_off=10&duration=once' }); },
2381
3903
  check: async ({ get }) => {
2382
3904
  const rows = ((await get('/v1/events')).data as Body[]) as unknown as StripeRow[];
2383
3905
  if (!rows.length) return false;
2384
3906
  const markup = renderSectionList('events', rows);
2385
- return listRows(markup) === rows.length && rows.some((e) => e.type === 'ui.check.event') && markup.includes('ui.check.event');
3907
+ return listRows(markup) === rows.length && rows.some((e) => e.type === 'coupon.created') && markup.includes('coupon.created');
2386
3908
  },
2387
3909
  })),
2388
- // TWIN-14 (B4) migration — was a marker-grep over the literals 'Credit Notes', 'Customer Tax
2389
- // IDs', 'Customer Balance'; now data-coupled: seed a credit note, a customer tax id, and a
2390
- // customer balance transaction, each with a distinct value → fetch the SAME `/v1/credit_notes`
2391
- // + `/v1/tax_ids` + `/v1/customer_balance_transactions` projections those screens read →
2392
- // render the mirror's OWN ListPane over each → assert every seeded value survives.
2393
- done('stripe.ui.billing_credit', 'ui-dashboard', 'Dashboard: Credit Notes + Customer Tax IDs + Customer Balance screens (data-coupled)', 'ui', 'common', uiDataCoupled({
2394
- markers: ['Credit Notes', 'Customer Tax IDs', 'Customer Balance', 'list-row'],
3910
+ // Dashboard: credit notes and the account's tax IDs, data-coupled: seed a credit note and the account's own VAT
3911
+ // number, fetch the same `/v1/credit_notes` and `/v1/tax_ids` lists those screens read, render the mirror's own
3912
+ // ListPane over each and assert the seeded rows survive.
3913
+ done('stripe.ui.billing_credit', 'ui-dashboard', 'Dashboard: Credit Notes + Tax IDs screens (data-coupled)', 'ui', 'common', uiDataCoupled({
3914
+ markers: ['Credit Notes', 'Tax IDs', 'list-row'],
2395
3915
  seed: async (h) => {
2396
3916
  const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=billing-credit-check@twin.test' });
2397
3917
  const custId = (cust.body as Body).id;
2398
3918
  const inv = await h({ m: 'POST', p: '/v1/invoices', b: `customer=${custId}` });
2399
3919
  await h({ m: 'POST', p: '/v1/credit_notes', b: `invoice=${(inv.body as Body).id}&amount=2500&reason=order_change` });
2400
- await h({ m: 'POST', p: `/v1/customers/${custId}/tax_ids`, b: 'type=eu_vat&value=DE999999999' });
2401
- await h({ m: 'POST', p: `/v1/customers/${custId}/balance_transactions`, b: 'amount=-750&currency=usd' });
3920
+ await h({ m: 'POST', p: '/v1/tax_ids', b: 'type=eu_vat&value=DE999999999' });
2402
3921
  },
2403
3922
  check: async ({ get }) => {
2404
3923
  const notes = ((await get('/v1/credit_notes')).data as Body[]) as unknown as StripeRow[];
2405
3924
  const taxIds = ((await get('/v1/tax_ids')).data as Body[]) as unknown as StripeRow[];
2406
- const custBal = ((await get('/v1/customer_balance_transactions')).data as Body[]) as unknown as StripeRow[];
2407
- if (!notes.length || !taxIds.length || !custBal.length) return false;
3925
+ if (!notes.length || !taxIds.length) return false;
2408
3926
  const notesMarkup = renderSectionList('credit_notes', notes);
2409
3927
  const taxMarkup = renderSectionList('tax_ids', taxIds);
2410
- const balMarkup = renderSectionList('customer_balance_transactions', custBal);
2411
3928
  return listRows(notesMarkup) === notes.length && notes.some((n) => n.amount === 2500) &&
2412
- listRows(taxMarkup) === taxIds.length && taxIds.some((t) => t.value === 'DE999999999') &&
2413
- listRows(balMarkup) === custBal.length && custBal.some((b) => b.amount === -750);
3929
+ listRows(taxMarkup) === taxIds.length && taxIds.some((t) => t.value === 'DE999999999');
2414
3930
  },
2415
3931
  })),
2416
3932
  // TWIN-14 (B4) migration — was a marker-grep over the literal 'Payment error'; now
@@ -2468,7 +3984,12 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
2468
3984
  // per seeded run carrying its report_type (fails on an empty workspace).
2469
3985
  done('stripe.ui.reports', 'ui-dashboard', 'Dashboard: Reports / analytics screen', 'ui', 'niche', uiDataCoupled({
2470
3986
  markers: ['Reports', 'reporting/report_runs', 'list-row'],
2471
- seed: async (h) => { await h({ m: 'POST', p: '/v1/reporting/report_runs', b: 'parameters[report_type]=balance.summary.1' }); },
3987
+ // made ten minutes ago on the World's clock: a run starts pending and succeeds a minute on ("Most runs complete within a
3988
+ // few minutes", docs.stripe.com/reports/api; stripe.reporting.report_runs), so one made at the instant the screen reads
3989
+ // it is still pending
3990
+ seed: async (_h, root) => {
3991
+ await handleStripeTwinRequest({ method: 'POST', path: '/v1/reporting/report_runs', body: 'report_type=balance.summary.1&parameters[interval_start]=0&parameters[interval_end]=100', root, occurredAt: new Date(Date.now() - 10 * 60_000).toISOString() });
3992
+ },
2472
3993
  check: async ({ get }) => {
2473
3994
  const rows = ((await get('/v1/reporting/report_runs')).data as Body[]) as unknown as StripeRow[];
2474
3995
  if (!rows || rows.length < 1) return false;
@@ -2482,7 +4003,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
2482
4003
  done('stripe.ui.settings', 'ui-dashboard', 'Dashboard: Settings (account/team/api keys/webhooks)', 'ui', 'common', uiDataCoupled({
2483
4004
  markers: ['settings-block', 'data-settings', 'API keys', 'settings-payout-schedule', 'payoutScheduleText'],
2484
4005
  seed: async (h) => {
2485
- await h({ m: 'POST', p: '/v1/account', b: 'settings[payouts][schedule][interval]=weekly&settings[payouts][schedule][weekly_anchor]=monday' });
4006
+ await h({ m: 'POST', p: '/_twin/account', b: 'settings[payouts][schedule][interval]=weekly&settings[payouts][schedule][weekly_anchor]=monday' });
2486
4007
  await h({ m: 'POST', p: '/v1/webhook_endpoints', b: 'url=https://twin.test/hook&enabled_events[]=charge.succeeded' });
2487
4008
  },
2488
4009
  check: async ({ get }) => {
@@ -2541,14 +4062,19 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
2541
4062
  // ── SetupIntents full lifecycle (only confirm was tracked before) ──
2542
4063
  done('stripe.setup_intents.lifecycle', 'payment_methods', 'SetupIntents: create / retrieve / list / cancel', 'api', 'common', () =>
2543
4064
  withRoot(async (h) => {
4065
+ // made without a payment method it waits for one: "When the SetupIntent is created, it has a status of
4066
+ // requires_payment_method until a payment method is attached" (docs.stripe.com/payments/setupintents/lifecycle;
4067
+ // 45d8b30d1); with one it waits for confirmation
2544
4068
  const c = await h({ m: 'POST', p: '/v1/setup_intents', b: 'usage=off_session' });
2545
- if (!ok(c) || field(c, 'status') !== 'requires_confirmation') return false;
4069
+ if (!ok(c) || field(c, 'status') !== 'requires_payment_method') return false;
2546
4070
  const g = await h({ m: 'GET', p: `/v1/setup_intents/${id(c)}` });
2547
4071
  const l = await h({ m: 'GET', p: '/v1/setup_intents' });
2548
4072
  const x = await h({ m: 'POST', p: `/v1/setup_intents/${id(c)}/cancel` });
2549
4073
  // cancelling a succeeded SI is rejected (negative path)
2550
- const conf = await h({ m: 'POST', p: '/v1/setup_intents', b: 'usage=off_session' });
2551
- await h({ m: 'POST', p: `/v1/setup_intents/${id(conf)}/confirm` });
4074
+ const conf = await h({ m: 'POST', p: '/v1/setup_intents', b: 'usage=off_session&payment_method=pm_card_visa' });
4075
+ if (field(conf, 'status') !== 'requires_confirmation') return false;
4076
+ const confirmed = await h({ m: 'POST', p: `/v1/setup_intents/${id(conf)}/confirm` });
4077
+ if (field(confirmed, 'status') !== 'succeeded') return false;
2552
4078
  const badCancel = await h({ m: 'POST', p: `/v1/setup_intents/${id(conf)}/cancel` });
2553
4079
  return ok(g) && id(g) === id(c) && field(l, 'object') === 'list'
2554
4080
  && ok(x) && field(x, 'status') === 'canceled' && badCancel.status === 400;
@@ -2574,24 +4100,40 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
2574
4100
  // ── Mandates retrieve ──
2575
4101
  done('stripe.mandates.retrieve', 'payment_methods', 'Mandates: retrieve (404 unknown)', 'api', 'niche', () =>
2576
4102
  withRoot(async (h) => {
2577
- const m = await h({ m: 'POST', p: '/v1/mandates', b: 'payment_method=pm_card_visa' });
2578
- if (!ok(m) || field(m, 'object') !== 'mandate') return false;
2579
- const g = await h({ m: 'GET', p: `/v1/mandates/${id(m)}` });
4103
+ // Stripe's API makes no mandate on request (the twin's seeding POST /v1/mandates was invented and is gone,
4104
+ // 5decb1d77): a mandate is the customer's acceptance a verified bank account carries, and a SetupIntent verified by
4105
+ // micro-deposits names its multi-use one (docs.stripe.com/api/mandates, docs.stripe.com/api/setup_intents/object mandate)
4106
+ const si = await h({ m: 'POST', p: '/v1/setup_intents', b: 'allowed_payment_method_types[]=us_bank_account' });
4107
+ await h({ m: 'POST', p: `/v1/setup_intents/${id(si)}/confirm`, b: 'payment_method=pm_us_bank_account' });
4108
+ const verified = await h({ m: 'POST', p: `/v1/setup_intents/${id(si)}/verify_microdeposits`, b: 'descriptor_code=SM11AA' });
4109
+ const mandateId = String(field(verified, 'mandate'));
4110
+ const g = await h({ m: 'GET', p: `/v1/mandates/${mandateId}` });
2580
4111
  const miss = await h({ m: 'GET', p: '/v1/mandates/mandate_nope' });
2581
- return ok(g) && id(g) === id(m) && field(g, 'status') === 'active' && miss.status === 404;
4112
+ return ok(g) && field(g, 'object') === 'mandate' && id(g) === mandateId && field(g, 'status') === 'active' && field(g, 'type') === 'multi_use' && miss.status === 404;
2582
4113
  }),
2583
4114
  ),
2584
4115
 
2585
4116
  // ── Refund cancel ──
2586
- done('stripe.refunds.cancel', 'refunds', 'Refunds: cancel a pending refund (400 if not pending)', 'api', 'niche', () =>
2587
- withRoot(async (h) => {
2588
- const pending = await h({ m: 'POST', p: '/v1/refunds', b: 'amount=500&currency=usd&status=pending' });
2589
- if (!ok(pending)) return false;
2590
- const x = await h({ m: 'POST', p: `/v1/refunds/${id(pending)}/cancel` });
2591
- const succeeded = await h({ m: 'POST', p: '/v1/refunds', b: 'amount=500&currency=usd' });
2592
- const bad = await h({ m: 'POST', p: `/v1/refunds/${id(succeeded)}/cancel` });
4117
+ // A bank-transfer payment's refund waits in requires_action for the customer's bank details (Stripe emails them), and
4118
+ // only such a refund can be canceled, which gives the charge back its amount; a succeeded refund cannot be canceled
4119
+ // (docs.stripe.com/payments/customer-balance/refunding, docs.stripe.com/api/refunds/cancel).
4120
+ done('stripe.refunds.cancel', 'refunds', 'Refunds: a bank-transfer refund awaiting bank details is canceled (400 once succeeded)', 'api', 'niche', () =>
4121
+ withRoot(async (h) => {
4122
+ const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=wire@twin.test' });
4123
+ await h({ m: 'POST', p: `/v1/test_helpers/customers/${id(cust)}/fund_cash_balance`, b: 'amount=2000&currency=usd' });
4124
+ const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: `amount=2000&currency=usd&customer=${id(cust)}&allowed_payment_method_types[]=customer_balance` });
4125
+ await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/apply_customer_balance` });
4126
+ const waiting = await h({ m: 'POST', p: '/v1/refunds', b: `payment_intent=${id(pi)}&amount=500&instructions_email=wire@twin.test` });
4127
+ if (!ok(waiting) || field(waiting, 'status') !== 'requires_action') return false;
4128
+ const charge = String(field(waiting, 'charge'));
4129
+ const held = await h({ m: 'GET', p: `/v1/charges/${charge}` });
4130
+ const x = await h({ m: 'POST', p: `/v1/refunds/${id(waiting)}/cancel` });
4131
+ const released = await h({ m: 'GET', p: `/v1/charges/${charge}` });
4132
+ const back = await h({ m: 'POST', p: '/v1/refunds', b: `payment_intent=${id(pi)}&amount=500&origin=customer_balance` });
4133
+ const bad = await h({ m: 'POST', p: `/v1/refunds/${id(back)}/cancel` });
2593
4134
  const miss = await h({ m: 'POST', p: '/v1/refunds/re_nope/cancel' });
2594
- return ok(x) && field(x, 'status') === 'canceled' && bad.status === 400 && miss.status === 404;
4135
+ return ok(x) && field(x, 'status') === 'canceled' && field(back, 'status') === 'succeeded' && bad.status === 400 && miss.status === 404
4136
+ && field(held, 'amount_refunded') === 500 && field(released, 'amount_refunded') === 0;
2595
4137
  }),
2596
4138
  ),
2597
4139
 
@@ -2604,10 +4146,313 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
2604
4146
  const inc = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/increment_authorization`, b: 'amount=1500' });
2605
4147
  // must be greater than current → reject lowering
2606
4148
  const bad = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/increment_authorization`, b: 'amount=1000' });
2607
- return ok(inc) && field(inc, 'amount') === 1500 && field(inc, 'amount_capturable') === 1500 && field(inc, 'status') === 'requires_capture' && bad.status === 400;
4149
+ // the charge holds the grown authorization, so capturing all of it releases nothing
4150
+ const held = await h({ m: 'GET', p: `/v1/charges/${String(field(inc, 'latest_charge'))}` });
4151
+ await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/capture` });
4152
+ const taken = await h({ m: 'GET', p: `/v1/charges/${String(field(inc, 'latest_charge'))}` });
4153
+ return ok(inc) && field(inc, 'amount') === 1500 && field(inc, 'amount_capturable') === 1500 && field(inc, 'status') === 'requires_capture' && bad.status === 400
4154
+ && field(held, 'amount') === 1500 && field(taken, 'amount_captured') === 1500 && field(taken, 'amount_refunded') === 0 && field(taken, 'refunded') === false;
2608
4155
  }),
2609
4156
  ),
2610
4157
 
4158
+ // ── Connect OAuth for Standard accounts (screens/connect-oauth.tsx; docs.stripe.com/connect/oauth-reference) ──
4159
+ // The platform's OAuth settings: "Enable onboarding accounts with OAuth", "Copy your client_id", "Set your redirect_uri
4160
+ // ... If you don't include the redirect_uri parameter in your request, Stripe defaults to using the first address you've
4161
+ // configured" (docs.stripe.com/connect/oauth-standard-accounts). The page shows the client_id and what was saved, and
4162
+ // authorize holds the platform to it.
4163
+ done('stripe.connect.oauth_settings', 'connect', 'Dashboard Connect OAuth settings: the client_id shown, OAuth enabled and redirect URIs registered, which authorize holds the platform to', 'ui', 'common', () =>
4164
+ withOAuth(async (w) => {
4165
+ const read = async (path: string) => (await w.f(new Request(`https://dashboard.stripe.com${path}`))).text();
4166
+ const first = await read('/settings/connect');
4167
+ const shownId = /data-testid="connect-client-id">(ca_[^<]+)</.exec(first)?.[1];
4168
+ // a new platform has OAuth off: authorize refuses it, in JSON, with the state
4169
+ const off = await w.authorize(authorizeQuery());
4170
+ const offBody = (await off.json()) as Body;
4171
+ // a redirect URI that is not an http(s) URL is refused on the page, and nothing is saved
4172
+ const bad = await w.settings(true, 'ftp://files.test/cb');
4173
+ const fragment = await w.settings(true, 'https://app.test/cb#tab');
4174
+ const stillOff = ((await (await w.authorize(authorizeQuery())).json()) as Body).error === 'invalid_client';
4175
+ // OAuth on with no redirect URI: a request naming none has nowhere to go
4176
+ await w.settings(true, '');
4177
+ const nowhere = (await (await w.authorize(authorizeQuery('', null))).json()) as Body;
4178
+ const saved = await w.settings(true, `https://app.test/first\n${OAUTH_CALLBACK}?tab=stripe`);
4179
+ const listed = await read('/settings/connect/onboarding-options/oauth');
4180
+ const uris = [...listed.matchAll(/data-testid="connect-redirect-uri">([^<]+)</g)].map((m) => m[1]!.replace(/&amp;/g, '&'));
4181
+ // no redirect_uri: the first registered; a registered one with its own query keeps it; an unregistered one is refused
4182
+ const defaulted = (await w.authorize(authorizeQuery('', null), 'skip')).headers.get('location') ?? '';
4183
+ const named = (await w.authorize(authorizeQuery('', `${OAUTH_CALLBACK}?tab=stripe`), 'skip')).headers.get('location') ?? '';
4184
+ const unregistered = await w.authorize(authorizeQuery('', OAUTH_CALLBACK));
4185
+ const unregisteredBody = (await unregistered.json()) as Body;
4186
+ // turned off again, authorize refuses again
4187
+ await w.settings(false, OAUTH_CALLBACK);
4188
+ const offAgain = ((await (await w.authorize(authorizeQuery())).json()) as Body).error === 'invalid_client';
4189
+ const testLink = (await read('/test/settings/connect/onboarding-options/oauth')).includes('OAuth is disabled.');
4190
+ return shownId === 'ca_twin_self' && first.includes('OAuth is disabled.') && off.status === 400 && offBody.error === 'invalid_client' && offBody.state === 'st8'
4191
+ && bad.status === 400 && fragment.status === 400 && stillOff && nowhere.error === 'invalid_redirect_uri' && saved.status === 303 && uris.length === 2 && uris[0] === 'https://app.test/first' && uris[1] === `${OAUTH_CALLBACK}?tab=stripe`
4192
+ && listed.includes('OAuth is enabled.') && defaulted.startsWith('https://app.test/first?scope=read_write&code=ac_') && named.startsWith(`${OAUTH_CALLBACK}?tab=stripe&scope=read_write&code=ac_`)
4193
+ && unregistered.status === 400 && unregisteredBody.error === 'invalid_redirect_uri' && String(unregisteredBody.error_description).includes(OAUTH_CALLBACK)
4194
+ && offAgain && testLink;
4195
+ }, false),
4196
+ ),
4197
+ // GET connect.stripe.com/oauth/authorize: the request checked as the reference lists its errors (a JSON dictionary with
4198
+ // error, error_description and state, never a redirect), the page the person answers, "Skip this form" in test mode
4199
+ // creating a new Standard account from the prefill ("Any parameters with invalid values are silently ignored") and
4200
+ // redirecting with code, scope and state; a denial redirects with access_denied and creates nothing.
4201
+ done('stripe.connect.oauth_authorize', 'connect', 'Connect OAuth authorize: request errors, the connect page, Skip this form creates a prefilled Standard account and redirects with code/scope/state; Deny redirects access_denied', 'api', 'common', () =>
4202
+ withOAuth(async (w) => {
4203
+ const err = async (query: string) => { const r = await w.authorize(query); return { status: r.status, ...((await r.json()) as Body) } as Body; };
4204
+ const noClient = await err(authorizeQuery().replace('client_id=ca_twin_self&', ''));
4205
+ const unknown = await err(authorizeQuery().replace('ca_twin_self', 'ca_nope'));
4206
+ const noType = await err(authorizeQuery().replace('&response_type=code', ''));
4207
+ const tokenType = await err(authorizeQuery().replace('response_type=code', 'response_type=token'));
4208
+ const badScope = await err(authorizeQuery().replace('scope=read_write', 'scope=admin'));
4209
+ const errorsRight = noClient.status === 400 && noClient.error === 'invalid_request' && unknown.error === 'invalid_client' && unknown.error_description === 'No application matches the supplied client identifier'
4210
+ && noType.error === 'invalid_request' && tokenType.error === 'unsupported_response_type' && badScope.error === 'invalid_scope' && [noClient, unknown, noType, tokenType, badScope].every((e) => e.state === 'st8' && e.status === 400);
4211
+ const prefillQuery = '&stripe_user%5Bemail%5D=org%40cal.test&stripe_user%5Bfirst_name%5D=Ada&stripe_user%5Bbusiness_name%5D=Ada%20Consulting&stripe_user%5Bcountry%5D=CA&stripe_user%5Burl%5D=not-a-url&stripe_user%5Bbusiness_type%5D=llc&stripe_user%5Bproduct_description%5D=Consulting%20sessions&stripe_user%5Bphone_number%5D=6045550123';
4212
+ const shown = await (await w.authorize(authorizeQuery(prefillQuery))).text();
4213
+ const pageRight = shown.includes('Skip this form') && shown.includes('Deny access') && shown.includes('Read and write access') && shown.includes('org@cal.test') && shown.includes('Twin Inc.');
4214
+ // Deny: back to the platform with access_denied and the state, and no account made
4215
+ const denied = (await w.authorize(authorizeQuery(prefillQuery), 'deny')).headers.get('location');
4216
+ const noneMade = ((await w.api('GET', '/v1/accounts')).body.data as Body[]).length === 0;
4217
+ const { location, code, token } = await w.connect(prefillQuery);
4218
+ const acct = (await w.api('GET', `/v1/accounts/${String(token.stripe_user_id)}`)).body;
4219
+ const profile = acct.business_profile as Body;
4220
+ // scope defaults to read_only
4221
+ const readOnly = new URL((await w.authorize(authorizeQuery().replace('scope=read_write&', ''), 'skip')).headers.get('location') ?? 'about:blank').searchParams.get('scope');
4222
+ // an empty scope is no scope: the default, not invalid_scope
4223
+ const emptyScope = new URL((await w.authorize(authorizeQuery().replace('scope=read_write', 'scope='), 'skip')).headers.get('location') ?? 'about:blank').searchParams.get('scope');
4224
+ // two people connecting at once get two codes, each for its own account (a regression guard: in one process a
4225
+ // count and its write never interleave; the atomic mint is for Worlds whose store two processes write)
4226
+ const both = await Promise.all([w.authorize(authorizeQuery(), 'skip'), w.authorize(authorizeQuery(), 'skip')]);
4227
+ const codes = both.map((r) => new URL(r.headers.get('location') ?? 'about:blank').searchParams.get('code'));
4228
+ const bothTokens = await Promise.all(codes.map(async (c) => (await w.endpoint('token', `grant_type=authorization_code&code=${c}`)).body));
4229
+ return errorsRight && pageRight && noneMade
4230
+ && denied === `${OAUTH_CALLBACK}?error=access_denied&error_description=The%20user%20denied%20your%20request&state=st8`
4231
+ && `${location.origin}${location.pathname}` === OAUTH_CALLBACK && [...location.searchParams.keys()].join(',') === 'scope,code,state' && location.searchParams.get('scope') === 'read_write' && location.searchParams.get('state') === 'st8' && /^ac_/.test(code)
4232
+ && acct.id === token.stripe_user_id && acct.type === 'standard' && (acct.controller as Body)?.type === 'account' && acct.email === 'org@cal.test' && acct.country === 'CA' && acct.default_currency === 'cad'
4233
+ && profile?.name === 'Ada Consulting' && profile?.url === null && profile?.product_description === 'Consulting sessions' && profile?.support_phone === '6045550123'
4234
+ && acct.business_type === 'company' && ((acct.settings as Body)?.dashboard as Body)?.display_name === 'Ada Consulting'
4235
+ && ((acct.requirements as Body)?.currently_due as unknown[])?.length === 0 && acct.details_submitted === true && acct.payouts_enabled === true
4236
+ && acct.charges_enabled === true && (acct.capabilities as Body)?.card_payments === 'active'
4237
+ && readOnly === 'read_only' && emptyScope === 'read_only'
4238
+ && codes[0] !== codes[1] && bothTokens.every((t) => /^acct_/.test(String(t.stripe_user_id))) && bothTokens[0]!.stripe_user_id !== bothTokens[1]!.stripe_user_id && bothTokens[0]!.refresh_token !== bothTokens[1]!.refresh_token;
4239
+ }),
4240
+ ),
4241
+ // POST connect.stripe.com/oauth/token with grant_type=authorization_code, as stripe-node's oauth.token sends it (the
4242
+ // platform's secret key as Bearer; curl's -u as Basic): the reference's response, the connected account usable with the
4243
+ // Stripe-Account header at once; a code "can only be used once and expires in 5 minutes", and "Consuming an
4244
+ // authorization code more than once revokes the account connection"; the reference's error codes.
4245
+ done('stripe.connect.oauth_token', 'connect', 'Connect OAuth token: an authorization code becomes the connection (stripe_user_id, tokens), once, within 5 minutes; reuse revokes; invalid_request / invalid_grant / unsupported_grant_type', 'api', 'common', () =>
4246
+ withOAuth(async (w) => {
4247
+ const { code, token } = await w.connect();
4248
+ const account = String(token.stripe_user_id);
4249
+ const shape = Object.keys(token).sort().join(',') === 'access_token,livemode,refresh_token,scope,stripe_publishable_key,stripe_user_id,token_type'
4250
+ && token.token_type === 'bearer' && token.livemode === false && token.scope === 'read_write' && /^sk_test_/.test(String(token.access_token))
4251
+ && /^pk_test_/.test(String(token.stripe_publishable_key)) && /^rt_/.test(String(token.refresh_token)) && /^acct_/.test(account);
4252
+ // the platform acts as the account (Cal.com: a PaymentIntent with stripeAccount), and reads its default currency
4253
+ const pi = await w.api('POST', '/v1/payment_intents', 'amount=2500&currency=usd', account);
4254
+ const retrieved = (await w.api('GET', `/v1/accounts/${account}`)).body;
4255
+ const e = async (body: string, auth?: string) => { const r = await w.endpoint('token', body, auth); return { status: r.status, ...r.body } as Body; };
4256
+ const noKey = await e(`grant_type=authorization_code&code=${code}`, '');
4257
+ const publishable = await e(`grant_type=authorization_code&code=${code}`, 'Bearer pk_test_platform');
4258
+ const noGrant = await e(`code=${code}`);
4259
+ const password = await e('grant_type=password');
4260
+ const noCode = await e('grant_type=authorization_code');
4261
+ const missing = await e('grant_type=authorization_code&code=ac_nope');
4262
+ // curl -u sk_...: (Basic) works as Bearer does
4263
+ const second = await w.authorize(authorizeQuery(), 'skip');
4264
+ const basic = await e(`grant_type=authorization_code&code=${new URL(second.headers.get('location')!).searchParams.get('code')}`, `Basic ${btoa('sk_test_platform:')}`);
4265
+ // five minutes: a code 299 s old is exchanged, one 300 s old is not
4266
+ const fresh = new URL((await w.authorize(authorizeQuery(), 'skip')).headers.get('location')!).searchParams.get('code');
4267
+ const stale = new URL((await w.authorize(authorizeQuery(), 'skip')).headers.get('location')!).searchParams.get('code');
4268
+ w.at('2026-03-01T00:04:59.000Z');
4269
+ const inTime = await e(`grant_type=authorization_code&code=${fresh}`);
4270
+ w.at('2026-03-01T00:05:00.000Z');
4271
+ const late = await e(`grant_type=authorization_code&code=${stale}`);
4272
+ const liveCode = new URL((await w.authorize(authorizeQuery(), 'skip')).headers.get('location')!).searchParams.get('code');
4273
+ const liveKey = await e(`grant_type=authorization_code&code=${liveCode}`, 'Bearer sk_live_platform');
4274
+ // a JSON body is read as the form is
4275
+ const jsonCode = new URL((await w.authorize(authorizeQuery(), 'skip')).headers.get('location')!).searchParams.get('code');
4276
+ const asJson = await w.f(new Request('https://connect.stripe.com/oauth/token', { method: 'POST', headers: { authorization: 'Bearer sk_test_platform', 'content-type': 'application/json' }, body: JSON.stringify({ grant_type: 'authorization_code', code: jsonCode }) }));
4277
+ const asJsonBody = (await asJson.json()) as Body;
4278
+ // the first code again: refused, and the connection it made is revoked
4279
+ const reused = await e(`grant_type=authorization_code&code=${code}`);
4280
+ const after = await w.api('POST', '/v1/payment_intents', 'amount=2500&currency=usd', account);
4281
+ const others = await w.api('POST', '/v1/payment_intents', 'amount=2500&currency=usd', String(basic.stripe_user_id));
4282
+ // the same code exchanged twice at once (a callback run twice): one exchange wins, and still the connection is revoked
4283
+ // (the order the loser marks first is pinned below, `midway`)
4284
+ const ids = async () => ((await w.api('GET', '/v1/accounts?limit=100')).body.data as Body[]).map((a) => String(a.id));
4285
+ const known = await ids();
4286
+ const raced = new URL((await w.authorize(authorizeQuery(), 'skip')).headers.get('location')!).searchParams.get('code');
4287
+ const racedAccount = (await ids()).find((a) => !known.includes(a)) ?? '';
4288
+ const pair = await Promise.all([e(`grant_type=authorization_code&code=${raced}`), e(`grant_type=authorization_code&code=${raced}`)]);
4289
+ const racedAfter = racedAccount ? await w.api('GET', '/v1/customers', undefined, racedAccount) : { status: 0, body: {} };
4290
+ // the order a race can take the other way: the second use marks the code while the first exchange is still writing
4291
+ // its connection (the state that second use leaves, seeded as it is written), so the first exchange, finding the
4292
+ // mark after its write, revokes the connection itself
4293
+ const midway = new URL((await w.authorize(authorizeQuery(), 'skip')).headers.get('location')!).searchParams.get('code')!;
4294
+ const midAccount = (await ids()).find((a) => !known.includes(a) && a !== racedAccount) ?? '';
4295
+ await applyTwinWrite('stripe', { operation: 'oauth_code.reuse', subjectType: '_oauth_code', subjectId: midway, fields: { reused: true }, occurredAt: '2026-03-01T00:05:00.000Z', actor: { kind: 'agent' } }, w.root);
4296
+ const midExchange = await e(`grant_type=authorization_code&code=${midway}`);
4297
+ const midAfter = midAccount ? await w.api('GET', '/v1/customers', undefined, midAccount) : { status: 0, body: {} };
4298
+ return shape && pi.status === 200 && pi.body.object === 'payment_intent' && retrieved.default_currency === 'usd'
4299
+ && midExchange.status === 400 && midExchange.error === 'invalid_grant' && midAfter.status === 403
4300
+ && noKey.status === 401 && publishable.status === 401 && noGrant.status === 400 && noGrant.error === 'invalid_request' && password.error === 'unsupported_grant_type'
4301
+ && noCode.error === 'invalid_request' && missing.status === 400 && missing.error === 'invalid_grant' && missing.error_description === 'Authorization code does not exist: ac_nope'
4302
+ && basic.status === 200 && basic.token_type === 'bearer' && basic.stripe_user_id !== account
4303
+ && inTime.status === 200 && late.status === 400 && late.error === 'invalid_grant' && String(late.error_description).includes('expired')
4304
+ && liveKey.error === 'invalid_grant' && asJson.status === 200 && /^acct_/.test(String(asJsonBody.stripe_user_id))
4305
+ && reused.status === 400 && reused.error === 'invalid_grant' && String(reused.error_description).includes('already been used')
4306
+ && after.status === 403 && (after.body.error as Body)?.code === 'account_invalid' && others.status === 200
4307
+ && pair.filter((p) => p.status === 200).length <= 1 && pair.some((p) => p.error === 'invalid_grant') && racedAfter.status === 403;
4308
+ }),
4309
+ ),
4310
+ // grant_type=refresh_token: "a new access token of an equal or lesser scope"; "Any existing access token with the same
4311
+ // scope and mode ... is revoked"; a refresh token of a revoked connection is refused.
4312
+ done('stripe.connect.oauth_refresh', 'connect', 'Connect OAuth refresh_token grant: a new access token of equal or lesser scope; invalid_scope / invalid_grant', 'api', 'niche', () =>
4313
+ withOAuth(async (w) => {
4314
+ const { token } = await w.connect();
4315
+ const e = async (body: string) => { const r = await w.endpoint('token', body); return { status: r.status, ...r.body } as Body; };
4316
+ const same = await e(`grant_type=refresh_token&refresh_token=${String(token.refresh_token)}`);
4317
+ const lesser = await e(`grant_type=refresh_token&refresh_token=${String(token.refresh_token)}&scope=read_only`);
4318
+ const bogus = await e(`grant_type=refresh_token&refresh_token=${String(token.refresh_token)}&scope=admin`);
4319
+ const liveRefresh = await w.endpoint('token', `grant_type=refresh_token&refresh_token=${String(token.refresh_token)}`, 'Bearer sk_live_platform');
4320
+ const noToken = await e('grant_type=refresh_token');
4321
+ const unknown = await e('grant_type=refresh_token&refresh_token=rt_nope');
4322
+ // a read_only connection cannot refresh into read_write
4323
+ const ro = (await w.connect('').then(async () => {
4324
+ const loc = new URL((await w.authorize(authorizeQuery().replace('scope=read_write', 'scope=read_only'), 'skip')).headers.get('location')!);
4325
+ return (await w.endpoint('token', `grant_type=authorization_code&code=${loc.searchParams.get('code')}`)).body;
4326
+ }));
4327
+ const greater = await e(`grant_type=refresh_token&refresh_token=${String(ro.refresh_token)}&scope=read_write`);
4328
+ await w.endpoint('deauthorize', `client_id=ca_twin_self&stripe_user_id=${String(token.stripe_user_id)}`);
4329
+ const revoked = await e(`grant_type=refresh_token&refresh_token=${String(token.refresh_token)}`);
4330
+ return same.status === 200 && same.scope === 'read_write' && same.refresh_token === token.refresh_token && same.stripe_user_id === token.stripe_user_id
4331
+ && same.access_token !== token.access_token && /^sk_test_/.test(String(same.access_token))
4332
+ && lesser.status === 200 && lesser.scope === 'read_only' && lesser.access_token !== same.access_token
4333
+ && bogus.error === 'invalid_scope' && liveRefresh.body.error === 'invalid_grant' && noToken.error === 'invalid_request' && unknown.status === 400 && unknown.error === 'invalid_grant'
4334
+ && ro.scope === 'read_only' && greater.error === 'invalid_scope' && revoked.error === 'invalid_grant';
4335
+ }),
4336
+ ),
4337
+ // POST connect.stripe.com/oauth/deauthorize: `{ stripe_user_id }`, and then "the account can't be accessed by your
4338
+ // platform ... through the API" (the Stripe-Account header, /v1/accounts/{account}); invalid_request and invalid_client
4339
+ // as the reference lists them. "You can only revoke a Standard account's access": an account the platform made itself
4340
+ // was never connected by OAuth, so it is not connected to the application.
4341
+ done('stripe.connect.oauth_deauthorize', 'connect', 'Connect OAuth deauthorize: revokes the connection; the account is then refused with the Stripe-Account header; invalid_request / invalid_client', 'api', 'common', () =>
4342
+ withOAuth(async (w) => {
4343
+ const { token } = await w.connect();
4344
+ const other = (await w.connect()).token;
4345
+ const account = String(token.stripe_user_id);
4346
+ const e = async (body: string, auth?: string) => { const r = await w.endpoint('deauthorize', body, auth); return { status: r.status, ...r.body } as Body; };
4347
+ const made = String((await w.api('POST', '/v1/accounts', 'type=standard&country=US')).body.id);
4348
+ const noClient = await e(`stripe_user_id=${account}`);
4349
+ const noKey = await e(`client_id=ca_twin_self&stripe_user_id=${account}`, '');
4350
+ const noAccount = await e('client_id=ca_twin_self');
4351
+ const wrongClient = await e(`client_id=ca_nope&stripe_user_id=${account}`);
4352
+ const liveKey = await e(`client_id=ca_twin_self&stripe_user_id=${account}`, 'Bearer sk_live_platform');
4353
+ const notConnected = await e(`client_id=ca_twin_self&stripe_user_id=${made}`);
4354
+ const before = await w.api('GET', '/v1/customers', undefined, account);
4355
+ const revoked = await e(`client_id=ca_twin_self&stripe_user_id=${account}`);
4356
+ const asIt = await w.api('GET', '/v1/customers', undefined, account);
4357
+ const named = await w.api('GET', `/v1/accounts/${account}`);
4358
+ const again = await e(`client_id=ca_twin_self&stripe_user_id=${account}`);
4359
+ const listed = ((await w.api('GET', '/v1/accounts?limit=100')).body.data as Body[]).map((a) => a.id);
4360
+ const stillOthers = await w.api('GET', '/v1/customers', undefined, String(other.stripe_user_id));
4361
+ const stillMade = await w.api('GET', '/v1/customers', undefined, made);
4362
+ return before.status === 200 && revoked.status === 200 && Object.keys(revoked).sort().join(',') === 'status,stripe_user_id' && revoked.stripe_user_id === account
4363
+ && asIt.status === 403 && (asIt.body.error as Body)?.code === 'account_invalid' && String((asIt.body.error as Body)?.message).includes(account)
4364
+ && named.status === 403 && again.status === 400 && again.error === 'invalid_client'
4365
+ && noKey.status === 401 && noClient.error === 'invalid_request' && noAccount.error === 'invalid_request' && wrongClient.error === 'invalid_client' && liveKey.error === 'invalid_client'
4366
+ && notConnected.error === 'invalid_client' && stillOthers.status === 200 && stillMade.status === 200
4367
+ && !listed.includes(account) && listed.includes(other.stripe_user_id) && listed.includes(made);
4368
+ }),
4369
+ ),
4370
+ // account.application.authorized "Occurs whenever a user authorizes an application" and account.application.deauthorized
4371
+ // "whenever a user deauthorizes an application" (docs.stripe.com/api/events/types), each the connected account's event
4372
+ // (top-level `account`, Connect endpoints: docs.stripe.com/connect/webhooks) carrying the application.
4373
+ done('stripe.connect.oauth_events', 'connect', 'Connect OAuth events: account.application.authorized and .deauthorized reach Connect endpoints with the account and the application', 'api', 'common', async () => {
4374
+ const rx = await webhookReceiver();
4375
+ try {
4376
+ return await withOAuth(async (w) => {
4377
+ await w.api('POST', '/v1/webhook_endpoints', `url=${rx.url('/platform')}&enabled_events[]=*`);
4378
+ const connectEndpoint = await w.api('POST', '/v1/webhook_endpoints', `url=${rx.url('/connect')}&enabled_events[]=*&connect=true`);
4379
+ const { token } = await w.connect();
4380
+ const account = String(token.stripe_user_id);
4381
+ // a charge on the account, whose funds come due after the connection is revoked
4382
+ await w.api('POST', '/v1/charges', 'amount=5000&currency=usd&source=tok_visa', account);
4383
+ const before = rx.got('/connect').filter((e) => e.account === account && e.type === 'charge.succeeded').length;
4384
+ const authorized = rx.got('/connect').filter((e) => e.type === 'account.application.authorized');
4385
+ // two deauthorizations at once: one revokes (and one event is sent), the other finds it no longer connected (a
4386
+ // regression guard: in one process the check and the revoke never interleave)
4387
+ const twice = await Promise.all([1, 2].map(() => w.endpoint('deauthorize', `client_id=ca_twin_self&stripe_user_id=${account}`)));
4388
+ const deauthorized = rx.got('/connect').filter((e) => e.type === 'account.application.deauthorized');
4389
+ const seen = rx.got('/connect').length;
4390
+ // days later its funds are available: the account's own balance.available no longer reaches the platform
4391
+ w.at('2026-03-06T00:00:00.000Z');
4392
+ const drained = await w.f(new Request('http://stripe.test/_twin/drain', { method: 'POST' }));
4393
+ const drainedBody = (await drained.json()) as { delivered: Array<{ type: string; account?: string }> };
4394
+ const leaked = rx.got('/connect').slice(seen).filter((e) => e.account === account);
4395
+ const app = authorized[0]?.data.object as Body | undefined;
4396
+ return connectEndpoint.status === 200 && connectEndpoint.body.connect === true
4397
+ && authorized.length === 1 && authorized[0]!.account === account && app?.object === 'application' && app?.id === 'ca_twin_self' && typeof app?.name === 'string'
4398
+ && before === 1 && drainedBody.delivered.some((d) => d.account === account && d.type === 'balance.available') && leaked.length === 0
4399
+ && twice.filter((r) => r.status === 200).length === 1 && twice.some((r) => r.body.error === 'invalid_client')
4400
+ && deauthorized.length === 1 && deauthorized[0]!.account === account && (deauthorized[0]!.data.object as Body).id === 'ca_twin_self'
4401
+ && !rx.got('/platform').some((e) => e.type.startsWith('account.application.'));
4402
+ });
4403
+ } finally {
4404
+ rx.close();
4405
+ }
4406
+ }),
4407
+ // The token answer's access_token and stripe_publishable_key act as the connected account ("Use the Stripe-Account
4408
+ // header with your platform's secret key", the reference, for the deprecated keys): a request made with one is the
4409
+ // account's (its events are its own, GET /v1/account answers it), and it is not the platform's key at the OAuth
4410
+ // endpoints; a key no live connection holds, after a refresh
4411
+ // ("Any existing access token with the same scope and mode ... is revoked") or a deauthorize, is an invalid API key.
4412
+ done('stripe.connect.oauth_access_token_auth', 'connect', 'Connect OAuth keys: the access_token and stripe_publishable_key act as the connected account; a refreshed-away or revoked token is refused 401', 'api', 'niche', () =>
4413
+ withOAuth(async (w) => {
4414
+ const { token } = await w.connect();
4415
+ const account = String(token.stripe_user_id);
4416
+ const as = async (m: string, p: string, key: unknown, b?: string, acct?: string) => {
4417
+ const r = await w.f(new Request(`http://stripe.test${p}`, { method: m, headers: { authorization: `Bearer ${String(key)}`, 'content-type': 'application/x-www-form-urlencoded', ...(acct ? { 'stripe-account': acct } : {}) }, ...(b !== undefined ? { body: b } : {}) }));
4418
+ return { status: r.status, body: (await r.json()) as Body };
4419
+ };
4420
+ const me = await as('GET', '/v1/account', token.access_token);
4421
+ // curl -u sk_…: (Basic) is the same key
4422
+ const basicMe = await w.f(new Request('http://stripe.test/v1/account', { headers: { authorization: `Basic ${btoa(`${String(token.access_token)}:`)}` } }));
4423
+ const basicBody = (await basicMe.json()) as Body;
4424
+ const unknownAccount = await w.api('GET', '/v1/account', undefined, 'acct_twin_nope');
4425
+ // the account's key is not the platform's: it cannot exchange, refresh or deauthorize
4426
+ const asPlatform = await w.endpoint('token', `grant_type=refresh_token&refresh_token=${String(token.refresh_token)}`, `Bearer ${String(token.access_token)}`);
4427
+ const deauthAsIt = await w.endpoint('deauthorize', `client_id=ca_twin_self&stripe_user_id=${account}`, `Bearer ${String(token.access_token)}`);
4428
+ const platformSelf = await w.api('GET', '/v1/account');
4429
+ const viaHeader = await w.api('GET', '/v1/account', undefined, account);
4430
+ const made = await as('POST', '/v1/customers', token.access_token, 'email=buyer@connected.test');
4431
+ const theirs = ((await w.api('GET', '/v1/events?type=customer.created', undefined, account)).body.data as Body[]).map((e) => ((e.data as Body).object as Body).id);
4432
+ const ours = ((await w.api('GET', '/v1/events?type=customer.created')).body.data as Body[]).map((e) => ((e.data as Body).object as Body).id);
4433
+ // the publishable key acts as the account and is still only a publishable key
4434
+ const pkRead = await as('GET', '/v1/customers', token.stripe_publishable_key);
4435
+ const elsewhere = await as('GET', '/v1/customers', token.access_token, undefined, 'acct_twin_other');
4436
+ const refreshed = (await w.endpoint('token', `grant_type=refresh_token&refresh_token=${String(token.refresh_token)}`)).body;
4437
+ const oldToken = await as('GET', '/v1/customers', token.access_token);
4438
+ const newToken = await as('GET', '/v1/customers', refreshed.access_token);
4439
+ const forged = await as('GET', '/v1/customers', `sk_test_oauth_${account}_9`);
4440
+ await w.endpoint('deauthorize', `client_id=ca_twin_self&stripe_user_id=${account}`);
4441
+ const afterRevoke = await as('GET', '/v1/customers', refreshed.access_token);
4442
+ return me.status === 200 && me.body.id === account && me.body.type === 'standard' && platformSelf.body.id === 'acct_twin_self' && viaHeader.body.id === account
4443
+ && basicMe.status === 200 && basicBody.id === account && unknownAccount.status === 403 && (unknownAccount.body.error as Body)?.code === 'account_invalid'
4444
+ && asPlatform.status === 401 && deauthAsIt.status === 401
4445
+ && made.status === 200 && theirs.includes(made.body.id) && !ours.includes(made.body.id)
4446
+ && pkRead.status === 403 && (pkRead.body.error as Body)?.code === 'secret_key_required'
4447
+ && elsewhere.status === 403 && (elsewhere.body.error as Body)?.code === 'account_invalid'
4448
+ && oldToken.status === 401 && newToken.status === 200 && ((newToken.body.data as Body[]) ?? []).some((c) => c.id === made.body.id)
4449
+ && forged.status === 401 && afterRevoke.status === 401;
4450
+ }),
4451
+ ),
4452
+ todo('stripe.connect.oauth_scope_enforcement', 'connect', 'Connect OAuth: a read_only connection (or access token) is refused writes as the account', 'api', 'niche'),
4453
+ todo('stripe.connect.oauth_existing_account', 'connect', 'Connect OAuth: sign in and connect an existing Stripe account instead of creating one', 'api', 'niche'),
4454
+ todo('stripe.connect.oauth_account_application', 'connect', 'Connect OAuth: the full account application form (the page offers only the test-mode Skip this form)', 'ui', 'niche'),
4455
+
2611
4456
  // ── Connect: account links (hosted onboarding) ──
2612
4457
  done('stripe.connect.account_links', 'connect', 'Connect: account_links (onboarding URL)', 'api', 'niche', () =>
2613
4458
  withRoot(async (h) => {
@@ -2667,15 +4512,20 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
2667
4512
  const g = await h({ m: 'GET', p: `/v1/accounts/${id(acct)}/capabilities/card_payments` });
2668
4513
  const req = await h({ m: 'POST', p: `/v1/accounts/${id(acct)}/capabilities/card_payments`, b: 'requested=true' });
2669
4514
  const miss = await h({ m: 'GET', p: `/v1/accounts/${id(acct)}/capabilities/bogus` });
2670
- return ok(g) && field(g, 'object') === 'capability' && ok(req) && field(req, 'status') === 'pending' && miss.status === 404;
4515
+ // requested on an account that still owes its information, Stripe's review leaves it inactive with what is due
4516
+ // (the capability's `requirements`; "inactive ... Check requirements", docs.stripe.com/api/capabilities/object;
4517
+ // semantics/connect.ts review, 44dea3cb3), where it used to answer a pending that nothing ever moved
4518
+ const due = ((field(req, 'requirements') as Body | undefined)?.currently_due as string[] | undefined) ?? [];
4519
+ return ok(g) && field(g, 'object') === 'capability' && ok(req) && field(req, 'requested') === true && field(req, 'status') === 'inactive'
4520
+ && due.includes('business_profile.mcc') && miss.status === 404;
2671
4521
  }),
2672
4522
  ),
2673
4523
 
2674
4524
  // ── Connect: transfer reversals ──
2675
4525
  done('stripe.connect.transfer_reversals', 'connect', 'Connect: transfer reversals (partial + full)', 'api', 'niche', () =>
2676
4526
  withRoot(async (h) => {
2677
- const acct = await h({ m: 'POST', p: '/v1/accounts', b: 'type=standard' });
2678
- const tr = await h({ m: 'POST', p: '/v1/transfers', b: `amount=1000&currency=usd&destination=${id(acct)}` });
4527
+ const seller = await onboardedSeller(h);
4528
+ const tr = await h({ m: 'POST', p: '/v1/transfers', b: `amount=1000&currency=usd&destination=${seller}` });
2679
4529
  if (!ok(tr)) return false;
2680
4530
  const rev1 = await h({ m: 'POST', p: `/v1/transfers/${id(tr)}/reversals`, b: 'amount=400' });
2681
4531
  if (!ok(rev1) || field(rev1, 'object') !== 'transfer_reversal') return false;
@@ -2692,8 +4542,11 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
2692
4542
  // ── Connect: application fees + refunds ──
2693
4543
  done('stripe.connect.application_fees', 'connect', 'Connect: application fees + fee refunds', 'api', 'niche', () =>
2694
4544
  withRoot(async (h) => {
2695
- const fee = await h({ m: 'POST', p: '/v1/application_fees', b: 'amount=1000&currency=usd&charge=ch_twin_1&account=acct_1' });
2696
- if (!ok(fee) || field(fee, 'object') !== 'application_fee') return false;
4545
+ // a destination charge with an application fee earns the platform its fee
4546
+ const acct = await h({ m: 'POST', p: '/v1/accounts', b: 'type=express&country=US' });
4547
+ await h({ m: 'POST', p: '/v1/charges', b: `amount=5000&currency=usd&source=tok_visa&application_fee_amount=1000&transfer_data[destination]=${id(acct)}` });
4548
+ const fee = { status: 200, body: (((await h({ m: 'GET', p: '/v1/application_fees' })).body as Body).data as Body[])[0]! } as StripeResponse;
4549
+ if (!fee.body || field(fee, 'object') !== 'application_fee' || field(fee, 'amount') !== 1000) return false;
2697
4550
  const g = await h({ m: 'GET', p: `/v1/application_fees/${id(fee)}` });
2698
4551
  const l = await h({ m: 'GET', p: '/v1/application_fees' });
2699
4552
  const ref = await h({ m: 'POST', p: `/v1/application_fees/${id(fee)}/refunds`, b: 'amount=400' });
@@ -2706,11 +4559,11 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
2706
4559
  // ── Files (uploads) + retrieve ──
2707
4560
  done('stripe.files.upload', 'files', 'Files: upload (purpose required) + retrieve/list', 'api', 'niche', () =>
2708
4561
  withRoot(async (h) => {
2709
- const f = await h({ m: 'POST', p: '/v1/files', b: 'purpose=dispute_evidence&filename=evidence.png' });
4562
+ const f = await h({ m: 'POST', p: '/v1/files', b: 'purpose=dispute_evidence&file=evidence.png' });
2710
4563
  if (!ok(f) || field(f, 'object') !== 'file' || field(f, 'purpose') !== 'dispute_evidence') return false;
2711
4564
  const g = await h({ m: 'GET', p: `/v1/files/${id(f)}` });
2712
4565
  const l = await h({ m: 'GET', p: '/v1/files' });
2713
- const bad = await h({ m: 'POST', p: '/v1/files', b: 'filename=x.png' });
4566
+ const bad = await h({ m: 'POST', p: '/v1/files', b: 'file=x.png' });
2714
4567
  return ok(g) && id(g) === id(f) && field(l, 'object') === 'list' && bad.status === 400;
2715
4568
  }),
2716
4569
  ),
@@ -2741,13 +4594,17 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
2741
4594
  // scoped overwrite + find/delete are produced ONLY by this feature.)
2742
4595
  done('stripe.connect.secrets', 'connect', 'Connect: apps secret store', 'api', 'niche', () =>
2743
4596
  withRoot(async (h) => {
4597
+ // a secret's value is "nullable string Includable": answered only when the request expands it
4598
+ // (docs.stripe.com/api/secret_management; stripe-version.ts INCLUDABLE, 1d786078c), never on a plain answer
2744
4599
  const set = await h({ m: 'POST', p: '/v1/apps/secrets', b: 'name=api_key&scope[type]=account&payload=sk_secret_1' });
2745
- if (!ok(set) || field(set, 'object') !== 'apps.secret' || field(set, 'payload') !== 'sk_secret_1') return false;
2746
- const find = await h({ m: 'GET', p: '/v1/apps/secrets/find?name=api_key&scope[type]=account' });
4600
+ if (!ok(set) || field(set, 'object') !== 'apps.secret' || field(set, 'payload') !== undefined) return false;
4601
+ const plainFind = await h({ m: 'GET', p: '/v1/apps/secrets/find?name=api_key&scope[type]=account' });
4602
+ if (!ok(plainFind) || field(plainFind, 'payload') !== undefined) return false;
4603
+ const find = await h({ m: 'GET', p: '/v1/apps/secrets/find?name=api_key&scope[type]=account&expand[]=payload' });
2747
4604
  if (!ok(find) || field(find, 'payload') !== 'sk_secret_1') return false;
2748
4605
  // overwrite: same name+scope updates in place (no duplicate in the list).
2749
4606
  const set2 = await h({ m: 'POST', p: '/v1/apps/secrets', b: 'name=api_key&scope[type]=account&payload=sk_secret_2' });
2750
- const find2 = await h({ m: 'GET', p: '/v1/apps/secrets/find?name=api_key&scope[type]=account' });
4607
+ const find2 = await h({ m: 'GET', p: '/v1/apps/secrets/find?name=api_key&scope[type]=account&expand[]=payload' });
2751
4608
  const list = await h({ m: 'GET', p: '/v1/apps/secrets?scope[type]=account' });
2752
4609
  if (!ok(set2) || field(find2, 'payload') !== 'sk_secret_2' || ((list.body as Body).data as Body[]).length !== 1) return false;
2753
4610
  // delete → find 404s.
@@ -2770,16 +4627,17 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
2770
4627
  done('stripe.payment_intents.apply_customer_balance', 'payment_intents', 'PaymentIntents: apply_customer_balance', 'api', 'niche', () =>
2771
4628
  withRoot(async (h) => {
2772
4629
  const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=acb@twin.test' });
2773
- // partial funds first.
2774
- await h({ m: 'POST', p: `/v1/customers/${id(cust)}/cash_balance_transactions`, b: 'amount=600&currency=usd' });
2775
- const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: `amount=1000&currency=usd&customer=${id(cust)}&payment_method_types[]=customer_balance` });
4630
+ // partial funds first, through the test helper ("Create an incoming testmode bank transfer",
4631
+ // docs.stripe.com/api/cash_balance/fund_cash_balance): the twin-invented POST .../cash_balance_transactions is gone (5decb1d77)
4632
+ await h({ m: 'POST', p: `/v1/test_helpers/customers/${id(cust)}/fund_cash_balance`, b: 'amount=600&currency=usd' });
4633
+ const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: `amount=1000&currency=usd&customer=${id(cust)}&allowed_payment_method_types[]=customer_balance` });
2776
4634
  if (!ok(pi)) return false;
2777
4635
  const partial = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/apply_customer_balance` });
2778
4636
  if (!ok(partial) || field(partial, 'status') !== 'requires_action') return false;
2779
4637
  const na = field(partial, 'next_action') as Body;
2780
4638
  if (na?.type !== 'display_bank_transfer_instructions' || (na.display_bank_transfer_instructions as Body).amount_remaining !== 400) return false;
2781
4639
  // top up the rest → succeeds.
2782
- await h({ m: 'POST', p: `/v1/customers/${id(cust)}/cash_balance_transactions`, b: 'amount=400&currency=usd' });
4640
+ await h({ m: 'POST', p: `/v1/test_helpers/customers/${id(cust)}/fund_cash_balance`, b: 'amount=400&currency=usd' });
2783
4641
  const full = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/apply_customer_balance` });
2784
4642
  if (!ok(full) || field(full, 'status') !== 'succeeded' || field(full, 'amount_received') !== 1000) return false;
2785
4643
  // a succeeded PI 400s; unknown id 404.
@@ -2795,7 +4653,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
2795
4653
  // + the amount-match check are produced ONLY by this feature.)
2796
4654
  done('stripe.payment_intents.verify_microdeposits', 'payment_intents', 'PaymentIntents: verify_microdeposits (ACH)', 'api', 'common', () =>
2797
4655
  withRoot(async (h) => {
2798
- const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=5000&currency=usd&payment_method_types[]=us_bank_account' });
4656
+ const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=5000&currency=usd&allowed_payment_method_types[]=us_bank_account' });
2799
4657
  if (!ok(pi)) return false;
2800
4658
  const conf = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/confirm`, b: 'payment_method=pm_us_bank_account' });
2801
4659
  if (!ok(conf) || field(conf, 'status') !== 'requires_action') return false;
@@ -2806,10 +4664,14 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
2806
4664
  const both = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/verify_microdeposits`, b: 'amounts[]=32&amounts[]=45&descriptor_code=SM11AA' });
2807
4665
  const none = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/verify_microdeposits` });
2808
4666
  if (both.status !== 400 || none.status !== 400) return false;
2809
- // correct amounts → succeeded, next_action cleared.
4667
+ // correct amounts → processing, next_action cleared: "When the bank account is successfully verified, Stripe returns
4668
+ // the PaymentIntent object with a status of `processing`" (docs.stripe.com/payments/ach-direct-debit/accept-a-payment;
4669
+ // 242035d1c); a test-mode debit then settles ("Test transactions settle instantly"), so the next read has succeeded
2810
4670
  const good = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/verify_microdeposits`, b: 'amounts[]=32&amounts[]=45' });
4671
+ const settled = await h({ m: 'GET', p: `/v1/payment_intents/${id(pi)}` });
2811
4672
  const missing = await h({ m: 'POST', p: '/v1/payment_intents/pi_nope/verify_microdeposits', b: 'amounts[]=32&amounts[]=45' });
2812
- return ok(good) && field(good, 'status') === 'succeeded' && field(good, 'next_action') === null && missing.status === 404;
4673
+ return ok(good) && field(good, 'status') === 'processing' && field(good, 'next_action') === null
4674
+ && field(settled, 'status') === 'succeeded' && missing.status === 404;
2813
4675
  }),
2814
4676
  ),
2815
4677
  // SetupIntent micro-deposit verification: same flow for saving an ACH bank account — confirm
@@ -2817,7 +4679,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
2817
4679
  // wrong descriptor 400s. (the requires_action microdeposit state is produced ONLY by this feature.)
2818
4680
  done('stripe.setup_intents.verify_microdeposits', 'payment_methods', 'SetupIntents: verify_microdeposits (ACH)', 'api', 'common', () =>
2819
4681
  withRoot(async (h) => {
2820
- const si = await h({ m: 'POST', p: '/v1/setup_intents', b: 'payment_method_types[]=us_bank_account' });
4682
+ const si = await h({ m: 'POST', p: '/v1/setup_intents', b: 'allowed_payment_method_types[]=us_bank_account' });
2821
4683
  if (!ok(si)) return false;
2822
4684
  const conf = await h({ m: 'POST', p: `/v1/setup_intents/${id(si)}/confirm`, b: 'payment_method=pm_us_bank_account' });
2823
4685
  if (!ok(conf) || field(conf, 'status') !== 'requires_action' || (field(conf, 'next_action') as Body)?.type !== 'verify_with_microdeposits') return false;
@@ -2957,7 +4819,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
2957
4819
  // editing a finalized (non-draft) invoice 400s; missing lines 400; unknown invoice 404.
2958
4820
  await h({ m: 'POST', p: `/v1/invoices/${id(inv)}/finalize` });
2959
4821
  const afterFinal = await h({ m: 'POST', p: `/v1/invoices/${id(inv)}/add_lines`, b: 'lines[0][amount]=100' });
2960
- const noLines = await h({ m: 'POST', p: `/v1/invoices/${id(inv)}/add_lines`, b: 'metadata[x]=1' });
4822
+ const noLines = await h({ m: 'POST', p: `/v1/invoices/${id(inv)}/add_lines`, b: 'invoice_metadata[x]=1' });
2961
4823
  const missing = await h({ m: 'GET', p: '/v1/invoices/in_nope/lines' });
2962
4824
  return afterFinal.status === 400 && (noLines.status === 400) && missing.status === 404;
2963
4825
  }),
@@ -3026,28 +4888,35 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
3026
4888
  // summary are produced ONLY by this feature.)
3027
4889
  done('stripe.billing.credit_grants', 'billing', 'Billing credit grants (prepaid credits)', 'api', 'niche', () =>
3028
4890
  withRoot(async (h) => {
4891
+ // the served spec requires what a grant applies to (applicability_config) and what a balance summary filters on
4892
+ // (filter), which the parameter check enforces (e6bfff516: "a missing required one [answers] 400
4893
+ // parameter_missing", docs.stripe.com/error-codes#parameter-missing); every request gives them, so each refusal
4894
+ // below is still the one it names
4895
+ const APPLIES = 'applicability_config[scope][price_type]=metered';
4896
+ const METERED = 'filter[type]=applicability_scope&filter[applicability_scope][price_type]=metered';
3029
4897
  const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=credgr@twin.test' });
3030
- const g = await h({ m: 'POST', p: '/v1/billing/credit_grants', b: `customer=${id(cust)}&category=promotional&name=Welcome&amount[type]=monetary&amount[monetary][value]=1000&amount[monetary][currency]=usd` });
4898
+ const g = await h({ m: 'POST', p: '/v1/billing/credit_grants', b: `customer=${id(cust)}&category=promotional&name=Welcome&amount[type]=monetary&amount[monetary][value]=1000&amount[monetary][currency]=usd&${APPLIES}` });
3031
4899
  if (!ok(g) || field(g, 'object') !== 'billing.credit_grant' || field(g, 'category') !== 'promotional') return false;
3032
4900
  if (((field(g, 'amount') as Body)?.monetary as Body)?.value !== 1000) return false;
3033
4901
  const get = await h({ m: 'GET', p: `/v1/billing/credit_grants/${id(g)}` });
3034
4902
  const list = await h({ m: 'GET', p: `/v1/billing/credit_grants?customer=${id(cust)}` });
3035
4903
  if (!ok(get) || id(get) !== id(g) || ((list.body as Body).data as Body[]).length !== 1) return false;
3036
4904
  // balance summary reflects the active grant.
3037
- const sum = await h({ m: 'GET', p: `/v1/billing/credit_balance_summary?customer=${id(cust)}` });
4905
+ const sum = await h({ m: 'GET', p: `/v1/billing/credit_balance_summary?customer=${id(cust)}&${METERED}` });
3038
4906
  const balances = (sum.body as Body).balances as Body[];
3039
4907
  if (!ok(sum) || (((balances[0]!.available_balance as Body).monetary as Body).value) !== 1000) return false;
3040
4908
  // void clears it from the balance.
3041
4909
  const voided = await h({ m: 'POST', p: `/v1/billing/credit_grants/${id(g)}/void` });
3042
4910
  if (!ok(voided) || field(voided, 'voided_at') === null) return false;
3043
- const sum2 = await h({ m: 'GET', p: `/v1/billing/credit_balance_summary?customer=${id(cust)}` });
4911
+ const sum2 = await h({ m: 'GET', p: `/v1/billing/credit_balance_summary?customer=${id(cust)}&${METERED}` });
3044
4912
  if (((sum2.body as Body).balances as Body[]).length !== 0) return false;
3045
4913
  // vendor errors.
3046
- 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' });
3047
- 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` });
3048
- const noAmt = await h({ m: 'POST', p: '/v1/billing/credit_grants', b: `customer=${id(cust)}&category=paid` });
4914
+ const noCust = await h({ m: 'POST', p: '/v1/billing/credit_grants', b: 'customer=cus_nope&category=paid&amount[type]=monetary&amount[monetary][value]=10&amount[monetary][currency]=usd&' + APPLIES });
4915
+ const badCat = await h({ m: 'POST', p: '/v1/billing/credit_grants', b: `customer=${id(cust)}&category=bogus&amount[type]=monetary&amount[monetary][value]=10&amount[monetary][currency]=usd&${APPLIES}` });
4916
+ const noAmt = await h({ m: 'POST', p: '/v1/billing/credit_grants', b: `customer=${id(cust)}&category=paid&${APPLIES}` });
3049
4917
  const missing = await h({ m: 'GET', p: '/v1/billing/credit_grants/credgr_nope' });
3050
- return noCust.status === 404 && badCat.status === 400 && noAmt.status === 400 && missing.status === 404;
4918
+ return noCust.status === 404 && badCat.status === 400 && ((badCat.body as Body).error as Body)?.code === 'parameter_invalid_string_enum'
4919
+ && noAmt.status === 400 && ((noAmt.body as Body).error as Body)?.param === 'amount' && missing.status === 404;
3051
4920
  }),
3052
4921
  ),
3053
4922
  // Billing meters lifecycle: create→list (status filter)→deactivate (active→inactive)→
@@ -3064,12 +4933,14 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
3064
4933
  const e1 = await h({ m: 'POST', p: '/v1/billing/meter_events', b: `event_name=api_request&timestamp=100&payload[value]=3&payload[stripe_customer_id]=${id(cust)}` });
3065
4934
  const e2 = await h({ m: 'POST', p: '/v1/billing/meter_events', b: `event_name=api_request&timestamp=200&payload[value]=4&payload[stripe_customer_id]=${id(cust)}` });
3066
4935
  if (!ok(e1) || !ok(e2) || field(e1, 'object') !== 'billing.meter_event') return false;
3067
- const summary = await h({ m: 'GET', p: `/v1/billing/meters/${id(m)}/event_summaries?customer=${id(cust)}&start_time=0&end_time=1000` });
4936
+ const summary = await h({ m: 'GET', p: `/v1/billing/meters/${id(m)}/event_summaries?customer=${id(cust)}&start_time=0&end_time=1020` });
3068
4937
  const sData = (summary.body as Body).data as Body[];
3069
4938
  if (!ok(summary) || sData[0]!.aggregated_value !== 7) return false;
3070
4939
  // an out-of-window event is excluded.
3071
- const summaryNarrow = await h({ m: 'GET', p: `/v1/billing/meters/${id(m)}/event_summaries?customer=${id(cust)}&start_time=150&end_time=1000` });
4940
+ const summaryNarrow = await h({ m: 'GET', p: `/v1/billing/meters/${id(m)}/event_summaries?customer=${id(cust)}&start_time=120&end_time=1020` });
3072
4941
  if (((summaryNarrow.body as Body).data as Body[])[0]!.aggregated_value !== 4) return false;
4942
+ // the window must be aligned with minute boundaries
4943
+ if ((await h({ m: 'GET', p: `/v1/billing/meters/${id(m)}/event_summaries?customer=${id(cust)}&start_time=150&end_time=1020` })).status !== 400) return false;
3073
4944
  // deactivate → inactive; the active filter excludes it; reactivate → active.
3074
4945
  const off = await h({ m: 'POST', p: `/v1/billing/meters/${id(m)}/deactivate` });
3075
4946
  if (!ok(off) || field(off, 'status') !== 'inactive' || (field(off, 'status_transitions') as Body).deactivated_at === null) return false;
@@ -3088,7 +4959,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
3088
4959
  done('stripe.billing.alerts', 'billing', 'Billing usage alerts (thresholds)', 'api', 'niche', () =>
3089
4960
  withRoot(async (h) => {
3090
4961
  const m = await h({ m: 'POST', p: '/v1/billing/meters', b: 'display_name=Calls&event_name=calls&default_aggregation[formula]=count' });
3091
- const a = await h({ m: 'POST', p: `/v1/billing/alerts`, b: `alert_type=usage_threshold&title=Heavy use&usage_threshold[gte]=100&usage_threshold[meter]=${id(m)}` });
4962
+ const a = await h({ m: 'POST', p: `/v1/billing/alerts`, b: `alert_type=usage_threshold&title=Heavy use&usage_threshold[gte]=100&usage_threshold[meter]=${id(m)}&usage_threshold[recurrence]=one_time` });
3092
4963
  if (!ok(a) || field(a, 'object') !== 'billing.alert' || field(a, 'status') !== 'active') return false;
3093
4964
  if ((field(a, 'usage_threshold') as Body).gte !== 100) return false;
3094
4965
  const get = await h({ m: 'GET', p: `/v1/billing/alerts/${id(a)}` });
@@ -3100,7 +4971,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
3100
4971
  const arch = await h({ m: 'POST', p: `/v1/billing/alerts/${id(a)}/archive` });
3101
4972
  // vendor errors.
3102
4973
  const badType = await h({ m: 'POST', p: '/v1/billing/alerts', b: 'alert_type=bogus&title=x&usage_threshold[gte]=1&usage_threshold[meter]=' + id(m) });
3103
- const badMeter = await h({ m: 'POST', p: '/v1/billing/alerts', b: 'alert_type=usage_threshold&title=x&usage_threshold[gte]=1&usage_threshold[meter]=mtr_nope' });
4974
+ const badMeter = await h({ m: 'POST', p: '/v1/billing/alerts', b: 'alert_type=usage_threshold&title=x&usage_threshold[gte]=1&usage_threshold[meter]=mtr_nope&usage_threshold[recurrence]=one_time' });
3104
4975
  const missing = await h({ m: 'GET', p: '/v1/billing/alerts/alert_nope' });
3105
4976
  return ok(on) && field(on, 'status') === 'active' && ok(arch) && field(arch, 'status') === 'archived' &&
3106
4977
  badType.status === 400 && badMeter.status === 400 && missing.status === 404;
@@ -3155,8 +5026,9 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
3155
5026
  // before subscribing: no active entitlements.
3156
5027
  const before = await h({ m: 'GET', p: `/v1/entitlements/active_entitlements?customer=${id(cust)}` });
3157
5028
  if (!ok(before) || ((before.body as Body).data as Body[]).length !== 0) return false;
3158
- // subscribe → entitled to the product's feature.
3159
- const sub = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&items[0][price]=${id(price)}` });
5029
+ // subscribe (the card pays the first invoice) → entitled to the product's feature.
5030
+ const card = await h({ m: 'POST', p: '/v1/payment_methods/pm_card_visa/attach', b: `customer=${id(cust)}` });
5031
+ const sub = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&items[0][price]=${id(price)}&default_payment_method=${id(card)}` });
3160
5032
  if (!ok(sub)) return false;
3161
5033
  const after = await h({ m: 'GET', p: `/v1/entitlements/active_entitlements?customer=${id(cust)}` });
3162
5034
  const data = (after.body as Body).data as Body[];
@@ -3176,7 +5048,9 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
3176
5048
  withRoot(async (h) => {
3177
5049
  const calc = await h({ m: 'POST', p: '/v1/tax/calculations', b: 'currency=usd&line_items[0][amount]=1000&line_items[0][reference]=sku_1&customer_details[address][country]=US' });
3178
5050
  if (!ok(calc)) return false;
3179
- const txn = await h({ m: 'POST', p: '/v1/tax/transactions/create_from_calculation', b: `calculation=${id(calc)}&reference=order_123` });
5051
+ // a transaction's line_items is "nullable object Includable" (docs.stripe.com/api/tax/transactions/object; 107591e5f):
5052
+ // absent unless the request expands it, as it is here and on the reversal
5053
+ const txn = await h({ m: 'POST', p: '/v1/tax/transactions/create_from_calculation', b: `calculation=${id(calc)}&reference=order_123&expand[]=line_items` });
3180
5054
  if (!ok(txn) || field(txn, 'object') !== 'tax.transaction' || field(txn, 'type') !== 'transaction' || field(txn, 'reference') !== 'order_123') return false;
3181
5055
  const txnLines = ((field(txn, 'line_items') as Body).data as Body[]) ?? [];
3182
5056
  if (txnLines.length !== 1 || txnLines[0]!.amount_tax !== 100) return false;
@@ -3184,7 +5058,8 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
3184
5058
  const li = await h({ m: 'GET', p: `/v1/tax/transactions/${id(txn)}/line_items` });
3185
5059
  if (!ok(g) || id(g) !== id(txn) || ((li.body as Body).data as Body[]).length !== 1) return false;
3186
5060
  // a reversal NEGATES the amounts.
3187
- const rev = await h({ m: 'POST', p: '/v1/tax/transactions/create_reversal', b: `original_transaction=${id(txn)}&reference=refund_123&mode=full` });
5061
+ if (field(g, 'line_items') !== undefined) return false;
5062
+ const rev = await h({ m: 'POST', p: '/v1/tax/transactions/create_reversal', b: `original_transaction=${id(txn)}&reference=refund_123&mode=full&expand[]=line_items` });
3188
5063
  if (!ok(rev) || field(rev, 'type') !== 'reversal') return false;
3189
5064
  const revLines = ((field(rev, 'line_items') as Body).data as Body[]) ?? [];
3190
5065
  if (revLines[0]!.amount_tax !== -100 || (field(rev, 'reversal') as Body)?.original_transaction !== id(txn)) return false;
@@ -3243,25 +5118,19 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
3243
5118
  ok(li) && ((li.body as Body).data as Body[]).length === 1;
3244
5119
  }),
3245
5120
  ),
3246
- // Discount delete: attach a coupon to a customer (materializes the discount object) and to a
3247
- // subscription, then DELETE …/discount on each removes it (→ {object:'discount',deleted:true})
3248
- // and the resource's discount is cleared. Unknown customer 404. (the customer discount apply +
3249
- // both deletes are produced ONLY by this feature.)
3250
- done('stripe.discounts.delete', 'discounts', 'Customer/Subscription discount delete', 'api', 'common', () =>
5121
+ // Discount delete: a subscription's coupon (discounts[0][coupon]) is removed by DELETE …/discount
5122
+ // (→ {object:'discount',deleted:true}) and the subscription's discounts are cleared. A customer's discount has no
5123
+ // create parameter in the served version (the customer-level coupon was removed), so an unknown customer's DELETE
5124
+ // answers 404.
5125
+ done('stripe.discounts.delete', 'discounts', 'Subscription discount delete', 'api', 'common', () =>
3251
5126
  withRoot(async (h) => {
3252
5127
  const coupon = await h({ m: 'POST', p: '/v1/coupons', b: 'percent_off=20&duration=forever' });
3253
- const cust = await h({ m: 'POST', p: '/v1/customers', b: `email=disc-del@twin.test&coupon=${id(coupon)}` });
3254
- if (!ok(cust) || (field(cust, 'discount') as Body)?.object !== 'discount') return false;
3255
- const delCust = await h({ m: 'DELETE', p: `/v1/customers/${id(cust)}/discount` });
3256
- if (!ok(delCust) || field(delCust, 'deleted') !== true) return false;
3257
- const c = await h({ m: 'GET', p: `/v1/customers/${id(cust)}` });
3258
- if (field(c, 'discount') !== null) return false;
3259
- // subscription discount delete.
3260
- const sub = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&coupon=${id(coupon)}` });
5128
+ const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=disc-del@twin.test' });
5129
+ const sub = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&discounts[0][coupon]=${id(coupon)}` });
3261
5130
  if (((field(sub, 'discounts') as Body[]) ?? []).length !== 1) return false;
3262
5131
  const delSub = await h({ m: 'DELETE', p: `/v1/subscriptions/${id(sub)}/discount` });
3263
5132
  const s = await h({ m: 'GET', p: `/v1/subscriptions/${id(sub)}` });
3264
- const badCoupon = await h({ m: 'POST', p: '/v1/customers', b: 'email=x@x.co&coupon=coupon_nope' });
5133
+ const badCoupon = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&discounts[0][coupon]=coupon_nope` });
3265
5134
  const missing = await h({ m: 'DELETE', p: '/v1/customers/cus_nope/discount' });
3266
5135
  return ok(delSub) && field(delSub, 'deleted') === true && ((field(s, 'discounts') as Body[]) ?? []).length === 0 &&
3267
5136
  badCoupon.status === 400 && missing.status === 404;
@@ -3272,7 +5141,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
3272
5141
  done('stripe.promotion_codes.update', 'discounts', 'Promotion codes: update (active toggle)', 'api', 'common', () =>
3273
5142
  withRoot(async (h) => {
3274
5143
  const coupon = await h({ m: 'POST', p: '/v1/coupons', b: 'percent_off=10&duration=once' });
3275
- const pc = await h({ m: 'POST', p: '/v1/promotion_codes', b: `coupon=${id(coupon)}&code=UPD10` });
5144
+ const pc = await h({ m: 'POST', p: '/v1/promotion_codes', b: `promotion[type]=coupon&promotion[coupon]=${id(coupon)}&code=UPD10` });
3276
5145
  if (!ok(pc) || field(pc, 'active') !== true) return false;
3277
5146
  const off = await h({ m: 'POST', p: `/v1/promotion_codes/${id(pc)}`, b: 'active=false&metadata[campaign]=spring' });
3278
5147
  if (!ok(off) || field(off, 'active') !== false || ((field(off, 'metadata') as Body)?.campaign) !== 'spring') return false;
@@ -3327,21 +5196,23 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
3327
5196
  // retrieve returned nothing, if a status update didn't persist, or if filtering were broken.
3328
5197
  done('stripe.issuing.tokens', 'issuing', 'Issuing: network tokens', 'api', 'niche', () =>
3329
5198
  withRoot(async (h) => {
3330
- // empty account: lists none, unknown ids 404 on retrieve + update.
3331
- const empty = await h({ m: 'GET', p: '/v1/issuing/tokens' });
5199
+ const ch = await h({ m: 'POST', p: '/v1/issuing/cardholders', b: 'name=Jane&type=individual&billing[address][country]=US' });
5200
+ const card = await h({ m: 'POST', p: '/v1/issuing/cards', b: `cardholder=${id(ch)}&currency=usd&type=virtual` });
5201
+ // a card with no wallet yet lists none; the list is always of one card's tokens (the served spec requires `card`,
5202
+ // which the parameter check enforces, e6bfff516), so the bare list is refused; unknown ids 404 on retrieve + update.
5203
+ const empty = await h({ m: 'GET', p: `/v1/issuing/tokens?card=${id(card)}` });
3332
5204
  if (!ok(empty) || field(empty, 'object') !== 'list' || ((empty.body as Body).data as Body[]).length !== 0) return false;
5205
+ const unscoped = await h({ m: 'GET', p: '/v1/issuing/tokens' });
5206
+ if (unscoped.status !== 400 || ((unscoped.body as Body).error as Body)?.param !== 'card') return false;
3333
5207
  const missing = await h({ m: 'GET', p: '/v1/issuing/tokens/iss_tok_nope' });
3334
5208
  const badUpd = await h({ m: 'POST', p: '/v1/issuing/tokens/iss_tok_nope', b: 'status=active' });
3335
5209
  if (missing.status !== 404 || badUpd.status !== 404) return false;
3336
- // seed: a token is network-minted for a real card (no public create) via test_helpers.
3337
- const ch = await h({ m: 'POST', p: '/v1/issuing/cardholders', b: 'name=Jane&type=individual&billing[address][country]=US' });
3338
- const card = await h({ m: 'POST', p: '/v1/issuing/cards', b: `cardholder=${id(ch)}&currency=usd&type=virtual` });
3339
- const seeded = await h({ m: 'POST', p: '/v1/test_helpers/issuing/tokens', b: `card=${id(card)}` });
5210
+ // a token is network-minted when the cardholder adds a real card to a phone's wallet (the twin's stand-in).
5211
+ const seeded = await h({ m: 'POST', p: `/_twin/issuing/cards/${id(card)}/wallets`, b: 'wallet_provider=apple_pay' });
3340
5212
  if (!ok(seeded) || field(seeded, 'object') !== 'issuing.token' || field(seeded, 'status') !== 'active' || field(seeded, 'card') !== id(card)) return false;
3341
5213
  // minting requires a real card.
3342
- const badCard = await h({ m: 'POST', p: '/v1/test_helpers/issuing/tokens', b: 'card=ic_nope' });
3343
- const noCard = await h({ m: 'POST', p: '/v1/test_helpers/issuing/tokens' });
3344
- if (badCard.status !== 400 || noCard.status !== 400) return false;
5214
+ const badCard = await h({ m: 'POST', p: '/_twin/issuing/cards/ic_nope/wallets', b: 'wallet_provider=apple_pay' });
5215
+ if (badCard.status !== 400) return false;
3345
5216
  // retrieve the seeded token.
3346
5217
  const get = await h({ m: 'GET', p: `/v1/issuing/tokens/${id(seeded)}` });
3347
5218
  if (!ok(get) || id(get) !== id(seeded) || field(get, 'card') !== id(card) || field(get, 'status') !== 'active') return false;
@@ -3373,8 +5244,12 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
3373
5244
  // (the design object + review status machine are produced ONLY by this feature.)
3374
5245
  done('stripe.issuing.personalization_designs', 'issuing', 'Issuing: card personalization designs', 'api', 'niche', () =>
3375
5246
  withRoot(async (h) => {
3376
- const d = await h({ m: 'POST', p: '/v1/issuing/personalization_designs', b: 'physical_bundle=ipb_test&name=Gold card' });
3377
- if (!ok(d) || field(d, 'object') !== 'issuing.personalization_design' || field(d, 'status') !== 'inactive') return false;
5247
+ // the card logo must be a file of purpose issuing_logo; another purpose is refused
5248
+ const logo = await h({ m: 'POST', p: '/v1/files', b: 'purpose=issuing_logo&file=logo.png' });
5249
+ const icon = await h({ m: 'POST', p: '/v1/files', b: 'purpose=business_logo&file=icon.png' });
5250
+ if ((await h({ m: 'POST', p: '/v1/issuing/personalization_designs', b: `physical_bundle=ics_NLuXJPDYSTjFON&card_logo=${id(icon)}` })).status !== 400) return false;
5251
+ const d = await h({ m: 'POST', p: '/v1/issuing/personalization_designs', b: `physical_bundle=ics_NLuXJPDYSTjFON&name=Gold card&card_logo=${id(logo)}` });
5252
+ if (!ok(d) || field(d, 'object') !== 'issuing.personalization_design' || field(d, 'status') !== 'review') return false;
3378
5253
  const get = await h({ m: 'GET', p: `/v1/issuing/personalization_designs/${id(d)}` });
3379
5254
  const upd = await h({ m: 'POST', p: `/v1/issuing/personalization_designs/${id(d)}`, b: 'name=Platinum' });
3380
5255
  if (!ok(get) || !ok(upd) || field(upd, 'name') !== 'Platinum') return false;
@@ -3383,7 +5258,8 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
3383
5258
  if (!ok(act) || field(act, 'status') !== 'active') return false;
3384
5259
  const list = await h({ m: 'GET', p: '/v1/issuing/personalization_designs?status=active' });
3385
5260
  if (((list.body as Body).data as Body[]).length !== 1) return false;
3386
- const rej = await h({ m: 'POST', p: `/v1/test_helpers/issuing/personalization_designs/${id(d)}/reject` });
5261
+ // a rejection says why: the served spec requires rejection_reasons (enforced by the parameter check, e6bfff516)
5262
+ const rej = await h({ m: 'POST', p: `/v1/test_helpers/issuing/personalization_designs/${id(d)}/reject`, b: 'rejection_reasons[card_logo][]=inappropriate' });
3387
5263
  const noBundle = await h({ m: 'POST', p: '/v1/issuing/personalization_designs', b: 'name=x' });
3388
5264
  const missing = await h({ m: 'GET', p: '/v1/issuing/personalization_designs/pd_nope' });
3389
5265
  return ok(rej) && field(rej, 'status') === 'rejected' && noBundle.status === 400 && missing.status === 404;
@@ -3424,12 +5300,17 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
3424
5300
  // produced ONLY by this feature.)
3425
5301
  done('stripe.treasury.financial_accounts', 'treasury', 'Treasury: financial accounts', 'api', 'niche', () =>
3426
5302
  withRoot(async (h) => {
3427
- const fa = await h({ m: 'POST', p: '/v1/treasury/financial_accounts', b: 'supported_currencies[]=usd&features[card_issuing][requested]=true&features[outbound_payments][requested]=true' });
5303
+ // features as the served spec's treasury.financial_account_features gives them: a toggle (card_issuing) or one per
5304
+ // network (outbound_payments.ach); a requested one is active at once in test mode and one never requested is left
5305
+ // out, each being optional in the spec (semantics/treasury.ts featuresOf, dd5d4344f)
5306
+ const fa = await h({ m: 'POST', p: '/v1/treasury/financial_accounts', b: 'supported_currencies[]=usd&features[card_issuing][requested]=true&features[outbound_payments][ach][requested]=true' });
3428
5307
  if (!ok(fa) || field(fa, 'object') !== 'treasury.financial_account' || field(fa, 'status') !== 'open') return false;
3429
5308
  const bal = field(fa, 'balance') as Body;
3430
5309
  if ((bal.cash as Body).usd !== 0) return false;
3431
- if ((((field(fa, 'features') as Body).card_issuing) as Body).status !== 'active') return false;
3432
- if ((((field(fa, 'features') as Body).deposit_insurance) as Body).status !== 'restricted') return false;
5310
+ const features = field(fa, 'features') as Body;
5311
+ if ((features.card_issuing as Body).status !== 'active' || ((features.outbound_payments as Body).ach as Body)?.status !== 'active') return false;
5312
+ if (features.deposit_insurance !== undefined) return false;
5313
+ if ((field(fa, 'active_features') as string[]).join(',') !== 'card_issuing,outbound_payments.ach') return false;
3433
5314
  const get = await h({ m: 'GET', p: `/v1/treasury/financial_accounts/${id(fa)}` });
3434
5315
  const list = await h({ m: 'GET', p: '/v1/treasury/financial_accounts?status=open' });
3435
5316
  const feats = await h({ m: 'GET', p: `/v1/treasury/financial_accounts/${id(fa)}/features` });
@@ -3489,20 +5370,27 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
3489
5370
  badFa.status === 400 && noDest.status === 400 && nope.status === 404;
3490
5371
  }),
3491
5372
  ),
3492
- // Treasury InboundTransfers: pull funds into a FinancialAccount. create→succeeded (test-mode
3493
- // synchronous settle), posting a ledger Transaction (cash↑). Requires amount/currency/
3494
- // financial_account/origin_payment_method. Unknown id 404. id ibt_.
5373
+ // Treasury InboundTransfers: pull funds into a FinancialAccount. create→processing (funds inbound_pending), the
5374
+ // test helper's succeed→succeeded, posting its ledger Transaction (cash↑). Requires amount/currency/
5375
+ // financial_account/origin_payment_method, a bank account set up for inbound flows and verified. Unknown id 404. id ibt_.
3495
5376
  done('stripe.treasury.inbound_transfers', 'treasury', 'Treasury: inbound transfers / received credits', 'api', 'niche', () =>
3496
5377
  withRoot(async (h) => {
3497
5378
  const fa = await h({ m: 'POST', p: '/v1/treasury/financial_accounts', b: 'supported_currencies[]=usd&features[inbound_transfers][requested]=true' });
3498
- const it = await h({ m: 'POST', p: `/v1/treasury/inbound_transfers`, b: `amount=3000&currency=usd&financial_account=${id(fa)}&origin_payment_method=pm_y` });
3499
- if (!ok(it) || field(it, 'object') !== 'treasury.inbound_transfer' || field(it, 'status') !== 'succeeded' || !id(it).startsWith('ibt_')) return false;
3500
- if ((field(it, 'status_transitions') as Body)?.succeeded_at == null) return false;
5379
+ // the pulled account is set up for inbound flows and verified with a SetupIntent; one that is not is refused
5380
+ const si = await h({ m: 'POST', p: '/v1/setup_intents', b: 'attach_to_self=true&flow_directions[]=inbound&allowed_payment_method_types[]=us_bank_account&payment_method_options[us_bank_account][verification_method]=microdeposits' });
5381
+ const conf = await h({ m: 'POST', p: `/v1/setup_intents/${id(si)}/confirm`, b: 'payment_method_data[type]=us_bank_account&payment_method_data[us_bank_account][account_number]=000123456789&payment_method_data[us_bank_account][routing_number]=110000000&payment_method_data[us_bank_account][account_holder_type]=company&payment_method_data[billing_details][name]=Ops' });
5382
+ const pm = String(field(conf, 'payment_method'));
5383
+ if ((await h({ m: 'POST', p: `/v1/treasury/inbound_transfers`, b: `amount=3000&currency=usd&financial_account=${id(fa)}&origin_payment_method=${pm}` })).status !== 400) return false;
5384
+ await h({ m: 'POST', p: `/v1/setup_intents/${id(si)}/verify_microdeposits`, b: 'amounts[]=32&amounts[]=45' });
5385
+ const it = await h({ m: 'POST', p: `/v1/treasury/inbound_transfers`, b: `amount=3000&currency=usd&financial_account=${id(fa)}&origin_payment_method=${pm}` });
5386
+ if (!ok(it) || field(it, 'object') !== 'treasury.inbound_transfer' || field(it, 'status') !== 'processing' || !id(it).startsWith('ibt_')) return false;
5387
+ const done = await h({ m: 'POST', p: `/v1/test_helpers/treasury/inbound_transfers/${id(it)}/succeed` });
5388
+ if (!ok(done) || field(done, 'status') !== 'succeeded' || (field(done, 'status_transitions') as Body)?.succeeded_at == null) return false;
3501
5389
  const get = await h({ m: 'GET', p: `/v1/treasury/inbound_transfers/${id(it)}` });
3502
5390
  const list = await h({ m: 'GET', p: `/v1/treasury/inbound_transfers?financial_account=${id(fa)}` });
3503
5391
  if (!ok(get) || ((list.body as Body).data as Body[]).length !== 1) return false;
3504
5392
  // a ledger transaction was posted with cash↑.
3505
- const txns = await h({ m: 'GET', p: `/v1/treasury/transactions?financial_account=${id(fa)}&flow_type=inbound_transfer` });
5393
+ const txns = await h({ m: 'GET', p: `/v1/treasury/transactions?financial_account=${id(fa)}` });
3506
5394
  const tdata = (txns.body as Body).data as Body[];
3507
5395
  if (tdata.length !== 1 || (tdata[0]!.balance_impact as Body)?.cash !== 3000 || tdata[0]!.status !== 'posted') return false;
3508
5396
  // received_credits/debits read surface exists (empty until a test flow materializes one).
@@ -3513,7 +5401,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
3513
5401
  }),
3514
5402
  ),
3515
5403
  // Climate (carbon removal): products/suppliers catalog (read-only) + orders. An order needs
3516
- // a valid product + exactly one of amount/metric_tons (else 400); starts awaiting_funds;
5404
+ // a valid product + exactly one of amount/metric_tons (else 400); is confirmed when made;
3517
5405
  // cancel→canceled. amount_total = subtotal+fees. Unknown product 400; unknown order 404.
3518
5406
  done('stripe.climate.orders', 'climate', 'Climate: orders / products / suppliers', 'api', 'niche', () =>
3519
5407
  withRoot(async (h) => {
@@ -3522,8 +5410,8 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
3522
5410
  if (pdata.length === 0 || pdata[0]!.object !== 'climate.product') return false;
3523
5411
  const suppliers = await h({ m: 'GET', p: '/v1/climate/suppliers' });
3524
5412
  if (((suppliers.body as Body).data as Body[])[0]?.object !== 'climate.supplier') return false;
3525
- const order = await h({ m: 'POST', p: '/v1/climate/orders', b: 'product=climsku_direct_air_capture&metric_tons=2' });
3526
- if (!ok(order) || field(order, 'object') !== 'climate.order' || field(order, 'status') !== 'awaiting_funds' || !id(order).startsWith('climorder_')) return false;
5413
+ const order = await h({ m: 'POST', p: '/v1/climate/orders', b: 'product=climsku_frontier_offtake_portfolio_2027&metric_tons=2' });
5414
+ if (!ok(order) || field(order, 'object') !== 'climate.order' || field(order, 'status') !== 'confirmed' || !id(order).startsWith('climorder_')) return false;
3527
5415
  if (field(order, 'amount_total') !== (field(order, 'amount_subtotal') as number) + (field(order, 'amount_fees') as number)) return false;
3528
5416
  const get = await h({ m: 'GET', p: `/v1/climate/orders/${id(order)}` });
3529
5417
  const list = await h({ m: 'GET', p: '/v1/climate/orders' });
@@ -3531,25 +5419,27 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
3531
5419
  const cancel = await h({ m: 'POST', p: `/v1/climate/orders/${id(order)}/cancel` });
3532
5420
  if (!ok(cancel) || field(cancel, 'status') !== 'canceled') return false;
3533
5421
  const badProduct = await h({ m: 'POST', p: '/v1/climate/orders', b: 'product=climsku_nope&metric_tons=1' });
3534
- const bothAmounts = await h({ m: 'POST', p: '/v1/climate/orders', b: 'product=climsku_direct_air_capture&metric_tons=1&amount=1000' });
5422
+ const bothAmounts = await h({ m: 'POST', p: '/v1/climate/orders', b: 'product=climsku_frontier_offtake_portfolio_2027&metric_tons=1&amount=1000' });
3535
5423
  const nope = await h({ m: 'GET', p: '/v1/climate/orders/climorder_nope' });
3536
5424
  return badProduct.status === 400 && bothAmounts.status === 400 && nope.status === 404;
3537
5425
  }),
3538
5426
  ),
3539
- // Financial Connections: create a session (requires account_holder + permissions), link an
3540
- // account via the hosted-flow test helper, then read accounts + the session's accounts
3541
- // sub-list. id fcsess_ / fca_. Missing permissions 400; unknown id 404.
5427
+ // Financial Connections: create a session (requires account_holder + permissions), the person links the test
5428
+ // bank's accounts in the flow Stripe.js opens, then read accounts + the session's accounts sub-list. id fcsess_ /
5429
+ // fca_. Missing permissions 400; unknown id 404.
3542
5430
  done('stripe.financial_connections.sessions', 'financial_connections', 'Financial Connections: sessions + accounts', 'api', 'niche', () =>
3543
- withRoot(async (h) => {
5431
+ withRoot(async (h, root) => {
3544
5432
  const sess = await h({ m: 'POST', p: '/v1/financial_connections/sessions', b: 'account_holder[type]=customer&account_holder[customer]=cus_1&permissions[]=transactions&permissions[]=balances' });
3545
5433
  if (!ok(sess) || field(sess, 'object') !== 'financial_connections.session' || !id(sess).startsWith('fcsess_')) return false;
3546
5434
  if (typeof field(sess, 'client_secret') !== 'string') return false;
3547
- const acc = await h({ m: 'POST', p: `/v1/financial_connections/sessions/${id(sess)}/link_account` });
3548
- if (!ok(acc) || field(acc, 'object') !== 'financial_connections.account' || !id(acc).startsWith('fca_') || field(acc, 'status') !== 'active') return false;
5435
+ await hostedSubmit(root, stripeFinancialConnectionsFlow, `https://js.stripe.com/v3/financial-connections/${String(field(sess, 'client_secret'))}`, { answer: 'link' });
3549
5436
  const getSess = await h({ m: 'GET', p: `/v1/financial_connections/sessions/${id(sess)}` });
3550
- if (((field(getSess, 'accounts') as Body)?.data as Body[]).length !== 1) return false;
5437
+ const linked = ((field(getSess, 'accounts') as Body)?.data as Body[]) ?? [];
5438
+ if (!linked.length) return false;
5439
+ const acc = { status: 200, body: linked[0]! } as StripeResponse;
5440
+ if (field(acc, 'object') !== 'financial_connections.account' || !id(acc).startsWith('fca_') || field(acc, 'status') !== 'active') return false;
3551
5441
  const accounts = await h({ m: 'GET', p: '/v1/financial_connections/accounts' });
3552
- if (((accounts.body as Body).data as Body[]).length !== 1) return false;
5442
+ if (((accounts.body as Body).data as Body[]).length !== linked.length) return false;
3553
5443
  const getAcc = await h({ m: 'GET', p: `/v1/financial_connections/accounts/${id(acc)}` });
3554
5444
  const disc = await h({ m: 'POST', p: `/v1/financial_connections/accounts/${id(acc)}/disconnect` });
3555
5445
  const noPerms = await h({ m: 'POST', p: '/v1/financial_connections/sessions', b: 'account_holder[type]=customer&account_holder[customer]=cus_1' });
@@ -3557,20 +5447,17 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
3557
5447
  return ok(getAcc) && ok(disc) && field(disc, 'status') === 'disconnected' && noPerms.status === 400 && nope.status === 404;
3558
5448
  }),
3559
5449
  ),
3560
- // Financial Connections account transactions: list a linked account's transactions
3561
- // (also via /v1/financial_connections/transactions?account=). Requires account; 404 unknown.
5450
+ // Financial Connections transactions: a linked account's, listed by account. Requires account.
3562
5451
  done('stripe.financial_connections.transactions', 'financial_connections', 'Financial Connections: account transactions', 'api', 'niche', () =>
3563
- withRoot(async (h) => {
5452
+ withRoot(async (h, root) => {
3564
5453
  const sess = await h({ m: 'POST', p: '/v1/financial_connections/sessions', b: 'account_holder[type]=customer&account_holder[customer]=cus_1&permissions[]=transactions' });
3565
- const acc = await h({ m: 'POST', p: `/v1/financial_connections/sessions/${id(sess)}/link_account` });
3566
- const txns = await h({ m: 'GET', p: `/v1/financial_connections/accounts/${id(acc)}/transactions` });
3567
- const tdata = (txns.body as Body).data as Body[];
3568
- if (tdata.length !== 2 || tdata[0]!.object !== 'financial_connections.transaction' || tdata[0]!.account !== id(acc)) return false;
3569
- const flat = await h({ m: 'GET', p: `/v1/financial_connections/transactions?account=${id(acc)}` });
3570
- if (((flat.body as Body).data as Body[]).length !== 2) return false;
5454
+ await hostedSubmit(root, stripeFinancialConnectionsFlow, `https://js.stripe.com/v3/financial-connections/${String(field(sess, 'client_secret'))}`, { answer: 'link' });
5455
+ const acc = ((field(await h({ m: 'GET', p: `/v1/financial_connections/sessions/${id(sess)}` }), 'accounts') as Body).data as Body[])[0]!;
5456
+ const flat = await h({ m: 'GET', p: `/v1/financial_connections/transactions?account=${String(acc.id)}` });
5457
+ const tdata = (flat.body as Body).data as Body[];
5458
+ if (!tdata.length || tdata[0]!.object !== 'financial_connections.transaction' || tdata[0]!.account !== acc.id) return false;
3571
5459
  const noAcct = await h({ m: 'GET', p: '/v1/financial_connections/transactions' });
3572
- const nope = await h({ m: 'GET', p: '/v1/financial_connections/accounts/fca_nope/transactions' });
3573
- return noAcct.status === 400 && nope.status === 404;
5460
+ return noAcct.status === 400;
3574
5461
  }),
3575
5462
  ),
3576
5463
  // Forwarding: PAN forwarding requests. create requires payment_method + url; stores the
@@ -3589,19 +5476,10 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
3589
5476
  return noPm.status === 400 && noUrl.status === 400 && nope.status === 404;
3590
5477
  }),
3591
5478
  ),
3592
- // Crypto onramp sessions: create an initialized session with a client_secret + transaction
3593
- // details, then retrieve. id cos_. Unknown id 404. (The hosted purchase widget is OOS.)
3594
- done('stripe.crypto.onramp', 'core', 'Crypto onramp sessions', 'api', 'niche', () =>
3595
- withRoot(async (h) => {
3596
- 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' });
3597
- if (!ok(cos) || field(cos, 'object') !== 'crypto.onramp_session' || field(cos, 'status') !== 'initialized' || !id(cos).startsWith('cos_')) return false;
3598
- if (typeof field(cos, 'client_secret') !== 'string') return false;
3599
- if ((field(cos, 'transaction_details') as Body)?.destination_currency !== 'eth') return false;
3600
- const get = await h({ m: 'GET', p: `/v1/crypto/onramp_sessions/${id(cos)}` });
3601
- const nope = await h({ m: 'GET', p: '/v1/crypto/onramp_sessions/cos_nope' });
3602
- return ok(get) && field(get, 'id') === id(cos) && nope.status === 404;
3603
- }),
3604
- ),
5479
+ // Crypto onramp sessions (POST/GET /v1/crypto/onramp_sessions, docs.stripe.com/crypto/onramp/api-reference) are Stripe
5480
+ // API, but not in the spec this pack vendors and serves; the twin's hand route for them was removed with the other
5481
+ // routes outside the served spec (5decb1d77), so the capability is a todo again until the lane models it.
5482
+ todo('stripe.crypto.onramp', 'core', 'Crypto onramp sessions', 'api', 'niche'),
3605
5483
 
3606
5484
  // ── HONEST DENOMINATOR GROWTH (real Stripe surfaces NOT yet modeled) ────────────────
3607
5485
  // The gaps above were closed this cycle; these enumerate genuine remaining surface so the