@volter/twin-stripe 0.1.2 → 2.0.0

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 (219) hide show
  1. package/README.md +64 -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 +73 -0
  20. package/dist/src/manifest.d.ts +2 -0
  21. package/dist/src/manifest.js +1065 -0
  22. package/dist/src/screens/checkout.d.ts +31 -0
  23. package/dist/src/screens/checkout.js +241 -0
  24. package/dist/src/screens/consent-skin.d.ts +4 -0
  25. package/dist/src/screens/consent-skin.js +18 -0
  26. package/dist/src/screens/financial-connections.d.ts +5 -0
  27. package/dist/src/screens/financial-connections.js +90 -0
  28. package/dist/src/screens/identity.d.ts +5 -0
  29. package/dist/src/screens/identity.js +86 -0
  30. package/dist/src/screens/industries.d.ts +1 -0
  31. package/dist/src/screens/industries.js +267 -0
  32. package/dist/src/screens/onboarding.d.ts +13 -0
  33. package/dist/src/screens/onboarding.js +225 -0
  34. package/dist/src/screens/portal.d.ts +5 -0
  35. package/dist/src/screens/portal.js +214 -0
  36. package/dist/src/screens/public-details.d.ts +5 -0
  37. package/dist/src/screens/public-details.js +90 -0
  38. package/dist/src/semantics/after-payment.d.ts +22 -0
  39. package/dist/src/semantics/after-payment.js +93 -0
  40. package/dist/src/semantics/apps-secrets.d.ts +2 -0
  41. package/dist/src/semantics/apps-secrets.js +54 -0
  42. package/dist/src/semantics/balance.d.ts +11 -0
  43. package/dist/src/semantics/balance.js +195 -0
  44. package/dist/src/semantics/billing.d.ts +2 -0
  45. package/dist/src/semantics/billing.js +220 -0
  46. package/dist/src/semantics/charges.d.ts +28 -0
  47. package/dist/src/semantics/charges.js +201 -0
  48. package/dist/src/semantics/checkout.d.ts +15 -0
  49. package/dist/src/semantics/checkout.js +303 -0
  50. package/dist/src/semantics/connect.d.ts +5 -0
  51. package/dist/src/semantics/connect.js +476 -0
  52. package/dist/src/semantics/coupons.d.ts +6 -0
  53. package/dist/src/semantics/coupons.js +92 -0
  54. package/dist/src/semantics/credit-notes.d.ts +2 -0
  55. package/dist/src/semantics/credit-notes.js +172 -0
  56. package/dist/src/semantics/customers.d.ts +6 -0
  57. package/dist/src/semantics/customers.js +429 -0
  58. package/dist/src/semantics/disputes.d.ts +2 -0
  59. package/dist/src/semantics/disputes.js +51 -0
  60. package/dist/src/semantics/entitlements.d.ts +2 -0
  61. package/dist/src/semantics/entitlements.js +95 -0
  62. package/dist/src/semantics/ephemeral-keys.d.ts +2 -0
  63. package/dist/src/semantics/ephemeral-keys.js +34 -0
  64. package/dist/src/semantics/files.d.ts +2 -0
  65. package/dist/src/semantics/files.js +125 -0
  66. package/dist/src/semantics/invoices.d.ts +18 -0
  67. package/dist/src/semantics/invoices.js +541 -0
  68. package/dist/src/semantics/issuing.d.ts +13 -0
  69. package/dist/src/semantics/issuing.js +570 -0
  70. package/dist/src/semantics/ledger.d.ts +54 -0
  71. package/dist/src/semantics/ledger.js +181 -0
  72. package/dist/src/semantics/payment-intents.d.ts +18 -0
  73. package/dist/src/semantics/payment-intents.js +404 -0
  74. package/dist/src/semantics/payment-links.d.ts +2 -0
  75. package/dist/src/semantics/payment-links.js +133 -0
  76. package/dist/src/semantics/payment-methods.d.ts +20 -0
  77. package/dist/src/semantics/payment-methods.js +138 -0
  78. package/dist/src/semantics/plans.d.ts +5 -0
  79. package/dist/src/semantics/plans.js +121 -0
  80. package/dist/src/semantics/platform.d.ts +9 -0
  81. package/dist/src/semantics/platform.js +206 -0
  82. package/dist/src/semantics/products.d.ts +2 -0
  83. package/dist/src/semantics/products.js +140 -0
  84. package/dist/src/semantics/radar.d.ts +2 -0
  85. package/dist/src/semantics/radar.js +83 -0
  86. package/dist/src/semantics/refunds.d.ts +9 -0
  87. package/dist/src/semantics/refunds.js +195 -0
  88. package/dist/src/semantics/renewals.d.ts +47 -0
  89. package/dist/src/semantics/renewals.js +251 -0
  90. package/dist/src/semantics/setup-intents.d.ts +2 -0
  91. package/dist/src/semantics/setup-intents.js +84 -0
  92. package/dist/src/semantics/shared.d.ts +78 -0
  93. package/dist/src/semantics/shared.js +192 -0
  94. package/dist/src/semantics/subscription-schedules.d.ts +2 -0
  95. package/dist/src/semantics/subscription-schedules.js +119 -0
  96. package/dist/src/semantics/subscriptions.d.ts +11 -0
  97. package/dist/src/semantics/subscriptions.js +605 -0
  98. package/dist/src/semantics/tax.d.ts +2 -0
  99. package/dist/src/semantics/tax.js +197 -0
  100. package/dist/src/semantics/terminal.d.ts +5 -0
  101. package/dist/src/semantics/terminal.js +182 -0
  102. package/dist/src/semantics/test-clocks.d.ts +6 -0
  103. package/dist/src/semantics/test-clocks.js +73 -0
  104. package/dist/src/semantics/tokens.d.ts +4 -0
  105. package/dist/src/semantics/tokens.js +44 -0
  106. package/dist/src/semantics/transfers.d.ts +2 -0
  107. package/dist/src/semantics/transfers.js +154 -0
  108. package/dist/src/semantics/treasury.d.ts +2 -0
  109. package/dist/src/semantics/treasury.js +377 -0
  110. package/dist/src/semantics/webhook-endpoints.d.ts +3 -0
  111. package/dist/src/semantics/webhook-endpoints.js +85 -0
  112. package/dist/src/stripe-budget.d.ts +55 -0
  113. package/dist/src/stripe-budget.js +155 -0
  114. package/dist/src/stripe-capabilities.d.ts +3 -0
  115. package/dist/src/stripe-capabilities.js +5052 -0
  116. package/dist/src/stripe-conformance.d.ts +41 -0
  117. package/dist/src/stripe-conformance.js +96 -0
  118. package/dist/src/stripe-connector.d.ts +161 -0
  119. package/dist/src/stripe-connector.js +414 -0
  120. package/dist/src/stripe-emit.d.ts +2 -0
  121. package/dist/src/stripe-emit.js +145 -0
  122. package/dist/src/stripe-events.d.ts +93 -0
  123. package/dist/src/stripe-events.js +388 -0
  124. package/dist/src/stripe-js.d.ts +4 -0
  125. package/dist/src/stripe-js.js +70 -0
  126. package/dist/src/stripe-mirror-ui.d.ts +15 -0
  127. package/dist/src/stripe-mirror-ui.js +87 -0
  128. package/dist/src/stripe-params.d.ts +3 -0
  129. package/dist/src/stripe-params.js +43 -0
  130. package/dist/src/stripe-perform-harness.d.ts +9 -0
  131. package/dist/src/stripe-perform-harness.js +26 -0
  132. package/dist/src/stripe-server.d.ts +33 -0
  133. package/dist/src/stripe-server.js +326 -0
  134. package/dist/src/stripe-shared.d.ts +106 -0
  135. package/dist/src/stripe-shared.js +273 -0
  136. package/dist/src/stripe-twin.d.ts +155 -0
  137. package/dist/src/stripe-twin.js +1226 -0
  138. package/dist/src/stripe-ui-conformance.d.ts +5 -0
  139. package/dist/src/stripe-ui-conformance.js +79 -0
  140. package/dist/src/stripe-ui-structure.d.ts +3 -0
  141. package/dist/src/stripe-ui-structure.js +168 -0
  142. package/dist/src/stripe-version.d.ts +10 -0
  143. package/dist/src/stripe-version.js +285 -0
  144. package/dist/test-fixtures/stripe-known-deviations.json +105 -0
  145. package/dist/test-fixtures/stripe-openapi-operations.SOURCE.md +14 -0
  146. package/dist/test-fixtures/stripe-openapi-operations.json +4717 -0
  147. package/dist/test-fixtures/stripe-schemas.SOURCE.md +35 -0
  148. package/dist/test-fixtures/stripe-schemas.json +3740 -0
  149. package/package.json +18 -10
  150. package/src/cli.ts +7 -7
  151. package/src/generated/events.gen.json +1 -0
  152. package/src/generated/surface.gen.json +1 -0
  153. package/src/generated/ui.gen.json +1 -0
  154. package/src/index.ts +31 -9
  155. package/src/manifest.ts +1097 -0
  156. package/src/screens/checkout.tsx +252 -0
  157. package/src/screens/consent-skin.ts +20 -0
  158. package/src/screens/financial-connections.tsx +101 -0
  159. package/src/screens/identity.tsx +96 -0
  160. package/src/screens/industries.ts +267 -0
  161. package/src/screens/onboarding.tsx +243 -0
  162. package/src/screens/portal.tsx +218 -0
  163. package/src/screens/public-details.tsx +105 -0
  164. package/src/semantics/after-payment.ts +113 -0
  165. package/src/semantics/apps-secrets.ts +58 -0
  166. package/src/semantics/balance.ts +209 -0
  167. package/src/semantics/billing.ts +216 -0
  168. package/src/semantics/charges.ts +211 -0
  169. package/src/semantics/checkout.ts +297 -0
  170. package/src/semantics/connect.ts +471 -0
  171. package/src/semantics/coupons.ts +97 -0
  172. package/src/semantics/credit-notes.ts +168 -0
  173. package/src/semantics/customers.ts +432 -0
  174. package/src/semantics/disputes.ts +62 -0
  175. package/src/semantics/entitlements.ts +94 -0
  176. package/src/semantics/ephemeral-keys.ts +34 -0
  177. package/src/semantics/files.ts +143 -0
  178. package/src/semantics/invoices.ts +541 -0
  179. package/src/semantics/issuing.ts +585 -0
  180. package/src/semantics/ledger.ts +216 -0
  181. package/src/semantics/payment-intents.ts +420 -0
  182. package/src/semantics/payment-links.ts +148 -0
  183. package/src/semantics/payment-methods.ts +143 -0
  184. package/src/semantics/plans.ts +131 -0
  185. package/src/semantics/platform.ts +220 -0
  186. package/src/semantics/products.ts +154 -0
  187. package/src/semantics/radar.ts +85 -0
  188. package/src/semantics/refunds.ts +218 -0
  189. package/src/semantics/renewals.ts +274 -0
  190. package/src/semantics/setup-intents.ts +87 -0
  191. package/src/semantics/shared.ts +215 -0
  192. package/src/semantics/subscription-schedules.ts +129 -0
  193. package/src/semantics/subscriptions.ts +610 -0
  194. package/src/semantics/tax.ts +220 -0
  195. package/src/semantics/terminal.ts +195 -0
  196. package/src/semantics/test-clocks.ts +77 -0
  197. package/src/semantics/tokens.ts +52 -0
  198. package/src/semantics/transfers.ts +174 -0
  199. package/src/semantics/treasury.ts +383 -0
  200. package/src/semantics/webhook-endpoints.ts +87 -0
  201. package/src/stripe-budget.ts +4 -4
  202. package/src/stripe-capabilities.ts +1456 -222
  203. package/src/stripe-conformance.ts +6 -5
  204. package/src/stripe-connector.ts +68 -40
  205. package/src/stripe-emit.ts +14 -7
  206. package/src/stripe-events.ts +94 -36
  207. package/src/stripe-js.ts +70 -0
  208. package/src/stripe-mirror-ui.ts +28 -298
  209. package/src/stripe-params.ts +44 -0
  210. package/src/stripe-perform-harness.ts +29 -0
  211. package/src/stripe-server.ts +263 -38
  212. package/src/stripe-shared.ts +294 -0
  213. package/src/stripe-twin.ts +429 -5325
  214. package/src/stripe-ui-conformance.ts +70 -107
  215. package/src/stripe-ui-structure.ts +124 -348
  216. package/src/stripe-version.ts +278 -0
  217. package/test-fixtures/stripe-known-deviations.json +2 -7
  218. package/test-fixtures/stripe-openapi-operations.json +1188 -2855
  219. 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 { 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,12 @@ 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 { pack } from './index.ts';
35
+ import { stripeIdentityFlow } from './screens/identity.tsx';
36
+ import { stripeFinancialConnectionsFlow } from './screens/financial-connections.tsx';
37
+ import { currentlyDue, stripeOnboardingFlow } from './screens/onboarding.tsx';
38
+ import { stripePortalFlow } from './screens/portal.tsx';
30
39
 
31
40
  // ── UI verify: the built mirror bundle must contain the screen's load-bearing markers ──
32
41
  let bundle: Promise<string> | null = null;
@@ -52,7 +61,7 @@ function uiDataCoupled(opts: {
52
61
  const js = await mirrorBundle();
53
62
  if (!opts.markers.every((m) => js.includes(m))) return false;
54
63
  const root = mkdtempSync(join(tmpdir(), 'stp-ui-'));
55
- const server = createStripeMirrorServer({ root, port: 0 });
64
+ const server = await createStripeMirrorServer({ root, port: 0 });
56
65
  try {
57
66
  const h = (s: Step) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root });
58
67
  await opts.seed(h);
@@ -95,17 +104,76 @@ type Step = { m: string; p: string; b?: string };
95
104
  type Body = Record<string, unknown>;
96
105
 
97
106
  /** 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> {
107
+ async function withRoot(steps: (h: (s: Step) => Promise<StripeResponse>, root: string) => Promise<boolean>): Promise<boolean> {
99
108
  const root = mkdtempSync(join(tmpdir(), 'stp-cap-'));
100
109
  const h = (s: Step) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root });
101
110
  try {
102
- return await verifyBoundary('stripe.withRoot', () => steps(h));
111
+ return await verifyBoundary('stripe.withRoot', () => steps(h, root));
103
112
  } finally {
104
113
  rmSync(root, { recursive: true, force: true });
105
114
  }
106
115
  }
107
116
 
117
+ /** Funds the Issuing balance with a top-up made a week before `at` (default now): a top-up "can take up to 5 business
118
+ * days to become available. While they're pending, they won't be added to your Issuing balance"
119
+ * (docs.stripe.com/issuing/funding/balance), so the next request at `at` finds it settled. */
120
+ async function fundIssuing(root: string, amount: number, at?: string): Promise<StripeResponse> {
121
+ const t = at ? Date.parse(at) : Date.now();
122
+ 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() });
123
+ }
124
+
108
125
  const ok = (r: StripeResponse) => r.status >= 200 && r.status < 300;
126
+ /** A seller the platform can pay: a Custom account onboarded through the API (its business profile, the owner's
127
+ * acceptance of the terms, a bank account), and funds in the platform's balance to send it. Answers the account id. */
128
+ async function onboardedSeller(h: (s: Step) => Promise<StripeResponse>): Promise<string> {
129
+ const a = await h({ m: 'POST', p: '/v1/accounts', b: 'type=custom&country=US&capabilities[transfers][requested]=true&business_profile[mcc]=5462&business_profile[url]=https://seller.test&tos_acceptance[date]=1767225600&tos_acceptance[ip]=203.0.113.9' });
130
+ await h({ m: 'POST', p: `/v1/accounts/${id(a)}/external_accounts`, b: 'external_account[object]=bank_account&external_account[country]=US&external_account[currency]=usd&external_account[routing_number]=110000000&external_account[account_number]=000123456789' });
131
+ await h({ m: 'POST', p: '/v1/charges', b: 'amount=100000&currency=usd&source=tok_bypassPending' });
132
+ return id(a);
133
+ }
134
+ /** A local webhook receiver, as an application runs one: a node:http server on 127.0.0.1 that keeps each event POSTed
135
+ * to it by path (`/platform`, `/connect`, …). */
136
+ async function webhookReceiver(): Promise<{ url: (path: string) => string; got: (path: string) => StripeEvent[]; close: () => void }> {
137
+ const got = new Map<string, StripeEvent[]>();
138
+ const server = createServer((req, res) => {
139
+ let body = '';
140
+ req.on('data', (d) => { body += String(d); });
141
+ req.on('end', () => { got.set(String(req.url), [...(got.get(String(req.url)) ?? []), JSON.parse(body) as StripeEvent]); res.end('ok'); });
142
+ });
143
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
144
+ const port = (server.address() as AddressInfo).port;
145
+ return { url: (path) => `http://127.0.0.1:${port}${path}`, got: (path) => got.get(path) ?? [], close: () => server.close() };
146
+ }
147
+ /** A request to the twin's fetch at a World instant, as an account (the Stripe-Account header) when one is named. */
148
+ function fetcher(f: (r: Request) => Promise<Response>): (m: string, p: string, b?: string, acct?: string) => Promise<Body> {
149
+ return async (m, p, b, acct) => (await (await f(new Request(`http://stripe.test${p}`, {
150
+ method: m, headers: { authorization: 'Bearer sk_test_twin', 'content-type': 'application/x-www-form-urlencoded', ...(acct ? { 'stripe-account': acct } : {}) }, ...(b !== undefined ? { body: b } : {}),
151
+ }))).json()) as Body;
152
+ }
153
+ /** A dispute as Stripe opens one: a charge whose test card the bank disputes (docs.stripe.com/testing#disputes). */
154
+ async function disputed(h: (s: Step) => Promise<StripeResponse>, amount = 1000): Promise<StripeResponse> {
155
+ await h({ m: 'POST', p: '/v1/charges', b: `amount=${amount}&currency=usd&source=tok_createDispute` });
156
+ const first = (((await h({ m: 'GET', p: '/v1/disputes' })).body as Body).data as Body[])[0]!;
157
+ return h({ m: 'GET', p: `/v1/disputes/${String(first.id)}` });
158
+ }
159
+ /** A field on a hosted page under the page's own formatting script: typing (one character at a time), deleting, or a
160
+ * paste or autofill of a whole value, each answering the field's value after the script has run. */
161
+ function expiryField(script: string): { type: (s: string) => string; back: () => string; fill: (s: string) => string } {
162
+ const listeners: Array<(e: { inputType: string }) => void> = [];
163
+ const el = { value: '', addEventListener: (_: string, f: (e: { inputType: string }) => void) => { listeners.push(f); } };
164
+ new Function('document', script)({ querySelectorAll: () => [el] });
165
+ const fire = (inputType: string) => { for (const f of listeners) f({ inputType }); return el.value; };
166
+ return {
167
+ type: (s) => { for (const c of s) { el.value += c; fire('insertText'); } return el.value; },
168
+ back: () => { el.value = el.value.slice(0, -1); return fire('deleteContentBackward'); },
169
+ fill: (s) => { el.value = s; return fire('insertFromPaste'); },
170
+ };
171
+ }
172
+
173
+ /** A person's submission on one of Stripe's hosted pages (verify.stripe.com, the bank-linking flow Stripe.js opens). */
174
+ async function hostedSubmit(root: string, flow: (scope: { root?: string }) => (request: Request) => Promise<Response | undefined>, url: string, form: Record<string, string>): Promise<Response | undefined> {
175
+ return flow({ root })(new Request(url, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(form).toString() }));
176
+ }
109
177
  const id = (r: StripeResponse) => (r.body as Body)?.id as string;
110
178
  const field = (r: StripeResponse, k: string) => (r.body as Body)?.[k];
111
179
 
@@ -119,7 +187,6 @@ const listOk = (path: string) => () =>
119
187
  // ── shorthands (mirror the linear manifest) ──
120
188
  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
189
  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
190
 
124
191
  // The real Stripe surface (the target). Entries with verify() + expected:'done' are what we
125
192
  // currently claim are working against THIS twin; everything else (the majority) is a gap.
@@ -298,16 +365,25 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
298
365
  }),
299
366
  ),
300
367
  // 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.
368
+ // amount_capturable=amount) with its authorization a charge, succeeded and uncaptured (charge.succeeded), then
369
+ // /capture → succeeded (amount_received set, capturable cleared) capturing that same charge (charge.captured,
370
+ // "Occurs whenever a previously uncaptured charge is captured"). Capturing an already-captured PI is the vendor 400
371
+ // payment_intent_unexpected_state; unknown id 404.
303
372
  done('stripe.payment_intents.capture', 'payment_intents', 'PaymentIntent manual capture (capture_method=manual + /capture)', 'api', 'core', () =>
304
373
  withRoot(async (h) => {
305
374
  const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=2000&currency=usd&capture_method=manual' });
306
375
  if (!ok(pi)) return false;
307
376
  const conf = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/confirm`, b: 'payment_method=pm_card_visa' });
308
377
  if (!ok(conf) || field(conf, 'status') !== 'requires_capture' || field(conf, 'amount_capturable') !== 2000) return false;
378
+ const held = await h({ m: 'GET', p: `/v1/charges/${String(field(conf, 'latest_charge'))}` });
379
+ if (field(held, 'status') !== 'succeeded' || field(held, 'captured') !== false || field(held, 'amount_captured') !== 0 || field(held, 'balance_transaction') !== null) return false;
309
380
  const cap = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/capture` });
310
381
  if (!ok(cap) || field(cap, 'status') !== 'succeeded' || field(cap, 'amount_received') !== 2000 || field(cap, 'amount_capturable') !== 0) return false;
382
+ const taken = await h({ m: 'GET', p: `/v1/charges/${String(field(cap, 'latest_charge'))}` });
383
+ if (id(taken) !== id(held) || field(taken, 'captured') !== true || field(taken, 'amount_captured') !== 2000 || typeof field(taken, 'balance_transaction') !== 'string') return false;
384
+ const events = (((await h({ m: 'GET', p: '/v1/events?limit=100' })).body as Body).data as Body[]) ?? [];
385
+ 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);
386
+ 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
387
  // re-read persists; double-capture is the vendor unexpected-state 400; unknown id 404
312
388
  const g = await h({ m: 'GET', p: `/v1/payment_intents/${id(pi)}` });
313
389
  const again = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/capture` });
@@ -317,6 +393,51 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
317
393
  missing.status === 404;
318
394
  }),
319
395
  ),
396
+ // A declined confirm records the attempt as a failed charge the intent's latest_charge names, written as the event
397
+ // Stripe sends, charge.failed ("Occurs whenever a failed charge attempt occurs", docs.stripe.com/api/events/types), and
398
+ // carrying what every charge of the intent carries: its transfer_group and description. Dub reads the payout invoice a
399
+ // failed payment belongs to from transfer_group.
400
+ 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', () =>
401
+ withRoot(async (h) => {
402
+ const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=2000&currency=usd&transfer_group=inv_77&description=Payout%20invoice%2077' });
403
+ const dec = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/confirm`, b: 'payment_method=pm_card_chargeDeclined' });
404
+ const chargeId = ((dec.body as Body).error as Body | undefined)?.charge;
405
+ const after = await h({ m: 'GET', p: `/v1/payment_intents/${id(pi)}` });
406
+ const charge = await h({ m: 'GET', p: `/v1/charges/${String(chargeId)}` });
407
+ const events = (((await h({ m: 'GET', p: '/v1/events?type=charge.failed' })).body as Body).data as Body[]) ?? [];
408
+ const object = (events[0]?.data as Body | undefined)?.object as Body | undefined;
409
+ return dec.status === 402 && field(after, 'latest_charge') === chargeId && field(charge, 'status') === 'failed'
410
+ && field(charge, 'transfer_group') === 'inv_77' && field(charge, 'description') === 'Payout invoice 77'
411
+ && events.length === 1 && object?.id === chargeId && object?.transfer_group === 'inv_77' && object?.description === 'Payout invoice 77';
412
+ }),
413
+ ),
414
+ // Cancelling a manual-capture intent releases its authorization: "For PaymentIntents with a `status` of
415
+ // `requires_capture`, the remaining `amount_capturable` is automatically refunded" (docs.stripe.com/api/payment_intents/cancel).
416
+ // Since basil the cancellation makes no Refund ("`refunded` will no longer be `true` for payment cancellation flows",
417
+ // the basil changelog), no money moves, and capturing the charge is refused, the intent being canceled.
418
+ 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', () =>
419
+ withRoot(async (h) => {
420
+ // the whole balance, available and pending
421
+ const avail = async (): Promise<number> => {
422
+ const b = (await h({ m: 'GET', p: '/v1/balance' })).body as Body;
423
+ return [...((b.available as Body[]) ?? []), ...((b.pending as Body[]) ?? [])].reduce((n, x) => n + (Number(x.amount) || 0), 0);
424
+ };
425
+ 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' });
426
+ const chargeId = String(field(pi, 'latest_charge'));
427
+ const before = await avail();
428
+ const cancel = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/cancel` });
429
+ const ch = await h({ m: 'GET', p: `/v1/charges/${chargeId}` });
430
+ const refunds = ((field(ch, 'refunds') as Body)?.data as Body[]) ?? [];
431
+ const capture = await h({ m: 'POST', p: `/v1/charges/${chargeId}/capture` });
432
+ const after = await avail();
433
+ // since basil a cancellation makes no Refund: amount_captured 0, amount_refunded and refunded untouched
434
+ return field(cancel, 'status') === 'canceled' && field(cancel, 'amount_capturable') === 0
435
+ && field(ch, 'captured') === false && field(ch, 'refunded') === false && field(ch, 'amount_refunded') === 0 && field(ch, 'amount_captured') === 0
436
+ && refunds.length === 0
437
+ && capture.status === 400 && ((capture.body as Body).error as Body)?.code === 'payment_intent_unexpected_state'
438
+ && before === 0 && after === 0;
439
+ }),
440
+ ),
320
441
  // Cancel: a non-terminal PI cancels → status canceled (+ cancellation_reason). Canceling a
321
442
  // succeeded PI is the vendor 400 payment_intent_unexpected_state; an unknown id is 404.
322
443
  done('stripe.payment_intents.cancel', 'payment_intents', 'PaymentIntent cancel', 'api', 'core', () =>
@@ -402,7 +523,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
402
523
  return ok(g) && id(g) === id(ch) && field(l, 'object') === 'list';
403
524
  }),
404
525
  ),
405
- done('stripe.charges.test_declines', 'charges', 'Test-card declines → 402 typed card_error (no charge persisted)', 'api', 'core', () =>
526
+ done('stripe.charges.test_declines', 'charges', 'Test-card declines → 402 typed card_error naming the failed charge', 'api', 'core', () =>
406
527
  withRoot(async (h) => {
407
528
  const dec = await h({ m: 'POST', p: '/v1/charges', b: 'amount=1000&currency=usd&source[number]=4000000000000002' });
408
529
  const ins = await h({ m: 'POST', p: '/v1/charges', b: 'amount=1000&currency=usd&source[number]=4000000000009995' });
@@ -411,6 +532,26 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
411
532
  return dec.status === 402 && decErr?.code === 'card_declined' && ins.status === 402 && insErr?.decline_code === 'insufficient_funds';
412
533
  }),
413
534
  ),
535
+ // POST /v1/charges writes the event Stripe sends for the charge (docs.stripe.com/api/events/types; Stripe has no
536
+ // charge.created): charge.succeeded, "Occurs whenever a charge is successful", for one made and for an authorization
537
+ // (capture=false), charge.failed, "Occurs whenever a failed charge attempt occurs", for a declined attempt, and
538
+ // charge.captured, "Occurs whenever a previously uncaptured charge is captured", when the authorization is captured.
539
+ done('stripe.charges.events', 'charges', 'Charges fire charge.succeeded (made or authorized), charge.failed (declined) and charge.captured', 'api', 'core', () =>
540
+ withRoot(async (h) => {
541
+ const made = await h({ m: 'POST', p: '/v1/charges', b: 'amount=2000&currency=usd&source=tok_visa' });
542
+ const auth = await h({ m: 'POST', p: '/v1/charges', b: 'amount=3000&currency=usd&source=tok_visa&capture=false' });
543
+ const dec = await h({ m: 'POST', p: '/v1/charges', b: 'amount=1000&currency=usd&source=tok_chargeDeclined' });
544
+ const declined = ((dec.body as Body).error as Body | undefined)?.charge;
545
+ await h({ m: 'POST', p: `/v1/charges/${id(auth)}/capture` });
546
+ const events = (((await h({ m: 'GET', p: '/v1/events?limit=100' })).body as Body).data as Body[]) ?? [];
547
+ const of = (type: string) => events.filter((e) => e.type === type).map((e) => (e.data as Body).object as Body);
548
+ const succeeded = of('charge.succeeded');
549
+ return dec.status === 402 && typeof declined === 'string'
550
+ && succeeded.some((c) => c.id === id(made) && c.captured === true) && succeeded.some((c) => c.id === id(auth) && c.captured === false)
551
+ && of('charge.failed').some((c) => c.id === declined && c.status === 'failed')
552
+ && of('charge.captured').some((c) => c.id === id(auth) && c.captured === true && c.amount_captured === 3000);
553
+ }),
554
+ ),
414
555
  // Charge capture: a charge created with capture=false is an uncaptured auth (captured:false,
415
556
  // amount_captured:0); /capture captures it (captured:true, amount_captured set). Re-capture
416
557
  // is the vendor 400 charge_already_captured; an immediately-captured charge (default) cannot
@@ -422,6 +563,12 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
422
563
  const cap = await h({ m: 'POST', p: `/v1/charges/${id(auth)}/capture` });
423
564
  if (!ok(cap) || field(cap, 'captured') !== true || field(cap, 'amount_captured') !== 3000) return false;
424
565
  const again = await h({ m: 'POST', p: `/v1/charges/${id(auth)}/capture` });
566
+ // A PARTIAL capture releases the remainder with no Refund (the basil change): amount_refunded stays 0
567
+ const part = await h({ m: 'POST', p: '/v1/charges', b: 'amount=3000&currency=usd&capture=false' });
568
+ const capPart = await h({ m: 'POST', p: `/v1/charges/${id(part)}/capture`, b: 'amount=1200' });
569
+ if (!ok(capPart) || field(capPart, 'amount_captured') !== 1200 || field(capPart, 'amount_refunded') !== 0 || field(capPart, 'refunded') !== false) return false;
570
+ const released = ((field(capPart, 'refunds') as Body)?.data as Body[]) ?? [];
571
+ if (released.length !== 0) return false;
425
572
  const def = await h({ m: 'POST', p: '/v1/charges', b: 'amount=1000&currency=usd' });
426
573
  const capDef = await h({ m: 'POST', p: `/v1/charges/${id(def)}/capture` });
427
574
  const missing = await h({ m: 'POST', p: '/v1/charges/ch_nope/capture' });
@@ -464,30 +611,108 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
464
611
  // ── Refunds ─────────────────────────────────────────────────────────────────────
465
612
  done('stripe.refunds.crud', 'refunds', 'Refunds: create + retrieve + list', 'api', 'core', () =>
466
613
  withRoot(async (h) => {
467
- const r = await h({ m: 'POST', p: '/v1/refunds', b: 'charge=ch_twin&amount=500' });
614
+ const c = await h({ m: 'POST', p: '/v1/charges', b: 'amount=2000&currency=usd' });
615
+ const r = await h({ m: 'POST', p: '/v1/refunds', b: `charge=${id(c)}&amount=500` });
468
616
  if (!ok(r) || field(r, 'object') !== 'refund') return false;
469
617
  const g = await h({ m: 'GET', p: `/v1/refunds/${id(r)}` });
470
618
  const l = await h({ m: 'GET', p: '/v1/refunds' });
471
619
  return ok(g) && id(g) === id(r) && field(l, 'object') === 'list';
472
620
  }),
473
621
  ),
622
+ // The money model's central claim: a refund is VISIBLE FROM THE CHARGE it came out of.
623
+ // amount_refunded accumulates, `refunded` flips only at the full amount, and the charge's
624
+ // own refunds sub-list carries the refund objects. A twin that answers a succeeded refund
625
+ // while the charge still reads amount_refunded 0 hands a reconciler the wrong ledger with
626
+ // no error to tell them — which is why the over-refund refusal is part of the same claim.
627
+ 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', () =>
628
+ withRoot(async (h) => {
629
+ const c = await h({ m: 'POST', p: '/v1/charges', b: 'amount=2000&currency=usd' });
630
+ const r1 = await h({ m: 'POST', p: '/v1/refunds', b: `charge=${id(c)}&amount=500` });
631
+ if (!ok(r1)) return false;
632
+ const partial = await h({ m: 'GET', p: `/v1/charges/${id(c)}` });
633
+ if (field(partial, 'amount_refunded') !== 500 || field(partial, 'refunded') !== false) return false;
634
+ const listed = (field(partial, 'refunds') as Body)?.data as Body[] | undefined;
635
+ if (!Array.isArray(listed) || listed.length !== 1 || listed[0]?.id !== id(r1)) return false;
636
+ // more than the charge has left to give is refused, not silently accepted
637
+ const tooMuch = await h({ m: 'POST', p: '/v1/refunds', b: `charge=${id(c)}&amount=1600` });
638
+ if (tooMuch.status !== 400) return false;
639
+ // an omitted amount refunds the whole remainder, and THEN the charge is fully refunded
640
+ // (taken through the charge's OWN refund endpoint — Stripe serves both spellings)
641
+ const r2 = await h({ m: 'POST', p: `/v1/charges/${id(c)}/refunds` });
642
+ if (!ok(r2) || field(r2, 'amount') !== 1500) return false;
643
+ const full = await h({ m: 'GET', p: `/v1/charges/${id(c)}` });
644
+ // and the charge's own refunds sub-route answers the same two refunds
645
+ const sub = await h({ m: 'GET', p: `/v1/charges/${id(c)}/refunds` });
646
+ const none = await h({ m: 'POST', p: '/v1/refunds', b: 'amount=100' });
647
+ const nosuch = await h({ m: 'POST', p: '/v1/refunds', b: 'charge=ch_nope&amount=100' });
648
+ return field(full, 'amount_refunded') === 2000 && field(full, 'refunded') === true
649
+ && ((field(sub, 'data') as Body[])?.length === 2)
650
+ && none.status === 400 && nosuch.status === 404;
651
+ }),
652
+ ),
653
+ // A refund reached through its PaymentIntent lands on the SAME charge: a succeeded intent
654
+ // has a latest_charge (real Stripe always mints one), and that is what the refund hits.
655
+ done('stripe.refunds.by_payment_intent', 'refunds', 'Refund by payment_intent resolves the intent’s charge', 'api', 'common', () =>
656
+ withRoot(async (h) => {
657
+ const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=3000&currency=usd' });
658
+ const con = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/confirm`, b: 'payment_method=pm_card_visa' });
659
+ const charge = field(con, 'latest_charge') as string | undefined;
660
+ if (!charge) return false;
661
+ const r = await h({ m: 'POST', p: '/v1/refunds', b: `payment_intent=${id(pi)}&amount=1200` });
662
+ if (!ok(r) || field(r, 'charge') !== charge || field(r, 'payment_intent') !== id(pi)) return false;
663
+ const ch = await h({ m: 'GET', p: `/v1/charges/${charge}` });
664
+ const missing = await h({ m: 'POST', p: '/v1/refunds', b: 'payment_intent=pi_nope&amount=1' });
665
+ return field(ch, 'amount_refunded') === 1200 && missing.status === 404;
666
+ }),
667
+ ),
474
668
  // Refund update: POST /v1/refunds/:id persists metadata; retrieve round-trips it. Unknown id 404.
669
+ // An uncaptured charge holds an authorization, not money. A PaymentIntent's "remains uncaptured and can't be refunded
670
+ // directly. You must cancel the PaymentIntent" (docs.stripe.com/refunds), so its refund is refused, through the intent
671
+ // or the charge; a capture=false charge's refund releases it (refunded in full, no balance moved) and its capture is
672
+ // then refused ("unless the charge is already refunded", docs.stripe.com/api/charges/capture).
673
+ 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', () =>
674
+ withRoot(async (h) => {
675
+ const whole = async (): Promise<number> => {
676
+ const b = (await h({ m: 'GET', p: '/v1/balance' })).body as Body;
677
+ return [...((b.available as Body[]) ?? []), ...((b.pending as Body[]) ?? [])].reduce((n, x) => n + (Number(x.amount) || 0), 0);
678
+ };
679
+ 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' });
680
+ const viaIntent = await h({ m: 'POST', p: '/v1/refunds', b: `payment_intent=${id(pi)}` });
681
+ const viaCharge = await h({ m: 'POST', p: '/v1/refunds', b: `charge=${String(field(pi, 'latest_charge'))}` });
682
+ const still = await h({ m: 'GET', p: `/v1/charges/${String(field(pi, 'latest_charge'))}` });
683
+ if (viaIntent.status !== 400 || viaCharge.status !== 400 || field(still, 'refunded') !== false || field(still, 'amount_refunded') !== 0) return false;
684
+ const auth = await h({ m: 'POST', p: '/v1/charges', b: 'amount=3000&currency=usd&source=tok_visa&capture=false' });
685
+ const refund = await h({ m: 'POST', p: '/v1/refunds', b: `charge=${id(auth)}` });
686
+ const released = await h({ m: 'GET', p: `/v1/charges/${id(auth)}` });
687
+ const capture = await h({ m: 'POST', p: `/v1/charges/${id(auth)}/capture` });
688
+ return ok(refund) && field(refund, 'amount') === 3000 && field(refund, 'balance_transaction') === null
689
+ && field(released, 'refunded') === true && field(released, 'captured') === false && field(released, 'amount_refunded') === 3000
690
+ && capture.status === 400 && ((capture.body as Body).error as Body)?.code === 'charge_already_refunded' && await whole() === 0;
691
+ }),
692
+ ),
475
693
  done('stripe.refunds.update', 'refunds', 'Refund update (metadata)', 'api', 'common', () =>
476
694
  withRoot(async (h) => {
477
- const r = await h({ m: 'POST', p: '/v1/refunds', b: 'charge=ch_twin&amount=500' });
695
+ const c = await h({ m: 'POST', p: '/v1/charges', b: 'amount=2000&currency=usd' });
696
+ const r = await h({ m: 'POST', p: '/v1/refunds', b: `charge=${id(c)}&amount=500` });
478
697
  const u = await h({ m: 'POST', p: `/v1/refunds/${id(r)}`, b: 'metadata[reason]=duplicate' });
479
698
  if (!ok(u) || ((field(u, 'metadata') as Body)?.reason) !== 'duplicate') return false;
480
699
  const g = await h({ m: 'GET', p: `/v1/refunds/${id(r)}` });
481
700
  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;
701
+ // the same refund, read and written THROUGH its charge (Stripe's charge-scoped routes)
702
+ const viaCharge = await h({ m: 'GET', p: `/v1/charges/${id(c)}/refunds/${id(r)}` });
703
+ const editedViaCharge = await h({ m: 'POST', p: `/v1/charges/${id(c)}/refunds/${id(r)}`, b: 'metadata[note]=via-charge' });
704
+ const wrongCharge = await h({ m: 'GET', p: `/v1/charges/${id(c)}/refunds/re_nope` });
705
+ return ok(g) && ((field(g, 'metadata') as Body)?.reason) === 'duplicate' && missing.status === 404
706
+ && ok(viaCharge) && id(viaCharge) === id(r) && wrongCharge.status === 404
707
+ && ((field(editedViaCharge, 'metadata') as Body)?.note) === 'via-charge';
483
708
  }),
484
709
  ),
485
710
  // (refunds.cancel upgraded to done() in the AUDIT GROWTH block below.)
486
711
 
487
712
  // ── Disputes ────────────────────────────────────────────────────────────────────
488
- done('stripe.disputes.crud', 'disputes', 'Disputes: create + retrieve + list', 'api', 'common', () =>
713
+ done('stripe.disputes.crud', 'disputes', 'Disputes: a disputed charge opens one; retrieve + list', 'api', 'common', () =>
489
714
  withRoot(async (h) => {
490
- const d = await h({ m: 'POST', p: '/v1/disputes', b: 'charge=ch_twin&amount=1000&currency=usd' });
715
+ const d = await disputed(h);
491
716
  if (!ok(d) || field(d, 'object') !== 'dispute') return false;
492
717
  const g = await h({ m: 'GET', p: `/v1/disputes/${id(d)}` });
493
718
  const l = await h({ m: 'GET', p: '/v1/disputes' });
@@ -496,13 +721,27 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
496
721
  ),
497
722
  done('stripe.disputes.evidence', 'disputes', 'Dispute evidence submission (update) + close', 'api', 'common', () =>
498
723
  withRoot(async (h) => {
499
- const d = await h({ m: 'POST', p: '/v1/disputes', b: 'charge=ch_twin&amount=1000&currency=usd' });
724
+ const d = await disputed(h);
500
725
  const ev = await h({ m: 'POST', p: `/v1/disputes/${id(d)}`, b: 'evidence[uncategorized_text]=we shipped it' });
501
726
  const cl = await h({ m: 'POST', p: `/v1/disputes/${id(d)}/close` });
502
727
  return ok(ev) && ok(cl) && field(cl, 'status') === 'lost';
503
728
  }),
504
729
  ),
505
730
 
731
+ // The dispute test cards open a dispute through Checkout as through the API, and test-mode evidence decides it:
732
+ // `winning_evidence` wins (the amount comes back), `losing_evidence` loses (docs.stripe.com/testing#disputes, #evidence).
733
+ done('stripe.disputes.test_outcomes', 'disputes', 'Dispute test cards through Checkout; winning_evidence / losing_evidence decide the dispute', 'api', 'common', () =>
734
+ withRoot(async (h) => {
735
+ 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' });
736
+ 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' });
737
+ const opened = ((await h({ m: 'GET', p: '/v1/disputes' })).body as Body).data as Body[];
738
+ const won = await h({ m: 'POST', p: `/v1/disputes/${String(opened[0]?.id)}`, b: 'evidence[uncategorized_text]=winning_evidence' });
739
+ const lostOne = await disputed(h);
740
+ const lost = await h({ m: 'POST', p: `/v1/disputes/${id(lostOne)}`, b: 'evidence[uncategorized_text]=losing_evidence' });
741
+ return opened[0]?.reason === 'product_not_received' && field(won, 'status') === 'won' && field(lost, 'status') === 'lost';
742
+ }),
743
+ ),
744
+
506
745
  // ── Balance / BalanceTransactions / Payouts ───────────────────────────────────────
507
746
  done('stripe.balance.retrieve', 'balance', 'Balance retrieve (available/pending)', 'api', 'core', () =>
508
747
  withRoot(async (h) => {
@@ -510,17 +749,37 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
510
749
  return ok(r) && field(r, 'object') === 'balance';
511
750
  }),
512
751
  ),
513
- done('stripe.balance_transactions.crud', 'balance', 'BalanceTransactions: create + retrieve + list', 'api', 'core', () =>
752
+ done('stripe.balance_transactions.crud', 'balance', 'BalanceTransactions: a charge\'s ledger entry; retrieve + list', 'api', 'core', () =>
514
753
  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;
754
+ await h({ m: 'POST', p: '/v1/charges', b: 'amount=1000&currency=usd&source=tok_visa' });
755
+ const t = { status: 200, body: (((await h({ m: 'GET', p: '/v1/balance_transactions' })).body as Body).data as Body[])[0]! } as StripeResponse;
756
+ if (!t.body || field(t, 'amount') !== 1000) return false;
517
757
  const g = await h({ m: 'GET', p: `/v1/balance_transactions/${id(t)}` });
518
758
  const l = await h({ m: 'GET', p: '/v1/balance_transactions' });
519
759
  return ok(g) && id(g) === id(t) && field(l, 'object') === 'list';
520
760
  }),
521
761
  ),
762
+ // Every money movement writes its balance transaction (semantics/ledger.ts): a captured charge credits its
763
+ // amount less Stripe's standard fee, pending until available_on; a refund and a payout debit at once; a
764
+ // platform payout beyond the available balance is balance_insufficient.
765
+ 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', () =>
766
+ withRoot(async (h) => {
767
+ const ch = await h({ m: 'POST', p: '/v1/charges', b: 'amount=10000&currency=usd&source=tok_visa' });
768
+ const btId = field(ch, 'balance_transaction') as string;
769
+ const bt = await h({ m: 'GET', p: `/v1/balance_transactions/${btId}` });
770
+ if (!(ok(bt) && field(bt, 'type') === 'charge' && field(bt, 'fee') === 320 && field(bt, 'net') === 9680 && field(bt, 'status') === 'pending')) return false;
771
+ // pending funds cannot be paid out; funds that settle at once can
772
+ if ((await h({ m: 'POST', p: '/v1/payouts', b: 'amount=5000&currency=usd' })).status !== 400) return false;
773
+ await h({ m: 'POST', p: '/v1/charges', b: 'amount=10000&currency=usd&source=tok_bypassPending' });
774
+ const po = await h({ m: 'POST', p: '/v1/payouts', b: 'amount=5000&currency=usd' });
775
+ const bal = await h({ m: 'GET', p: '/v1/balance' });
776
+ const available = ((bal.body as { available: Array<{ amount: number }> }).available[0]!).amount;
777
+ return ok(po) && available === 9680 - 5000;
778
+ }),
779
+ ),
522
780
  done('stripe.payouts.crud', 'payouts', 'Payouts: create + retrieve + list + cancel', 'api', 'core', () =>
523
781
  withRoot(async (h) => {
782
+ await h({ m: 'POST', p: '/v1/charges', b: 'amount=2000&currency=usd&source=tok_bypassPending' });
524
783
  const p = await h({ m: 'POST', p: '/v1/payouts', b: 'amount=1000&currency=usd' });
525
784
  if (!ok(p) || field(p, 'object') !== 'payout') return false;
526
785
  const g = await h({ m: 'GET', p: `/v1/payouts/${id(p)}` });
@@ -528,22 +787,33 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
528
787
  return ok(g) && id(g) === id(p) && ok(c);
529
788
  }),
530
789
  ),
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` });
790
+ // Payout reverse: a connected account's payout, once paid, is reversed as a NEW payout carrying original_payout;
791
+ // the original stays paid and names it (reversed_by). A pending payout is canceled instead (400), a platform payout
792
+ // cannot be reversed (400), reversing twice 400s, and an unknown id 404s.
793
+ done('stripe.payouts.reverse', 'payouts', 'Payout reverse (a connected account\'s paid payout)', 'api', 'niche', async () => {
794
+ const root = mkdtempSync(join(tmpdir(), 'stp-rev-'));
795
+ try {
796
+ const h = (s: Step & { acct?: string; day?: number }) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: `2026-06-${String(s.day ?? 1).padStart(2, '0')}T00:00:00Z`, ...(s.acct ? { stripeAccount: s.acct } : {}) });
797
+ const seller = await onboardedSeller(h);
798
+ await h({ m: 'POST', p: '/v1/transfers', b: `amount=5000&currency=usd&destination=${seller}` });
799
+ const p = await h({ m: 'POST', p: '/v1/payouts', b: 'amount=4000&currency=usd', acct: seller });
800
+ if (!ok(p) || (await h({ m: 'POST', p: `/v1/payouts/${id(p)}/reverse`, acct: seller })).status !== 400) return false;
801
+ const rev = await h({ m: 'POST', p: `/v1/payouts/${id(p)}/reverse`, acct: seller, day: 5 });
539
802
  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` });
803
+ const orig = await h({ m: 'GET', p: `/v1/payouts/${id(p)}`, acct: seller, day: 5 });
804
+ if (field(orig, 'status') !== 'paid' || field(orig, 'reversed_by') !== id(rev)) return false;
805
+ const plat = await h({ m: 'POST', p: '/v1/payouts', b: 'amount=1000&currency=usd' });
806
+ const platRev = await h({ m: 'POST', p: `/v1/payouts/${id(plat)}/reverse`, day: 5 });
807
+ const twice = await h({ m: 'POST', p: `/v1/payouts/${id(p)}/reverse`, acct: seller, day: 5 });
543
808
  const nope = await h({ m: 'POST', p: '/v1/payouts/po_nope/reverse' });
544
- return twice.status === 400 && nope.status === 404;
545
- }),
546
- ),
809
+ return platRev.status === 400 && twice.status === 400 && nope.status === 404;
810
+ } catch (err) {
811
+ if (isInfrastructureError(err)) throw harnessError('stripe.payouts.reverse', err);
812
+ return false;
813
+ } finally {
814
+ rmSync(root, { recursive: true, force: true });
815
+ }
816
+ }),
547
817
  // Payout schedule: the automatic-payout cadence lives in account.settings.payouts.schedule.
548
818
  // GET /v1/account returns the platform account with a default daily schedule; POST updates
549
819
  // it to e.g. weekly+anchor (anchors normalized — weekly_anchor set, monthly_anchor null) and
@@ -555,7 +825,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
555
825
  if (!ok(acct)) return false;
556
826
  const sched0 = ((field(acct, 'settings') as Body)?.payouts as Body)?.schedule as Body;
557
827
  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' });
828
+ const upd = await h({ m: 'POST', p: '/_twin/account', b: 'settings[payouts][schedule][interval]=weekly&settings[payouts][schedule][weekly_anchor]=friday' });
559
829
  const sched1 = ((field(upd, 'settings') as Body)?.payouts as Body)?.schedule as Body;
560
830
  if (!ok(upd) || sched1.interval !== 'weekly' || sched1.weekly_anchor !== 'friday' || sched1.monthly_anchor !== null) return false;
561
831
  // persists across a re-read.
@@ -569,6 +839,121 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
569
839
  }),
570
840
  ),
571
841
 
842
+ // Automatic payouts: on its schedule an account is paid out what has become available, and the ledger lists the
843
+ // entries each automatic payout paid (docs.stripe.com/payouts#payout-schedule,
844
+ // docs.stripe.com/api/balance_transactions/list#balance_transaction_list-payout).
845
+ done('stripe.payouts.automatic', 'payouts', 'Automatic payouts on the account\'s schedule, with the entries each paid out', 'api', 'core', async () => {
846
+ const root = mkdtempSync(join(tmpdir(), 'stp-auto-'));
847
+ try {
848
+ 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` });
849
+ const ch = await h({ m: 'POST', p: '/v1/charges', b: 'amount=2000&currency=usd&source=tok_visa' });
850
+ const early = await h({ m: 'GET', p: '/v1/payouts', day: 2 });
851
+ const later = await h({ m: 'GET', p: '/v1/payouts?created[gte]=1780272000', day: 6 });
852
+ const po = (((later.body as Body).data as Body[]) ?? [])[0];
853
+ const entries = await h({ m: 'GET', p: `/v1/balance_transactions?payout=${String(po?.id)}`, day: 6 });
854
+ const sources = (((entries.body as Body).data as Body[]) ?? []).map((t) => t.source);
855
+ return ((early.body as Body).data as Body[]).length === 0 && po?.automatic === true && po.amount === 2000 - 88
856
+ && sources.includes(id(ch)) && sources.includes(po.id);
857
+ } finally {
858
+ rmSync(root, { recursive: true, force: true });
859
+ }
860
+ }),
861
+
862
+ // Time's events (stripe-server.ts, the drain door): funds coming due send their account balance.available, a payout's
863
+ // arrival sends payout.paid, each once, a connected account's scoped to it (docs.stripe.com/api/events/types;
864
+ // docs.stripe.com/connect/webhooks). Dub pays a partner's connected account out on balance.available and completes
865
+ // the partner's payouts on payout.paid. The webhooks are read where an application reads them, a local receiver behind
866
+ // a platform endpoint and a `connect` one; the stored events are listed as each account (the Stripe-Account header).
867
+ 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 () => {
868
+ const root = mkdtempSync(join(tmpdir(), 'stp-drain-'));
869
+ const rx = await webhookReceiver();
870
+ try {
871
+ let now = '2026-06-01T12:00:00.000Z';
872
+ const h = fetcher(createStripeTwinFetch({ root, clock: () => now }));
873
+ await h('POST', '/v1/webhook_endpoints', `url=${rx.url('/platform')}&enabled_events[]=balance.available&enabled_events[]=payout.paid`);
874
+ await h('POST', '/v1/webhook_endpoints', `url=${rx.url('/connect')}&enabled_events[]=balance.available&enabled_events[]=payout.paid&connect=true`);
875
+ const seller = String((await h('POST', '/v1/accounts', 'type=custom&country=US&capabilities[transfers][requested]=true&business_profile[mcc]=5462&business_profile[url]=https://seller.test&tos_acceptance[date]=1767225600&tos_acceptance[ip]=203.0.113.9&settings[payouts][schedule][interval]=manual')).id);
876
+ await h('POST', `/v1/accounts/${seller}/external_accounts`, 'external_account[object]=bank_account&external_account[country]=US&external_account[currency]=usd&external_account[routing_number]=110000000&external_account[account_number]=000123456789');
877
+ await h('POST', '/_twin/account', 'settings[payouts][schedule][interval]=manual');
878
+ const pi = await h('POST', '/v1/payment_intents', 'amount=2592&currency=usd&payment_method=pm_card_visa&confirm=true');
879
+ await h('POST', '/v1/transfers', `amount=2400&currency=usd&destination=${seller}&source_transaction=${String(pi.latest_charge)}`);
880
+ const sent = async (): Promise<string[]> => ((await h('POST', '/_twin/drain')).delivered as Body[]).map((d) => `${String(d.type)}@${String(d.account ?? 'platform')}`);
881
+ const early = await sent();
882
+ now = '2026-06-04T12:00:00.000Z';
883
+ const due = await sent();
884
+ const again = await sent();
885
+ const po = await h('POST', '/v1/payouts', 'amount=2400&currency=usd', seller);
886
+ now = '2026-06-07T12:00:00.000Z';
887
+ const arrived = await sent();
888
+ // the connected account's webhooks reach only the connect endpoint, naming it; the platform's only the other
889
+ const theirs = rx.got('/connect');
890
+ const ours = rx.got('/platform');
891
+ const balance = theirs.find((e) => e.type === 'balance.available');
892
+ const paid = theirs.find((e) => e.type === 'payout.paid');
893
+ const hook = balance?.account === seller && ((balance.data.object as Body).available as Body[])?.[0]?.amount === 2400
894
+ && paid?.account === seller && (paid.data.object as Body).id === po.id && theirs.length === 2
895
+ && ours.length === 1 && ours[0]!.type === 'balance.available' && !('account' in ours[0]!);
896
+ 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')}`));
897
+ const sellerEvents = await listed(seller);
898
+ const platformEvents = await listed();
899
+ return early.length === 0 && due.includes(`balance.available@${seller}`) && due.includes('balance.available@platform') && again.length === 0
900
+ && po.status === 'pending' && arrived.includes(`payout.paid@${seller}`) && hook
901
+ && sellerEvents.includes(`balance.available@${seller}`) && sellerEvents.includes(`payout.paid@${seller}`)
902
+ && platformEvents.includes('balance.available@platform') && !platformEvents.some((e) => e.endsWith(`@${seller}`));
903
+ } finally {
904
+ rx.close();
905
+ rmSync(root, { recursive: true, force: true });
906
+ }
907
+ }),
908
+
909
+ // One event, one id: the webhook a consumer receives and the event the Events API stores carry the same id, and no two
910
+ // events share one — Dub's queue deduplicates by event.id, and a Balance has no id of its own to tell two accounts'
911
+ // balance.available apart. Two partners' funds coming due in one drain are two events, each retrievable by its id as
912
+ // its account (the Stripe-Account header).
913
+ 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 () => {
914
+ const root = mkdtempSync(join(tmpdir(), 'stp-evid-'));
915
+ const rx = await webhookReceiver();
916
+ try {
917
+ let now = '2026-06-01T12:00:00.000Z';
918
+ const h = fetcher(createStripeTwinFetch({ root, clock: () => now }));
919
+ await h('POST', '/v1/webhook_endpoints', `url=${rx.url('/platform')}&enabled_events[]=*`);
920
+ await h('POST', '/v1/webhook_endpoints', `url=${rx.url('/connect')}&enabled_events[]=balance.available&connect=true`);
921
+ await h('POST', '/_twin/account', 'settings[payouts][schedule][interval]=manual');
922
+ const partner = async (): Promise<string> => {
923
+ const a = String((await h('POST', '/v1/accounts', 'type=custom&country=US&capabilities[transfers][requested]=true&business_profile[mcc]=5462&business_profile[url]=https://seller.test&tos_acceptance[date]=1767225600&tos_acceptance[ip]=203.0.113.9&settings[payouts][schedule][interval]=manual')).id);
924
+ await h('POST', `/v1/accounts/${a}/external_accounts`, 'external_account[object]=bank_account&external_account[country]=US&external_account[currency]=usd&external_account[routing_number]=110000000&external_account[account_number]=000123456789');
925
+ const pi = await h('POST', '/v1/payment_intents', 'amount=2000&currency=usd&payment_method=pm_card_visa&confirm=true');
926
+ await h('POST', '/v1/transfers', `amount=1500&currency=usd&destination=${a}&source_transaction=${String(pi.latest_charge)}`);
927
+ return a;
928
+ };
929
+ const [first, second] = [await partner(), await partner()];
930
+ const customer = await h('POST', '/v1/customers', 'email=ids@twin.test');
931
+ // two writes at once: each event takes its own number, though both are delivered before either answers
932
+ const [c1, c2] = await Promise.all([h('POST', '/v1/customers', 'email=ids1@twin.test'), h('POST', '/v1/customers', 'email=ids2@twin.test')]);
933
+ now = '2026-06-04T12:00:00.000Z';
934
+ await h('POST', '/_twin/drain');
935
+ const due = rx.got('/connect').filter((e) => e.type === 'balance.available');
936
+ const of = (acct: string) => due.find((e) => e.account === acct);
937
+ const [a, b] = [of(first), of(second)];
938
+ if (!a || !b || a.id === b.id || due.length !== 2) return false;
939
+ const [ga, gb] = [await h('GET', `/v1/events/${a.id}`, undefined, first), await h('GET', `/v1/events/${b.id}`, undefined, second)];
940
+ const hook = rx.got('/platform').find((e) => e.type === 'customer.created');
941
+ const stored = await h('GET', `/v1/events/${String(hook?.id)}`);
942
+ const ids = rx.got('/platform').concat(rx.got('/connect')).map((e) => e.id);
943
+ return ga.id === a.id && ga.account === first && ga.type === 'balance.available' && gb.id === b.id && gb.account === second
944
+ && stored.id === hook?.id && ((stored.data as Body)?.object as Body)?.id === customer.id && new Set(ids).size === ids.length
945
+ && await (async () => {
946
+ const both = rx.got('/platform').filter((e) => e.type === 'customer.created' && [c1.id, c2.id].includes(((e.data as Body)?.object as Body)?.id));
947
+ const looked = await Promise.all(both.map((e) => h('GET', `/v1/events/${String(e.id)}`)));
948
+ return both.length === 2 && both[0]!.id !== both[1]!.id
949
+ && looked.every((g, i) => ((g.data as Body)?.object as Body)?.id === ((both[i]!.data as Body)?.object as Body)?.id);
950
+ })();
951
+ } finally {
952
+ rx.close();
953
+ rmSync(root, { recursive: true, force: true });
954
+ }
955
+ }),
956
+
572
957
  // ── Products / Prices ─────────────────────────────────────────────────────────────
573
958
  done('stripe.products.crud', 'catalog', 'Products: create + retrieve + update + list', 'api', 'core', () =>
574
959
  withRoot(async (h) => {
@@ -592,6 +977,53 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
592
977
  return ok(g) && field(l, 'object') === 'list' && bad.status >= 400;
593
978
  }),
594
979
  ),
980
+ // Plans: the Prices API "replaces the Plans API and is backwards compatible" (docs.stripe.com/api/plans/create), so a
981
+ // plan made with the id its creator chose ("You can optionally override this ID") is a recurring price under that id:
982
+ // read as a price, found by a lookup_key set on the price, and listed, updated and deleted as a plan. "Only one of
983
+ // `amount` and `amount_decimal` can be set"; plan writes send plan.created / plan.updated / plan.deleted carrying the
984
+ // plan; and "Existing subscribers aren't affected" by a deletion (docs.stripe.com/api/plans/delete).
985
+ done('stripe.plans.crud', 'catalog', 'Plans: create (with a chosen id) + retrieve + list + update + delete, each plan the recurring price it is', 'api', 'common', () =>
986
+ withRoot(async (h) => {
987
+ const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Business' });
988
+ const plan = await h({ m: 'POST', p: '/v1/plans', b: `id=price_business_monthly&amount=9000&currency=usd&interval=month&product=${id(prod)}` });
989
+ if (!ok(plan) || field(plan, 'object') !== 'plan' || id(plan) !== 'price_business_monthly' || field(plan, 'amount') !== 9000) return false;
990
+ const asPrice = await h({ m: 'GET', p: '/v1/prices/price_business_monthly' });
991
+ const keyed = await h({ m: 'POST', p: '/v1/prices/price_business_monthly', b: 'lookup_key=business_monthly' });
992
+ const found = await h({ m: 'GET', p: '/v1/prices?lookup_keys[]=business_monthly' });
993
+ const again = await h({ m: 'POST', p: '/v1/plans', b: `id=price_business_monthly&amount=1&currency=usd&interval=month&product=${id(prod)}` });
994
+ const renamed = await h({ m: 'POST', p: '/v1/plans/price_business_monthly', b: 'nickname=Business%20monthly' });
995
+ const listed = await h({ m: 'GET', p: '/v1/plans' });
996
+ // a subscriber on the plan is not affected by its deletion: the price its item names stays, inactive
997
+ const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=plan@twin.test' });
998
+ const sub = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&items[0][price]=price_business_monthly&payment_behavior=default_incomplete` });
999
+ const gone = await h({ m: 'DELETE', p: '/v1/plans/price_business_monthly' });
1000
+ const after = await h({ m: 'GET', p: '/v1/plans/price_business_monthly' });
1001
+ const twice = await h({ m: 'DELETE', p: '/v1/plans/price_business_monthly' });
1002
+ const kept = await h({ m: 'GET', p: '/v1/prices/price_business_monthly' });
1003
+ const subAfter = await h({ m: 'GET', p: `/v1/subscriptions/${id(sub)}` });
1004
+ const item = ((field(subAfter, 'items') as Body)?.data as Body[] | undefined)?.[0];
1005
+ // amount_decimal is kept as the decimal given, and only one of amount and amount_decimal may be set
1006
+ const decimal = await h({ m: 'POST', p: '/v1/plans', b: `amount_decimal=1250.5&currency=usd&interval=month&product=${id(prod)}` });
1007
+ const both = await h({ m: 'POST', p: '/v1/plans', b: `amount=100&amount_decimal=100&currency=usd&interval=month&product=${id(prod)}` });
1008
+ // 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
1009
+ 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` });
1010
+ const decimalInvoice = await h({ m: 'GET', p: `/v1/invoices/${String(field(onDecimal, 'latest_invoice'))}` });
1011
+ // plan writes send plan events carrying the plan
1012
+ const events = (((await h({ m: 'GET', p: '/v1/events?limit=100' })).body as Body).data as Body[]) ?? [];
1013
+ const planEvent = (type: string) => events.find((e) => e.type === type && ((e.data as Body).object as Body).id === 'price_business_monthly');
1014
+ const sent = ['plan.created', 'plan.updated', 'plan.deleted'].every((t) => ((planEvent(t)?.data as Body | undefined)?.object as Body | undefined)?.object === 'plan')
1015
+ && (((planEvent('plan.created')?.data as Body).object as Body).amount === 9000);
1016
+ const data = (field(found, 'data') ?? []) as Array<{ id?: string }>;
1017
+ return ok(asPrice) && field(asPrice, 'object') === 'price' && field(asPrice, 'unit_amount') === 9000
1018
+ && ok(keyed) && data.length === 1 && data[0]!.id === 'price_business_monthly'
1019
+ && again.status === 400
1020
+ && field(renamed, 'nickname') === 'Business monthly'
1021
+ && field(listed, 'object') === 'list' && ok(gone) && field(gone, 'deleted') === true && after.status === 404 && twice.status === 404
1022
+ && ok(kept) && field(kept, 'active') === false && (item?.price as Body | undefined)?.id === 'price_business_monthly'
1023
+ && ok(decimal) && field(decimal, 'amount_decimal') === '1250.5' && field(decimal, 'amount') === null
1024
+ && both.status === 400 && sent && field(decimalInvoice, 'total') === 3752;
1025
+ }),
1026
+ ),
595
1027
  // Tiered pricing: a billing_scheme=tiered price requires tiers_mode (graduated|volume) +
596
1028
  // tiers[] (each up_to + unit_amount/flat_amount); the twin normalizes the tiers, forces
597
1029
  // unit_amount null, and round-trips them. currency_options pass through (a per-currency
@@ -671,7 +1103,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
671
1103
  done('stripe.promotion_codes.create', 'discounts', 'PromotionCode create + activate/deactivate', 'api', 'common', () =>
672
1104
  withRoot(async (h) => {
673
1105
  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` });
1106
+ const pc = await h({ m: 'POST', p: '/v1/promotion_codes', b: `promotion[type]=coupon&promotion[coupon]=${id(coupon)}&code=SAVE10` });
675
1107
  if (!ok(pc) || field(pc, 'object') !== 'promotion_code' || field(pc, 'code') !== 'SAVE10' || field(pc, 'active') !== true || field(pc, 'coupon') !== id(coupon)) return false;
676
1108
  const g = await h({ m: 'GET', p: `/v1/promotion_codes/${id(pc)}` });
677
1109
  const off = await h({ m: 'POST', p: `/v1/promotion_codes/${id(pc)}`, b: 'active=false' });
@@ -680,7 +1112,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
680
1112
  if (!ok(g) || id(g) !== id(pc) || !ok(off) || field(off, 'active') !== false || !ok(on) || field(on, 'active') !== true) return false;
681
1113
  if (((lst.body as Body).data as Body[]).length !== 1) return false;
682
1114
  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' });
1115
+ const badCoupon = await h({ m: 'POST', p: '/v1/promotion_codes', b: 'promotion[type]=coupon&promotion[coupon]=coupon_nope&code=Y' });
684
1116
  return noCoupon.status === 400 && badCoupon.status === 400 && ((badCoupon.body as Body).error as Body)?.code === 'resource_missing';
685
1117
  }),
686
1118
  ),
@@ -707,7 +1139,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
707
1139
  if (!card || card.last4 !== '4242') return false;
708
1140
  // last4 FIDELITY (not just presence): a raw test PAN must surface its TRUE last4 —
709
1141
  // 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
1142
+ // fallback. The form reader coerces an all-digit card[number] to a JS number (like
711
1143
  // any numeric-looking form field), so cover BOTH wire shapes: the numeric-coerced
712
1144
  // bare PAN and a string PAN (spaces keep it a string through the form parser).
713
1145
  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 +1217,54 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
785
1217
  const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=sub@twin.test' });
786
1218
  const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Plan' });
787
1219
  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)}` });
1220
+ // the customer's card pays the first invoice, so the subscription starts active
1221
+ const card = await h({ m: 'POST', p: '/v1/payment_methods/pm_card_visa/attach', b: `customer=${id(cust)}` });
1222
+ const sub = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&items[0][price]=${id(price)}&default_payment_method=${id(card)}` });
789
1223
  if (!ok(sub) || field(sub, 'status') !== 'active') return false;
790
1224
  const g = await h({ m: 'GET', p: `/v1/subscriptions/${id(sub)}` });
791
1225
  const cancel = await h({ m: 'DELETE', p: `/v1/subscriptions/${id(sub)}` });
792
1226
  return ok(g) && ok(cancel) && field(cancel, 'status') === 'canceled';
793
1227
  }),
794
1228
  ),
1229
+ // An update's `items` changes the subscription's items (docs.stripe.com/api/subscriptions/update): an entry with an
1230
+ // `id` swaps that item's price ("`quantity` is set to 1 unless a `quantity` parameter is provided") or its quantity, or
1231
+ // with `deleted` removes it; one without adds an item, from a price or from `price_data`, which generates a Price
1232
+ // inline, archived (active=false; docs.stripe.com/products-prices/manage-prices). A refused entry refuses the whole
1233
+ // update, leaving the items as they were. Dub's trial switch to Advanced replaced the price of its one item.
1234
+ 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', () =>
1235
+ withRoot(async (h) => {
1236
+ const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=items@twin.test' });
1237
+ const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Pro' });
1238
+ const pro = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=1000&currency=usd&recurring[interval]=month&product=${id(prod)}` });
1239
+ const advanced = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=3000&currency=usd&recurring[interval]=month&product=${id(prod)}` });
1240
+ 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` });
1241
+ const items = (r: StripeResponse): Body[] => ((field(r, 'items') as Body)?.data as Body[]) ?? [];
1242
+ const si = String(items(sub)[0]?.id);
1243
+ const upd = (b: string) => h({ m: 'POST', p: `/v1/subscriptions/${id(sub)}`, b });
1244
+ const swapped = await upd(`items[0][id]=${si}&items[0][price]=${id(advanced)}`);
1245
+ const s0 = items(swapped);
1246
+ if (s0.length !== 1 || s0[0]?.id !== si || (s0[0]?.price as Body)?.id !== id(advanced) || s0[0]?.quantity !== 1) return false;
1247
+ const more = items(await upd(`items[0][id]=${si}&items[0][quantity]=5`));
1248
+ if (more.length !== 1 || more[0]?.quantity !== 5 || (more[0]?.price as Body)?.id !== id(advanced)) return false;
1249
+ // naming the item's own price is no change of price: the quantity stays
1250
+ const same = items(await upd(`items[0][id]=${si}&items[0][price]=${id(advanced)}`));
1251
+ if (same[0]?.quantity !== 5) return false;
1252
+ 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`));
1253
+ const inline = added.find((i) => i.id !== si)?.price as Body | undefined;
1254
+ if (added.length !== 2 || inline?.unit_amount !== 250 || inline?.active !== false || inline?.product !== id(prod)) return false;
1255
+ // one bad entry refuses the update: the delete beside it is not applied
1256
+ const refused = await upd(`items[0][id]=${si}&items[0][deleted]=true&items[1][price]=price_nope`);
1257
+ const kept = items(await h({ m: 'GET', p: `/v1/subscriptions/${id(sub)}` }));
1258
+ if (refused.status !== 400 || kept.length !== 2 || !kept.some((i) => i.id === si)) return false;
1259
+ const removed = items(await upd(`items[0][id]=${si}&items[0][deleted]=true`));
1260
+ const listed = await h({ m: 'GET', p: `/v1/subscription_items?subscription=${id(sub)}` });
1261
+ // a price_data amount that is not a whole number of cents is kept as the decimal, never truncated
1262
+ 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`));
1263
+ const decimalPrice = decimal.map((i) => i.price as Body).find((p) => p?.unit_amount_decimal === '12.5');
1264
+ return removed.length === 1 && removed[0]?.id !== si && ((listed.body as Body).data as Body[]).length === 1
1265
+ && decimalPrice !== undefined && decimalPrice.unit_amount === null;
1266
+ }),
1267
+ ),
795
1268
  done('stripe.subscriptions.list', 'subscriptions', 'Subscription list + filter by customer', 'api', 'core', () =>
796
1269
  withRoot(async (h) => {
797
1270
  const r = await h({ m: 'GET', p: '/v1/subscriptions' });
@@ -810,13 +1283,207 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
810
1283
  const sub = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&items[0][price]=${id(price)}&trial_period_days=14` });
811
1284
  if (!ok(sub) || field(sub, 'status') !== 'trialing' || typeof field(sub, 'trial_end') !== 'number') return false;
812
1285
  // 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` });
1286
+ const up = await h({ m: 'POST', p: '/v1/invoices/create_preview', b: `subscription=${id(sub)}&subscription_details[proration_date]=100` });
814
1287
  if (!ok(up) || field(up, 'object') !== 'invoice' || field(up, 'id') !== null) return false;
815
1288
  const lines = (field(up, 'lines') as Body).data as Body[];
816
1289
  const prorations = lines.filter((l) => l.proration === true);
817
1290
  return prorations.length === 2 && prorations.some((l) => Number(l.amount) < 0) && prorations.some((l) => Number(l.amount) > 0);
818
1291
  }),
819
1292
  ),
1293
+ // Renewal over time: at the period's end a subscription_cycle invoice is drafted and, an hour on, charged to the
1294
+ // default payment method; a card that declines when charged leaves it open and the subscription past_due, and
1295
+ // paying it makes the subscription active (docs.stripe.com/billing/subscriptions/overview#payment-status).
1296
+ done('stripe.subscriptions.renewal', 'subscriptions', 'Subscriptions renew at each period end; a declined renewal is past_due until its invoice is paid', 'api', 'core', async () => {
1297
+ const root = mkdtempSync(join(tmpdir(), 'stp-renew-'));
1298
+ try {
1299
+ const h = (s: Step & { month?: number }) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: `2026-${String(s.month ?? 1).padStart(2, '0')}-10T00:00:00Z` });
1300
+ const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Box' });
1301
+ const price = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=3500&currency=usd&recurring[interval]=month&product=${id(prod)}` });
1302
+ 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' });
1303
+ const good = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(payer)}&items[0][price]=${id(price)}&default_payment_method=pm_card_visa` });
1304
+ const failing = await h({ m: 'POST', p: '/v1/customers', b: 'email=declines@twin.test' });
1305
+ 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` });
1306
+ const renewed = await h({ m: 'GET', p: `/v1/invoices?subscription=${id(good)}`, month: 2 });
1307
+ const cycle = ((renewed.body as Body).data as Body[]).find((i) => i.billing_reason === 'subscription_cycle');
1308
+ const pastDue = await h({ m: 'GET', p: `/v1/subscriptions/${id(bad)}`, month: 2 });
1309
+ const open = (((await h({ m: 'GET', p: `/v1/invoices?subscription=${id(bad)}&status=open`, month: 2 })).body as Body).data as Body[])[0];
1310
+ const paid = await h({ m: 'POST', p: `/v1/invoices/${String(open?.id)}/pay`, b: 'payment_method=pm_card_visa', month: 2 });
1311
+ const back = await h({ m: 'GET', p: `/v1/subscriptions/${id(bad)}`, month: 2 });
1312
+ // made when Stripe would have made it, not when the February request caught it up
1313
+ const stamped = cycle?.created === cycle?.period_start && (cycle?.status_transitions as Body | undefined)?.paid_at === Number(cycle?.period_start) + 3600;
1314
+ return stamped && cycle?.status === 'paid' && cycle.amount_paid === 3500 && field(pastDue, 'status') === 'past_due'
1315
+ && open?.attempt_count === 1 && field(paid, 'status') === 'paid' && field(back, 'status') === 'active';
1316
+ } finally {
1317
+ rmSync(root, { recursive: true, force: true });
1318
+ }
1319
+ }),
1320
+ // A `once` coupon discounts one invoice: the one the subscription finalizes next after it was applied, which records it,
1321
+ // and is then removed from the subscription's discounts (docs.stripe.com/billing/subscriptions/coupons#coupon-duration),
1322
+ // however that invoice is finalized: by time, or through the Invoices API.
1323
+ 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 () => {
1324
+ const root = mkdtempSync(join(tmpdir(), 'stp-once-'));
1325
+ try {
1326
+ 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' });
1327
+ const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Box' });
1328
+ const price = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=9000&currency=usd&recurring[interval]=month&product=${id(prod)}` });
1329
+ 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' });
1330
+ const coupon = await h({ m: 'POST', p: '/v1/coupons', b: 'id=HALF&percent_off=50&duration=once' });
1331
+ const invoices = async (sub: StripeResponse, at: string) => ((((await h({ m: 'GET', p: `/v1/invoices?subscription=${id(sub)}`, at })).body as Body).data as Body[]) ?? []);
1332
+ const discounts = (r: StripeResponse) => ((field(r, 'discounts') as unknown[] | undefined) ?? []).length;
1333
+ // added after April's renewal was drafted (00:00) and before it is collected (01:00): April is full, May is halved
1334
+ const late = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&items[0][price]=${id(price)}` });
1335
+ await h({ m: 'GET', p: `/v1/subscriptions/${id(late)}`, at: '2026-04-01T00:10:00Z' });
1336
+ await h({ m: 'POST', p: `/v1/subscriptions/${id(late)}`, b: `discounts[0][coupon]=${id(coupon)}`, at: '2026-04-01T00:20:00Z' });
1337
+ const april = (await invoices(late, '2026-04-01T02:00:00Z')).find((i) => i.period_start === Date.parse('2026-04-01T00:00:00Z') / 1000);
1338
+ const stillThere = await h({ m: 'GET', p: `/v1/subscriptions/${id(late)}`, at: '2026-04-01T02:00:00Z' });
1339
+ const may = (await invoices(late, '2026-05-01T02:00:00Z')).find((i) => i.period_start === Date.parse('2026-05-01T00:00:00Z') / 1000);
1340
+ const gone = await h({ m: 'GET', p: `/v1/subscriptions/${id(late)}`, at: '2026-05-01T02:00:00Z' });
1341
+ if (april?.total !== 9000 || discounts(stillThere) !== 1 || may?.total !== 4500 || discounts(gone) !== 0) return false;
1342
+ // the renewal's draft finalized and paid through the Invoices API spends it too: June is full again
1343
+ const other = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&items[0][price]=${id(price)}` });
1344
+ await h({ m: 'POST', p: `/v1/subscriptions/${id(other)}`, b: `discounts[0][coupon]=${id(coupon)}`, at: '2026-03-15T00:00:00Z' });
1345
+ const draft = (await invoices(other, '2026-04-01T00:10:00Z')).find((i) => i.status === 'draft');
1346
+ await h({ m: 'POST', p: `/v1/invoices/${String(draft?.id)}/finalize`, at: '2026-04-01T00:20:00Z' });
1347
+ const afterFinalize = await h({ m: 'GET', p: `/v1/subscriptions/${id(other)}`, at: '2026-04-01T00:30:00Z' });
1348
+ const mayOther = (await invoices(other, '2026-05-01T02:00:00Z')).find((i) => i.period_start === Date.parse('2026-05-01T00:00:00Z') / 1000);
1349
+ return draft?.total === 4500 && discounts(afterFinalize) === 0 && mayOther?.total === 9000;
1350
+ } finally {
1351
+ rmSync(root, { recursive: true, force: true });
1352
+ }
1353
+ }),
1354
+ // Ending a trial early: `trial_end=now` on a trialing subscription starts a paid period now, invoices it and charges it
1355
+ // at once (active; a decline leaves it past_due, error_if_incomplete refuses with 402 and changes nothing); a future
1356
+ // timestamp moves the trial's end and the period with it; a past one is refused (semantics/subscriptions.ts
1357
+ // applyTrialEnd; docs.stripe.com/api/subscriptions/update#update_subscription-trial_end,
1358
+ // docs.stripe.com/billing/subscriptions/upgrade-downgrade#immediate-payment). Read in basil's shape, as Dub reads it.
1359
+ 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 () => {
1360
+ const root = mkdtempSync(join(tmpdir(), 'stp-trialend-'));
1361
+ try {
1362
+ 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` });
1363
+ const day = (d: number) => Date.parse(`2026-03-${String(d).padStart(2, '0')}T00:00:00Z`) / 1000;
1364
+ const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Business' });
1365
+ const price = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=9000&currency=usd&recurring[interval]=month&product=${id(prod)}` });
1366
+ const trialing = async (email: string, pm: string) => {
1367
+ const c = await h({ m: 'POST', p: '/v1/customers', b: `email=${email}&payment_method=${pm}&invoice_settings[default_payment_method]=${pm}` });
1368
+ return h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(c)}&items[0][price]=${id(price)}&trial_period_days=14` });
1369
+ };
1370
+ const period = (r: StripeResponse) => ((field(r, 'items') as Body | undefined)?.data as Body[] | undefined)?.[0] ?? {};
1371
+ const sub = await trialing('trial%40twin.test', 'pm_card_visa');
1372
+ // a trial's end is its billing anchor
1373
+ if (field(sub, 'billing_cycle_anchor') !== field(sub, 'trial_end')) return false;
1374
+ // refused before anything changes: a past timestamp, a value that is no timestamp
1375
+ const past = await h({ m: 'POST', p: `/v1/subscriptions/${id(sub)}`, b: 'trial_end=1700000000', day: 3 });
1376
+ const junk = await h({ m: 'POST', p: `/v1/subscriptions/${id(sub)}`, b: 'trial_end=tomorrow', day: 3 });
1377
+ const pastError = (past.body as { error?: Body }).error;
1378
+ 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;
1379
+ // a future timestamp moves the trial's end, the period's end and the billing anchor
1380
+ const moved = await h({ m: 'POST', p: `/v1/subscriptions/${id(sub)}`, b: `trial_end=${day(20)}`, day: 3 });
1381
+ 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;
1382
+ // `now`: the trial ends, a month's period starts now and its invoice is paid by the default payment method
1383
+ const ended = await h({ m: 'POST', p: `/v1/subscriptions/${id(sub)}`, b: 'trial_end=now&cancel_at_period_end=false', day: 5 });
1384
+ const inv = await h({ m: 'GET', p: `/v1/invoices/${String(field(ended, 'latest_invoice'))}`, day: 5 });
1385
+ const invoices = (((await h({ m: 'GET', p: `/v1/invoices?customer=${String(field(sub, 'customer'))}`, day: 5 })).body as Body).data as Body[]) ?? [];
1386
+ 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);
1387
+ const endedOk = ok(ended) && field(ended, 'status') === 'active' && field(ended, 'trial_end') === day(5) && field(ended, 'billing_cycle_anchor') === day(5)
1388
+ && period(ended).current_period_start === day(5) && period(ended).current_period_end === Date.parse('2026-04-05T00:00:00Z') / 1000;
1389
+ const invoiceOk = field(inv, 'status') === 'paid' && field(inv, 'billing_reason') === 'subscription_update' && field(inv, 'amount_paid') === 9000
1390
+ && field(inv, 'period_start') === day(5) && invoices.some((i) => i.id === id(inv));
1391
+ if (!endedOk || !invoiceOk || !['customer.subscription.updated', 'invoice.created', 'invoice.paid'].every((t) => events.includes(t))) return false;
1392
+ // a cancel scheduled during the trial follows the new period; a `once` coupon added during the trial, or with the
1393
+ // update itself, takes the invoice ending it and is then spent
1394
+ const once = await h({ m: 'POST', p: '/v1/coupons', b: 'id=HALFOFF&percent_off=50&duration=once', day: 2 });
1395
+ const leaving = await trialing('leaving%40twin.test', 'pm_card_visa');
1396
+ await h({ m: 'POST', p: `/v1/subscriptions/${id(leaving)}`, b: `cancel_at_period_end=true&discounts[0][coupon]=${id(once)}`, day: 2 });
1397
+ const early = await h({ m: 'POST', p: `/v1/subscriptions/${id(leaving)}`, b: 'trial_end=now', day: 5 });
1398
+ const halved = await h({ m: 'GET', p: `/v1/invoices/${String(field(early, 'latest_invoice'))}`, day: 5 });
1399
+ const sameCall = await trialing('same%40twin.test', 'pm_card_visa');
1400
+ const withCoupon = await h({ m: 'POST', p: `/v1/subscriptions/${id(sameCall)}`, b: `trial_end=now&discounts[0][coupon]=${id(once)}`, day: 5 });
1401
+ const halvedToo = await h({ m: 'GET', p: `/v1/invoices/${String(field(withCoupon, 'latest_invoice'))}`, day: 5 });
1402
+ const spent = (r: StripeResponse) => ((field(r, 'discounts') as Body[] | undefined) ?? []).length === 0;
1403
+ if (field(early, 'cancel_at') !== period(early).current_period_end || field(early, 'cancel_at_period_end') !== true || field(halved, 'total') !== 4500
1404
+ || !spent(early) || field(halvedToo, 'total') !== 4500 || !spent(withCoupon)) return false;
1405
+ // what the update leaves bills: a quantity of 0 bills nothing (paid, active, with no card); an unknown item is refused as such
1406
+ const nobody = await h({ m: 'POST', p: '/v1/customers', b: 'email=nobody%40twin.test' });
1407
+ const idle = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(nobody)}&items[0][price]=${id(price)}&trial_period_days=14` });
1408
+ 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 });
1409
+ const zeroInvoice = await h({ m: 'GET', p: `/v1/invoices/${String(field(zero, 'latest_invoice'))}`, day: 5 });
1410
+ if (field(zero, 'status') !== 'active' || field(zeroInvoice, 'total') !== 0 || field(zeroInvoice, 'status') !== 'paid') return false;
1411
+ const idle2 = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(nobody)}&items[0][price]=${id(price)}&trial_period_days=14` });
1412
+ 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 });
1413
+ 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 });
1414
+ const idleAfter = await h({ m: 'GET', p: `/v1/subscriptions/${id(idle2)}`, day: 5 });
1415
+ if (decimal.status !== 400 || !String(((unknown.body as { error?: Body }).error)?.message).includes("No such subscription_item: 'si_nope'")
1416
+ || field(idleAfter, 'status') !== 'trialing' || period(idleAfter).id !== period(idle2).id) return false;
1417
+ // a trial made through the API opens its $0 invoice at once, which takes and spends a once coupon, as Checkout's does
1418
+ 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)}` });
1419
+ const trialInvoice = await h({ m: 'GET', p: `/v1/invoices/${String(field(withOnce, 'latest_invoice'))}` });
1420
+ if (field(trialInvoice, 'total') !== 0 || field(trialInvoice, 'status') !== 'paid' || field(trialInvoice, 'billing_reason') !== 'subscription_create'
1421
+ || ((field(trialInvoice, 'discounts') as unknown[] | undefined) ?? []).length !== 1 || !spent(withOnce)) return false;
1422
+ // and the subscription names it from its own create: the customer.subscription.created event carries it
1423
+ const createdEvent = ((((await h({ m: 'GET', p: '/v1/events?type=customer.subscription.created&limit=100' })).body as Body).data as Body[]) ?? [])
1424
+ .find((e) => ((e.data as Body).object as Body).id === id(withOnce));
1425
+ if (((createdEvent?.data as Body | undefined)?.object as Body | undefined)?.latest_invoice !== field(withOnce, 'latest_invoice')) return false;
1426
+ // a card that declines when charged: the change stands, the invoice is open and the subscription past_due
1427
+ const declining = await trialing('declines%40twin.test', 'pm_card_chargeCustomerFail');
1428
+ 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;
1429
+ const before = await invoicesOf(declining);
1430
+ const strict = await h({ m: 'POST', p: `/v1/subscriptions/${id(declining)}`, b: 'trial_end=now&payment_behavior=error_if_incomplete', day: 5 });
1431
+ const still = await h({ m: 'GET', p: `/v1/subscriptions/${id(declining)}`, day: 5 });
1432
+ if (await invoicesOf(declining) !== before) return false;
1433
+ // error_if_incomplete judges the period as the update leaves it: $0 items swapped for a paid price with no payment
1434
+ // method to charge is refused as the create refuses it (semantics/subscriptions.ts firstPaymentRefused)
1435
+ const freeProd = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=0&currency=usd&recurring[interval]=month&product=${id(prod)}` });
1436
+ const bare = await h({ m: 'POST', p: '/v1/customers', b: 'email=bare%40twin.test' });
1437
+ const free = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(bare)}&items[0][price]=${id(freeProd)}&trial_period_days=14` });
1438
+ const freeItem = String(period(free).id);
1439
+ 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 });
1440
+ const unchanged = await h({ m: 'GET', p: `/v1/subscriptions/${id(free)}`, day: 5 });
1441
+ if (upgrade.status !== 400 || field(unchanged, 'status') !== 'trialing' || (period(unchanged).price as Body | undefined)?.id !== id(freeProd)) return false;
1442
+ // default_incomplete: the period is invoiced and left open, no payment attempted
1443
+ const quiet = await trialing('quiet%40twin.test', 'pm_card_visa');
1444
+ const deferred = await h({ m: 'POST', p: `/v1/subscriptions/${id(quiet)}`, b: 'trial_end=now&payment_behavior=default_incomplete', day: 5 });
1445
+ const unpaid = await h({ m: 'GET', p: `/v1/invoices/${String(field(deferred, 'latest_invoice'))}`, day: 5 });
1446
+ if (field(deferred, 'status') !== 'past_due' || field(unpaid, 'status') !== 'open' || field(unpaid, 'attempted') !== false || field(unpaid, 'amount_paid') !== 0) return false;
1447
+ const lenient = await h({ m: 'POST', p: `/v1/subscriptions/${id(declining)}`, b: 'trial_end=now', day: 5 });
1448
+ const open = await h({ m: 'GET', p: `/v1/invoices/${String(field(lenient, 'latest_invoice'))}`, day: 5 });
1449
+ return strict.status === 402 && field(still, 'status') === 'trialing' && field(still, 'latest_invoice') === field(declining, 'latest_invoice')
1450
+ && field(lenient, 'status') === 'past_due' && field(open, 'status') === 'open' && field(open, 'amount_remaining') === 9000;
1451
+ } finally {
1452
+ rmSync(root, { recursive: true, force: true });
1453
+ }
1454
+ }),
1455
+ // Automatic reconciliation: a bank transfer arriving in the customer's cash balance pays their open invoice that takes
1456
+ // transfers (docs.stripe.com/payments/customer-balance/reconciliation).
1457
+ done('stripe.customers.cash_balance_reconciliation', 'customers', 'Cash balance funding pays an open bank-transfer invoice (automatic reconciliation)', 'api', 'common', () =>
1458
+ withRoot(async (h) => {
1459
+ const cust = await h({ m: 'POST', p: '/v1/customers', b: 'name=Cowork&email=ap@cowork.test' });
1460
+ 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` });
1461
+ await h({ m: 'POST', p: '/v1/invoiceitems', b: `customer=${id(cust)}&invoice=${id(inv)}&amount=24000&currency=usd` });
1462
+ await h({ m: 'POST', p: `/v1/invoices/${id(inv)}/finalize` });
1463
+ await h({ m: 'POST', p: `/v1/test_helpers/customers/${id(cust)}/fund_cash_balance`, b: 'amount=24000&currency=usd' });
1464
+ const paid = await h({ m: 'GET', p: `/v1/invoices/${id(inv)}` });
1465
+ const bal = await h({ m: 'GET', p: `/v1/customers/${id(cust)}/cash_balance` });
1466
+ return field(paid, 'status') === 'paid' && field(paid, 'amount_paid') === 24000 && ((field(bal, 'available') as Body | null)?.usd ?? 0) === 0;
1467
+ }),
1468
+ ),
1469
+ // A subscription set to cancel at its period's end is canceled when the period ends, with no renewal invoice
1470
+ // (docs.stripe.com/billing/subscriptions/cancel#cancel-at-end-of-cycle).
1471
+ done('stripe.subscriptions.cancel_at_period_end', 'subscriptions', 'cancel_at_period_end cancels the subscription when its period ends', 'api', 'common', async () => {
1472
+ const root = mkdtempSync(join(tmpdir(), 'stp-cape-'));
1473
+ try {
1474
+ const h = (s: Step & { month?: number }) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: `2026-${String(s.month ?? 1).padStart(2, '0')}-10T00:00:00Z` });
1475
+ const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Box' });
1476
+ const price = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=3500&currency=usd&recurring[interval]=month&product=${id(prod)}` });
1477
+ const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=leaving@twin.test' });
1478
+ const sub = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&items[0][price]=${id(price)}&default_payment_method=pm_card_visa` });
1479
+ const set = await h({ m: 'POST', p: `/v1/subscriptions/${id(sub)}`, b: 'cancel_at_period_end=true' });
1480
+ const after = await h({ m: 'GET', p: `/v1/subscriptions/${id(sub)}`, month: 3 });
1481
+ const invoices = ((await h({ m: 'GET', p: `/v1/invoices?subscription=${id(sub)}`, month: 3 })).body as Body).data as Body[];
1482
+ return typeof field(set, 'cancel_at') === 'number' && field(after, 'status') === 'canceled' && typeof field(after, 'ended_at') === 'number' && invoices.length === 1;
1483
+ } finally {
1484
+ rmSync(root, { recursive: true, force: true });
1485
+ }
1486
+ }),
820
1487
  // Pause/resume: set pause_collection[behavior]=void → the sub records pause_collection (and
821
1488
  // the customer.subscription.paused event fires); clearing it (pause_collection="") resumes
822
1489
  // (pause_collection null). cancel_at_period_end toggles a scheduled cancel and reactivation.
@@ -894,12 +1561,12 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
894
1561
  withRoot(async (h) => {
895
1562
  const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=disc@twin.test' });
896
1563
  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)}` });
1564
+ const sub = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&discounts[0][coupon]=${id(coupon)}` });
898
1565
  if (!ok(sub)) return false;
899
1566
  const discs = (field(sub, 'discounts') as Body[]) ?? [];
900
1567
  if (discs.length !== 1 || discs[0]!.object !== 'discount' || (discs[0]!.coupon as Body)?.id !== id(coupon)) return false;
901
1568
  // unknown coupon on a fresh sub 400s.
902
- const bad = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&coupon=coupon_nope` });
1569
+ const bad = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&discounts[0][coupon]=coupon_nope` });
903
1570
  if (bad.status !== 400) return false;
904
1571
  // remove the discount.
905
1572
  const del = await h({ m: 'DELETE', p: `/v1/subscriptions/${id(sub)}/discount` });
@@ -945,12 +1612,35 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
945
1612
  return ok(payGood) && field(payGood, 'status') === 'paid';
946
1613
  }),
947
1614
  ),
948
- done('stripe.invoiceitems.crud', 'invoices', 'InvoiceItems: create + list', 'api', 'core', () =>
1615
+ done('stripe.invoiceitems.crud', 'invoices', 'InvoiceItems: create + retrieve + update + list + delete', 'api', 'core', () =>
949
1616
  withRoot(async (h) => {
950
1617
  const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=ii@twin.test' });
951
1618
  const ii = await h({ m: 'POST', p: '/v1/invoiceitems', b: `customer=${id(cust)}&amount=400&currency=usd` });
952
1619
  const l = await h({ m: 'GET', p: '/v1/invoiceitems' });
953
- return ok(ii) && field(ii, 'object') === 'invoiceitem' && field(l, 'object') === 'list';
1620
+ if (!ok(ii) || field(ii, 'object') !== 'invoiceitem' || field(l, 'object') !== 'list') return false;
1621
+ // an item is reachable by the id its own create handed back
1622
+ const g = await h({ m: 'GET', p: `/v1/invoiceitems/${id(ii)}` });
1623
+ const miss = await h({ m: 'GET', p: '/v1/invoiceitems/ii_nope' });
1624
+ if (!ok(g) || id(g) !== id(ii) || miss.status !== 404) return false;
1625
+ // The earlier pending 400 is swept onto the invoice at creation.
1626
+ // An item on a DRAFT invoice moves the invoice's total when it changes, and again when
1627
+ // it is deleted — an invoice billing for a line that no longer says that is money in
1628
+ // two places at once.
1629
+ const inv = await h({ m: 'POST', p: '/v1/invoices', b: `customer=${id(cust)}` });
1630
+ const on = await h({ m: 'POST', p: '/v1/invoiceitems', b: `customer=${id(cust)}&invoice=${id(inv)}&amount=900&currency=usd` });
1631
+ const u = await h({ m: 'POST', p: `/v1/invoiceitems/${id(on)}`, b: 'amount=1500' });
1632
+ const afterUpdate = await h({ m: 'GET', p: `/v1/invoices/${id(inv)}` });
1633
+ if (!ok(u) || field(afterUpdate, 'amount_due') !== 1900) return false;
1634
+ const del = await h({ m: 'DELETE', p: `/v1/invoiceitems/${id(on)}` });
1635
+ const afterDelete = await h({ m: 'GET', p: `/v1/invoices/${id(inv)}` });
1636
+ const gone = await h({ m: 'GET', p: `/v1/invoiceitems/${id(on)}` });
1637
+ // and once the invoice is finalized its items are no longer deletable, like Stripe
1638
+ const inv2 = await h({ m: 'POST', p: '/v1/invoices', b: `customer=${id(cust)}` });
1639
+ const locked = await h({ m: 'POST', p: '/v1/invoiceitems', b: `customer=${id(cust)}&invoice=${id(inv2)}&amount=700&currency=usd` });
1640
+ await h({ m: 'POST', p: `/v1/invoices/${id(inv2)}/finalize` });
1641
+ const refused = await h({ m: 'DELETE', p: `/v1/invoiceitems/${id(locked)}` });
1642
+ return ok(del) && field(del, 'deleted') === true && field(afterDelete, 'amount_due') === 400
1643
+ && gone.status === 404 && refused.status === 400;
954
1644
  }),
955
1645
  ),
956
1646
  // Invoice send / mark_uncollectible / pay-out-of-band: send auto-finalizes a draft (→ open);
@@ -984,14 +1674,14 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
984
1674
  const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Plan' });
985
1675
  const price = await h({ m: 'POST', p: `/v1/prices`, b: `unit_amount=3000&currency=usd&recurring[interval]=month&product=${id(prod)}` });
986
1676
  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)}` });
1677
+ const sub = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&items[0][price]=${id(price)}&discounts[0][coupon]=${id(coupon)}` });
1678
+ const up = await h({ m: 'POST', p: '/v1/invoices/create_preview', b: `subscription=${id(sub)}` });
989
1679
  if (!ok(up) || field(up, 'object') !== 'invoice' || field(up, 'id') !== null) return false;
990
1680
  // subtotal 3000, 10% off → total 2700.
991
1681
  if (field(up, 'subtotal') !== 3000 || field(up, 'total') !== 2700) return false;
992
1682
  const lines = (field(up, 'lines') as Body).data as Body[];
993
1683
  if (lines.length !== 1 || lines[0]!.amount !== 3000) return false;
994
- const noCust = await h({ m: 'GET', p: '/v1/invoices/upcoming?customer=cus_nope' });
1684
+ const noCust = await h({ m: 'POST', p: '/v1/invoices/create_preview', b: 'customer=cus_nope' });
995
1685
  return noCust.status === 404;
996
1686
  }),
997
1687
  ),
@@ -1040,6 +1730,20 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1040
1730
  return ok(g) && ok(li) && field(li, 'object') === 'list' && ok(exp);
1041
1731
  }),
1042
1732
  ),
1733
+ // A Checkout line item's description: "An arbitrary string attached to the object. Often useful for displaying to users.
1734
+ // Defaults to product name." (spec/openapi.json.gz, the `item` schema). The session create's line_items take no
1735
+ // description of their own (price, price_data, quantity, adjustable_quantity, metadata, tax_rates), so the name is the
1736
+ // price's product's, or the product_data an inline price names. Dub's Checkout showed its Business plan as "Item".
1737
+ 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', () =>
1738
+ withRoot(async (h) => {
1739
+ const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Business' });
1740
+ const price = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=9000&currency=usd&product=${id(prod)}` });
1741
+ 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` });
1742
+ const items = (((await h({ m: 'GET', p: `/v1/checkout/sessions/${id(cs)}/line_items` })).body as Body).data as Body[]) ?? [];
1743
+ return items.length === 2 && items[0]?.description === 'Business' && items[0]?.amount_total === 18000
1744
+ && items[1]?.description === 'Setup fee' && items[1]?.amount_total === 100;
1745
+ }),
1746
+ ),
1043
1747
  // Completion copies the session's create-only subscription_data onto the created
1044
1748
  // subscription (metadata is how apps bind the checkout attempt → subscription; trial
1045
1749
  // becomes a real trialing window) — and the Session itself never echoes the param.
@@ -1048,7 +1752,8 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1048
1752
  const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=subdata@twin.test' });
1049
1753
  const cs = await h({ m: 'POST', p: '/v1/checkout/sessions', b: `mode=subscription&success_url=https://x.test&customer=${id(cust)}&line_items[0][price_data][currency]=usd&line_items[0][price_data][unit_amount]=999&line_items[0][quantity]=1&subscription_data[metadata][attemptId]=att_cap_1&subscription_data[trial_period_days]=7` });
1050
1754
  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' });
1755
+ await h({ m: 'POST', p: `/c/pay/${id(cs)}`, b: 'email=pay%40twin.test&cardNumber=4242424242424242&cardExpiry=12%2F34&cardCvc=123&billingName=Twin&billingCountry=US&billingPostalCode=94105' });
1756
+ const completed = await h({ m: 'GET', p: `/v1/checkout/sessions/${id(cs)}` });
1052
1757
  if (!ok(completed) || !field(completed, 'subscription')) return false;
1053
1758
  const sub = await h({ m: 'GET', p: `/v1/subscriptions/${field(completed, 'subscription')}` });
1054
1759
  const md = field(sub, 'metadata') as Body | undefined;
@@ -1056,13 +1761,219 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1056
1761
  && typeof field(sub, 'trial_end') === 'number' && field(sub, 'trial_end') === field(sub, 'current_period_end');
1057
1762
  }),
1058
1763
  ),
1764
+ // A subscription session's customer pays its first invoice on the page: the invoice is paid through a succeeded
1765
+ // PaymentIntent and its charge, and the card is the subscription's default payment method
1766
+ // (docs.stripe.com/payments/checkout/how-checkout-works).
1767
+ 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', () =>
1768
+ withRoot(async (h) => {
1769
+ const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Box' });
1770
+ const price = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=3500&currency=usd&product=${id(prod)}&recurring[interval]=month` });
1771
+ 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` });
1772
+ 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' });
1773
+ const done = await h({ m: 'GET', p: `/v1/checkout/sessions/${id(cs)}` });
1774
+ const sub = await h({ m: 'GET', p: `/v1/subscriptions/${String(field(done, 'subscription'))}` });
1775
+ const inv = await h({ m: 'GET', p: `/v1/invoices/${String(field(done, 'invoice'))}` });
1776
+ const pi = await h({ m: 'GET', p: `/v1/payment_intents/${String(field(inv, 'payment_intent'))}` });
1777
+ return field(sub, 'status') === 'active' && typeof field(sub, 'default_payment_method') === 'string'
1778
+ && field(inv, 'status') === 'paid' && field(inv, 'billing_reason') === 'subscription_create' && field(inv, 'amount_paid') === 3500
1779
+ && field(pi, 'status') === 'succeeded' && typeof field(pi, 'latest_charge') === 'string';
1780
+ }),
1781
+ ),
1782
+ // Checkout's expiry field takes the digits typed straight through, as Stripe's formats `1230` into `12 / 30`: the page
1783
+ // marks the field for its formatter, and the posted digits pay and are the card's expiry (screens/checkout.tsx
1784
+ // parseExpiry); two digits are still incomplete.
1785
+ 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 () => {
1786
+ const root = mkdtempSync(join(tmpdir(), 'stp-expiry-'));
1787
+ try {
1788
+ const f = createStripeTwinFetch({ root, clock: () => '2026-03-01T00:00:00Z' });
1789
+ const api = async (method: string, path: string, body?: string) => (await f(new Request(`https://api.stripe.com${path}`, { method, headers: { 'content-type': 'application/x-www-form-urlencoded' }, ...(body ? { body } : {}) }))).json() as Promise<Body>;
1790
+ const pay = (cs: Body, expiry: string) => f(new Request(String(cs.url), { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ email: 'pay@twin.test', cardNumber: '4242424242424242', cardExpiry: expiry, cardCvc: '123', billingName: 'Twin', billingCountry: 'US', billingPostalCode: '94105' }).toString() }));
1791
+ const session = () => api('POST', '/v1/checkout/sessions', 'mode=payment&success_url=https://x.test&line_items[0][price_data][currency]=usd&line_items[0][price_data][unit_amount]=2500&line_items[0][price_data][product_data][name]=Pro&line_items[0][quantity]=1');
1792
+ const cs = await session();
1793
+ const html = await (await f(new Request(String(cs.url)))).text();
1794
+ const script = /<script>([\s\S]*?)<\/script>/.exec(html)?.[1] ?? '';
1795
+ const marked = /<input[^>]*id="cardExpiry"[^>]*data-format="card-expiry"/.test(html) && script === FORMAT_SCRIPT;
1796
+ // the page's own script, run over a field as a person types, deletes, pastes or fills it
1797
+ const typed = expiryField(script);
1798
+ const formats = typed.type('1230') === '12 / 30' && expiryField(script).type('5') === '05 / ' && expiryField(script).type('1/3') === '01 / 3'
1799
+ && expiryField(script).fill('12/2030') === '12 / 30' && expiryField(script).fill('122030') === '12 / 30'
1800
+ && (typed.back(), typed.back()) === '12 / ' && typed.back() === '12' && typed.back() === '1';
1801
+ const short = await pay(cs, '12');
1802
+ const shortText = await short.text();
1803
+ const paid = await pay(cs, '1230');
1804
+ const done = await api('GET', `/v1/checkout/sessions/${String(cs.id)}`);
1805
+ const pi = await api('GET', `/v1/payment_intents/${String(done.payment_intent)}`);
1806
+ const card = (await api('GET', `/v1/payment_methods/${String(pi.payment_method)}`)).card as Body | undefined;
1807
+ // a first digit above 1 is the month on its own: 530 is May 2030
1808
+ const other = await session();
1809
+ const may = await pay(other, '530');
1810
+ 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;
1811
+ return marked && formats && short.status === 402 && shortText.includes('expiration date is incomplete')
1812
+ && paid.status === 303 && done.status === 'complete' && card?.exp_month === 12 && card?.exp_year === 2030
1813
+ && may.status === 303 && pmMay?.exp_month === 5 && pmMay?.exp_year === 2030;
1814
+ } finally {
1815
+ rmSync(root, { recursive: true, force: true });
1816
+ }
1817
+ }),
1818
+ // A subscription session with a trial shows the trial as Checkout does: "14 days free", "Then $90.00 per month", a
1819
+ // "Start trial" button and, under it, when the trial's end charges; one without a trial says Subscribe and its amount
1820
+ // (screens/checkout.tsx trialSummary). The amount and date come from the seeded price and the session's trial.
1821
+ 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 () => {
1822
+ const root = mkdtempSync(join(tmpdir(), 'stp-trialpage-'));
1823
+ try {
1824
+ const f = createStripeTwinFetch({ root, clock: () => '2026-03-01T00:00:00Z' });
1825
+ const api = async (method: string, path: string, body?: string) => (await f(new Request(`https://api.stripe.com${path}`, { method, headers: { 'content-type': 'application/x-www-form-urlencoded' }, ...(body ? { body } : {}) }))).json() as Promise<Body>;
1826
+ const text = async (cs: Body) => (await (await f(new Request(String(cs.url)))).text()).replace(/<script[\s\S]*?<\/script>/g, '').replace(/<[^>]+>/g, '|');
1827
+ const prod = await api('POST', '/v1/products', 'name=Business');
1828
+ const price = await api('POST', '/v1/prices', `unit_amount=9000&currency=usd&recurring[interval]=month&product=${String(prod.id)}`);
1829
+ const yearly = await api('POST', '/v1/prices', `unit_amount=90000&currency=usd&recurring[interval]=year&product=${String(prod.id)}`);
1830
+ 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`;
1831
+ const trial = await text(await api('POST', '/v1/checkout/sessions', `${line(price)}&subscription_data[trial_period_days]=14`));
1832
+ // a trial set by its end: 2026-03-08 is a week on
1833
+ const byEnd = await text(await api('POST', '/v1/checkout/sessions', `${line(yearly)}&subscription_data[trial_end]=${Date.parse('2026-03-08T00:00:00Z') / 1000}`));
1834
+ const plain = await text(await api('POST', '/v1/checkout/sessions', line(price)));
1835
+ // after the trial a `once` coupon is spent (the trial's $0 invoice took it) and a one-time item is not billed again;
1836
+ // a `forever` coupon still applies
1837
+ const once = await api('POST', '/v1/coupons', 'id=ONCE50&percent_off=50&duration=once');
1838
+ const forever = await api('POST', '/v1/coupons', 'id=EVER10&percent_off=10&duration=forever');
1839
+ const setup = await api('POST', '/v1/prices', `unit_amount=2500&currency=usd&product=${String(prod.id)}`);
1840
+ const spent = await text(await api('POST', '/v1/checkout/sessions', `${line(price)}&subscription_data[trial_period_days]=14&discounts[0][coupon]=${String(once.id)}&line_items[1][price]=${String(setup.id)}&line_items[1][quantity]=1`));
1841
+ const kept = await text(await api('POST', '/v1/checkout/sessions', `${line(price)}&subscription_data[trial_period_days]=14&discounts[0][coupon]=${String(forever.id)}`));
1842
+ // Checkout refuses a trial_end under 48 hours away
1843
+ const soon = await f(new Request('https://api.stripe.com/v1/checkout/sessions', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: `${line(price)}&subscription_data[trial_end]=${Date.parse('2026-03-02T12:00:00Z') / 1000}` }));
1844
+ if (!spent.includes('|Then $90.00 per month|') || !kept.includes('|Then $81.00 per month|') || soon.status !== 400) return false;
1845
+ // and the twin bills what the page says: the one-time price on the first invoice, then $90.00 a period
1846
+ const paid = await api('POST', '/v1/checkout/sessions', `${line(price)}&subscription_data[trial_period_days]=14&line_items[1][price]=${String(setup.id)}&line_items[1][quantity]=1`);
1847
+ await f(new Request(String(paid.url), { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'email=t%40twin.test&cardNumber=4242424242424242&cardExpiry=1230&cardCvc=123&billingName=T&billingCountry=US&billingPostalCode=94105' }));
1848
+ const later = createStripeTwinFetch({ root, clock: () => '2026-03-20T00:00:00Z' });
1849
+ const read = async (path: string) => (await later(new Request(`https://api.stripe.com${path}`))).json() as Promise<Body>;
1850
+ const done = await read(`/v1/checkout/sessions/${String(paid.id)}`);
1851
+ const first = await read(`/v1/invoices/${String(done.invoice)}`);
1852
+ const bills = ((await read(`/v1/invoices?subscription=${String(done.subscription)}`)).data as Body[] | undefined) ?? [];
1853
+ const cycle = bills.find((b) => b.billing_reason === 'subscription_cycle');
1854
+ if (first.total !== 2500 || first.status !== 'paid' || cycle?.total !== 9000) return false;
1855
+ // the same sale in inline price_data: the page and the invoices agree ($25 now, $90 a period)
1856
+ const inlineLines = 'mode=subscription&success_url=https://x.test&customer_email=t%40twin.test&subscription_data[trial_period_days]=14'
1857
+ + `&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`
1858
+ + '&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';
1859
+ const inlineCs = await api('POST', '/v1/checkout/sessions', inlineLines);
1860
+ const inlinePage = await text(inlineCs);
1861
+ 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' }));
1862
+ const inlineDone = await read(`/v1/checkout/sessions/${String(inlineCs.id)}`);
1863
+ const inlineFirst = await read(`/v1/invoices/${String(inlineDone.invoice)}`);
1864
+ const inlineBills = ((await read(`/v1/invoices?subscription=${String(inlineDone.subscription)}`)).data as Body[] | undefined) ?? [];
1865
+ if (!inlinePage.includes('|Then $90.00 per month|') || inlineFirst.total !== 2500 || inlineBills.find((b) => b.billing_reason === 'subscription_cycle')?.total !== 9000) return false;
1866
+ return trial.includes('|14 days free|') && trial.includes('|Then $90.00 per month|') && trial.includes('|Start trial|')
1867
+ && trial.includes('After your trial ends, you will be charged $90.00 per month starting March 15, 2026. You can always cancel before then.')
1868
+ && !trial.includes('|Subscribe|')
1869
+ && byEnd.includes('|7 days free|') && byEnd.includes('|Then $900.00 per year|') && byEnd.includes('starting March 8, 2026')
1870
+ && plain.includes('|Subscribe|') && plain.includes('|$90.00|') && !plain.includes('days free') && !plain.includes('Start trial');
1871
+ } finally {
1872
+ rmSync(root, { recursive: true, force: true });
1873
+ }
1874
+ }),
1875
+ // A session's promotion code discounts it and the subscription it makes; an unknown code and an expired one are
1876
+ // refused at create (docs.stripe.com/api/checkout/sessions/create#create_checkout_session-discounts).
1877
+ done('stripe.checkout.promotion_codes', 'checkout', 'Checkout discounts[].promotion_code: applied, or refused when unknown or expired', 'api', 'common', async () => {
1878
+ const root = mkdtempSync(join(tmpdir(), 'stp-promo-'));
1879
+ try {
1880
+ 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` });
1881
+ const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Box' });
1882
+ const price = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=3500&currency=usd&recurring[interval]=month&product=${id(prod)}` });
1883
+ const coupon = await h({ m: 'POST', p: '/v1/coupons', b: 'percent_off=50&duration=once' });
1884
+ const code = await h({ m: 'POST', p: '/v1/promotion_codes', b: `promotion[type]=coupon&promotion[coupon]=${id(coupon)}&code=SPRING&expires_at=1780272000` });
1885
+ 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 });
1886
+ const good = await make(2, id(code));
1887
+ const unknown = await make(2, 'promo_nope');
1888
+ const late = await make(30, id(code));
1889
+ const lateOk = late.status === 200;
1890
+ 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' });
1891
+ return field(good, 'amount_total') === 1750 && unknown.status === 400 && lateOk && june.status === 400 && ((june.body as Body).error as Body)?.code === 'coupon_expired';
1892
+ } finally {
1893
+ rmSync(root, { recursive: true, force: true });
1894
+ }
1895
+ }),
1896
+ // A payment session's payment_intent_data makes a destination charge: the payment transfers to the connected account
1897
+ // less the platform's application fee (docs.stripe.com/connect/destination-charges).
1898
+ done('stripe.checkout.destination_charge', 'checkout', 'Checkout payment_intent_data: destination charge with an application fee', 'api', 'common', () =>
1899
+ withRoot(async (h) => {
1900
+ const seller = await onboardedSeller(h);
1901
+ 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}` });
1902
+ 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' });
1903
+ const done = await h({ m: 'GET', p: `/v1/checkout/sessions/${id(cs)}?expand[]=payment_intent.latest_charge` });
1904
+ const charge = ((field(done, 'payment_intent') as Body)?.latest_charge ?? {}) as Body;
1905
+ const transfer = await h({ m: 'GET', p: `/v1/transfers/${String(charge.transfer)}` });
1906
+ return field(transfer, 'destination') === seller && field(transfer, 'amount') === 4250 && charge.application_fee_amount === 750;
1907
+ }),
1908
+ ),
1909
+ // Refunding a destination charge with reverse_transfer and refund_application_fee reverses the transfer and refunds the
1910
+ // application fee (docs.stripe.com/connect/destination-charges#issue-refunds).
1911
+ done('stripe.refunds.reverse_transfer', 'payments', 'Refund reverse_transfer + refund_application_fee on a destination charge', 'api', 'common', () =>
1912
+ withRoot(async (h) => {
1913
+ const seller = await onboardedSeller(h);
1914
+ 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}` });
1915
+ const refund = await h({ m: 'POST', p: '/v1/refunds', b: `payment_intent=${id(pi)}&reverse_transfer=true&refund_application_fee=true` });
1916
+ const fees = ((await h({ m: 'GET', p: '/v1/application_fees' })).body as Body).data as Body[];
1917
+ const reversal = typeof field(refund, 'transfer_reversal') === 'string';
1918
+ return ok(refund) && reversal && field(refund, 'reverse_transfer') === undefined && fees[0]?.refunded === true && fees[0]?.amount_refunded === 750;
1919
+ }),
1920
+ ),
1921
+ // Hosted Express onboarding: an Account Link is a single-use entry point to the onboarding session
1922
+ // (docs.stripe.com/connect/hosted-onboarding), which collects what a US account owes before card payments
1923
+ // (docs.stripe.com/connect/required-verification-information: eighteen requirements for an individual), the business, its representative and the payout bank account, refuses a
1924
+ // submit that leaves one out, and stores what it was given on the account (docs.stripe.com/connect/express-accounts).
1925
+ done('stripe.connect.hosted_onboarding', 'connect', 'Hosted onboarding collects and stores the business, representative and bank account', 'api', 'common', async () => {
1926
+ const root = mkdtempSync(join(tmpdir(), 'stp-onb-'));
1927
+ try {
1928
+ const h = (s: Step) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root });
1929
+ const acct = await h({ m: 'POST', p: '/v1/accounts', b: 'type=express&country=US&business_type=individual&email=grower@twin.test' });
1930
+ const owed = (((field(acct, 'requirements') as Body)?.currently_due as string[]) ?? []).length;
1931
+ 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` });
1932
+ // visiting the link consumes it and opens the onboarding session; a second visit is sent to refresh_url
1933
+ const url = String(field(link, 'url'));
1934
+ const visited = await stripeOnboardingFlow({ root })(new Request(url));
1935
+ const session = visited?.headers.get('location') ?? '';
1936
+ const again = await stripeOnboardingFlow({ root })(new Request(url));
1937
+ // the page offers Stripe's supported industries (a creator or marketer finds Advertising Services) and every state
1938
+ const shown = await (await stripeOnboardingFlow({ root })(new Request(session)))?.text() ?? '';
1939
+ const offers = shown.includes('value="7311"') && shown.includes('Advertising Services') && shown.includes('value="5815"')
1940
+ && shown.includes('value="WY"') && shown.includes('value="DC"') && !shown.includes('value="5967"')
1941
+ && (shown.match(/<option /g) ?? []).length >= 256 + 51;
1942
+ // no industry is chosen for the person, and a padded code is kept as the code
1943
+ const unchosen = shown.includes('<select id="mcc" name="mcc"><option value="" selected="">Select your industry</option>');
1944
+ // an account the platform gave an industry the page offers opens on it; one it does not offer, on no industry
1945
+ const opened = async (mcc: string): Promise<string> => {
1946
+ const a = await h({ m: 'POST', p: '/v1/accounts', b: `type=express&country=US&business_type=individual&business_profile[mcc]=${mcc}` });
1947
+ 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` });
1948
+ const s = (await stripeOnboardingFlow({ root })(new Request(String(field(l, 'url')))))?.headers.get('location') ?? '';
1949
+ return await (await stripeOnboardingFlow({ root })(new Request(s)))?.text() ?? '';
1950
+ };
1951
+ const prefilled = (await opened('7311')).includes('<option value="7311" selected="">') && (await opened('5912')).includes('<option value="" selected="">Select your industry</option>');
1952
+ // an industry the page does not offer (a restricted code) leaves the MCC due
1953
+ const unlisted = currentlyDue('individual', { mcc: '5967' }).includes('business_profile.mcc') && !currentlyDue('individual', { mcc: '7311' }).includes('business_profile.mcc');
1954
+ const partial = await hostedSubmit(root, stripeOnboardingFlow, session, { business_type: 'individual', first_name: 'Ada' });
1955
+ const full = await hostedSubmit(root, stripeOnboardingFlow, session, {
1956
+ mcc: ' 5261 ', url: 'https://grower.test', statement_descriptor: 'GROWER TEST', line1: 'address_full_match', city: 'Portland', state: 'OR', postal_code: '97201',
1957
+ 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',
1958
+ });
1959
+ const after = await h({ m: 'GET', p: `/v1/accounts/${id(acct)}` });
1960
+ const banks = await h({ m: 'GET', p: `/v1/accounts/${id(acct)}/external_accounts` });
1961
+ 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
1962
+ && session.startsWith('https://connect.stripe.com/setup/s/') && again?.headers.get('location') === 'https://x.test/r'
1963
+ && partial?.status === 400 && full?.status === 302 && field(after, 'details_submitted') === true
1964
+ && ((field(after, 'individual') as Body)?.last_name === 'Grower') && ((banks.body as Body).data as Body[])[0]?.last4 === '6789';
1965
+ } finally {
1966
+ rmSync(root, { recursive: true, force: true });
1967
+ }
1968
+ }),
1059
1969
  // Completing a session fires checkout.session.completed (data.object = the completed
1060
1970
  // Session) and stores it in the Events API — the event most billing flows fulfill from.
1061
1971
  done('stripe.checkout.completed_event', 'checkout', 'Checkout completion emits checkout.session.completed with the session payload', 'api', 'core', () =>
1062
1972
  withRoot(async (h) => {
1063
1973
  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
1974
  if (!ok(cs)) return false;
1065
- const completed = await h({ m: 'POST', p: `/v1/checkout/sessions/${id(cs)}`, b: 'status=complete' });
1975
+ 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' });
1976
+ const completed = await h({ m: 'GET', p: `/v1/checkout/sessions/${id(cs)}` });
1066
1977
  if (!ok(completed) || field(completed, 'status') !== 'complete') return false;
1067
1978
  const ev = await h({ m: 'GET', p: '/v1/events?type=checkout.session.completed' });
1068
1979
  const rows = ((ev.body as Body)?.data ?? []) as Body[];
@@ -1071,6 +1982,105 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1071
1982
  && payload?.id === id(cs) && payload?.status === 'complete' && payload?.payment_status === 'paid';
1072
1983
  }),
1073
1984
  ),
1985
+ // Stripe.js (stripe-js.ts): the @stripe/stripe-js loader injects or accepts a script whose src matches
1986
+ // `/^https:\/\/js\.stripe\.com\/v3\/?(\?.*)?$/` or `/^https:\/\/js\.stripe\.com\/(v3|[a-z]+)\/stripe\.js(\?.*)?$/`
1987
+ // (@stripe/stripe-js 7.x dist/index.mjs) and reads `Stripe.version` (3 for v3, else the train's name). Each such path the
1988
+ // pack's js.stripe.com host claims is served a script defining Stripe with that version, and redirectToCheckout sends
1989
+ // the page to the session's hosted page, checkout.stripe.com/c/pay/{id}.
1990
+ 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 () => {
1991
+ const root = mkdtempSync(join(tmpdir(), 'stp-js-'));
1992
+ try {
1993
+ const f = createStripeTwinFetch({ root });
1994
+ const host = pack.hosts?.find((x) => 'host' in x && x.host === 'js.stripe.com');
1995
+ const claims = (p: string) => !!host?.pathPattern && new RegExp(host.pathPattern).test(p);
1996
+ const load = async (p: string): Promise<{ version: unknown; opened: string[] } | undefined> => {
1997
+ const res = await f(new Request(`https://js.stripe.com${p}`));
1998
+ if (res.status !== 200 || !claims(p)) return undefined;
1999
+ const opened: string[] = [];
2000
+ const win: Record<string, unknown> = { location: { assign: (u: string) => opened.push(u) } };
2001
+ new Function('window', await res.text())(win);
2002
+ const Stripe = win.Stripe as ((key: string) => { redirectToCheckout: (o: { sessionId: string }) => unknown }) & { version: unknown };
2003
+ void Stripe('pk_test_twin').redirectToCheckout({ sessionId: 'cs_test_1' });
2004
+ return { version: Stripe.version, opened };
2005
+ };
2006
+ const [v3, v3slash, v3file, basil] = [await load('/v3'), await load('/v3/'), await load('/v3/stripe.js'), await load('/basil/stripe.js')];
2007
+ const other = await f(new Request('https://js.stripe.com/v3/other.js'));
2008
+ return [v3, v3slash, v3file].every((x) => x?.version === 3) && basil?.version === 'basil'
2009
+ && [v3, v3slash, v3file, basil].every((x) => x?.opened.length === 1 && x.opened[0] === 'https://checkout.stripe.com/c/pay/cs_test_1')
2010
+ && other.status === 404;
2011
+ } finally {
2012
+ rmSync(root, { recursive: true, force: true });
2013
+ }
2014
+ }),
2015
+ // The name customers see: the operator saves the business name on the Dashboard's Public details page
2016
+ // (dashboard.stripe.com/settings/public), which is the platform account's business_profile.name ("The customer-facing
2017
+ // business name", docs.stripe.com/api/accounts/object), and Checkout and the customer portal show it ("You can change a
2018
+ // Checkout page's name by modifying the Business name field", docs.stripe.com/payments/checkout/customization/appearance).
2019
+ 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 () => {
2020
+ const root = mkdtempSync(join(tmpdir(), 'stp-public-'));
2021
+ try {
2022
+ const f = createStripeTwinFetch({ root });
2023
+ const api = async (method: string, path: string, body?: string, account?: string) => (await f(new Request(`https://api.stripe.com${path}`, { method, headers: { 'content-type': 'application/x-www-form-urlencoded', ...(account ? { 'stripe-account': account } : {}) }, ...(body ? { body } : {}) }))).json() as Promise<Body>;
2024
+ const page = async (url: string) => (await f(new Request(url))).text();
2025
+ const save = (name: string) => f(new Request('https://dashboard.stripe.com/settings/public', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ business_name: name }).toString() }));
2026
+ 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');
2027
+ const before = await page(`https://checkout.stripe.com/c/pay/${String(cs.id)}`);
2028
+ const empty = await save(' ');
2029
+ const saved = await save('Dub');
2030
+ const form = await page('https://dashboard.stripe.com/settings/public');
2031
+ const account = await api('GET', '/v1/account');
2032
+ const checkout = await page(`https://checkout.stripe.com/c/pay/${String(cs.id)}`);
2033
+ const cust = await api('POST', '/v1/customers', 'email=public@twin.test');
2034
+ const bps = await api('POST', '/v1/billing_portal/sessions', `customer=${String(cust.id)}&return_url=https://x.test/account`);
2035
+ const portal = await page(String(bps.url));
2036
+ // a second save changes the name in place and keeps the account's other public details
2037
+ await save('Dub Technologies');
2038
+ const again = await api('GET', '/v1/account');
2039
+ // each save is the account's update, the first included
2040
+ const updated = ((await api('GET', '/v1/events?type=account.updated')).data as Body[] | undefined) ?? [];
2041
+ // a session's own branding_settings.display_name wins; a direct charge's page shows the connected account's name
2042
+ 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';
2043
+ const branded = await page(String((await api('POST', '/v1/checkout/sessions', `${line}&branding_settings[display_name]=Dub Links`)).url));
2044
+ const grower = await api('POST', '/v1/accounts', 'type=standard&country=US&business_profile[name]=Grower Co');
2045
+ const directSession = await api('POST', '/v1/checkout/sessions', line, String(grower.id));
2046
+ const direct = await page(String(directSession.url));
2047
+ // the name is the page's only use of the account a session was made on: paying it keeps its events where they were
2048
+ // (the twin makes a hosted page's payment on the platform's books), all of them together
2049
+ 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' }));
2050
+ 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);
2051
+ const completedThere = ((await api('GET', '/v1/events?type=checkout.session.completed', undefined, String(grower.id))).data as Body[] | undefined) ?? [];
2052
+ 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));
2053
+ 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));
2054
+ 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));
2055
+ 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));
2056
+ // the page's trailing-slash address posts to itself and comes back to itself
2057
+ const slashed = await page('https://dashboard.stripe.com/settings/public/');
2058
+ 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' }));
2059
+ const landed = new URL(slashSave.headers.get('location') ?? '', 'https://dashboard.stripe.com/settings/public/').pathname;
2060
+ // the /_twin/account door, written first, starts the platform's record from the same default and is an update too
2061
+ const door = createStripeTwinFetch({ root: join(root, 'door') });
2062
+ await door(new Request('https://api.stripe.com/_twin/account', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'settings[payouts][schedule][interval]=manual&business_profile[support_email]=help@dub.test' }));
2063
+ // and a save on the page keeps the public details it does not show, as the door keeps the name the page saved
2064
+ await door(new Request('https://dashboard.stripe.com/settings/public', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'business_name=Dub' }));
2065
+ await door(new Request('https://api.stripe.com/_twin/account', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'business_profile[url]=https://dub.test' }));
2066
+ const doorAccount = await (await door(new Request('https://api.stripe.com/v1/account'))).json() as Body;
2067
+ const doorEvents = (((await (await door(new Request('https://api.stripe.com/v1/events?type=account.updated'))).json()) as Body).data as Body[] | undefined) ?? [];
2068
+ const long = await save('x'.repeat(5001));
2069
+ return before.includes('Twin Inc.') && !before.includes('Dub') && empty.status === 400 && saved.status === 303 && long.status === 400
2070
+ && 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')
2071
+ && saved.headers.get('location') === '?saved=1' && form.includes('value="Dub"') && !form.includes('action=')
2072
+ && onBehalf.includes('Pay Grower Co') && subBehalf.includes('<div class="pay-merchant">Grower Co</div>') && portalAsGrower.includes('Return to Grower Co')
2073
+ && slashed.includes('name="business_name"') && slashSave.status === 303 && landed === '/settings/public/'
2074
+ && doorAccount.created === 1767225600 && (doorAccount.capabilities as Body)?.card_payments === 'active' && doorEvents.length === 3
2075
+ && (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'
2076
+ && (((doorAccount.settings as Body)?.payouts as Body)?.schedule as Body)?.interval === 'manual'
2077
+ && (account.business_profile as Body)?.name === 'Dub' && account.id === 'acct_twin_self' && account.charges_enabled === true
2078
+ && checkout.includes('<div class="pay-merchant">Dub</div>') && checkout.includes('Pay Dub') && !checkout.includes('Twin Inc.')
2079
+ && portal.includes('Return to Dub') && portal.includes('<div class="portal-merchant">Dub</div>') && (again.business_profile as Body)?.name === 'Dub Technologies';
2080
+ } finally {
2081
+ rmSync(root, { recursive: true, force: true });
2082
+ }
2083
+ }),
1074
2084
  done('stripe.billing_portal.session', 'checkout', 'Billing Portal Session create (referential to customer)', 'api', 'common', () =>
1075
2085
  withRoot(async (h) => {
1076
2086
  const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=portal@twin.test' });
@@ -1078,6 +2088,31 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1078
2088
  return ok(bps) && field(bps, 'object') === 'billing_portal.session' && !!field(bps, 'url');
1079
2089
  }),
1080
2090
  ),
2091
+ // The portal's payment method and invoices: the customer saves a new card (the default their subscription charges)
2092
+ // and pays the open invoice a declined renewal left, which makes the subscription active
2093
+ // (docs.stripe.com/customer-management/configure-portal).
2094
+ done('stripe.billing_portal.payment_method_and_pay', 'checkout', 'Billing Portal: update the payment method and pay an open invoice', 'api', 'common', async () => {
2095
+ const root = mkdtempSync(join(tmpdir(), 'stp-portal-'));
2096
+ try {
2097
+ const h = (s: Step & { month?: number }) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: `2026-${String(s.month ?? 1).padStart(2, '0')}-10T00:00:00Z` });
2098
+ const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Box' });
2099
+ const price = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=3500&currency=usd&recurring[interval]=month&product=${id(prod)}` });
2100
+ const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=portal-pay@twin.test' });
2101
+ 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` });
2102
+ const open = (((await h({ m: 'GET', p: `/v1/invoices?subscription=${id(sub)}&status=open`, month: 2 })).body as Body).data as Body[])[0];
2103
+ const bps = await h({ m: 'POST', p: '/v1/billing_portal/sessions', b: `customer=${id(cust)}`, month: 2 });
2104
+ const flow = stripePortalFlow({ root, clock: () => '2026-02-10T00:00:00Z' });
2105
+ 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() }));
2106
+ const saved = await post('payment_method', { cardNumber: '4242424242424242', cardExpiry: '12/34', cardCvc: '123', billingName: 'Twin', billingCountry: 'US', billingPostalCode: '94105' });
2107
+ const paid = await post('pay', { invoice: String(open?.id) });
2108
+ const after = await h({ m: 'GET', p: `/v1/subscriptions/${id(sub)}`, month: 2 });
2109
+ const inv = await h({ m: 'GET', p: `/v1/invoices/${String(open?.id)}`, month: 2 });
2110
+ return saved?.status === 303 && paid?.status === 303 && field(inv, 'status') === 'paid'
2111
+ && field(after, 'status') === 'active' && field(after, 'default_payment_method') !== 'pm_card_chargeCustomerFail';
2112
+ } finally {
2113
+ rmSync(root, { recursive: true, force: true });
2114
+ }
2115
+ }),
1081
2116
  done('stripe.billing_portal.configuration', 'checkout', 'Billing Portal Configurations: create + retrieve + update + list', 'api', 'common', () =>
1082
2117
  withRoot(async (h) => {
1083
2118
  const c = await h({ m: 'POST', p: '/v1/billing_portal/configurations', b: 'business_profile[headline]=Hi&features[customer_update][enabled]=true' });
@@ -1087,8 +2122,6 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1087
2122
  return ok(g) && id(g) === id(c) && field(l, 'object') === 'list';
1088
2123
  }),
1089
2124
  ),
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
2125
 
1093
2126
  // ── Payment Links / Quotes ────────────────────────────────────────────────────────
1094
2127
  // Payment Links: create (referential to a Price) → active link with a share url + line_items
@@ -1150,10 +2183,13 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1150
2183
  return ok(g) && ok(u) && ok(ll) && field(ll, 'object') === 'login_link' && ok(del) && field(del, 'deleted') === true;
1151
2184
  }),
1152
2185
  ),
1153
- done('stripe.connect.transfers', 'connect', 'Connect Transfers: create (referential to dest account) + retrieve + list', 'api', 'niche', () =>
2186
+ 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
2187
  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)}` });
2188
+ const seller = await onboardedSeller(h);
2189
+ // an account still onboarding cannot receive transfers
2190
+ const fresh = await h({ m: 'POST', p: '/v1/accounts', b: 'type=express&capabilities[transfers][requested]=true' });
2191
+ if ((await h({ m: 'POST', p: '/v1/transfers', b: `amount=500&currency=usd&destination=${id(fresh)}` })).status !== 400) return false;
2192
+ const t = await h({ m: 'POST', p: '/v1/transfers', b: `amount=500&currency=usd&destination=${seller}` });
1157
2193
  if (!ok(t) || field(t, 'object') !== 'transfer') return false;
1158
2194
  const g = await h({ m: 'GET', p: `/v1/transfers/${id(t)}` });
1159
2195
  const l = await h({ m: 'GET', p: '/v1/transfers' });
@@ -1162,6 +2198,48 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1162
2198
  return ok(g) && field(l, 'object') === 'list' && bad.status >= 400;
1163
2199
  }),
1164
2200
  ),
2201
+ // docs.stripe.com/connect/separate-charges-and-transfers, "Transfer availability": a transfer tied to a charge succeeds
2202
+ // while the charge's funds are pending ("if the related charge hasn't settled yet"; once they have, it is held to the
2203
+ // available balance), never takes more than the charge (less what was reversed of it), takes the charge's
2204
+ // transfer_group over one the request names or, the charge having none, `group_` plus its PaymentIntent id, which the
2205
+ // charge takes too, and pays the destination when the charge's funds become available. (Dub pays its partners this
2206
+ // way: a card charge, then source_transaction.)
2207
+ done('stripe.connect.transfer_source_transaction', 'connect', 'Connect Transfers from a charge\'s pending funds (source_transaction)', 'api', 'common', async () => {
2208
+ const root = mkdtempSync(join(tmpdir(), 'stp-src-'));
2209
+ try {
2210
+ const h = (s: Step & { acct?: string; day?: number }) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: `2026-06-${String(s.day ?? 1).padStart(2, '0')}T12:00:00Z`, ...(s.acct ? { stripeAccount: s.acct } : {}) });
2211
+ const a = await h({ m: 'POST', p: '/v1/accounts', b: 'type=custom&country=US&capabilities[transfers][requested]=true&business_profile[mcc]=5462&business_profile[url]=https://seller.test&tos_acceptance[date]=1767225600&tos_acceptance[ip]=203.0.113.9&settings[payouts][schedule][interval]=manual' });
2212
+ const seller = id(a);
2213
+ await h({ m: 'POST', p: `/v1/accounts/${seller}/external_accounts`, b: 'external_account[object]=bank_account&external_account[country]=US&external_account[currency]=usd&external_account[routing_number]=110000000&external_account[account_number]=000123456789' });
2214
+ await h({ m: 'POST', p: '/_twin/account', b: 'settings[payouts][schedule][interval]=manual' });
2215
+ 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' });
2216
+ const charge = field(pi, 'latest_charge') as string;
2217
+ const t = await h({ m: 'POST', p: '/v1/transfers', b: `amount=2400&currency=usd&destination=${seller}&source_transaction=${charge}&transfer_group=mine` });
2218
+ const over = await h({ m: 'POST', p: '/v1/transfers', b: `amount=500&currency=usd&destination=${seller}&source_transaction=${charge}` });
2219
+ const sellerBalance = await h({ m: 'GET', p: '/v1/balance', acct: seller });
2220
+ const pending = ((sellerBalance.body as Body).pending as Array<{ amount: number }> | undefined)?.[0]?.amount;
2221
+ if (!ok(pi) || !ok(t) || field(t, 'source_transaction') !== charge || field(t, 'transfer_group') !== 'inv_7' || over.status !== 400 || pending !== 2400) return false;
2222
+ // what was reversed of a transfer is room again
2223
+ await h({ m: 'POST', p: `/v1/transfers/${id(t)}/reversals`, b: 'amount=400' });
2224
+ const again = await h({ m: 'POST', p: '/v1/transfers', b: `amount=500&currency=usd&destination=${seller}&source_transaction=${charge}` });
2225
+ // no transfer_group on the charge: group_ plus its PaymentIntent id, on the transfer and on the charge
2226
+ const plain = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=2000&currency=usd&payment_method=pm_card_visa&confirm=true' });
2227
+ const plainCharge = field(plain, 'latest_charge') as string;
2228
+ const grouped = await h({ m: 'POST', p: '/v1/transfers', b: `amount=500&currency=usd&destination=${seller}&source_transaction=${plainCharge}` });
2229
+ const chargeAfter = await h({ m: 'GET', p: `/v1/charges/${plainCharge}` });
2230
+ if (!ok(again) || field(grouped, 'transfer_group') !== `group_${id(plain)}` || field(chargeAfter, 'transfer_group') !== `group_${id(plain)}`) return false;
2231
+ // day 5: both charges have settled, so a transfer from one is held to the platform's available balance
2232
+ // (2487 + 1912 − 2400 + 400 − 500 − 500 = 1399)
2233
+ const tooMuch = await h({ m: 'POST', p: '/v1/transfers', b: `amount=1500&currency=usd&destination=${seller}&source_transaction=${plainCharge}`, day: 5 });
2234
+ const fits = await h({ m: 'POST', p: '/v1/transfers', b: `amount=1000&currency=usd&destination=${seller}&source_transaction=${plainCharge}`, day: 5 });
2235
+ // an authorization is no source of funds until it is captured (the twin's rule: the docs are silent)
2236
+ 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 });
2237
+ 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 });
2238
+ const sellerAfter = (((await h({ m: 'GET', p: '/v1/transfers', day: 5 })).body as Body).data as Body[]) ?? [];
2239
+ return tooMuch.status === 400 && ((tooMuch.body as Body).error as Body)?.code === 'balance_insufficient' && ok(fits)
2240
+ && fromHold.status === 400 && !sellerAfter.some((x) => x.source_transaction === field(held, 'latest_charge'));
2241
+ } finally { rmSync(root, { recursive: true, force: true }); }
2242
+ }),
1165
2243
  // (account_links, persons, external_accounts, transfer_reversals, application_fees,
1166
2244
  // capabilities upgraded to done() in the AUDIT GROWTH block below.)
1167
2245
  // Connect payouts (Stripe-Account): a payout created WITH a Stripe-Account header is attributed
@@ -1171,8 +2249,10 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1171
2249
  const root = mkdtempSync(join(tmpdir(), 'stp-cpo-'));
1172
2250
  try {
1173
2251
  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).
2252
+ const seller = await onboardedSeller(h);
2253
+ const acct = { status: 200, body: { id: seller } } as StripeResponse;
2254
+ await h({ m: 'POST', p: '/v1/transfers', b: `amount=3000&currency=usd&destination=${seller}` });
2255
+ // a platform payout (no header) and a connected-account payout (with header), each from its own balance.
1176
2256
  const plat = await h({ m: 'POST', p: '/v1/payouts', b: 'amount=1000&currency=usd' });
1177
2257
  const conn = await h({ m: 'POST', p: '/v1/payouts', b: 'amount=2000&currency=usd', acct: id(acct) });
1178
2258
  if (!ok(plat) || !ok(conn)) return false;
@@ -1196,6 +2276,65 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1196
2276
  // Top-ups: POST /v1/topups funds the platform balance (amount + currency required); the twin
1197
2277
  // succeeds a test top-up immediately. retrieve/list/update round-trip; a succeeded top-up
1198
2278
  // cannot be canceled (400); missing money 400; unknown id 404.
2279
+ // Connect webhooks (docs.stripe.com/connect/webhooks): an endpoint created with `connect` true receives the Connected
2280
+ // accounts scope, one without it Your account; "Each event for a connected account contains a top-level `account`
2281
+ // property that identifies the connected account". A write made as the account (the Stripe-Account header) and the
2282
+ // account's own account.updated are its events; the platform's own writes are the platform's.
2283
+ 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 () => {
2284
+ const root = mkdtempSync(join(tmpdir(), 'stp-scope-'));
2285
+ const rx = await webhookReceiver();
2286
+ try {
2287
+ const h = fetcher(createStripeTwinFetch({ root }));
2288
+ await h('POST', '/v1/webhook_endpoints', `url=${rx.url('/platform')}&enabled_events[]=*`);
2289
+ await h('POST', '/v1/webhook_endpoints', `url=${rx.url('/connect')}&enabled_events[]=*&connect=true`);
2290
+ const seller = String((await h('POST', '/v1/accounts', 'type=express&country=US')).id);
2291
+ const theirs = String((await h('POST', '/v1/customers', 'email=buyer@seller.test', seller)).id);
2292
+ const ours = String((await h('POST', '/v1/customers', 'email=buyer@platform.test')).id);
2293
+ const at = (path: string, type: string, object: string) => rx.got(path).filter((e) => e.type === type && (e.data.object as Body).id === object);
2294
+ const theirsC = at('/connect', 'customer.created', theirs);
2295
+ const oursP = at('/platform', 'customer.created', ours);
2296
+ return theirsC.length === 1 && theirsC[0]!.account === seller && at('/platform', 'customer.created', theirs).length === 0
2297
+ && oursP.length === 1 && !('account' in oursP[0]!) && at('/connect', 'customer.created', ours).length === 0
2298
+ && rx.got('/connect').some((e) => e.type === 'account.updated' && e.account === seller)
2299
+ && !rx.got('/platform').some((e) => e.type === 'account.updated');
2300
+ } finally {
2301
+ rx.close();
2302
+ rmSync(root, { recursive: true, force: true });
2303
+ }
2304
+ }),
2305
+ // A connected account's event is its own even when no request acted as it: the automatic payout World time makes for
2306
+ // it (semantics/balance.ts) goes to connect endpoints with `account`. The Events API answers each account its own
2307
+ // events: with the Stripe-Account header that account's, without it the platform's (semantics/webhook-endpoints.ts).
2308
+ 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 () => {
2309
+ const root = mkdtempSync(join(tmpdir(), 'stp-evscope-'));
2310
+ const rx = await webhookReceiver();
2311
+ try {
2312
+ let now = '2026-06-01T12:00:00.000Z';
2313
+ const h = fetcher(createStripeTwinFetch({ root, clock: () => now }));
2314
+ await h('POST', '/v1/webhook_endpoints', `url=${rx.url('/platform')}&enabled_events[]=*`);
2315
+ await h('POST', '/v1/webhook_endpoints', `url=${rx.url('/connect')}&enabled_events[]=*&connect=true`);
2316
+ const seller = String((await h('POST', '/v1/accounts', 'type=custom&country=US&capabilities[transfers][requested]=true&business_profile[mcc]=5462&business_profile[url]=https://seller.test&tos_acceptance[date]=1767225600&tos_acceptance[ip]=203.0.113.9')).id);
2317
+ await h('POST', `/v1/accounts/${seller}/external_accounts`, 'external_account[object]=bank_account&external_account[country]=US&external_account[currency]=usd&external_account[routing_number]=110000000&external_account[account_number]=000123456789');
2318
+ await h('POST', '/v1/charges', 'amount=100000&currency=usd&source=tok_bypassPending');
2319
+ await h('POST', '/v1/transfers', `amount=5000&currency=usd&destination=${seller}`);
2320
+ now = '2026-06-03T12:00:00.000Z';
2321
+ await h('GET', '/v1/balance');
2322
+ const theirPayout = rx.got('/connect').find((e) => e.type === 'payout.created');
2323
+ if (theirPayout?.account !== seller || (theirPayout.data.object as Body).amount !== 5000) return false;
2324
+ if (rx.got('/platform').some((e) => e.type === 'payout.created' && (e.data.object as Body).id === (theirPayout.data.object as Body).id)) return false;
2325
+ const theirs = ((await h('GET', '/v1/events?limit=100', undefined, seller)).data as Body[]) ?? [];
2326
+ const ours = ((await h('GET', '/v1/events?limit=100')).data as Body[]) ?? [];
2327
+ const listed = theirs.find((e) => e.type === 'payout.created');
2328
+ const hidden = await h('GET', `/v1/events/${String(listed?.id)}`);
2329
+ const shown = await h('GET', `/v1/events/${String(listed?.id)}`, undefined, seller);
2330
+ return theirs.length > 0 && theirs.every((e) => e.account === seller) && listed !== undefined
2331
+ && ours.length > 0 && ours.every((e) => !('account' in e)) && ours.some((e) => e.type === 'transfer.created')
2332
+ && (hidden.error as Body | undefined)?.code === 'resource_missing' && shown.id === listed.id && shown.account === seller;
2333
+ } finally {
2334
+ rx.close();
2335
+ rmSync(root, { recursive: true, force: true });
2336
+ }
2337
+ }),
1199
2338
  done('stripe.connect.top_ups', 'connect', 'Top-ups (fund platform balance)', 'api', 'niche', () =>
1200
2339
  withRoot(async (h) => {
1201
2340
  const tu = await h({ m: 'POST', p: '/v1/topups', b: 'amount=50000&currency=usd&statement_descriptor=Top up' });
@@ -1226,19 +2365,24 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1226
2365
  // the old fabricating path returned 200 for a missing file and would fail this assertion.
1227
2366
  done('stripe.file_links.create', 'files', 'FileLinks: create (requires file)', 'api', 'niche', () =>
1228
2367
  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;
2368
+ const up = await h({ m: 'POST', p: '/v1/files', b: 'purpose=dispute_evidence&file=evidence.png' });
2369
+ const f = await h({ m: 'POST', p: '/v1/file_links', b: `file=${id(up)}` });
2370
+ if (!ok(f) || field(f, 'object') !== 'file_link' || field(f, 'file') !== id(up)) return false;
2371
+ // a file of a purpose the link's file parameter does not list is refused
2372
+ const logo = await h({ m: 'POST', p: '/v1/files', b: 'purpose=issuing_logo&file=logo.png' });
2373
+ if ((await h({ m: 'POST', p: '/v1/file_links', b: `file=${id(logo)}` })).status !== 400) return false;
1231
2374
  const missing = await h({ m: 'POST', p: '/v1/file_links', b: 'expires_at=0' });
1232
2375
  return missing.status === 400 && ((missing.body as Body).error as Body)?.code === 'parameter_missing';
1233
2376
  }),
1234
2377
  ),
1235
2378
  // (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.
2379
+ // Identity VerificationReports: the person verifies on verify.stripe.com, which makes the report; then list
2380
+ // (filterable by verification_session) + retrieve. Unknown id 404.
1238
2381
  done('stripe.identity.verification_reports', 'identity', 'Identity VerificationReports', 'api', 'niche', () =>
1239
- withRoot(async (h) => {
2382
+ withRoot(async (h, root) => {
1240
2383
  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` });
2384
+ await hostedSubmit(root, stripeIdentityFlow, `https://verify.stripe.com/start/${id(vs)}`, { outcome: 'verified' });
2385
+ const ver = await h({ m: 'GET', p: `/v1/identity/verification_sessions/${id(vs)}` });
1242
2386
  if (!ok(ver) || field(ver, 'status') !== 'verified') return false;
1243
2387
  const reportId = field(ver, 'last_verification_report') as string;
1244
2388
  if (!reportId?.startsWith('vr_')) return false;
@@ -1253,10 +2397,12 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1253
2397
  ),
1254
2398
 
1255
2399
  // ── Events / Webhooks / Idempotency ───────────────────────────────────────────────
1256
- done('stripe.events.list', 'events', 'Events: create + retrieve + list', 'api', 'core', () =>
2400
+ done('stripe.events.list', 'events', 'Events: a write records its event; retrieve + list', 'api', 'core', () =>
1257
2401
  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;
2402
+ await h({ m: 'POST', p: '/v1/customers', b: 'email=events@twin.test' });
2403
+ const first = (((await h({ m: 'GET', p: '/v1/events' })).body as Body).data as Body[])[0]!;
2404
+ const e = { status: 200, body: first } as StripeResponse;
2405
+ if (field(e, 'object') !== 'event' || field(e, 'type') !== 'customer.created') return false;
1260
2406
  const g = await h({ m: 'GET', p: `/v1/events/${id(e)}` });
1261
2407
  const l = await h({ m: 'GET', p: '/v1/events' });
1262
2408
  return ok(g) && id(g) === id(e) && field(l, 'object') === 'list';
@@ -1284,7 +2430,17 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1284
2430
  if (!report.ok || posts.length !== 1) return false;
1285
2431
  // verified with the same scheme stripe.webhooks.constructEvent implements
1286
2432
  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;
2433
+ if (evt.type !== 'invoice.paid' || (evt.data.object as Body).id !== id(inv) || 'account' in evt) return false;
2434
+ // a connected account's object re-fires as its event: a payout kept on its books carries the account
2435
+ // (at noon: an automatic payout, made at midnight, would take the funds first)
2436
+ 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 } : {}) });
2437
+ const seller = await onboardedSeller((s) => noon(s.m, s.p, s.b));
2438
+ await noon('POST', '/v1/transfers', `amount=5000&currency=usd&destination=${seller}`);
2439
+ const po = await noon('POST', '/v1/payouts', 'amount=4000&currency=usd', seller);
2440
+ await H('POST', '/v1/webhook_endpoints', `url=${encodeURIComponent('http://127.0.0.1:1/connect')}&enabled_events[0]=payout.*&connect=true`);
2441
+ const scoped: string[] = [];
2442
+ await emitTwinEvent(stripeEmitter, { type: 'payout.created', subjectId: id(po), root, fetchFn: async (_url, init) => { scoped.push(init.body); return { status: 200 }; } });
2443
+ if (scoped.length === 0 || !scoped.every((b) => (JSON.parse(b) as Body).account === seller)) return false;
1288
2444
  // unknown subject → LOUD, listing what exists
1289
2445
  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
2446
  });
@@ -1412,7 +2568,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1412
2568
  withRoot(async (h) => {
1413
2569
  const cus = await h({ m: 'POST', p: '/v1/customers', b: 'email=ev@example.com' });
1414
2570
  if (!ok(cus)) return false;
1415
- const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=2000&currency=usd' });
2571
+ const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=2000&currency=usd&transfer_group=inv_42' });
1416
2572
  const conf = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/confirm`, b: 'payment_method=pm_card_visa' });
1417
2573
  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
2574
  const att = await h({ m: 'POST', p: `/v1/payment_methods/${id(pm)}/attach`, b: `customer=${id(cus)}` });
@@ -1421,8 +2577,11 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1421
2577
  if (!ok(list) || (list.body as Body).object !== 'list') return false;
1422
2578
  const events = (list.body as Body).data as Array<Record<string, unknown>>;
1423
2579
  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;
2580
+ const wanted = ['customer.created', 'payment_intent.created', 'payment_intent.succeeded', 'charge.succeeded', 'payment_method.attached'];
2581
+ if (!wanted.every((t) => types.has(t)) || types.has('charge.created')) return false;
2582
+ // the payment's charge is the event's object and carries the intent's transfer_group (Dub reads its payout invoice from it)
2583
+ const charged = (events.find((e) => e.type === 'charge.succeeded')?.data as Body)?.object as Body | undefined;
2584
+ if (charged?.object !== 'charge' || charged.payment_intent !== id(pi) || charged.transfer_group !== 'inv_42') return false;
1426
2585
  // envelope shape: object 'event', carries the resource snapshot under data.object
1427
2586
  const succeeded = events.find((e) => e.type === 'payment_intent.succeeded');
1428
2587
  if (!succeeded || succeeded.object !== 'event' || ((succeeded.data as Body)?.object as Body)?.status !== 'succeeded') return false;
@@ -1447,8 +2606,8 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1447
2606
  const e1 = await h({ m: 'GET', p: `/v1/payment_intents/${id(pi)}?expand[]=customer` });
1448
2607
  if (!ok(e1) || ((field(e1, 'customer') as Body)?.object) !== 'customer' || (field(e1, 'customer') as Body)?.id !== id(cust)) return false;
1449
2608
  // 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` });
2609
+ 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` });
2610
+ const e2 = await h({ m: 'GET', p: `/v1/charges/${String(field(ch, 'latest_charge'))}?expand[]=payment_intent.customer` });
1452
2611
  const expandedPi = field(e2, 'payment_intent') as Body;
1453
2612
  if (!expandedPi || expandedPi.object !== 'payment_intent' || (expandedPi.customer as Body)?.id !== id(cust)) return false;
1454
2613
  // list-level expand (expand[]=data.customer) expands the field on every row.
@@ -1522,43 +2681,48 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1522
2681
  // ── Test helpers ──────────────────────────────────────────────────────────────────
1523
2682
  // Test clocks: create a clock at a frozen_time, then advance it; advancing past a trialing
1524
2683
  // 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.)
2684
+ // Advancing backwards 400s; advancing while advancing 400s test_clock_not_ready; missing frozen_time 400s; delete
2685
+ // removes it.
1527
2686
  done('stripe.test_clocks', 'test-helpers', 'Test clocks (advance time for subscription/invoice)', 'api', 'common', () =>
1528
- withRoot(async (h) => {
2687
+ withRoot(async (h, root) => {
1529
2688
  const clock = await h({ m: 'POST', p: '/v1/test_helpers/test_clocks', b: 'frozen_time=1000&name=T' });
1530
2689
  if (!ok(clock) || field(clock, 'object') !== 'test_helpers.test_clock' || field(clock, 'frozen_time') !== 1000) return false;
1531
2690
  // a trialing subscription whose trial_end is 5000.
1532
2691
  const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=clock@twin.test' });
1533
2692
  const sub = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&trial_end=5000` });
1534
2693
  if (field(sub, 'status') !== 'trialing') return false;
1535
- // advancing PAST trial_end transitions the sub to active.
2694
+ // advancing PAST trial_end: the clock advances for a few seconds (a second advance meanwhile is refused), then the
2695
+ // trial has ended and the sub is active
1536
2696
  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)}` });
2697
+ if (!ok(adv) || field(adv, 'status') !== 'advancing') return false;
2698
+ const busy = await h({ m: 'POST', p: `/v1/test_helpers/test_clocks/${id(clock)}/advance`, b: 'frozen_time=7000' });
2699
+ if (busy.status !== 400 || ((busy.body as Body).error as Body)?.code !== 'test_clock_not_ready') return false;
2700
+ const later = new Date(Date.now() + 10_000).toISOString();
2701
+ const subAfter = await handleStripeTwinRequest({ method: 'GET', path: `/v1/subscriptions/${id(sub)}`, root, occurredAt: later });
1539
2702
  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' });
2703
+ const back = await handleStripeTwinRequest({ method: 'POST', path: `/v1/test_helpers/test_clocks/${id(clock)}/advance`, body: 'frozen_time=1', root, occurredAt: later });
1541
2704
  const noTime = await h({ m: 'POST', p: '/v1/test_helpers/test_clocks' });
1542
2705
  const del = await h({ m: 'DELETE', p: `/v1/test_helpers/test_clocks/${id(clock)}` });
1543
2706
  const gone = await h({ m: 'GET', p: `/v1/test_helpers/test_clocks/${id(clock)}` });
1544
2707
  return back.status === 400 && noTime.status === 400 && ok(del) && field(del, 'deleted') === true && gone.status === 404;
1545
2708
  }),
1546
2709
  ),
1547
- // Test-helper Issuing endpoints: fund_balance accrues the issuing balance; present an
2710
+ // Issuing: a top-up to the Issuing balance funds it; the test-helper endpoints present an
1548
2711
  // authorization (the test-mode way to simulate card usage) → a 'pending' authorization
1549
2712
  // awaiting approve/decline; create_force_capture lands a settled transaction directly.
1550
2713
  // 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;
2714
+ done('stripe.test_helpers.issuing', 'test-helpers', 'Test-helper endpoints (present authorization, force capture), on an Issuing balance a top-up funded', 'api', 'niche', () =>
2715
+ withRoot(async (h, root) => {
2716
+ const fund = await fundIssuing(root, 100000);
2717
+ const bal = await h({ m: 'GET', p: '/v1/balance' });
2718
+ if (!ok(fund) || ((field(bal, 'issuing') as Body)?.available as Body[])[0]?.amount !== 100000) return false;
1555
2719
  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
2720
  const card = await h({ m: 'POST', p: `/v1/issuing/cards`, b: `cardholder=${id(ch)}&currency=usd&type=virtual` });
1557
2721
  const auth = await h({ m: 'POST', p: '/v1/test_helpers/issuing/authorizations', b: `card=${id(card)}&amount=2500` });
1558
2722
  if (!ok(auth) || field(auth, 'status') !== 'pending' || (field(auth, 'card') as Body)?.id !== id(card) || field(auth, 'cardholder') !== id(ch)) return false;
1559
2723
  const forced = await h({ m: 'POST', p: '/v1/test_helpers/issuing/transactions/create_force_capture', b: `card=${id(card)}&amount=1000` });
1560
2724
  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' });
2725
+ const noAmt = await h({ m: 'POST', p: '/v1/topups', b: 'currency=usd&destination_balance=issuing' });
1562
2726
  const badCard = await h({ m: 'POST', p: '/v1/test_helpers/issuing/authorizations', b: 'card=ic_nope&amount=100' });
1563
2727
  return noAmt.status === 400 && badCard.status === 400;
1564
2728
  }),
@@ -1665,18 +2829,27 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1665
2829
  ((l.body as Body).data as Body[]).length === 2 && badCh.status === 400 && badStatus.status === 400;
1666
2830
  }),
1667
2831
  ),
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.
2832
+ // Authorizations (+approve/decline): an authorization is PRESENTED via the test helper (pending);
2833
+ // /approve keeps it pending (approved:true, funds held) until the merchant's capture closes it and
2834
+ // makes the transaction; /decline closes it (approved:false). Deciding twice 400s; unknown id 404.
1671
2835
  done('stripe.issuing.authorizations', 'issuing', 'Issuing Authorizations (+approve/decline)', 'api', 'niche', () =>
1672
- withRoot(async (h) => {
2836
+ withRoot(async (h, root) => {
2837
+ await fundIssuing(root, 10000000); // the Issuing balance authorizations are paid from
1673
2838
  const ch = await h({ m: 'POST', p: '/v1/issuing/cardholders', b: 'name=Jane&type=individual&billing[address][country]=US' });
1674
2839
  const card = await h({ m: 'POST', p: `/v1/issuing/cards`, b: `cardholder=${id(ch)}&currency=usd&type=virtual` });
2840
+ // with no real-time endpoint an authorization is approved at once (card_active)
2841
+ const plain = await h({ m: 'POST', p: '/v1/test_helpers/issuing/authorizations', b: `card=${id(card)}&amount=1000` });
2842
+ if (field(plain, 'approved') !== true || ((field(plain, 'request_history') as Body[])?.[0] as Body | undefined)?.reason !== 'card_active') return false;
2843
+ // an endpoint that does not answer leaves it pending for the deprecated approve and decline
2844
+ await h({ m: 'POST', p: '/v1/webhook_endpoints', b: `url=${encodeURIComponent('https://rt.twin.test/auth')}&enabled_events[]=issuing_authorization.request` });
1675
2845
  const auth = await h({ m: 'POST', p: '/v1/test_helpers/issuing/authorizations', b: `card=${id(card)}&amount=3000` });
1676
2846
  if (field(auth, 'status') !== 'pending') return false;
1677
2847
  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.
2848
+ if (!ok(appr) || field(appr, 'status') !== 'pending' || field(appr, 'approved') !== true) return false;
2849
+ // nothing is transacted until the merchant captures
2850
+ if ((((await h({ m: 'GET', p: '/v1/issuing/transactions' })).body as Body).data as Body[]).length !== 0) return false;
2851
+ const cap = await h({ m: 'POST', p: `/v1/test_helpers/issuing/authorizations/${id(auth)}/capture` });
2852
+ if (!ok(cap) || field(cap, 'status') !== 'closed') return false;
1680
2853
  const txns = await h({ m: 'GET', p: '/v1/issuing/transactions' });
1681
2854
  const txnData = (txns.body as Body).data as Body[];
1682
2855
  if (txnData.length !== 1 || txnData[0]!.authorization !== id(auth) || txnData[0]!.amount !== -3000) return false;
@@ -1696,7 +2869,8 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1696
2869
  // retrieved/updated and disputed. A dispute requires transaction + evidence[reason]; /submit
1697
2870
  // transitions unsubmitted→submitted; submitting twice 400s; unknown transaction 400.
1698
2871
  done('stripe.issuing.transactions', 'issuing', 'Issuing Transactions + disputes', 'api', 'niche', () =>
1699
- withRoot(async (h) => {
2872
+ withRoot(async (h, root) => {
2873
+ await fundIssuing(root, 10000000); // the Issuing balance authorizations are paid from
1700
2874
  const ch = await h({ m: 'POST', p: '/v1/issuing/cardholders', b: 'name=Jane&type=individual&billing[address][country]=US' });
1701
2875
  const card = await h({ m: 'POST', p: `/v1/issuing/cards`, b: `cardholder=${id(ch)}&currency=usd&type=virtual` });
1702
2876
  const auth = await h({ m: 'POST', p: '/v1/test_helpers/issuing/authorizations', b: `card=${id(card)}&amount=4200` });
@@ -1729,6 +2903,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1729
2903
  const root = mkdtempSync(join(tmpdir(), 'stp-cap-'));
1730
2904
  try {
1731
2905
  const h = (s: Step) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: '2026-06-14T00:00:00Z' });
2906
+ await fundIssuing(root, 10000000, '2026-06-14T00:00:00Z'); // the Issuing balance authorizations are paid from
1732
2907
  const delivered: StripeEvent[] = [];
1733
2908
  setStripeEventDelivery((_url, event) => {
1734
2909
  delivered.push(event);
@@ -1781,15 +2956,16 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1781
2956
  rmSync(root, { recursive: true, force: true });
1782
2957
  }
1783
2958
  }),
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`)
2959
+ // Real-time authorization fallbacks (the 2s window): a webhook decline closes the
2960
+ // authorization (reason webhook_declined); a TIMEOUT leaves it pending for the deprecated
2961
+ // approve/decline until the window ends, then declines it with reason webhook_timeout; an invalid response (non-2xx, non-JSON, missing `approved`)
1787
2962
  // declines with reason webhook_error + a reason_message; a partial `amount` in the
1788
2963
  // 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 () => {
2964
+ 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
2965
  const root = mkdtempSync(join(tmpdir(), 'stp-cap-'));
1791
2966
  try {
1792
2967
  const h = (s: Step) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: '2026-06-14T00:00:00Z' });
2968
+ await fundIssuing(root, 10000000, '2026-06-14T00:00:00Z'); // the Issuing balance authorizations are paid from
1793
2969
  const ch = await h({ m: 'POST', p: '/v1/issuing/cardholders', b: 'name=FB&type=individual&billing[address][country]=US' });
1794
2970
  const card = await h({ m: 'POST', p: '/v1/issuing/cards', b: `cardholder=${id(ch)}&currency=usd&type=virtual` });
1795
2971
  await h({ m: 'POST', p: '/v1/webhook_endpoints', b: `url=${encodeURIComponent('https://rt.twin.test/auth')}&enabled_events[]=issuing_authorization.request` });
@@ -1805,10 +2981,17 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1805
2981
  respond({ status: 200, body: '{"approved":false}' });
1806
2982
  const dec = await present(`card=${id(card)}&amount=900`);
1807
2983
  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)
2984
+ // an unanswered request (the exact TimeoutError AbortSignal.timeout raises) is no decision: pending with its request
1809
2985
  respond(() => { const e = new Error('timed out'); e.name = 'TimeoutError'; throw e; });
1810
2986
  const to = await present(`card=${id(card)}&amount=900`);
1811
- if (field(to, 'approved') !== false || reasonOf(to).reason !== 'webhook_timeout') return false;
2987
+ if (field(to, 'status') !== 'pending' || field(to, 'approved') !== false || !field(to, 'pending_request')) return false;
2988
+ // the deprecated approve inside the window decides it
2989
+ const held = await present(`card=${id(card)}&amount=800`);
2990
+ const approved = await h({ m: 'POST', p: `/v1/issuing/authorizations/${id(held)}/approve` });
2991
+ if (field(approved, 'approved') !== true || reasonOf(approved).reason !== 'webhook_approved') return false;
2992
+ // once the 2-second window has passed, the one left unanswered is declined with reason webhook_timeout
2993
+ const later = await handleStripeTwinRequest({ method: 'GET', path: `/v1/issuing/authorizations/${id(to)}`, root, occurredAt: '2026-06-14T00:00:03Z' });
2994
+ if (field(later, 'status') !== 'closed' || field(later, 'approved') !== false || reasonOf(later).reason !== 'webhook_timeout') return false;
1812
2995
  // invalid responses → webhook_error with a reason_message
1813
2996
  for (const bad of [{ status: 500, body: 'oops' }, { status: 200, body: 'not-json' }, { status: 200, body: '{"ok":true}' }]) {
1814
2997
  respond(bad);
@@ -1838,6 +3021,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1838
3021
  const root = mkdtempSync(join(tmpdir(), 'stp-cap-'));
1839
3022
  try {
1840
3023
  const h = (s: Step) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: '2026-06-14T00:00:00Z' });
3024
+ await fundIssuing(root, 10000000, '2026-06-14T00:00:00Z'); // the Issuing balance authorizations are paid from
1841
3025
  setStripeEventDelivery((_url, event) => {
1842
3026
  if (event.type === 'issuing_authorization.request') return { status: 200, body: '{"approved":true}' };
1843
3027
  });
@@ -1876,7 +3060,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1876
3060
  rmSync(root, { recursive: true, force: true });
1877
3061
  }
1878
3062
  }),
1879
- // Capture debits the issuing balance: fund_balance accrues the float, a webhook-approved
3063
+ // Capture debits the issuing balance: a top-up to Issuing funds the float, a webhook-approved
1880
3064
  // authorization is captured via POST /v1/test_helpers/issuing/authorizations/:id/capture
1881
3065
  // (partial capture_amount honored, refused above the held amount / on a non-approved or
1882
3066
  // closed authorization), the materialized transaction is type 'capture' with a NEGATIVE
@@ -1897,8 +3081,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1897
3081
  const ch = await h({ m: 'POST', p: '/v1/issuing/cardholders', b: 'name=Cap&type=individual&billing[address][country]=US' });
1898
3082
  const card = await h({ m: 'POST', p: '/v1/issuing/cards', b: `cardholder=${id(ch)}&currency=usd&type=virtual` });
1899
3083
  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;
3084
+ await fundIssuing(root, 100000, '2026-06-14T00:00:00Z');
1902
3085
  if (await issuingAvailable() !== 100000) return false;
1903
3086
  const auth = await h({ m: 'POST', p: '/v1/test_helpers/issuing/authorizations', b: `card=${id(card)}&amount=30000` });
1904
3087
  if (field(auth, 'approved') !== true) return false;
@@ -1941,6 +3124,29 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
1941
3124
  // Readers (+process_payment_intent): a reader registers with a registration_code (status
1942
3125
  // 'online'); handing it a PaymentIntent sets reader.action to process_payment_intent
1943
3126
  // in_progress; cancel_action clears it. Connection tokens mint a secret. Missing code 400.
3127
+ // A simulated reader processing a PaymentIntent is presented a card by the test helper: the intent is paid and the
3128
+ // reader's action succeeds (docs.stripe.com/api/terminal/readers/present_payment_method).
3129
+ done('stripe.terminal.present_payment_method', 'terminal', 'Terminal test helper: present a card to a reader processing a PaymentIntent', 'api', 'common', () =>
3130
+ withRoot(async (h) => {
3131
+ 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' });
3132
+ const rdr = await h({ m: 'POST', p: '/v1/terminal/readers', b: `registration_code=simulated-wpe&location=${id(loc)}` });
3133
+ const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=2400&currency=usd&allowed_payment_method_types[0]=card_present' });
3134
+ await h({ m: 'POST', p: `/v1/terminal/readers/${id(rdr)}/process_payment_intent`, b: `payment_intent=${id(pi)}` });
3135
+ const done = await h({ m: 'POST', p: `/v1/test_helpers/terminal/readers/${id(rdr)}/present_payment_method` });
3136
+ const paid = await h({ m: 'GET', p: `/v1/payment_intents/${id(pi)}` });
3137
+ // under manual capture the card holds the amount as an uncaptured charge, which the intent's capture takes
3138
+ 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' });
3139
+ await h({ m: 'POST', p: `/v1/terminal/readers/${id(rdr)}/process_payment_intent`, b: `payment_intent=${id(held)}` });
3140
+ await h({ m: 'POST', p: `/v1/test_helpers/terminal/readers/${id(rdr)}/present_payment_method` });
3141
+ const hold = await h({ m: 'GET', p: `/v1/payment_intents/${id(held)}` });
3142
+ const auth = await h({ m: 'GET', p: `/v1/charges/${String(field(hold, 'latest_charge'))}` });
3143
+ const cap = await h({ m: 'POST', p: `/v1/payment_intents/${id(held)}/capture` });
3144
+ const taken = await h({ m: 'GET', p: `/v1/charges/${String(field(hold, 'latest_charge'))}` });
3145
+ return ((field(done, 'action') as Body)?.status === 'succeeded') && field(paid, 'status') === 'succeeded' && typeof field(paid, 'latest_charge') === 'string'
3146
+ && field(hold, 'status') === 'requires_capture' && field(auth, 'captured') === false && field(auth, 'amount') === 1800
3147
+ && field(cap, 'latest_charge') === id(auth) && field(taken, 'captured') === true && field(taken, 'amount_captured') === 1800;
3148
+ }),
3149
+ ),
1944
3150
  done('stripe.terminal.readers', 'terminal', 'Terminal Readers (+process_payment_intent)', 'api', 'niche', () =>
1945
3151
  withRoot(async (h) => {
1946
3152
  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' });
@@ -2090,22 +3296,23 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
2090
3296
 
2091
3297
  // ── Reporting ────────────────────────────────────────────────────────────────────
2092
3298
  // 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.
3299
+ // report_type (a valid type from the catalog); a run starts pending and completes a
3300
+ // minute later (status 'succeeded') with a result File ref. Unknown/missing report_type 400.
2095
3301
  // The report-type catalog is listable/retrievable.
2096
3302
  done('stripe.reporting.report_runs', 'reporting', 'Reporting (report_runs + report_types)', 'api', 'niche', () =>
2097
- withRoot(async (h) => {
3303
+ withRoot(async (h, root) => {
2098
3304
  const types = await h({ m: 'GET', p: '/v1/reporting/report_types' });
2099
3305
  if (!ok(types) || ((types.body as Body).data as Body[]).length === 0) return false;
2100
3306
  const one = await h({ m: 'GET', p: '/v1/reporting/report_types/balance.summary.1' });
2101
3307
  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)}` });
3308
+ 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' });
3309
+ if (!ok(run) || field(run, 'object') !== 'reporting.report_run' || field(run, 'status') !== 'pending' || field(run, 'report_type') !== 'balance.summary.1') return false;
3310
+ // "Most runs complete within a few minutes" (docs.stripe.com/reports/api): read two minutes on, it has succeeded
3311
+ const g = await handleStripeTwinRequest({ method: 'GET', path: `/v1/reporting/report_runs/${id(run)}`, root, occurredAt: new Date(Date.now() + 120_000).toISOString() });
3312
+ if (field(g, 'status') !== 'succeeded' || (field(g, 'result') as Body)?.object !== 'file') return false;
2106
3313
  const l = await h({ m: 'GET', p: '/v1/reporting/report_runs' });
2107
3314
  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' });
3315
+ const badType = await h({ m: 'POST', p: '/v1/reporting/report_runs', b: 'report_type=bogus.report' });
2109
3316
  const nope = await h({ m: 'GET', p: '/v1/reporting/report_runs/frr_nope' });
2110
3317
  const badTypeRetrieve = await h({ m: 'GET', p: '/v1/reporting/report_types/bogus.report' });
2111
3318
  return ok(g) && id(g) === id(run) && ((l.body as Body).data as Body[]).length === 1 &&
@@ -2113,25 +3320,24 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
2113
3320
  }),
2114
3321
  ),
2115
3322
  // 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.
3323
+ // report_runs create→result. A run requires a valid report_type (else 400),
3324
+ // starts pending and completes to 'succeeded' with a result File ref a minute later. The Sigma scheduled-query
3325
+ // surface is filed separately as a todo. Unknown type 400.
2119
3326
  done('stripe.sigma_financial', 'reporting', 'Financial reports / Sigma (Reporting API)', 'api', 'niche', () =>
2120
- withRoot(async (h) => {
3327
+ withRoot(async (h, root) => {
2121
3328
  const types = await h({ m: 'GET', p: '/v1/reporting/report_types' });
2122
3329
  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)}` });
3330
+ const run = await h({ m: 'POST', p: '/v1/reporting/report_runs', b: 'report_type=balance.summary.1' });
3331
+ if (!ok(run) || field(run, 'object') !== 'reporting.report_run' || field(run, 'status') !== 'pending') return false;
3332
+ const get = await handleStripeTwinRequest({ method: 'GET', path: `/v1/reporting/report_runs/${id(run)}`, root, occurredAt: new Date(Date.now() + 120_000).toISOString() });
3333
+ if (field(get, 'status') !== 'succeeded' || (field(get, 'result') as Body)?.object !== 'file') return false;
2127
3334
  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' });
3335
+ const badType = await h({ m: 'POST', p: '/v1/reporting/report_runs', b: 'report_type=not.a.type' });
3336
+ const missing = await h({ m: 'POST', p: '/v1/reporting/report_runs', b: 'parameters[interval_start]=0' });
2130
3337
  return ok(get) && field(list, 'object') === 'list' && badType.status === 400 && missing.status === 400;
2131
3338
  }),
2132
3339
  ),
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).'),
3340
+ 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
3341
 
2136
3342
  // ── Connector (full pull/push) ────────────────────────────────────────────────────
2137
3343
  done('stripe.connector.read_surface', 'connector', 'Connector read surface (list core collections in one pass)', 'connector', 'core', () =>
@@ -2165,8 +3371,11 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
2165
3371
  pushed.push(`${method} ${path}`);
2166
3372
  return { id: 'ext_pushed', object: 'customer' };
2167
3373
  }) as unknown as Parameters<typeof fullSyncStripe>[0];
3374
+ const { performPending } = await import('./stripe-perform-harness.ts');
3375
+ // protocol 2: the head performs the pending write, the refresh folds the account — two halves, not one
3376
+ const sent = await performPending(execute as never, { root, occurredAt: '2024-01-01T00:00:00.000Z' });
2168
3377
  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
3378
+ if (sent.pushed < 1 || pushed.length < 1) return false; // the pending create was performed
2170
3379
  if (res.observed < 1 || res.collections < 11) return false; // all collections + webhooks pulled
2171
3380
  // the pulled real customer is now in the twin's projection.
2172
3381
  const list = await handleStripeTwinRequest({ method: 'GET', path: '/v1/customers', root });
@@ -2225,12 +3434,15 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
2225
3434
  // screen reads → render the mirror's OWN ListPane → assert the seeded charge id survives.
2226
3435
  done('stripe.ui.refunds', 'ui-dashboard', 'Dashboard: Refunds screen (data-coupled)', 'ui', 'core', uiDataCoupled({
2227
3436
  markers: ['Refunds', 'list-row'],
2228
- seed: async (h) => { await h({ m: 'POST', p: '/v1/refunds', b: 'charge=ch_ui_check_refund&amount=555' }); },
3437
+ seed: async (h) => {
3438
+ const charge = await h({ m: 'POST', p: '/v1/charges', b: 'amount=1000&currency=usd&description=ui_check_refund' });
3439
+ await h({ m: 'POST', p: '/v1/refunds', b: `charge=${id(charge)}&amount=555` });
3440
+ },
2229
3441
  check: async ({ get }) => {
2230
3442
  const rows = ((await get('/v1/refunds')).data as Body[]) as unknown as StripeRow[];
2231
3443
  if (!rows.length) return false;
2232
3444
  const markup = renderSectionList('refunds', rows);
2233
- return listRows(markup) === rows.length && rows.some((r) => r.charge === 'ch_ui_check_refund' && r.amount === 555);
3445
+ return listRows(markup) === rows.length && rows.some((r) => typeof r.charge === 'string' && r.amount === 555);
2234
3446
  },
2235
3447
  })),
2236
3448
  // TWIN-14 (B4) migration — was a marker-grep over the literal 'Subscriptions'; now
@@ -2308,12 +3520,12 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
2308
3520
  // screen reads → render the mirror's OWN ListPane → assert the seeded charge id survives.
2309
3521
  done('stripe.ui.disputes', 'ui-dashboard', 'Dashboard: Disputes screen (data-coupled)', 'ui', 'common', uiDataCoupled({
2310
3522
  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' }); },
3523
+ seed: async (h) => { await disputed(h, 1234); },
2312
3524
  check: async ({ get }) => {
2313
3525
  const rows = ((await get('/v1/disputes')).data as Body[]) as unknown as StripeRow[];
2314
3526
  if (!rows.length) return false;
2315
3527
  const markup = renderSectionList('disputes', rows);
2316
- return listRows(markup) === rows.length && rows.some((d) => d.charge === 'ch_ui_check_dispute' && d.amount === 1000);
3528
+ return listRows(markup) === rows.length && rows.some((d) => d.amount === 1234);
2317
3529
  },
2318
3530
  })),
2319
3531
  // TWIN-14 (B4) migration — was a marker-grep over the literal 'Payouts'; now data-coupled:
@@ -2321,7 +3533,10 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
2321
3533
  // reads → render the mirror's OWN ListPane → assert the seeded amount survives.
2322
3534
  done('stripe.ui.payouts', 'ui-dashboard', 'Dashboard: Payouts screen (data-coupled)', 'ui', 'core', uiDataCoupled({
2323
3535
  markers: ['Payouts', 'list-row'],
2324
- seed: async (h) => { await h({ m: 'POST', p: '/v1/payouts', b: 'amount=7777&currency=usd' }); },
3536
+ seed: async (h) => {
3537
+ await h({ m: 'POST', p: '/v1/charges', b: 'amount=10000&currency=usd&source=tok_bypassPending' });
3538
+ await h({ m: 'POST', p: '/v1/payouts', b: 'amount=7777&currency=usd' });
3539
+ },
2325
3540
  check: async ({ get }) => {
2326
3541
  const rows = ((await get('/v1/payouts')).data as Body[]) as unknown as StripeRow[];
2327
3542
  if (!rows.length) return false;
@@ -2338,7 +3553,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
2338
3553
  // empty workspace's balance has no buckets → `BalanceSummary` renders nothing.
2339
3554
  done('stripe.ui.balance', 'ui-dashboard', 'Dashboard: Balance + Balance Transactions screens (data-coupled)', 'ui', 'core', uiDataCoupled({
2340
3555
  markers: ['Balance', 'Balance Transactions', 'balance-summary'],
2341
- seed: async (h) => { await h({ m: 'POST', p: '/v1/balance_transactions', b: 'amount=424200&currency=usd' }); },
3556
+ seed: async (h) => { await h({ m: 'POST', p: '/v1/charges', b: 'amount=424200&currency=usd&source=tok_bypassPending' }); },
2342
3557
  check: async ({ get }) => {
2343
3558
  const balance = await get('/v1/balance');
2344
3559
  const txns = ((await get('/v1/balance_transactions')).data as Body[]) as unknown as StripeRow[];
@@ -2358,8 +3573,8 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
2358
3573
  done('stripe.ui.connect', 'ui-dashboard', 'Dashboard: Connect (Connected Accounts + Transfers) screens (data-coupled)', 'ui', 'niche', uiDataCoupled({
2359
3574
  markers: ['Connected Accounts', 'Transfers', 'connect-flag'],
2360
3575
  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}` });
3576
+ const seller = await onboardedSeller(h);
3577
+ await h({ m: 'POST', p: '/v1/transfers', b: `amount=999&currency=usd&destination=${seller}` });
2363
3578
  },
2364
3579
  check: async ({ get }) => {
2365
3580
  const accounts = ((await get('/v1/accounts')).data as Body[]) as unknown as StripeRow[];
@@ -2377,40 +3592,34 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
2377
3592
  // screen reads → render the mirror's OWN ListPane → assert the seeded type survives.
2378
3593
  done('stripe.ui.events', 'ui-dashboard', 'Dashboard: Events log screen (data-coupled)', 'ui', 'core', uiDataCoupled({
2379
3594
  markers: ['Events', 'list-row'],
2380
- seed: async (h) => { await h({ m: 'POST', p: '/v1/events', b: 'type=ui.check.event' }); },
3595
+ seed: async (h) => { await h({ m: 'POST', p: '/v1/coupons', b: 'percent_off=10&duration=once' }); },
2381
3596
  check: async ({ get }) => {
2382
3597
  const rows = ((await get('/v1/events')).data as Body[]) as unknown as StripeRow[];
2383
3598
  if (!rows.length) return false;
2384
3599
  const markup = renderSectionList('events', rows);
2385
- return listRows(markup) === rows.length && rows.some((e) => e.type === 'ui.check.event') && markup.includes('ui.check.event');
3600
+ return listRows(markup) === rows.length && rows.some((e) => e.type === 'coupon.created') && markup.includes('coupon.created');
2386
3601
  },
2387
3602
  })),
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'],
3603
+ // Dashboard: credit notes and the account's tax IDs, data-coupled: seed a credit note and the account's own VAT
3604
+ // number, fetch the same `/v1/credit_notes` and `/v1/tax_ids` lists those screens read, render the mirror's own
3605
+ // ListPane over each and assert the seeded rows survive.
3606
+ done('stripe.ui.billing_credit', 'ui-dashboard', 'Dashboard: Credit Notes + Tax IDs screens (data-coupled)', 'ui', 'common', uiDataCoupled({
3607
+ markers: ['Credit Notes', 'Tax IDs', 'list-row'],
2395
3608
  seed: async (h) => {
2396
3609
  const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=billing-credit-check@twin.test' });
2397
3610
  const custId = (cust.body as Body).id;
2398
3611
  const inv = await h({ m: 'POST', p: '/v1/invoices', b: `customer=${custId}` });
2399
3612
  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' });
3613
+ await h({ m: 'POST', p: '/v1/tax_ids', b: 'type=eu_vat&value=DE999999999' });
2402
3614
  },
2403
3615
  check: async ({ get }) => {
2404
3616
  const notes = ((await get('/v1/credit_notes')).data as Body[]) as unknown as StripeRow[];
2405
3617
  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;
3618
+ if (!notes.length || !taxIds.length) return false;
2408
3619
  const notesMarkup = renderSectionList('credit_notes', notes);
2409
3620
  const taxMarkup = renderSectionList('tax_ids', taxIds);
2410
- const balMarkup = renderSectionList('customer_balance_transactions', custBal);
2411
3621
  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);
3622
+ listRows(taxMarkup) === taxIds.length && taxIds.some((t) => t.value === 'DE999999999');
2414
3623
  },
2415
3624
  })),
2416
3625
  // TWIN-14 (B4) migration — was a marker-grep over the literal 'Payment error'; now
@@ -2468,7 +3677,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
2468
3677
  // per seeded run carrying its report_type (fails on an empty workspace).
2469
3678
  done('stripe.ui.reports', 'ui-dashboard', 'Dashboard: Reports / analytics screen', 'ui', 'niche', uiDataCoupled({
2470
3679
  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' }); },
3680
+ seed: async (h) => { await h({ m: 'POST', p: '/v1/reporting/report_runs', b: 'report_type=balance.summary.1' }); },
2472
3681
  check: async ({ get }) => {
2473
3682
  const rows = ((await get('/v1/reporting/report_runs')).data as Body[]) as unknown as StripeRow[];
2474
3683
  if (!rows || rows.length < 1) return false;
@@ -2482,7 +3691,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
2482
3691
  done('stripe.ui.settings', 'ui-dashboard', 'Dashboard: Settings (account/team/api keys/webhooks)', 'ui', 'common', uiDataCoupled({
2483
3692
  markers: ['settings-block', 'data-settings', 'API keys', 'settings-payout-schedule', 'payoutScheduleText'],
2484
3693
  seed: async (h) => {
2485
- await h({ m: 'POST', p: '/v1/account', b: 'settings[payouts][schedule][interval]=weekly&settings[payouts][schedule][weekly_anchor]=monday' });
3694
+ await h({ m: 'POST', p: '/_twin/account', b: 'settings[payouts][schedule][interval]=weekly&settings[payouts][schedule][weekly_anchor]=monday' });
2486
3695
  await h({ m: 'POST', p: '/v1/webhook_endpoints', b: 'url=https://twin.test/hook&enabled_events[]=charge.succeeded' });
2487
3696
  },
2488
3697
  check: async ({ get }) => {
@@ -2583,15 +3792,26 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
2583
3792
  ),
2584
3793
 
2585
3794
  // ── 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` });
3795
+ // A bank-transfer payment's refund waits in requires_action for the customer's bank details (Stripe emails them), and
3796
+ // only such a refund can be canceled, which gives the charge back its amount; a succeeded refund cannot be canceled
3797
+ // (docs.stripe.com/payments/customer-balance/refunding, docs.stripe.com/api/refunds/cancel).
3798
+ done('stripe.refunds.cancel', 'refunds', 'Refunds: a bank-transfer refund awaiting bank details is canceled (400 once succeeded)', 'api', 'niche', () =>
3799
+ withRoot(async (h) => {
3800
+ const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=wire@twin.test' });
3801
+ await h({ m: 'POST', p: `/v1/test_helpers/customers/${id(cust)}/fund_cash_balance`, b: 'amount=2000&currency=usd' });
3802
+ const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: `amount=2000&currency=usd&customer=${id(cust)}&allowed_payment_method_types[]=customer_balance` });
3803
+ await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/apply_customer_balance` });
3804
+ const waiting = await h({ m: 'POST', p: '/v1/refunds', b: `payment_intent=${id(pi)}&amount=500&instructions_email=wire@twin.test` });
3805
+ if (!ok(waiting) || field(waiting, 'status') !== 'requires_action') return false;
3806
+ const charge = String(field(waiting, 'charge'));
3807
+ const held = await h({ m: 'GET', p: `/v1/charges/${charge}` });
3808
+ const x = await h({ m: 'POST', p: `/v1/refunds/${id(waiting)}/cancel` });
3809
+ const released = await h({ m: 'GET', p: `/v1/charges/${charge}` });
3810
+ const back = await h({ m: 'POST', p: '/v1/refunds', b: `payment_intent=${id(pi)}&amount=500&origin=customer_balance` });
3811
+ const bad = await h({ m: 'POST', p: `/v1/refunds/${id(back)}/cancel` });
2593
3812
  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;
3813
+ return ok(x) && field(x, 'status') === 'canceled' && field(back, 'status') === 'succeeded' && bad.status === 400 && miss.status === 404
3814
+ && field(held, 'amount_refunded') === 500 && field(released, 'amount_refunded') === 0;
2595
3815
  }),
2596
3816
  ),
2597
3817
 
@@ -2604,7 +3824,12 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
2604
3824
  const inc = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/increment_authorization`, b: 'amount=1500' });
2605
3825
  // must be greater than current → reject lowering
2606
3826
  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;
3827
+ // the charge holds the grown authorization, so capturing all of it releases nothing
3828
+ const held = await h({ m: 'GET', p: `/v1/charges/${String(field(inc, 'latest_charge'))}` });
3829
+ await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/capture` });
3830
+ const taken = await h({ m: 'GET', p: `/v1/charges/${String(field(inc, 'latest_charge'))}` });
3831
+ return ok(inc) && field(inc, 'amount') === 1500 && field(inc, 'amount_capturable') === 1500 && field(inc, 'status') === 'requires_capture' && bad.status === 400
3832
+ && field(held, 'amount') === 1500 && field(taken, 'amount_captured') === 1500 && field(taken, 'amount_refunded') === 0 && field(taken, 'refunded') === false;
2608
3833
  }),
2609
3834
  ),
2610
3835
 
@@ -2674,8 +3899,8 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
2674
3899
  // ── Connect: transfer reversals ──
2675
3900
  done('stripe.connect.transfer_reversals', 'connect', 'Connect: transfer reversals (partial + full)', 'api', 'niche', () =>
2676
3901
  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)}` });
3902
+ const seller = await onboardedSeller(h);
3903
+ const tr = await h({ m: 'POST', p: '/v1/transfers', b: `amount=1000&currency=usd&destination=${seller}` });
2679
3904
  if (!ok(tr)) return false;
2680
3905
  const rev1 = await h({ m: 'POST', p: `/v1/transfers/${id(tr)}/reversals`, b: 'amount=400' });
2681
3906
  if (!ok(rev1) || field(rev1, 'object') !== 'transfer_reversal') return false;
@@ -2692,8 +3917,11 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
2692
3917
  // ── Connect: application fees + refunds ──
2693
3918
  done('stripe.connect.application_fees', 'connect', 'Connect: application fees + fee refunds', 'api', 'niche', () =>
2694
3919
  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;
3920
+ // a destination charge with an application fee earns the platform its fee
3921
+ const acct = await h({ m: 'POST', p: '/v1/accounts', b: 'type=express&country=US' });
3922
+ await h({ m: 'POST', p: '/v1/charges', b: `amount=5000&currency=usd&source=tok_visa&application_fee_amount=1000&transfer_data[destination]=${id(acct)}` });
3923
+ const fee = { status: 200, body: (((await h({ m: 'GET', p: '/v1/application_fees' })).body as Body).data as Body[])[0]! } as StripeResponse;
3924
+ if (!fee.body || field(fee, 'object') !== 'application_fee' || field(fee, 'amount') !== 1000) return false;
2697
3925
  const g = await h({ m: 'GET', p: `/v1/application_fees/${id(fee)}` });
2698
3926
  const l = await h({ m: 'GET', p: '/v1/application_fees' });
2699
3927
  const ref = await h({ m: 'POST', p: `/v1/application_fees/${id(fee)}/refunds`, b: 'amount=400' });
@@ -2706,11 +3934,11 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
2706
3934
  // ── Files (uploads) + retrieve ──
2707
3935
  done('stripe.files.upload', 'files', 'Files: upload (purpose required) + retrieve/list', 'api', 'niche', () =>
2708
3936
  withRoot(async (h) => {
2709
- const f = await h({ m: 'POST', p: '/v1/files', b: 'purpose=dispute_evidence&filename=evidence.png' });
3937
+ const f = await h({ m: 'POST', p: '/v1/files', b: 'purpose=dispute_evidence&file=evidence.png' });
2710
3938
  if (!ok(f) || field(f, 'object') !== 'file' || field(f, 'purpose') !== 'dispute_evidence') return false;
2711
3939
  const g = await h({ m: 'GET', p: `/v1/files/${id(f)}` });
2712
3940
  const l = await h({ m: 'GET', p: '/v1/files' });
2713
- const bad = await h({ m: 'POST', p: '/v1/files', b: 'filename=x.png' });
3941
+ const bad = await h({ m: 'POST', p: '/v1/files', b: 'file=x.png' });
2714
3942
  return ok(g) && id(g) === id(f) && field(l, 'object') === 'list' && bad.status === 400;
2715
3943
  }),
2716
3944
  ),
@@ -2772,7 +4000,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
2772
4000
  const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=acb@twin.test' });
2773
4001
  // partial funds first.
2774
4002
  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` });
4003
+ 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
4004
  if (!ok(pi)) return false;
2777
4005
  const partial = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/apply_customer_balance` });
2778
4006
  if (!ok(partial) || field(partial, 'status') !== 'requires_action') return false;
@@ -2795,7 +4023,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
2795
4023
  // + the amount-match check are produced ONLY by this feature.)
2796
4024
  done('stripe.payment_intents.verify_microdeposits', 'payment_intents', 'PaymentIntents: verify_microdeposits (ACH)', 'api', 'common', () =>
2797
4025
  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' });
4026
+ const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=5000&currency=usd&allowed_payment_method_types[]=us_bank_account' });
2799
4027
  if (!ok(pi)) return false;
2800
4028
  const conf = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/confirm`, b: 'payment_method=pm_us_bank_account' });
2801
4029
  if (!ok(conf) || field(conf, 'status') !== 'requires_action') return false;
@@ -2817,7 +4045,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
2817
4045
  // wrong descriptor 400s. (the requires_action microdeposit state is produced ONLY by this feature.)
2818
4046
  done('stripe.setup_intents.verify_microdeposits', 'payment_methods', 'SetupIntents: verify_microdeposits (ACH)', 'api', 'common', () =>
2819
4047
  withRoot(async (h) => {
2820
- const si = await h({ m: 'POST', p: '/v1/setup_intents', b: 'payment_method_types[]=us_bank_account' });
4048
+ const si = await h({ m: 'POST', p: '/v1/setup_intents', b: 'allowed_payment_method_types[]=us_bank_account' });
2821
4049
  if (!ok(si)) return false;
2822
4050
  const conf = await h({ m: 'POST', p: `/v1/setup_intents/${id(si)}/confirm`, b: 'payment_method=pm_us_bank_account' });
2823
4051
  if (!ok(conf) || field(conf, 'status') !== 'requires_action' || (field(conf, 'next_action') as Body)?.type !== 'verify_with_microdeposits') return false;
@@ -2957,7 +4185,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
2957
4185
  // editing a finalized (non-draft) invoice 400s; missing lines 400; unknown invoice 404.
2958
4186
  await h({ m: 'POST', p: `/v1/invoices/${id(inv)}/finalize` });
2959
4187
  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' });
4188
+ const noLines = await h({ m: 'POST', p: `/v1/invoices/${id(inv)}/add_lines`, b: 'invoice_metadata[x]=1' });
2961
4189
  const missing = await h({ m: 'GET', p: '/v1/invoices/in_nope/lines' });
2962
4190
  return afterFinal.status === 400 && (noLines.status === 400) && missing.status === 404;
2963
4191
  }),
@@ -3064,12 +4292,14 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
3064
4292
  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
4293
  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
4294
  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` });
4295
+ const summary = await h({ m: 'GET', p: `/v1/billing/meters/${id(m)}/event_summaries?customer=${id(cust)}&start_time=0&end_time=1020` });
3068
4296
  const sData = (summary.body as Body).data as Body[];
3069
4297
  if (!ok(summary) || sData[0]!.aggregated_value !== 7) return false;
3070
4298
  // 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` });
4299
+ const summaryNarrow = await h({ m: 'GET', p: `/v1/billing/meters/${id(m)}/event_summaries?customer=${id(cust)}&start_time=120&end_time=1020` });
3072
4300
  if (((summaryNarrow.body as Body).data as Body[])[0]!.aggregated_value !== 4) return false;
4301
+ // the window must be aligned with minute boundaries
4302
+ 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
4303
  // deactivate → inactive; the active filter excludes it; reactivate → active.
3074
4304
  const off = await h({ m: 'POST', p: `/v1/billing/meters/${id(m)}/deactivate` });
3075
4305
  if (!ok(off) || field(off, 'status') !== 'inactive' || (field(off, 'status_transitions') as Body).deactivated_at === null) return false;
@@ -3088,7 +4318,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
3088
4318
  done('stripe.billing.alerts', 'billing', 'Billing usage alerts (thresholds)', 'api', 'niche', () =>
3089
4319
  withRoot(async (h) => {
3090
4320
  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)}` });
4321
+ 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
4322
  if (!ok(a) || field(a, 'object') !== 'billing.alert' || field(a, 'status') !== 'active') return false;
3093
4323
  if ((field(a, 'usage_threshold') as Body).gte !== 100) return false;
3094
4324
  const get = await h({ m: 'GET', p: `/v1/billing/alerts/${id(a)}` });
@@ -3100,7 +4330,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
3100
4330
  const arch = await h({ m: 'POST', p: `/v1/billing/alerts/${id(a)}/archive` });
3101
4331
  // vendor errors.
3102
4332
  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' });
4333
+ 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
4334
  const missing = await h({ m: 'GET', p: '/v1/billing/alerts/alert_nope' });
3105
4335
  return ok(on) && field(on, 'status') === 'active' && ok(arch) && field(arch, 'status') === 'archived' &&
3106
4336
  badType.status === 400 && badMeter.status === 400 && missing.status === 404;
@@ -3155,8 +4385,9 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
3155
4385
  // before subscribing: no active entitlements.
3156
4386
  const before = await h({ m: 'GET', p: `/v1/entitlements/active_entitlements?customer=${id(cust)}` });
3157
4387
  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)}` });
4388
+ // subscribe (the card pays the first invoice) → entitled to the product's feature.
4389
+ const card = await h({ m: 'POST', p: '/v1/payment_methods/pm_card_visa/attach', b: `customer=${id(cust)}` });
4390
+ const sub = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&items[0][price]=${id(price)}&default_payment_method=${id(card)}` });
3160
4391
  if (!ok(sub)) return false;
3161
4392
  const after = await h({ m: 'GET', p: `/v1/entitlements/active_entitlements?customer=${id(cust)}` });
3162
4393
  const data = (after.body as Body).data as Body[];
@@ -3243,25 +4474,19 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
3243
4474
  ok(li) && ((li.body as Body).data as Body[]).length === 1;
3244
4475
  }),
3245
4476
  ),
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', () =>
4477
+ // Discount delete: a subscription's coupon (discounts[0][coupon]) is removed by DELETE …/discount
4478
+ // (→ {object:'discount',deleted:true}) and the subscription's discounts are cleared. A customer's discount has no
4479
+ // create parameter in the served version (the customer-level coupon was removed), so an unknown customer's DELETE
4480
+ // answers 404.
4481
+ done('stripe.discounts.delete', 'discounts', 'Subscription discount delete', 'api', 'common', () =>
3251
4482
  withRoot(async (h) => {
3252
4483
  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)}` });
4484
+ const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=disc-del@twin.test' });
4485
+ const sub = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&discounts[0][coupon]=${id(coupon)}` });
3261
4486
  if (((field(sub, 'discounts') as Body[]) ?? []).length !== 1) return false;
3262
4487
  const delSub = await h({ m: 'DELETE', p: `/v1/subscriptions/${id(sub)}/discount` });
3263
4488
  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' });
4489
+ const badCoupon = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&discounts[0][coupon]=coupon_nope` });
3265
4490
  const missing = await h({ m: 'DELETE', p: '/v1/customers/cus_nope/discount' });
3266
4491
  return ok(delSub) && field(delSub, 'deleted') === true && ((field(s, 'discounts') as Body[]) ?? []).length === 0 &&
3267
4492
  badCoupon.status === 400 && missing.status === 404;
@@ -3272,7 +4497,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
3272
4497
  done('stripe.promotion_codes.update', 'discounts', 'Promotion codes: update (active toggle)', 'api', 'common', () =>
3273
4498
  withRoot(async (h) => {
3274
4499
  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` });
4500
+ const pc = await h({ m: 'POST', p: '/v1/promotion_codes', b: `promotion[type]=coupon&promotion[coupon]=${id(coupon)}&code=UPD10` });
3276
4501
  if (!ok(pc) || field(pc, 'active') !== true) return false;
3277
4502
  const off = await h({ m: 'POST', p: `/v1/promotion_codes/${id(pc)}`, b: 'active=false&metadata[campaign]=spring' });
3278
4503
  if (!ok(off) || field(off, 'active') !== false || ((field(off, 'metadata') as Body)?.campaign) !== 'spring') return false;
@@ -3333,15 +4558,14 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
3333
4558
  const missing = await h({ m: 'GET', p: '/v1/issuing/tokens/iss_tok_nope' });
3334
4559
  const badUpd = await h({ m: 'POST', p: '/v1/issuing/tokens/iss_tok_nope', b: 'status=active' });
3335
4560
  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.
4561
+ // a token is network-minted when the cardholder adds a real card to a phone's wallet (the twin's stand-in).
3337
4562
  const ch = await h({ m: 'POST', p: '/v1/issuing/cardholders', b: 'name=Jane&type=individual&billing[address][country]=US' });
3338
4563
  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)}` });
4564
+ const seeded = await h({ m: 'POST', p: `/_twin/issuing/cards/${id(card)}/wallets`, b: 'wallet_provider=apple_pay' });
3340
4565
  if (!ok(seeded) || field(seeded, 'object') !== 'issuing.token' || field(seeded, 'status') !== 'active' || field(seeded, 'card') !== id(card)) return false;
3341
4566
  // 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;
4567
+ const badCard = await h({ m: 'POST', p: '/_twin/issuing/cards/ic_nope/wallets', b: 'wallet_provider=apple_pay' });
4568
+ if (badCard.status !== 400) return false;
3345
4569
  // retrieve the seeded token.
3346
4570
  const get = await h({ m: 'GET', p: `/v1/issuing/tokens/${id(seeded)}` });
3347
4571
  if (!ok(get) || id(get) !== id(seeded) || field(get, 'card') !== id(card) || field(get, 'status') !== 'active') return false;
@@ -3373,8 +4597,12 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
3373
4597
  // (the design object + review status machine are produced ONLY by this feature.)
3374
4598
  done('stripe.issuing.personalization_designs', 'issuing', 'Issuing: card personalization designs', 'api', 'niche', () =>
3375
4599
  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;
4600
+ // the card logo must be a file of purpose issuing_logo; another purpose is refused
4601
+ const logo = await h({ m: 'POST', p: '/v1/files', b: 'purpose=issuing_logo&file=logo.png' });
4602
+ const icon = await h({ m: 'POST', p: '/v1/files', b: 'purpose=business_logo&file=icon.png' });
4603
+ if ((await h({ m: 'POST', p: '/v1/issuing/personalization_designs', b: `physical_bundle=ics_NLuXJPDYSTjFON&card_logo=${id(icon)}` })).status !== 400) return false;
4604
+ const d = await h({ m: 'POST', p: '/v1/issuing/personalization_designs', b: `physical_bundle=ics_NLuXJPDYSTjFON&name=Gold card&card_logo=${id(logo)}` });
4605
+ if (!ok(d) || field(d, 'object') !== 'issuing.personalization_design' || field(d, 'status') !== 'review') return false;
3378
4606
  const get = await h({ m: 'GET', p: `/v1/issuing/personalization_designs/${id(d)}` });
3379
4607
  const upd = await h({ m: 'POST', p: `/v1/issuing/personalization_designs/${id(d)}`, b: 'name=Platinum' });
3380
4608
  if (!ok(get) || !ok(upd) || field(upd, 'name') !== 'Platinum') return false;
@@ -3489,20 +4717,27 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
3489
4717
  badFa.status === 400 && noDest.status === 400 && nope.status === 404;
3490
4718
  }),
3491
4719
  ),
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_.
4720
+ // Treasury InboundTransfers: pull funds into a FinancialAccount. create→processing (funds inbound_pending), the
4721
+ // test helper's succeed→succeeded, posting its ledger Transaction (cash↑). Requires amount/currency/
4722
+ // financial_account/origin_payment_method, a bank account set up for inbound flows and verified. Unknown id 404. id ibt_.
3495
4723
  done('stripe.treasury.inbound_transfers', 'treasury', 'Treasury: inbound transfers / received credits', 'api', 'niche', () =>
3496
4724
  withRoot(async (h) => {
3497
4725
  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;
4726
+ // the pulled account is set up for inbound flows and verified with a SetupIntent; one that is not is refused
4727
+ 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' });
4728
+ 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' });
4729
+ const pm = String(field(conf, 'payment_method'));
4730
+ 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;
4731
+ await h({ m: 'POST', p: `/v1/setup_intents/${id(si)}/verify_microdeposits`, b: 'amounts[]=32&amounts[]=45' });
4732
+ const it = await h({ m: 'POST', p: `/v1/treasury/inbound_transfers`, b: `amount=3000&currency=usd&financial_account=${id(fa)}&origin_payment_method=${pm}` });
4733
+ if (!ok(it) || field(it, 'object') !== 'treasury.inbound_transfer' || field(it, 'status') !== 'processing' || !id(it).startsWith('ibt_')) return false;
4734
+ const done = await h({ m: 'POST', p: `/v1/test_helpers/treasury/inbound_transfers/${id(it)}/succeed` });
4735
+ if (!ok(done) || field(done, 'status') !== 'succeeded' || (field(done, 'status_transitions') as Body)?.succeeded_at == null) return false;
3501
4736
  const get = await h({ m: 'GET', p: `/v1/treasury/inbound_transfers/${id(it)}` });
3502
4737
  const list = await h({ m: 'GET', p: `/v1/treasury/inbound_transfers?financial_account=${id(fa)}` });
3503
4738
  if (!ok(get) || ((list.body as Body).data as Body[]).length !== 1) return false;
3504
4739
  // 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` });
4740
+ const txns = await h({ m: 'GET', p: `/v1/treasury/transactions?financial_account=${id(fa)}` });
3506
4741
  const tdata = (txns.body as Body).data as Body[];
3507
4742
  if (tdata.length !== 1 || (tdata[0]!.balance_impact as Body)?.cash !== 3000 || tdata[0]!.status !== 'posted') return false;
3508
4743
  // received_credits/debits read surface exists (empty until a test flow materializes one).
@@ -3513,7 +4748,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
3513
4748
  }),
3514
4749
  ),
3515
4750
  // 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;
4751
+ // a valid product + exactly one of amount/metric_tons (else 400); is confirmed when made;
3517
4752
  // cancel→canceled. amount_total = subtotal+fees. Unknown product 400; unknown order 404.
3518
4753
  done('stripe.climate.orders', 'climate', 'Climate: orders / products / suppliers', 'api', 'niche', () =>
3519
4754
  withRoot(async (h) => {
@@ -3522,8 +4757,8 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
3522
4757
  if (pdata.length === 0 || pdata[0]!.object !== 'climate.product') return false;
3523
4758
  const suppliers = await h({ m: 'GET', p: '/v1/climate/suppliers' });
3524
4759
  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;
4760
+ const order = await h({ m: 'POST', p: '/v1/climate/orders', b: 'product=climsku_frontier_offtake_portfolio_2027&metric_tons=2' });
4761
+ if (!ok(order) || field(order, 'object') !== 'climate.order' || field(order, 'status') !== 'confirmed' || !id(order).startsWith('climorder_')) return false;
3527
4762
  if (field(order, 'amount_total') !== (field(order, 'amount_subtotal') as number) + (field(order, 'amount_fees') as number)) return false;
3528
4763
  const get = await h({ m: 'GET', p: `/v1/climate/orders/${id(order)}` });
3529
4764
  const list = await h({ m: 'GET', p: '/v1/climate/orders' });
@@ -3531,25 +4766,27 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
3531
4766
  const cancel = await h({ m: 'POST', p: `/v1/climate/orders/${id(order)}/cancel` });
3532
4767
  if (!ok(cancel) || field(cancel, 'status') !== 'canceled') return false;
3533
4768
  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' });
4769
+ const bothAmounts = await h({ m: 'POST', p: '/v1/climate/orders', b: 'product=climsku_frontier_offtake_portfolio_2027&metric_tons=1&amount=1000' });
3535
4770
  const nope = await h({ m: 'GET', p: '/v1/climate/orders/climorder_nope' });
3536
4771
  return badProduct.status === 400 && bothAmounts.status === 400 && nope.status === 404;
3537
4772
  }),
3538
4773
  ),
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.
4774
+ // Financial Connections: create a session (requires account_holder + permissions), the person links the test
4775
+ // bank's accounts in the flow Stripe.js opens, then read accounts + the session's accounts sub-list. id fcsess_ /
4776
+ // fca_. Missing permissions 400; unknown id 404.
3542
4777
  done('stripe.financial_connections.sessions', 'financial_connections', 'Financial Connections: sessions + accounts', 'api', 'niche', () =>
3543
- withRoot(async (h) => {
4778
+ withRoot(async (h, root) => {
3544
4779
  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
4780
  if (!ok(sess) || field(sess, 'object') !== 'financial_connections.session' || !id(sess).startsWith('fcsess_')) return false;
3546
4781
  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;
4782
+ await hostedSubmit(root, stripeFinancialConnectionsFlow, `https://js.stripe.com/v3/financial-connections/${String(field(sess, 'client_secret'))}`, { answer: 'link' });
3549
4783
  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;
4784
+ const linked = ((field(getSess, 'accounts') as Body)?.data as Body[]) ?? [];
4785
+ if (!linked.length) return false;
4786
+ const acc = { status: 200, body: linked[0]! } as StripeResponse;
4787
+ if (field(acc, 'object') !== 'financial_connections.account' || !id(acc).startsWith('fca_') || field(acc, 'status') !== 'active') return false;
3551
4788
  const accounts = await h({ m: 'GET', p: '/v1/financial_connections/accounts' });
3552
- if (((accounts.body as Body).data as Body[]).length !== 1) return false;
4789
+ if (((accounts.body as Body).data as Body[]).length !== linked.length) return false;
3553
4790
  const getAcc = await h({ m: 'GET', p: `/v1/financial_connections/accounts/${id(acc)}` });
3554
4791
  const disc = await h({ m: 'POST', p: `/v1/financial_connections/accounts/${id(acc)}/disconnect` });
3555
4792
  const noPerms = await h({ m: 'POST', p: '/v1/financial_connections/sessions', b: 'account_holder[type]=customer&account_holder[customer]=cus_1' });
@@ -3557,20 +4794,17 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
3557
4794
  return ok(getAcc) && ok(disc) && field(disc, 'status') === 'disconnected' && noPerms.status === 400 && nope.status === 404;
3558
4795
  }),
3559
4796
  ),
3560
- // Financial Connections account transactions: list a linked account's transactions
3561
- // (also via /v1/financial_connections/transactions?account=). Requires account; 404 unknown.
4797
+ // Financial Connections transactions: a linked account's, listed by account. Requires account.
3562
4798
  done('stripe.financial_connections.transactions', 'financial_connections', 'Financial Connections: account transactions', 'api', 'niche', () =>
3563
- withRoot(async (h) => {
4799
+ withRoot(async (h, root) => {
3564
4800
  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;
4801
+ await hostedSubmit(root, stripeFinancialConnectionsFlow, `https://js.stripe.com/v3/financial-connections/${String(field(sess, 'client_secret'))}`, { answer: 'link' });
4802
+ const acc = ((field(await h({ m: 'GET', p: `/v1/financial_connections/sessions/${id(sess)}` }), 'accounts') as Body).data as Body[])[0]!;
4803
+ const flat = await h({ m: 'GET', p: `/v1/financial_connections/transactions?account=${String(acc.id)}` });
4804
+ const tdata = (flat.body as Body).data as Body[];
4805
+ if (!tdata.length || tdata[0]!.object !== 'financial_connections.transaction' || tdata[0]!.account !== acc.id) return false;
3571
4806
  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;
4807
+ return noAcct.status === 400;
3574
4808
  }),
3575
4809
  ),
3576
4810
  // Forwarding: PAN forwarding requests. create requires payment_method + url; stores the