@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.
- package/README.md +64 -27
- package/client/dashboard-api.ts +286 -0
- package/client/stripe-mirror.css +272 -159
- package/client/stripe-mirror.tsx +1384 -541
- package/dist/client/dashboard-api.d.ts +107 -0
- package/dist/client/dashboard-api.js +238 -0
- package/dist/client/dashboard-api.ts +286 -0
- package/dist/client/stripe-mirror.bundle.js +236 -0
- package/dist/client/stripe-mirror.css +275 -0
- package/dist/client/stripe-mirror.d.ts +134 -0
- package/dist/client/stripe-mirror.js +823 -0
- package/dist/client/stripe-mirror.tsx +1534 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +39 -0
- package/dist/src/generated/events.gen.json +1 -0
- package/dist/src/generated/surface.gen.json +1 -0
- package/dist/src/generated/ui.gen.json +1 -0
- package/dist/src/index.d.ts +14 -0
- package/dist/src/index.js +73 -0
- package/dist/src/manifest.d.ts +2 -0
- package/dist/src/manifest.js +1065 -0
- package/dist/src/screens/checkout.d.ts +31 -0
- package/dist/src/screens/checkout.js +241 -0
- package/dist/src/screens/consent-skin.d.ts +4 -0
- package/dist/src/screens/consent-skin.js +18 -0
- package/dist/src/screens/financial-connections.d.ts +5 -0
- package/dist/src/screens/financial-connections.js +90 -0
- package/dist/src/screens/identity.d.ts +5 -0
- package/dist/src/screens/identity.js +86 -0
- package/dist/src/screens/industries.d.ts +1 -0
- package/dist/src/screens/industries.js +267 -0
- package/dist/src/screens/onboarding.d.ts +13 -0
- package/dist/src/screens/onboarding.js +225 -0
- package/dist/src/screens/portal.d.ts +5 -0
- package/dist/src/screens/portal.js +214 -0
- package/dist/src/screens/public-details.d.ts +5 -0
- package/dist/src/screens/public-details.js +90 -0
- package/dist/src/semantics/after-payment.d.ts +22 -0
- package/dist/src/semantics/after-payment.js +93 -0
- package/dist/src/semantics/apps-secrets.d.ts +2 -0
- package/dist/src/semantics/apps-secrets.js +54 -0
- package/dist/src/semantics/balance.d.ts +11 -0
- package/dist/src/semantics/balance.js +195 -0
- package/dist/src/semantics/billing.d.ts +2 -0
- package/dist/src/semantics/billing.js +220 -0
- package/dist/src/semantics/charges.d.ts +28 -0
- package/dist/src/semantics/charges.js +201 -0
- package/dist/src/semantics/checkout.d.ts +15 -0
- package/dist/src/semantics/checkout.js +303 -0
- package/dist/src/semantics/connect.d.ts +5 -0
- package/dist/src/semantics/connect.js +476 -0
- package/dist/src/semantics/coupons.d.ts +6 -0
- package/dist/src/semantics/coupons.js +92 -0
- package/dist/src/semantics/credit-notes.d.ts +2 -0
- package/dist/src/semantics/credit-notes.js +172 -0
- package/dist/src/semantics/customers.d.ts +6 -0
- package/dist/src/semantics/customers.js +429 -0
- package/dist/src/semantics/disputes.d.ts +2 -0
- package/dist/src/semantics/disputes.js +51 -0
- package/dist/src/semantics/entitlements.d.ts +2 -0
- package/dist/src/semantics/entitlements.js +95 -0
- package/dist/src/semantics/ephemeral-keys.d.ts +2 -0
- package/dist/src/semantics/ephemeral-keys.js +34 -0
- package/dist/src/semantics/files.d.ts +2 -0
- package/dist/src/semantics/files.js +125 -0
- package/dist/src/semantics/invoices.d.ts +18 -0
- package/dist/src/semantics/invoices.js +541 -0
- package/dist/src/semantics/issuing.d.ts +13 -0
- package/dist/src/semantics/issuing.js +570 -0
- package/dist/src/semantics/ledger.d.ts +54 -0
- package/dist/src/semantics/ledger.js +181 -0
- package/dist/src/semantics/payment-intents.d.ts +18 -0
- package/dist/src/semantics/payment-intents.js +404 -0
- package/dist/src/semantics/payment-links.d.ts +2 -0
- package/dist/src/semantics/payment-links.js +133 -0
- package/dist/src/semantics/payment-methods.d.ts +20 -0
- package/dist/src/semantics/payment-methods.js +138 -0
- package/dist/src/semantics/plans.d.ts +5 -0
- package/dist/src/semantics/plans.js +121 -0
- package/dist/src/semantics/platform.d.ts +9 -0
- package/dist/src/semantics/platform.js +206 -0
- package/dist/src/semantics/products.d.ts +2 -0
- package/dist/src/semantics/products.js +140 -0
- package/dist/src/semantics/radar.d.ts +2 -0
- package/dist/src/semantics/radar.js +83 -0
- package/dist/src/semantics/refunds.d.ts +9 -0
- package/dist/src/semantics/refunds.js +195 -0
- package/dist/src/semantics/renewals.d.ts +47 -0
- package/dist/src/semantics/renewals.js +251 -0
- package/dist/src/semantics/setup-intents.d.ts +2 -0
- package/dist/src/semantics/setup-intents.js +84 -0
- package/dist/src/semantics/shared.d.ts +78 -0
- package/dist/src/semantics/shared.js +192 -0
- package/dist/src/semantics/subscription-schedules.d.ts +2 -0
- package/dist/src/semantics/subscription-schedules.js +119 -0
- package/dist/src/semantics/subscriptions.d.ts +11 -0
- package/dist/src/semantics/subscriptions.js +605 -0
- package/dist/src/semantics/tax.d.ts +2 -0
- package/dist/src/semantics/tax.js +197 -0
- package/dist/src/semantics/terminal.d.ts +5 -0
- package/dist/src/semantics/terminal.js +182 -0
- package/dist/src/semantics/test-clocks.d.ts +6 -0
- package/dist/src/semantics/test-clocks.js +73 -0
- package/dist/src/semantics/tokens.d.ts +4 -0
- package/dist/src/semantics/tokens.js +44 -0
- package/dist/src/semantics/transfers.d.ts +2 -0
- package/dist/src/semantics/transfers.js +154 -0
- package/dist/src/semantics/treasury.d.ts +2 -0
- package/dist/src/semantics/treasury.js +377 -0
- package/dist/src/semantics/webhook-endpoints.d.ts +3 -0
- package/dist/src/semantics/webhook-endpoints.js +85 -0
- package/dist/src/stripe-budget.d.ts +55 -0
- package/dist/src/stripe-budget.js +155 -0
- package/dist/src/stripe-capabilities.d.ts +3 -0
- package/dist/src/stripe-capabilities.js +5052 -0
- package/dist/src/stripe-conformance.d.ts +41 -0
- package/dist/src/stripe-conformance.js +96 -0
- package/dist/src/stripe-connector.d.ts +161 -0
- package/dist/src/stripe-connector.js +414 -0
- package/dist/src/stripe-emit.d.ts +2 -0
- package/dist/src/stripe-emit.js +145 -0
- package/dist/src/stripe-events.d.ts +93 -0
- package/dist/src/stripe-events.js +388 -0
- package/dist/src/stripe-js.d.ts +4 -0
- package/dist/src/stripe-js.js +70 -0
- package/dist/src/stripe-mirror-ui.d.ts +15 -0
- package/dist/src/stripe-mirror-ui.js +87 -0
- package/dist/src/stripe-params.d.ts +3 -0
- package/dist/src/stripe-params.js +43 -0
- package/dist/src/stripe-perform-harness.d.ts +9 -0
- package/dist/src/stripe-perform-harness.js +26 -0
- package/dist/src/stripe-server.d.ts +33 -0
- package/dist/src/stripe-server.js +326 -0
- package/dist/src/stripe-shared.d.ts +106 -0
- package/dist/src/stripe-shared.js +273 -0
- package/dist/src/stripe-twin.d.ts +155 -0
- package/dist/src/stripe-twin.js +1226 -0
- package/dist/src/stripe-ui-conformance.d.ts +5 -0
- package/dist/src/stripe-ui-conformance.js +79 -0
- package/dist/src/stripe-ui-structure.d.ts +3 -0
- package/dist/src/stripe-ui-structure.js +168 -0
- package/dist/src/stripe-version.d.ts +10 -0
- package/dist/src/stripe-version.js +285 -0
- package/dist/test-fixtures/stripe-known-deviations.json +105 -0
- package/dist/test-fixtures/stripe-openapi-operations.SOURCE.md +14 -0
- package/dist/test-fixtures/stripe-openapi-operations.json +4717 -0
- package/dist/test-fixtures/stripe-schemas.SOURCE.md +35 -0
- package/dist/test-fixtures/stripe-schemas.json +3740 -0
- package/package.json +18 -10
- package/src/cli.ts +7 -7
- package/src/generated/events.gen.json +1 -0
- package/src/generated/surface.gen.json +1 -0
- package/src/generated/ui.gen.json +1 -0
- package/src/index.ts +31 -9
- package/src/manifest.ts +1097 -0
- package/src/screens/checkout.tsx +252 -0
- package/src/screens/consent-skin.ts +20 -0
- package/src/screens/financial-connections.tsx +101 -0
- package/src/screens/identity.tsx +96 -0
- package/src/screens/industries.ts +267 -0
- package/src/screens/onboarding.tsx +243 -0
- package/src/screens/portal.tsx +218 -0
- package/src/screens/public-details.tsx +105 -0
- package/src/semantics/after-payment.ts +113 -0
- package/src/semantics/apps-secrets.ts +58 -0
- package/src/semantics/balance.ts +209 -0
- package/src/semantics/billing.ts +216 -0
- package/src/semantics/charges.ts +211 -0
- package/src/semantics/checkout.ts +297 -0
- package/src/semantics/connect.ts +471 -0
- package/src/semantics/coupons.ts +97 -0
- package/src/semantics/credit-notes.ts +168 -0
- package/src/semantics/customers.ts +432 -0
- package/src/semantics/disputes.ts +62 -0
- package/src/semantics/entitlements.ts +94 -0
- package/src/semantics/ephemeral-keys.ts +34 -0
- package/src/semantics/files.ts +143 -0
- package/src/semantics/invoices.ts +541 -0
- package/src/semantics/issuing.ts +585 -0
- package/src/semantics/ledger.ts +216 -0
- package/src/semantics/payment-intents.ts +420 -0
- package/src/semantics/payment-links.ts +148 -0
- package/src/semantics/payment-methods.ts +143 -0
- package/src/semantics/plans.ts +131 -0
- package/src/semantics/platform.ts +220 -0
- package/src/semantics/products.ts +154 -0
- package/src/semantics/radar.ts +85 -0
- package/src/semantics/refunds.ts +218 -0
- package/src/semantics/renewals.ts +274 -0
- package/src/semantics/setup-intents.ts +87 -0
- package/src/semantics/shared.ts +215 -0
- package/src/semantics/subscription-schedules.ts +129 -0
- package/src/semantics/subscriptions.ts +610 -0
- package/src/semantics/tax.ts +220 -0
- package/src/semantics/terminal.ts +195 -0
- package/src/semantics/test-clocks.ts +77 -0
- package/src/semantics/tokens.ts +52 -0
- package/src/semantics/transfers.ts +174 -0
- package/src/semantics/treasury.ts +383 -0
- package/src/semantics/webhook-endpoints.ts +87 -0
- package/src/stripe-budget.ts +4 -4
- package/src/stripe-capabilities.ts +1456 -222
- package/src/stripe-conformance.ts +6 -5
- package/src/stripe-connector.ts +68 -40
- package/src/stripe-emit.ts +14 -7
- package/src/stripe-events.ts +94 -36
- package/src/stripe-js.ts +70 -0
- package/src/stripe-mirror-ui.ts +28 -298
- package/src/stripe-params.ts +44 -0
- package/src/stripe-perform-harness.ts +29 -0
- package/src/stripe-server.ts +263 -38
- package/src/stripe-shared.ts +294 -0
- package/src/stripe-twin.ts +429 -5325
- package/src/stripe-ui-conformance.ts +70 -107
- package/src/stripe-ui-structure.ts +124 -348
- package/src/stripe-version.ts +278 -0
- package/test-fixtures/stripe-known-deviations.json +2 -7
- package/test-fixtures/stripe-openapi-operations.json +1188 -2855
- 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 {
|
|
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 {
|
|
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
|
|
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}¤cy=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¤cy=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}¤cy=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)
|
|
302
|
-
//
|
|
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¤cy=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¤cy=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¤cy=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
|
|
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¤cy=usd&source[number]=4000000000000002' });
|
|
408
529
|
const ins = await h({ m: 'POST', p: '/v1/charges', b: 'amount=1000¤cy=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¤cy=usd&source=tok_visa' });
|
|
542
|
+
const auth = await h({ m: 'POST', p: '/v1/charges', b: 'amount=3000¤cy=usd&source=tok_visa&capture=false' });
|
|
543
|
+
const dec = await h({ m: 'POST', p: '/v1/charges', b: 'amount=1000¤cy=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¤cy=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¤cy=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
|
|
614
|
+
const c = await h({ m: 'POST', p: '/v1/charges', b: 'amount=2000¤cy=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¤cy=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¤cy=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¤cy=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¤cy=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
|
|
695
|
+
const c = await h({ m: 'POST', p: '/v1/charges', b: 'amount=2000¤cy=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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
752
|
+
done('stripe.balance_transactions.crud', 'balance', 'BalanceTransactions: a charge\'s ledger entry; retrieve + list', 'api', 'core', () =>
|
|
514
753
|
withRoot(async (h) => {
|
|
515
|
-
|
|
516
|
-
|
|
754
|
+
await h({ m: 'POST', p: '/v1/charges', b: 'amount=1000¤cy=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¤cy=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¤cy=usd' })).status !== 400) return false;
|
|
773
|
+
await h({ m: 'POST', p: '/v1/charges', b: 'amount=10000¤cy=usd&source=tok_bypassPending' });
|
|
774
|
+
const po = await h({ m: 'POST', p: '/v1/payouts', b: 'amount=5000¤cy=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¤cy=usd&source=tok_bypassPending' });
|
|
524
783
|
const p = await h({ m: 'POST', p: '/v1/payouts', b: 'amount=1000¤cy=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:
|
|
532
|
-
//
|
|
533
|
-
//
|
|
534
|
-
done('stripe.payouts.reverse', 'payouts', 'Payout reverse', 'api', 'niche', () =>
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
const
|
|
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¤cy=usd&destination=${seller}` });
|
|
799
|
+
const p = await h({ m: 'POST', p: '/v1/payouts', b: 'amount=4000¤cy=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') !== '
|
|
542
|
-
const
|
|
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¤cy=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: '/
|
|
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¤cy=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¤cy=usd&payment_method=pm_card_visa&confirm=true');
|
|
879
|
+
await h('POST', '/v1/transfers', `amount=2400¤cy=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¤cy=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¤cy=usd&payment_method=pm_card_visa&confirm=true');
|
|
926
|
+
await h('POST', '/v1/transfers', `amount=1500¤cy=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¤cy=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¤cy=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¤cy=usd&interval=month&product=${id(prod)}` });
|
|
1007
|
+
const both = await h({ m: 'POST', p: '/v1/plans', b: `amount=100&amount_decimal=100¤cy=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.
|
|
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¤cy=usd&recurring[interval]=month&product=${id(prod)}` });
|
|
788
|
-
|
|
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¤cy=usd&recurring[interval]=month&product=${id(prod)}` });
|
|
1239
|
+
const advanced = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=3000¤cy=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: '
|
|
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¤cy=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¤cy=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¤cy=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¤cy=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¤cy=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¤cy=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¤cy=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¤cy=usd` });
|
|
952
1619
|
const l = await h({ m: 'GET', p: '/v1/invoiceitems' });
|
|
953
|
-
|
|
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¤cy=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¤cy=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¤cy=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: '
|
|
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: '
|
|
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¤cy=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
|
-
|
|
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¤cy=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¤cy=usd&recurring[interval]=month&product=${String(prod.id)}`);
|
|
1829
|
+
const yearly = await api('POST', '/v1/prices', `unit_amount=90000¤cy=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¤cy=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¤cy=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¤cy=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
|
-
|
|
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¤cy=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:
|
|
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
|
|
1156
|
-
|
|
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¤cy=usd&destination=${id(fresh)}` })).status !== 400) return false;
|
|
2192
|
+
const t = await h({ m: 'POST', p: '/v1/transfers', b: `amount=500¤cy=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¤cy=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¤cy=usd&destination=${seller}&source_transaction=${charge}&transfer_group=mine` });
|
|
2218
|
+
const over = await h({ m: 'POST', p: '/v1/transfers', b: `amount=500¤cy=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¤cy=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¤cy=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¤cy=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¤cy=usd&destination=${seller}&source_transaction=${plainCharge}`, day: 5 });
|
|
2234
|
+
const fits = await h({ m: 'POST', p: '/v1/transfers', b: `amount=1000¤cy=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¤cy=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¤cy=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
|
|
1175
|
-
|
|
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¤cy=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¤cy=usd' });
|
|
1177
2257
|
const conn = await h({ m: 'POST', p: '/v1/payouts', b: 'amount=2000¤cy=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¤cy=usd&source=tok_bypassPending');
|
|
2319
|
+
await h('POST', '/v1/transfers', `amount=5000¤cy=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¤cy=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
|
|
1230
|
-
|
|
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:
|
|
1237
|
-
//
|
|
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
|
-
|
|
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:
|
|
2400
|
+
done('stripe.events.list', 'events', 'Events: a write records its event; retrieve + list', 'api', 'core', () =>
|
|
1257
2401
|
withRoot(async (h) => {
|
|
1258
|
-
|
|
1259
|
-
|
|
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¤cy=usd&destination=${seller}`);
|
|
2439
|
+
const po = await noon('POST', '/v1/payouts', 'amount=4000¤cy=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¤cy=usd' });
|
|
2571
|
+
const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=2000¤cy=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:
|
|
1451
|
-
const e2 = await h({ m: 'GET', p: `/v1/charges/${
|
|
2609
|
+
const ch = await h({ m: 'POST', p: '/v1/payment_intents', b: `amount=1000¤cy=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
|
|
1526
|
-
//
|
|
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
|
|
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, '
|
|
1538
|
-
const
|
|
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
|
|
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
|
-
//
|
|
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 (
|
|
1552
|
-
withRoot(async (h) => {
|
|
1553
|
-
const fund = await
|
|
1554
|
-
|
|
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)}¤cy=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/
|
|
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
|
-
//
|
|
1670
|
-
// closes it (approved:false).
|
|
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)}¤cy=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') !== '
|
|
1679
|
-
//
|
|
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)}¤cy=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
|
|
1785
|
-
//
|
|
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;
|
|
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)}¤cy=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
|
-
//
|
|
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, '
|
|
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:
|
|
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)}¤cy=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
|
-
|
|
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¤cy=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¤cy=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
|
-
//
|
|
2094
|
-
//
|
|
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: '
|
|
2103
|
-
if (!ok(run) || field(run, 'object') !== 'reporting.report_run' || field(run, 'status') !== '
|
|
2104
|
-
|
|
2105
|
-
const g = await
|
|
3308
|
+
const run = await h({ m: 'POST', p: '/v1/reporting/report_runs', b: 'report_type=balance.summary.1¶meters[interval_start]=0¶meters[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: '
|
|
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
|
|
2117
|
-
// starts and completes to 'succeeded' with a result File ref. The Sigma
|
|
2118
|
-
//
|
|
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: '
|
|
2124
|
-
if (!ok(run) || field(run, 'object') !== 'reporting.report_run' || field(run, 'status') !== '
|
|
2125
|
-
|
|
2126
|
-
|
|
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: '
|
|
2129
|
-
const missing = await h({ m: 'POST', p: '/v1/reporting/report_runs', b: '
|
|
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
|
-
|
|
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 (
|
|
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) => {
|
|
3437
|
+
seed: async (h) => {
|
|
3438
|
+
const charge = await h({ m: 'POST', p: '/v1/charges', b: 'amount=1000¤cy=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 === '
|
|
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
|
|
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.
|
|
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) => {
|
|
3536
|
+
seed: async (h) => {
|
|
3537
|
+
await h({ m: 'POST', p: '/v1/charges', b: 'amount=10000¤cy=usd&source=tok_bypassPending' });
|
|
3538
|
+
await h({ m: 'POST', p: '/v1/payouts', b: 'amount=7777¤cy=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/
|
|
3556
|
+
seed: async (h) => { await h({ m: 'POST', p: '/v1/charges', b: 'amount=424200¤cy=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
|
|
2362
|
-
await h({ m: 'POST', p: '/v1/transfers', b: `amount=999¤cy=usd&destination=${
|
|
3576
|
+
const seller = await onboardedSeller(h);
|
|
3577
|
+
await h({ m: 'POST', p: '/v1/transfers', b: `amount=999¤cy=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/
|
|
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 === '
|
|
3600
|
+
return listRows(markup) === rows.length && rows.some((e) => e.type === 'coupon.created') && markup.includes('coupon.created');
|
|
2386
3601
|
},
|
|
2387
3602
|
})),
|
|
2388
|
-
//
|
|
2389
|
-
//
|
|
2390
|
-
//
|
|
2391
|
-
|
|
2392
|
-
|
|
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:
|
|
2401
|
-
await h({ m: 'POST', p: `/v1/customers/${custId}/balance_transactions`, b: 'amount=-750¤cy=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
|
-
|
|
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: '
|
|
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: '/
|
|
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
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
const
|
|
2592
|
-
|
|
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¤cy=usd' });
|
|
3802
|
+
const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: `amount=2000¤cy=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
|
-
|
|
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
|
|
2678
|
-
const tr = await h({ m: 'POST', p: '/v1/transfers', b: `amount=1000¤cy=usd&destination=${
|
|
3902
|
+
const seller = await onboardedSeller(h);
|
|
3903
|
+
const tr = await h({ m: 'POST', p: '/v1/transfers', b: `amount=1000¤cy=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
|
-
|
|
2696
|
-
|
|
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¤cy=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&
|
|
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: '
|
|
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¤cy=usd' });
|
|
2775
|
-
const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: `amount=1000¤cy=usd&customer=${id(cust)}&
|
|
4003
|
+
const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: `amount=1000¤cy=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¤cy=usd&
|
|
4026
|
+
const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=5000¤cy=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: '
|
|
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: '
|
|
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×tamp=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×tamp=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=
|
|
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=
|
|
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
|
|
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:
|
|
3247
|
-
//
|
|
3248
|
-
//
|
|
3249
|
-
//
|
|
3250
|
-
done('stripe.discounts.delete', 'discounts', '
|
|
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:
|
|
3254
|
-
|
|
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/
|
|
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
|
-
//
|
|
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)}¤cy=usd&type=virtual` });
|
|
3339
|
-
const seeded = await h({ m: 'POST', p:
|
|
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: '/
|
|
3343
|
-
|
|
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
|
-
|
|
3377
|
-
|
|
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→
|
|
3493
|
-
//
|
|
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
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
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¤cy=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¤cy=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)}
|
|
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);
|
|
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=
|
|
3526
|
-
if (!ok(order) || field(order, 'object') !== 'climate.order' || field(order, 'status') !== '
|
|
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=
|
|
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),
|
|
3540
|
-
//
|
|
3541
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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 !==
|
|
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
|
|
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
|
-
|
|
3566
|
-
const
|
|
3567
|
-
const
|
|
3568
|
-
|
|
3569
|
-
|
|
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
|
-
|
|
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
|