@volter/twin-stripe 0.1.2 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +96 -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 +75 -0
- package/dist/src/manifest.d.ts +2 -0
- package/dist/src/manifest.js +1070 -0
- package/dist/src/screens/checkout.d.ts +31 -0
- package/dist/src/screens/checkout.js +255 -0
- package/dist/src/screens/connect-oauth.d.ts +27 -0
- package/dist/src/screens/connect-oauth.js +414 -0
- package/dist/src/screens/connect-settings.d.ts +22 -0
- package/dist/src/screens/connect-settings.js +103 -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 +216 -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 +99 -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 +209 -0
- package/dist/src/semantics/checkout.d.ts +15 -0
- package/dist/src/semantics/checkout.js +316 -0
- package/dist/src/semantics/connect.d.ts +5 -0
- package/dist/src/semantics/connect.js +493 -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 +545 -0
- package/dist/src/semantics/issuing.d.ts +13 -0
- package/dist/src/semantics/issuing.js +575 -0
- package/dist/src/semantics/ledger.d.ts +59 -0
- package/dist/src/semantics/ledger.js +200 -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 +140 -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 +82 -0
- package/dist/src/semantics/shared.js +203 -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-cards.d.ts +4 -0
- package/dist/src/semantics/test-cards.js +7 -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 +5695 -0
- package/dist/src/stripe-conformance.d.ts +43 -0
- package/dist/src/stripe-conformance.js +105 -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 +392 -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 +393 -0
- package/dist/src/stripe-shared.d.ts +109 -0
- package/dist/src/stripe-shared.js +276 -0
- package/dist/src/stripe-twin.d.ts +155 -0
- package/dist/src/stripe-twin.js +1232 -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 +12 -0
- package/dist/src/stripe-version.js +287 -0
- package/dist/test-fixtures/stripe-known-deviations.json +110 -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 +3813 -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 +34 -10
- package/src/manifest.ts +1102 -0
- package/src/screens/checkout.tsx +267 -0
- package/src/screens/connect-oauth.tsx +400 -0
- package/src/screens/connect-settings.tsx +121 -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 +220 -0
- package/src/screens/public-details.tsx +105 -0
- package/src/semantics/after-payment.ts +118 -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 +220 -0
- package/src/semantics/checkout.ts +310 -0
- package/src/semantics/connect.ts +487 -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 +545 -0
- package/src/semantics/issuing.ts +590 -0
- package/src/semantics/ledger.ts +253 -0
- package/src/semantics/payment-intents.ts +420 -0
- package/src/semantics/payment-links.ts +148 -0
- package/src/semantics/payment-methods.ts +145 -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 +226 -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-cards.ts +7 -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 +2258 -380
- package/src/stripe-conformance.ts +19 -7
- package/src/stripe-connector.ts +68 -40
- package/src/stripe-emit.ts +15 -8
- package/src/stripe-events.ts +102 -40
- 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 +318 -38
- package/src/stripe-shared.ts +297 -0
- package/src/stripe-twin.ts +434 -5325
- package/src/stripe-ui-conformance.ts +70 -107
- package/src/stripe-ui-structure.ts +124 -348
- package/src/stripe-version.ts +281 -0
- package/test-fixtures/stripe-known-deviations.json +8 -8
- package/test-fixtures/stripe-openapi-operations.json +1188 -2855
- package/test-fixtures/stripe-schemas.json +85 -12
- 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 { applyTwinWrite, emitTwinEvent } from '@volter/world-core';
|
|
10
12
|
import { stripeEmitter } from './stripe-emit.ts';
|
|
11
13
|
import { tmpdir } from 'node:os';
|
|
12
14
|
import { join } from 'node:path';
|
|
13
15
|
import { createElement } from 'react';
|
|
14
16
|
import { renderToStaticMarkup } from 'react-dom/server';
|
|
15
|
-
import {
|
|
17
|
+
import { FORMAT_SCRIPT } from '@volter/world-ui';
|
|
18
|
+
import { checkCapabilities, type CapabilityReport, type CapabilitySpec, verifyBoundary, isInfrastructureError, harnessError } from '@volter/world-tooling';
|
|
16
19
|
import { ListPane, SECTIONS, PaymentErrorBanner, ConnectAccountPanel, BalanceSummary, type Section } from '../client/stripe-mirror.tsx';
|
|
17
20
|
import type { StripeRow } from './stripe-mirror-ui.ts';
|
|
18
21
|
import {
|
|
@@ -27,6 +30,13 @@ import {
|
|
|
27
30
|
import { fullSyncStripe } from './stripe-connector.ts';
|
|
28
31
|
import { buildStripeMirrorClient, createStripeMirrorServer, formatStripeAmount } from './stripe-mirror-ui.ts';
|
|
29
32
|
import { handleStripeTwinRequest, type StripeResponse } from './stripe-twin.ts';
|
|
33
|
+
import { createStripeTwinFetch } from './stripe-server.ts';
|
|
34
|
+
import { SERVED_VERSION } from './stripe-version.ts';
|
|
35
|
+
import { pack } from './index.ts';
|
|
36
|
+
import { stripeIdentityFlow } from './screens/identity.tsx';
|
|
37
|
+
import { stripeFinancialConnectionsFlow } from './screens/financial-connections.tsx';
|
|
38
|
+
import { currentlyDue, stripeOnboardingFlow } from './screens/onboarding.tsx';
|
|
39
|
+
import { stripePortalFlow } from './screens/portal.tsx';
|
|
30
40
|
|
|
31
41
|
// ── UI verify: the built mirror bundle must contain the screen's load-bearing markers ──
|
|
32
42
|
let bundle: Promise<string> | null = null;
|
|
@@ -45,17 +55,17 @@ type ServerFetch = (path: string) => Promise<Body>;
|
|
|
45
55
|
type ServerClient = { get: ServerFetch; post: (path: string, body?: string) => Promise<Body> };
|
|
46
56
|
function uiDataCoupled(opts: {
|
|
47
57
|
markers: string[];
|
|
48
|
-
seed: (h: (s: Step) => Promise<StripeResponse
|
|
58
|
+
seed: (h: (s: Step) => Promise<StripeResponse>, root: string) => Promise<void>;
|
|
49
59
|
check: (client: ServerClient) => Promise<boolean>;
|
|
50
60
|
}): () => Promise<boolean> {
|
|
51
61
|
return async () => {
|
|
52
62
|
const js = await mirrorBundle();
|
|
53
63
|
if (!opts.markers.every((m) => js.includes(m))) return false;
|
|
54
64
|
const root = mkdtempSync(join(tmpdir(), 'stp-ui-'));
|
|
55
|
-
const server = createStripeMirrorServer({ root, port: 0 });
|
|
65
|
+
const server = await createStripeMirrorServer({ root, port: 0 });
|
|
56
66
|
try {
|
|
57
67
|
const h = (s: Step) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root });
|
|
58
|
-
await opts.seed(h);
|
|
68
|
+
await opts.seed(h, root);
|
|
59
69
|
const base = `http://127.0.0.1:${server.port}`;
|
|
60
70
|
const get: ServerFetch = (path) => fetch(`${base}${path}`).then((r) => r.json() as Promise<Body>);
|
|
61
71
|
const post = (path: string, body?: string) => fetch(`${base}${path}`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: body ?? '' }).then((r) => r.json() as Promise<Body>);
|
|
@@ -95,17 +105,129 @@ type Step = { m: string; p: string; b?: string };
|
|
|
95
105
|
type Body = Record<string, unknown>;
|
|
96
106
|
|
|
97
107
|
/** Run a sequence of real Stripe requests against an isolated root; return all responses. */
|
|
98
|
-
async function withRoot(steps: (h: (s: Step) => Promise<StripeResponse
|
|
108
|
+
async function withRoot(steps: (h: (s: Step) => Promise<StripeResponse>, root: string) => Promise<boolean>): Promise<boolean> {
|
|
99
109
|
const root = mkdtempSync(join(tmpdir(), 'stp-cap-'));
|
|
100
110
|
const h = (s: Step) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root });
|
|
101
111
|
try {
|
|
102
|
-
return await verifyBoundary('stripe.withRoot', () => steps(h));
|
|
112
|
+
return await verifyBoundary('stripe.withRoot', () => steps(h, root));
|
|
103
113
|
} finally {
|
|
104
114
|
rmSync(root, { recursive: true, force: true });
|
|
105
115
|
}
|
|
106
116
|
}
|
|
107
117
|
|
|
118
|
+
/** Funds the Issuing balance with a top-up made a week before `at` (default now): a top-up "can take up to 5 business
|
|
119
|
+
* days to become available. While they're pending, they won't be added to your Issuing balance"
|
|
120
|
+
* (docs.stripe.com/issuing/funding/balance), so the next request at `at` finds it settled. */
|
|
121
|
+
async function fundIssuing(root: string, amount: number, at?: string): Promise<StripeResponse> {
|
|
122
|
+
const t = at ? Date.parse(at) : Date.now();
|
|
123
|
+
return handleStripeTwinRequest({ method: 'POST', path: '/v1/topups', body: `amount=${amount}¤cy=usd&destination_balance=issuing`, root, occurredAt: new Date(t - 7 * 86_400_000).toISOString() });
|
|
124
|
+
}
|
|
125
|
+
|
|
108
126
|
const ok = (r: StripeResponse) => r.status >= 200 && r.status < 300;
|
|
127
|
+
/** A seller the platform can pay: a Custom account onboarded through the API (its business profile, the owner's
|
|
128
|
+
* acceptance of the terms, the individual's name, a bank account), and funds in the platform's balance to send it.
|
|
129
|
+
* Answers the account id. The name is owed at once for an individual with transfers (e0e3b88c6: what Stripe's
|
|
130
|
+
* requirements endpoint lists for the business type and capabilities), so without it transfers stay inactive. */
|
|
131
|
+
async function onboardedSeller(h: (s: Step) => Promise<StripeResponse>): Promise<string> {
|
|
132
|
+
const a = await h({ m: 'POST', p: '/v1/accounts', b: 'type=custom&country=US&capabilities[transfers][requested]=true&business_profile[mcc]=5462&business_profile[url]=https://seller.test&tos_acceptance[date]=1767225600&tos_acceptance[ip]=203.0.113.9&individual[first_name]=Sam&individual[last_name]=Seller' });
|
|
133
|
+
await h({ m: 'POST', p: `/v1/accounts/${id(a)}/external_accounts`, b: 'external_account[object]=bank_account&external_account[country]=US&external_account[currency]=usd&external_account[routing_number]=110000000&external_account[account_number]=000123456789' });
|
|
134
|
+
await h({ m: 'POST', p: '/v1/charges', b: 'amount=100000¤cy=usd&source=tok_bypassPending' });
|
|
135
|
+
return id(a);
|
|
136
|
+
}
|
|
137
|
+
/** A local webhook receiver, as an application runs one: a node:http server on 127.0.0.1 that keeps each event POSTed
|
|
138
|
+
* to it by path (`/platform`, `/connect`, …). */
|
|
139
|
+
async function webhookReceiver(): Promise<{ url: (path: string) => string; got: (path: string) => StripeEvent[]; close: () => void }> {
|
|
140
|
+
const got = new Map<string, StripeEvent[]>();
|
|
141
|
+
const server = createServer((req, res) => {
|
|
142
|
+
let body = '';
|
|
143
|
+
req.on('data', (d) => { body += String(d); });
|
|
144
|
+
req.on('end', () => { got.set(String(req.url), [...(got.get(String(req.url)) ?? []), JSON.parse(body) as StripeEvent]); res.end('ok'); });
|
|
145
|
+
});
|
|
146
|
+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
147
|
+
const port = (server.address() as AddressInfo).port;
|
|
148
|
+
return { url: (path) => `http://127.0.0.1:${port}${path}`, got: (path) => got.get(path) ?? [], close: () => server.close() };
|
|
149
|
+
}
|
|
150
|
+
/** A request to the twin's fetch at a World instant, as an account (the Stripe-Account header) when one is named. */
|
|
151
|
+
function fetcher(f: (r: Request) => Promise<Response>): (m: string, p: string, b?: string, acct?: string) => Promise<Body> {
|
|
152
|
+
return async (m, p, b, acct) => (await (await f(new Request(`http://stripe.test${p}`, {
|
|
153
|
+
method: m, headers: { authorization: 'Bearer sk_test_twin', 'content-type': 'application/x-www-form-urlencoded', ...(acct ? { 'stripe-account': acct } : {}) }, ...(b !== undefined ? { body: b } : {}),
|
|
154
|
+
}))).json()) as Body;
|
|
155
|
+
}
|
|
156
|
+
// ── Connect OAuth (screens/connect-oauth.tsx, screens/connect-settings.tsx) ──
|
|
157
|
+
/** Cal.com's callback, the redirect URI its Stripe app registers. */
|
|
158
|
+
const OAUTH_CALLBACK = 'http://localhost:3000/api/integrations/stripepayment/callback';
|
|
159
|
+
/** An authorize query as Cal.com's Stripe app builds it (qs-stringify: nested stripe_user[...] keys), with `extra` added. */
|
|
160
|
+
const authorizeQuery = (extra = '', redirect: string | null = OAUTH_CALLBACK): string =>
|
|
161
|
+
`client_id=ca_twin_self&scope=read_write&response_type=code${redirect === null ? '' : `&redirect_uri=${encodeURIComponent(redirect)}`}&state=st8${extra}`;
|
|
162
|
+
type OAuthWorld = {
|
|
163
|
+
f: (r: Request) => Promise<Response>;
|
|
164
|
+
root: string;
|
|
165
|
+
api: (m: string, p: string, b?: string, acct?: string) => Promise<{ status: number; body: Body }>;
|
|
166
|
+
at: (iso: string) => void;
|
|
167
|
+
settings: (enabled: boolean, uris: string) => Promise<Response>;
|
|
168
|
+
authorize: (query: string, decision?: 'skip' | 'deny') => Promise<Response>;
|
|
169
|
+
endpoint: (path: 'token' | 'deauthorize', body: string, auth?: string) => Promise<{ status: number; body: Body }>;
|
|
170
|
+
/** Connect a new account through the page (skip) and exchange its code: the redirect, the code and the token answer. */
|
|
171
|
+
connect: (extra?: string) => Promise<{ location: URL; code: string; token: Body }>;
|
|
172
|
+
};
|
|
173
|
+
/** A fresh World with a Connect platform whose OAuth is on and whose callback is registered (unless `configure` is false). */
|
|
174
|
+
async function withOAuth(run: (w: OAuthWorld) => Promise<boolean>, configure = true): Promise<boolean> {
|
|
175
|
+
const root = mkdtempSync(join(tmpdir(), 'stp-oauth-'));
|
|
176
|
+
let now = '2026-03-01T00:00:00.000Z';
|
|
177
|
+
const f = createStripeTwinFetch({ root, clock: () => now });
|
|
178
|
+
const form = { 'content-type': 'application/x-www-form-urlencoded' };
|
|
179
|
+
const w: OAuthWorld = {
|
|
180
|
+
f,
|
|
181
|
+
root,
|
|
182
|
+
api: async (m, p, b, acct) => {
|
|
183
|
+
const r = await f(new Request(`http://stripe.test${p}`, { method: m, headers: { authorization: 'Bearer sk_test_platform', ...form, ...(acct ? { 'stripe-account': acct } : {}) }, ...(b !== undefined ? { body: b } : {}) }));
|
|
184
|
+
return { status: r.status, body: (await r.json()) as Body };
|
|
185
|
+
},
|
|
186
|
+
at: (iso) => { now = iso; },
|
|
187
|
+
settings: (enabled, uris) => f(new Request('https://dashboard.stripe.com/settings/connect/onboarding-options/oauth', { method: 'POST', headers: form, body: new URLSearchParams({ ...(enabled ? { oauth_enabled: 'on' } : {}), redirect_uris: uris }).toString() })),
|
|
188
|
+
authorize: (query, decision) => f(new Request(`https://connect.stripe.com/oauth/authorize?${query}`, decision ? { method: 'POST', headers: form, body: `decision=${decision}` } : {})),
|
|
189
|
+
endpoint: async (path, body, auth = 'Bearer sk_test_platform') => {
|
|
190
|
+
const r = await f(new Request(`https://connect.stripe.com/oauth/${path}`, { method: 'POST', headers: { ...form, ...(auth ? { authorization: auth } : {}) }, body }));
|
|
191
|
+
return { status: r.status, body: (await r.json()) as Body };
|
|
192
|
+
},
|
|
193
|
+
connect: async (extra = '') => {
|
|
194
|
+
const location = new URL((await w.authorize(authorizeQuery(extra), 'skip')).headers.get('location') ?? 'about:blank');
|
|
195
|
+
const code = location.searchParams.get('code') ?? '';
|
|
196
|
+
const token = (await w.endpoint('token', `grant_type=authorization_code&code=${code}`)).body;
|
|
197
|
+
return { location, code, token };
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
try {
|
|
201
|
+
if (configure) await w.settings(true, OAUTH_CALLBACK);
|
|
202
|
+
return await verifyBoundary('stripe.withOAuth', () => run(w));
|
|
203
|
+
} finally {
|
|
204
|
+
rmSync(root, { recursive: true, force: true });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/** A dispute as Stripe opens one: a charge whose test card the bank disputes (docs.stripe.com/testing#disputes). */
|
|
208
|
+
async function disputed(h: (s: Step) => Promise<StripeResponse>, amount = 1000): Promise<StripeResponse> {
|
|
209
|
+
await h({ m: 'POST', p: '/v1/charges', b: `amount=${amount}¤cy=usd&source=tok_createDispute` });
|
|
210
|
+
const first = (((await h({ m: 'GET', p: '/v1/disputes' })).body as Body).data as Body[])[0]!;
|
|
211
|
+
return h({ m: 'GET', p: `/v1/disputes/${String(first.id)}` });
|
|
212
|
+
}
|
|
213
|
+
/** A field on a hosted page under the page's own formatting script: typing (one character at a time), deleting, or a
|
|
214
|
+
* paste or autofill of a whole value, each answering the field's value after the script has run. */
|
|
215
|
+
function expiryField(script: string): { type: (s: string) => string; back: () => string; fill: (s: string) => string } {
|
|
216
|
+
const listeners: Array<(e: { inputType: string }) => void> = [];
|
|
217
|
+
const el = { value: '', addEventListener: (_: string, f: (e: { inputType: string }) => void) => { listeners.push(f); } };
|
|
218
|
+
new Function('document', script)({ querySelectorAll: () => [el] });
|
|
219
|
+
const fire = (inputType: string) => { for (const f of listeners) f({ inputType }); return el.value; };
|
|
220
|
+
return {
|
|
221
|
+
type: (s) => { for (const c of s) { el.value += c; fire('insertText'); } return el.value; },
|
|
222
|
+
back: () => { el.value = el.value.slice(0, -1); return fire('deleteContentBackward'); },
|
|
223
|
+
fill: (s) => { el.value = s; return fire('insertFromPaste'); },
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** A person's submission on one of Stripe's hosted pages (verify.stripe.com, the bank-linking flow Stripe.js opens). */
|
|
228
|
+
async function hostedSubmit(root: string, flow: (scope: { root?: string }) => (request: Request) => Promise<Response | undefined>, url: string, form: Record<string, string>): Promise<Response | undefined> {
|
|
229
|
+
return flow({ root })(new Request(url, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(form).toString() }));
|
|
230
|
+
}
|
|
109
231
|
const id = (r: StripeResponse) => (r.body as Body)?.id as string;
|
|
110
232
|
const field = (r: StripeResponse, k: string) => (r.body as Body)?.[k];
|
|
111
233
|
|
|
@@ -119,7 +241,6 @@ const listOk = (path: string) => () =>
|
|
|
119
241
|
// ── shorthands (mirror the linear manifest) ──
|
|
120
242
|
const done = (id: string, area: string, title: string, dimension: CapabilitySpec['dimension'], tier: CapabilitySpec['tier'], verify: CapabilitySpec['verify']): CapabilitySpec => ({ id, area, title, dimension, tier, expected: 'done', verify });
|
|
121
243
|
const todo = (id: string, area: string, title: string, dimension: CapabilitySpec['dimension'], tier: CapabilitySpec['tier']): CapabilitySpec => ({ id, area, title, dimension, tier, expected: 'todo' });
|
|
122
|
-
const outOfScope = (id: string, area: string, title: string, dimension: CapabilitySpec['dimension'], tier: CapabilitySpec['tier'], reason: string): CapabilitySpec => ({ id, area, title, dimension, tier, expected: 'todo', outOfScope: reason });
|
|
123
244
|
|
|
124
245
|
// The real Stripe surface (the target). Entries with verify() + expected:'done' are what we
|
|
125
246
|
// currently claim are working against THIS twin; everything else (the majority) is a gap.
|
|
@@ -189,8 +310,10 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
189
310
|
}),
|
|
190
311
|
),
|
|
191
312
|
// Cash balance: GET /v1/customers/:id/cash_balance returns the cash_balance summary
|
|
192
|
-
// (available currency→amount map, derived from the cash-balance ledger). Funding via
|
|
193
|
-
//
|
|
313
|
+
// (available currency→amount map, derived from the cash-balance ledger). Funding via the test helper
|
|
314
|
+
// POST /v1/test_helpers/customers/:id/fund_cash_balance ("Create an incoming testmode bank transfer",
|
|
315
|
+
// docs.stripe.com/api/cash_balance/fund_cash_balance) accrues into `available`; POST updates reconciliation_mode.
|
|
316
|
+
// (The twin-invented POST .../cash_balance_transactions left with 5decb1d77: Stripe's API has no such write.)
|
|
194
317
|
// An unknown customer 404s; an invalid reconciliation_mode 400s. (Only this feature produces
|
|
195
318
|
// the available map keyed by the funded currency.)
|
|
196
319
|
done('stripe.customers.cash_balance', 'customers', 'Customer cash balance', 'api', 'niche', () =>
|
|
@@ -198,9 +321,9 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
198
321
|
const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=cash@twin.test' });
|
|
199
322
|
const empty = await h({ m: 'GET', p: `/v1/customers/${id(cust)}/cash_balance` });
|
|
200
323
|
if (!ok(empty) || field(empty, 'object') !== 'cash_balance' || field(empty, 'available') !== null) return false;
|
|
201
|
-
const fund = await h({ m: 'POST', p: `/v1/customers/${id(cust)}/
|
|
324
|
+
const fund = await h({ m: 'POST', p: `/v1/test_helpers/customers/${id(cust)}/fund_cash_balance`, b: 'amount=5000¤cy=usd' });
|
|
202
325
|
if (!ok(fund) || field(fund, 'type') !== 'funded' || field(fund, 'ending_balance') !== 5000) return false;
|
|
203
|
-
const more = await h({ m: 'POST', p: `/v1/customers/${id(cust)}/
|
|
326
|
+
const more = await h({ m: 'POST', p: `/v1/test_helpers/customers/${id(cust)}/fund_cash_balance`, b: 'amount=2500¤cy=usd' });
|
|
204
327
|
if (field(more, 'ending_balance') !== 7500) return false;
|
|
205
328
|
const bal = await h({ m: 'GET', p: `/v1/customers/${id(cust)}/cash_balance` });
|
|
206
329
|
if ((field(bal, 'available') as Body)?.usd !== 7500) return false;
|
|
@@ -269,7 +392,11 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
269
392
|
const beforeN = ((before.body as Body).data as Body[]).length;
|
|
270
393
|
const afterN = ((after.body as Body).data as Body[]).length;
|
|
271
394
|
const again = await h({ m: 'DELETE', p: `/v1/customers/${id(c)}` });
|
|
272
|
-
|
|
395
|
+
// a deleted customer still RETRIEVES, as the stub: "If it's for a deleted Customer, a subset of the customer's
|
|
396
|
+
// information is returned, including a `deleted` property that's set to true" (docs.stripe.com/api/customers/retrieve;
|
|
397
|
+
// 8154bb647). It leaves the list, and a second delete finds nothing to delete.
|
|
398
|
+
return gone.status === 200 && field(gone, 'deleted') === true && field(gone, 'id') === id(c) && field(gone, 'email') === undefined
|
|
399
|
+
&& afterN === beforeN - 1 && again.status === 404;
|
|
273
400
|
}),
|
|
274
401
|
),
|
|
275
402
|
|
|
@@ -298,16 +425,25 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
298
425
|
}),
|
|
299
426
|
),
|
|
300
427
|
// Manual capture: a manual-capture PI confirmed lands in requires_capture (funds authorized,
|
|
301
|
-
// amount_capturable=amount)
|
|
302
|
-
//
|
|
428
|
+
// amount_capturable=amount) with its authorization a charge, succeeded and uncaptured (charge.succeeded), then
|
|
429
|
+
// /capture → succeeded (amount_received set, capturable cleared) capturing that same charge (charge.captured,
|
|
430
|
+
// "Occurs whenever a previously uncaptured charge is captured"). Capturing an already-captured PI is the vendor 400
|
|
431
|
+
// payment_intent_unexpected_state; unknown id 404.
|
|
303
432
|
done('stripe.payment_intents.capture', 'payment_intents', 'PaymentIntent manual capture (capture_method=manual + /capture)', 'api', 'core', () =>
|
|
304
433
|
withRoot(async (h) => {
|
|
305
434
|
const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=2000¤cy=usd&capture_method=manual' });
|
|
306
435
|
if (!ok(pi)) return false;
|
|
307
436
|
const conf = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/confirm`, b: 'payment_method=pm_card_visa' });
|
|
308
437
|
if (!ok(conf) || field(conf, 'status') !== 'requires_capture' || field(conf, 'amount_capturable') !== 2000) return false;
|
|
438
|
+
const held = await h({ m: 'GET', p: `/v1/charges/${String(field(conf, 'latest_charge'))}` });
|
|
439
|
+
if (field(held, 'status') !== 'succeeded' || field(held, 'captured') !== false || field(held, 'amount_captured') !== 0 || field(held, 'balance_transaction') !== null) return false;
|
|
309
440
|
const cap = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/capture` });
|
|
310
441
|
if (!ok(cap) || field(cap, 'status') !== 'succeeded' || field(cap, 'amount_received') !== 2000 || field(cap, 'amount_capturable') !== 0) return false;
|
|
442
|
+
const taken = await h({ m: 'GET', p: `/v1/charges/${String(field(cap, 'latest_charge'))}` });
|
|
443
|
+
if (id(taken) !== id(held) || field(taken, 'captured') !== true || field(taken, 'amount_captured') !== 2000 || typeof field(taken, 'balance_transaction') !== 'string') return false;
|
|
444
|
+
const events = (((await h({ m: 'GET', p: '/v1/events?limit=100' })).body as Body).data as Body[]) ?? [];
|
|
445
|
+
const on = (type: string) => events.filter((e) => e.type === type && ((e.data as Body).object as Body).id === id(held)).map((e) => (e.data as Body).object as Body);
|
|
446
|
+
if (on('charge.succeeded').length !== 1 || on('charge.succeeded')[0]!.captured !== false || on('charge.captured').length !== 1 || on('charge.captured')[0]!.captured !== true) return false;
|
|
311
447
|
// re-read persists; double-capture is the vendor unexpected-state 400; unknown id 404
|
|
312
448
|
const g = await h({ m: 'GET', p: `/v1/payment_intents/${id(pi)}` });
|
|
313
449
|
const again = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/capture` });
|
|
@@ -317,6 +453,51 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
317
453
|
missing.status === 404;
|
|
318
454
|
}),
|
|
319
455
|
),
|
|
456
|
+
// A declined confirm records the attempt as a failed charge the intent's latest_charge names, written as the event
|
|
457
|
+
// Stripe sends, charge.failed ("Occurs whenever a failed charge attempt occurs", docs.stripe.com/api/events/types), and
|
|
458
|
+
// carrying what every charge of the intent carries: its transfer_group and description. Dub reads the payout invoice a
|
|
459
|
+
// failed payment belongs to from transfer_group.
|
|
460
|
+
done('stripe.payment_intents.declined_charge', 'payment_intents', 'A declined confirm\'s failed charge fires charge.failed with the intent\'s transfer_group and description', 'api', 'common', () =>
|
|
461
|
+
withRoot(async (h) => {
|
|
462
|
+
const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=2000¤cy=usd&transfer_group=inv_77&description=Payout%20invoice%2077' });
|
|
463
|
+
const dec = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/confirm`, b: 'payment_method=pm_card_chargeDeclined' });
|
|
464
|
+
const chargeId = ((dec.body as Body).error as Body | undefined)?.charge;
|
|
465
|
+
const after = await h({ m: 'GET', p: `/v1/payment_intents/${id(pi)}` });
|
|
466
|
+
const charge = await h({ m: 'GET', p: `/v1/charges/${String(chargeId)}` });
|
|
467
|
+
const events = (((await h({ m: 'GET', p: '/v1/events?type=charge.failed' })).body as Body).data as Body[]) ?? [];
|
|
468
|
+
const object = (events[0]?.data as Body | undefined)?.object as Body | undefined;
|
|
469
|
+
return dec.status === 402 && field(after, 'latest_charge') === chargeId && field(charge, 'status') === 'failed'
|
|
470
|
+
&& field(charge, 'transfer_group') === 'inv_77' && field(charge, 'description') === 'Payout invoice 77'
|
|
471
|
+
&& events.length === 1 && object?.id === chargeId && object?.transfer_group === 'inv_77' && object?.description === 'Payout invoice 77';
|
|
472
|
+
}),
|
|
473
|
+
),
|
|
474
|
+
// Cancelling a manual-capture intent releases its authorization: "For PaymentIntents with a `status` of
|
|
475
|
+
// `requires_capture`, the remaining `amount_capturable` is automatically refunded" (docs.stripe.com/api/payment_intents/cancel).
|
|
476
|
+
// Since basil the cancellation makes no Refund ("`refunded` will no longer be `true` for payment cancellation flows",
|
|
477
|
+
// the basil changelog), no money moves, and capturing the charge is refused, the intent being canceled.
|
|
478
|
+
done('stripe.payment_intents.cancel_authorization', 'payment_intents', 'Cancelling a requires_capture intent releases its uncaptured charge (no Refund, no balance moved); capturing it is refused', 'api', 'common', () =>
|
|
479
|
+
withRoot(async (h) => {
|
|
480
|
+
// the whole balance, available and pending
|
|
481
|
+
const avail = async (): Promise<number> => {
|
|
482
|
+
const b = (await h({ m: 'GET', p: '/v1/balance' })).body as Body;
|
|
483
|
+
return [...((b.available as Body[]) ?? []), ...((b.pending as Body[]) ?? [])].reduce((n, x) => n + (Number(x.amount) || 0), 0);
|
|
484
|
+
};
|
|
485
|
+
const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=2000¤cy=usd&capture_method=manual&payment_method=pm_card_visa&confirm=true' });
|
|
486
|
+
const chargeId = String(field(pi, 'latest_charge'));
|
|
487
|
+
const before = await avail();
|
|
488
|
+
const cancel = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/cancel` });
|
|
489
|
+
const ch = await h({ m: 'GET', p: `/v1/charges/${chargeId}` });
|
|
490
|
+
const refunds = ((field(ch, 'refunds') as Body)?.data as Body[]) ?? [];
|
|
491
|
+
const capture = await h({ m: 'POST', p: `/v1/charges/${chargeId}/capture` });
|
|
492
|
+
const after = await avail();
|
|
493
|
+
// since basil a cancellation makes no Refund: amount_captured 0, amount_refunded and refunded untouched
|
|
494
|
+
return field(cancel, 'status') === 'canceled' && field(cancel, 'amount_capturable') === 0
|
|
495
|
+
&& field(ch, 'captured') === false && field(ch, 'refunded') === false && field(ch, 'amount_refunded') === 0 && field(ch, 'amount_captured') === 0
|
|
496
|
+
&& refunds.length === 0
|
|
497
|
+
&& capture.status === 400 && ((capture.body as Body).error as Body)?.code === 'payment_intent_unexpected_state'
|
|
498
|
+
&& before === 0 && after === 0;
|
|
499
|
+
}),
|
|
500
|
+
),
|
|
320
501
|
// Cancel: a non-terminal PI cancels → status canceled (+ cancellation_reason). Canceling a
|
|
321
502
|
// succeeded PI is the vendor 400 payment_intent_unexpected_state; an unknown id is 404.
|
|
322
503
|
done('stripe.payment_intents.cancel', 'payment_intents', 'PaymentIntent cancel', 'api', 'core', () =>
|
|
@@ -402,7 +583,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
402
583
|
return ok(g) && id(g) === id(ch) && field(l, 'object') === 'list';
|
|
403
584
|
}),
|
|
404
585
|
),
|
|
405
|
-
done('stripe.charges.test_declines', 'charges', 'Test-card declines → 402 typed card_error
|
|
586
|
+
done('stripe.charges.test_declines', 'charges', 'Test-card declines → 402 typed card_error naming the failed charge', 'api', 'core', () =>
|
|
406
587
|
withRoot(async (h) => {
|
|
407
588
|
const dec = await h({ m: 'POST', p: '/v1/charges', b: 'amount=1000¤cy=usd&source[number]=4000000000000002' });
|
|
408
589
|
const ins = await h({ m: 'POST', p: '/v1/charges', b: 'amount=1000¤cy=usd&source[number]=4000000000009995' });
|
|
@@ -411,6 +592,26 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
411
592
|
return dec.status === 402 && decErr?.code === 'card_declined' && ins.status === 402 && insErr?.decline_code === 'insufficient_funds';
|
|
412
593
|
}),
|
|
413
594
|
),
|
|
595
|
+
// POST /v1/charges writes the event Stripe sends for the charge (docs.stripe.com/api/events/types; Stripe has no
|
|
596
|
+
// charge.created): charge.succeeded, "Occurs whenever a charge is successful", for one made and for an authorization
|
|
597
|
+
// (capture=false), charge.failed, "Occurs whenever a failed charge attempt occurs", for a declined attempt, and
|
|
598
|
+
// charge.captured, "Occurs whenever a previously uncaptured charge is captured", when the authorization is captured.
|
|
599
|
+
done('stripe.charges.events', 'charges', 'Charges fire charge.succeeded (made or authorized), charge.failed (declined) and charge.captured', 'api', 'core', () =>
|
|
600
|
+
withRoot(async (h) => {
|
|
601
|
+
const made = await h({ m: 'POST', p: '/v1/charges', b: 'amount=2000¤cy=usd&source=tok_visa' });
|
|
602
|
+
const auth = await h({ m: 'POST', p: '/v1/charges', b: 'amount=3000¤cy=usd&source=tok_visa&capture=false' });
|
|
603
|
+
const dec = await h({ m: 'POST', p: '/v1/charges', b: 'amount=1000¤cy=usd&source=tok_chargeDeclined' });
|
|
604
|
+
const declined = ((dec.body as Body).error as Body | undefined)?.charge;
|
|
605
|
+
await h({ m: 'POST', p: `/v1/charges/${id(auth)}/capture` });
|
|
606
|
+
const events = (((await h({ m: 'GET', p: '/v1/events?limit=100' })).body as Body).data as Body[]) ?? [];
|
|
607
|
+
const of = (type: string) => events.filter((e) => e.type === type).map((e) => (e.data as Body).object as Body);
|
|
608
|
+
const succeeded = of('charge.succeeded');
|
|
609
|
+
return dec.status === 402 && typeof declined === 'string'
|
|
610
|
+
&& succeeded.some((c) => c.id === id(made) && c.captured === true) && succeeded.some((c) => c.id === id(auth) && c.captured === false)
|
|
611
|
+
&& of('charge.failed').some((c) => c.id === declined && c.status === 'failed')
|
|
612
|
+
&& of('charge.captured').some((c) => c.id === id(auth) && c.captured === true && c.amount_captured === 3000);
|
|
613
|
+
}),
|
|
614
|
+
),
|
|
414
615
|
// Charge capture: a charge created with capture=false is an uncaptured auth (captured:false,
|
|
415
616
|
// amount_captured:0); /capture captures it (captured:true, amount_captured set). Re-capture
|
|
416
617
|
// is the vendor 400 charge_already_captured; an immediately-captured charge (default) cannot
|
|
@@ -422,6 +623,12 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
422
623
|
const cap = await h({ m: 'POST', p: `/v1/charges/${id(auth)}/capture` });
|
|
423
624
|
if (!ok(cap) || field(cap, 'captured') !== true || field(cap, 'amount_captured') !== 3000) return false;
|
|
424
625
|
const again = await h({ m: 'POST', p: `/v1/charges/${id(auth)}/capture` });
|
|
626
|
+
// A PARTIAL capture releases the remainder with no Refund (the basil change): amount_refunded stays 0
|
|
627
|
+
const part = await h({ m: 'POST', p: '/v1/charges', b: 'amount=3000¤cy=usd&capture=false' });
|
|
628
|
+
const capPart = await h({ m: 'POST', p: `/v1/charges/${id(part)}/capture`, b: 'amount=1200' });
|
|
629
|
+
if (!ok(capPart) || field(capPart, 'amount_captured') !== 1200 || field(capPart, 'amount_refunded') !== 0 || field(capPart, 'refunded') !== false) return false;
|
|
630
|
+
const released = ((field(capPart, 'refunds') as Body)?.data as Body[]) ?? [];
|
|
631
|
+
if (released.length !== 0) return false;
|
|
425
632
|
const def = await h({ m: 'POST', p: '/v1/charges', b: 'amount=1000¤cy=usd' });
|
|
426
633
|
const capDef = await h({ m: 'POST', p: `/v1/charges/${id(def)}/capture` });
|
|
427
634
|
const missing = await h({ m: 'POST', p: '/v1/charges/ch_nope/capture' });
|
|
@@ -464,30 +671,112 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
464
671
|
// ── Refunds ─────────────────────────────────────────────────────────────────────
|
|
465
672
|
done('stripe.refunds.crud', 'refunds', 'Refunds: create + retrieve + list', 'api', 'core', () =>
|
|
466
673
|
withRoot(async (h) => {
|
|
467
|
-
const
|
|
674
|
+
const c = await h({ m: 'POST', p: '/v1/charges', b: 'amount=2000¤cy=usd' });
|
|
675
|
+
const r = await h({ m: 'POST', p: '/v1/refunds', b: `charge=${id(c)}&amount=500` });
|
|
468
676
|
if (!ok(r) || field(r, 'object') !== 'refund') return false;
|
|
469
677
|
const g = await h({ m: 'GET', p: `/v1/refunds/${id(r)}` });
|
|
470
678
|
const l = await h({ m: 'GET', p: '/v1/refunds' });
|
|
471
679
|
return ok(g) && id(g) === id(r) && field(l, 'object') === 'list';
|
|
472
680
|
}),
|
|
473
681
|
),
|
|
682
|
+
// The money model's central claim: a refund is VISIBLE FROM THE CHARGE it came out of.
|
|
683
|
+
// amount_refunded accumulates, `refunded` flips only at the full amount, and the charge's
|
|
684
|
+
// own refunds sub-list carries the refund objects. A twin that answers a succeeded refund
|
|
685
|
+
// while the charge still reads amount_refunded 0 hands a reconciler the wrong ledger with
|
|
686
|
+
// no error to tell them — which is why the over-refund refusal is part of the same claim.
|
|
687
|
+
done('stripe.refunds.visible_on_charge', 'refunds', 'A refund lands on its charge: amount_refunded / refunded / refunds list, and an over-refund is refused', 'api', 'core', () =>
|
|
688
|
+
withRoot(async (h) => {
|
|
689
|
+
const c = await h({ m: 'POST', p: '/v1/charges', b: 'amount=2000¤cy=usd' });
|
|
690
|
+
const r1 = await h({ m: 'POST', p: '/v1/refunds', b: `charge=${id(c)}&amount=500` });
|
|
691
|
+
if (!ok(r1)) return false;
|
|
692
|
+
// `refunds` is includable on a charge, "not returned by default" (docs.stripe.com/api/charges/object; 4388f04e9):
|
|
693
|
+
// the plain read leaves it out, and the read that expands it carries the refund
|
|
694
|
+
const plain = await h({ m: 'GET', p: `/v1/charges/${id(c)}` });
|
|
695
|
+
if (field(plain, 'refunds') !== undefined) return false;
|
|
696
|
+
const partial = await h({ m: 'GET', p: `/v1/charges/${id(c)}?expand[]=refunds` });
|
|
697
|
+
if (field(partial, 'amount_refunded') !== 500 || field(partial, 'refunded') !== false) return false;
|
|
698
|
+
const listed = (field(partial, 'refunds') as Body)?.data as Body[] | undefined;
|
|
699
|
+
if (!Array.isArray(listed) || listed.length !== 1 || listed[0]?.id !== id(r1)) return false;
|
|
700
|
+
// more than the charge has left to give is refused, not silently accepted
|
|
701
|
+
const tooMuch = await h({ m: 'POST', p: '/v1/refunds', b: `charge=${id(c)}&amount=1600` });
|
|
702
|
+
if (tooMuch.status !== 400) return false;
|
|
703
|
+
// an omitted amount refunds the whole remainder, and THEN the charge is fully refunded
|
|
704
|
+
// (taken through the charge's OWN refund endpoint — Stripe serves both spellings)
|
|
705
|
+
const r2 = await h({ m: 'POST', p: `/v1/charges/${id(c)}/refunds` });
|
|
706
|
+
if (!ok(r2) || field(r2, 'amount') !== 1500) return false;
|
|
707
|
+
const full = await h({ m: 'GET', p: `/v1/charges/${id(c)}` });
|
|
708
|
+
// and the charge's own refunds sub-route answers the same two refunds
|
|
709
|
+
const sub = await h({ m: 'GET', p: `/v1/charges/${id(c)}/refunds` });
|
|
710
|
+
const none = await h({ m: 'POST', p: '/v1/refunds', b: 'amount=100' });
|
|
711
|
+
const nosuch = await h({ m: 'POST', p: '/v1/refunds', b: 'charge=ch_nope&amount=100' });
|
|
712
|
+
return field(full, 'amount_refunded') === 2000 && field(full, 'refunded') === true
|
|
713
|
+
&& ((field(sub, 'data') as Body[])?.length === 2)
|
|
714
|
+
&& none.status === 400 && nosuch.status === 404;
|
|
715
|
+
}),
|
|
716
|
+
),
|
|
717
|
+
// A refund reached through its PaymentIntent lands on the SAME charge: a succeeded intent
|
|
718
|
+
// has a latest_charge (real Stripe always mints one), and that is what the refund hits.
|
|
719
|
+
done('stripe.refunds.by_payment_intent', 'refunds', 'Refund by payment_intent resolves the intent’s charge', 'api', 'common', () =>
|
|
720
|
+
withRoot(async (h) => {
|
|
721
|
+
const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=3000¤cy=usd' });
|
|
722
|
+
const con = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/confirm`, b: 'payment_method=pm_card_visa' });
|
|
723
|
+
const charge = field(con, 'latest_charge') as string | undefined;
|
|
724
|
+
if (!charge) return false;
|
|
725
|
+
const r = await h({ m: 'POST', p: '/v1/refunds', b: `payment_intent=${id(pi)}&amount=1200` });
|
|
726
|
+
if (!ok(r) || field(r, 'charge') !== charge || field(r, 'payment_intent') !== id(pi)) return false;
|
|
727
|
+
const ch = await h({ m: 'GET', p: `/v1/charges/${charge}` });
|
|
728
|
+
const missing = await h({ m: 'POST', p: '/v1/refunds', b: 'payment_intent=pi_nope&amount=1' });
|
|
729
|
+
return field(ch, 'amount_refunded') === 1200 && missing.status === 404;
|
|
730
|
+
}),
|
|
731
|
+
),
|
|
474
732
|
// Refund update: POST /v1/refunds/:id persists metadata; retrieve round-trips it. Unknown id 404.
|
|
733
|
+
// An uncaptured charge holds an authorization, not money. A PaymentIntent's "remains uncaptured and can't be refunded
|
|
734
|
+
// directly. You must cancel the PaymentIntent" (docs.stripe.com/refunds), so its refund is refused, through the intent
|
|
735
|
+
// or the charge; a capture=false charge's refund releases it (refunded in full, no balance moved) and its capture is
|
|
736
|
+
// then refused ("unless the charge is already refunded", docs.stripe.com/api/charges/capture).
|
|
737
|
+
done('stripe.refunds.uncaptured', 'refunds', 'Refunding an uncaptured charge: refused for a PaymentIntent\'s (cancel it), a release for a capture=false charge; no balance moved, capture refused after', 'api', 'common', () =>
|
|
738
|
+
withRoot(async (h) => {
|
|
739
|
+
const whole = async (): Promise<number> => {
|
|
740
|
+
const b = (await h({ m: 'GET', p: '/v1/balance' })).body as Body;
|
|
741
|
+
return [...((b.available as Body[]) ?? []), ...((b.pending as Body[]) ?? [])].reduce((n, x) => n + (Number(x.amount) || 0), 0);
|
|
742
|
+
};
|
|
743
|
+
const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=2000¤cy=usd&capture_method=manual&payment_method=pm_card_visa&confirm=true' });
|
|
744
|
+
const viaIntent = await h({ m: 'POST', p: '/v1/refunds', b: `payment_intent=${id(pi)}` });
|
|
745
|
+
const viaCharge = await h({ m: 'POST', p: '/v1/refunds', b: `charge=${String(field(pi, 'latest_charge'))}` });
|
|
746
|
+
const still = await h({ m: 'GET', p: `/v1/charges/${String(field(pi, 'latest_charge'))}` });
|
|
747
|
+
if (viaIntent.status !== 400 || viaCharge.status !== 400 || field(still, 'refunded') !== false || field(still, 'amount_refunded') !== 0) return false;
|
|
748
|
+
const auth = await h({ m: 'POST', p: '/v1/charges', b: 'amount=3000¤cy=usd&source=tok_visa&capture=false' });
|
|
749
|
+
const refund = await h({ m: 'POST', p: '/v1/refunds', b: `charge=${id(auth)}` });
|
|
750
|
+
const released = await h({ m: 'GET', p: `/v1/charges/${id(auth)}` });
|
|
751
|
+
const capture = await h({ m: 'POST', p: `/v1/charges/${id(auth)}/capture` });
|
|
752
|
+
return ok(refund) && field(refund, 'amount') === 3000 && field(refund, 'balance_transaction') === null
|
|
753
|
+
&& field(released, 'refunded') === true && field(released, 'captured') === false && field(released, 'amount_refunded') === 3000
|
|
754
|
+
&& capture.status === 400 && ((capture.body as Body).error as Body)?.code === 'charge_already_refunded' && await whole() === 0;
|
|
755
|
+
}),
|
|
756
|
+
),
|
|
475
757
|
done('stripe.refunds.update', 'refunds', 'Refund update (metadata)', 'api', 'common', () =>
|
|
476
758
|
withRoot(async (h) => {
|
|
477
|
-
const
|
|
759
|
+
const c = await h({ m: 'POST', p: '/v1/charges', b: 'amount=2000¤cy=usd' });
|
|
760
|
+
const r = await h({ m: 'POST', p: '/v1/refunds', b: `charge=${id(c)}&amount=500` });
|
|
478
761
|
const u = await h({ m: 'POST', p: `/v1/refunds/${id(r)}`, b: 'metadata[reason]=duplicate' });
|
|
479
762
|
if (!ok(u) || ((field(u, 'metadata') as Body)?.reason) !== 'duplicate') return false;
|
|
480
763
|
const g = await h({ m: 'GET', p: `/v1/refunds/${id(r)}` });
|
|
481
764
|
const missing = await h({ m: 'POST', p: '/v1/refunds/re_nope', b: 'metadata[x]=1' });
|
|
482
|
-
|
|
765
|
+
// the same refund, read and written THROUGH its charge (Stripe's charge-scoped routes)
|
|
766
|
+
const viaCharge = await h({ m: 'GET', p: `/v1/charges/${id(c)}/refunds/${id(r)}` });
|
|
767
|
+
const editedViaCharge = await h({ m: 'POST', p: `/v1/charges/${id(c)}/refunds/${id(r)}`, b: 'metadata[note]=via-charge' });
|
|
768
|
+
const wrongCharge = await h({ m: 'GET', p: `/v1/charges/${id(c)}/refunds/re_nope` });
|
|
769
|
+
return ok(g) && ((field(g, 'metadata') as Body)?.reason) === 'duplicate' && missing.status === 404
|
|
770
|
+
&& ok(viaCharge) && id(viaCharge) === id(r) && wrongCharge.status === 404
|
|
771
|
+
&& ((field(editedViaCharge, 'metadata') as Body)?.note) === 'via-charge';
|
|
483
772
|
}),
|
|
484
773
|
),
|
|
485
774
|
// (refunds.cancel upgraded to done() in the AUDIT GROWTH block below.)
|
|
486
775
|
|
|
487
776
|
// ── Disputes ────────────────────────────────────────────────────────────────────
|
|
488
|
-
done('stripe.disputes.crud', 'disputes', 'Disputes:
|
|
777
|
+
done('stripe.disputes.crud', 'disputes', 'Disputes: a disputed charge opens one; retrieve + list', 'api', 'common', () =>
|
|
489
778
|
withRoot(async (h) => {
|
|
490
|
-
const d = await h
|
|
779
|
+
const d = await disputed(h);
|
|
491
780
|
if (!ok(d) || field(d, 'object') !== 'dispute') return false;
|
|
492
781
|
const g = await h({ m: 'GET', p: `/v1/disputes/${id(d)}` });
|
|
493
782
|
const l = await h({ m: 'GET', p: '/v1/disputes' });
|
|
@@ -496,13 +785,27 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
496
785
|
),
|
|
497
786
|
done('stripe.disputes.evidence', 'disputes', 'Dispute evidence submission (update) + close', 'api', 'common', () =>
|
|
498
787
|
withRoot(async (h) => {
|
|
499
|
-
const d = await h
|
|
788
|
+
const d = await disputed(h);
|
|
500
789
|
const ev = await h({ m: 'POST', p: `/v1/disputes/${id(d)}`, b: 'evidence[uncategorized_text]=we shipped it' });
|
|
501
790
|
const cl = await h({ m: 'POST', p: `/v1/disputes/${id(d)}/close` });
|
|
502
791
|
return ok(ev) && ok(cl) && field(cl, 'status') === 'lost';
|
|
503
792
|
}),
|
|
504
793
|
),
|
|
505
794
|
|
|
795
|
+
// The dispute test cards open a dispute through Checkout as through the API, and test-mode evidence decides it:
|
|
796
|
+
// `winning_evidence` wins (the amount comes back), `losing_evidence` loses (docs.stripe.com/testing#disputes, #evidence).
|
|
797
|
+
done('stripe.disputes.test_outcomes', 'disputes', 'Dispute test cards through Checkout; winning_evidence / losing_evidence decide the dispute', 'api', 'common', () =>
|
|
798
|
+
withRoot(async (h) => {
|
|
799
|
+
const cs = await h({ m: 'POST', p: '/v1/checkout/sessions', b: 'mode=payment&success_url=https://x.test&line_items[0][price_data][currency]=usd&line_items[0][price_data][unit_amount]=6500&line_items[0][quantity]=1' });
|
|
800
|
+
await h({ m: 'POST', p: `/c/pay/${id(cs)}`, b: 'email=pay%40twin.test&cardNumber=4000000000002685&cardExpiry=12%2F34&cardCvc=123&billingName=Twin&billingCountry=US&billingPostalCode=94105' });
|
|
801
|
+
const opened = ((await h({ m: 'GET', p: '/v1/disputes' })).body as Body).data as Body[];
|
|
802
|
+
const won = await h({ m: 'POST', p: `/v1/disputes/${String(opened[0]?.id)}`, b: 'evidence[uncategorized_text]=winning_evidence' });
|
|
803
|
+
const lostOne = await disputed(h);
|
|
804
|
+
const lost = await h({ m: 'POST', p: `/v1/disputes/${id(lostOne)}`, b: 'evidence[uncategorized_text]=losing_evidence' });
|
|
805
|
+
return opened[0]?.reason === 'product_not_received' && field(won, 'status') === 'won' && field(lost, 'status') === 'lost';
|
|
806
|
+
}),
|
|
807
|
+
),
|
|
808
|
+
|
|
506
809
|
// ── Balance / BalanceTransactions / Payouts ───────────────────────────────────────
|
|
507
810
|
done('stripe.balance.retrieve', 'balance', 'Balance retrieve (available/pending)', 'api', 'core', () =>
|
|
508
811
|
withRoot(async (h) => {
|
|
@@ -510,17 +813,37 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
510
813
|
return ok(r) && field(r, 'object') === 'balance';
|
|
511
814
|
}),
|
|
512
815
|
),
|
|
513
|
-
done('stripe.balance_transactions.crud', 'balance', 'BalanceTransactions:
|
|
816
|
+
done('stripe.balance_transactions.crud', 'balance', 'BalanceTransactions: a charge\'s ledger entry; retrieve + list', 'api', 'core', () =>
|
|
514
817
|
withRoot(async (h) => {
|
|
515
|
-
|
|
516
|
-
|
|
818
|
+
await h({ m: 'POST', p: '/v1/charges', b: 'amount=1000¤cy=usd&source=tok_visa' });
|
|
819
|
+
const t = { status: 200, body: (((await h({ m: 'GET', p: '/v1/balance_transactions' })).body as Body).data as Body[])[0]! } as StripeResponse;
|
|
820
|
+
if (!t.body || field(t, 'amount') !== 1000) return false;
|
|
517
821
|
const g = await h({ m: 'GET', p: `/v1/balance_transactions/${id(t)}` });
|
|
518
822
|
const l = await h({ m: 'GET', p: '/v1/balance_transactions' });
|
|
519
823
|
return ok(g) && id(g) === id(t) && field(l, 'object') === 'list';
|
|
520
824
|
}),
|
|
521
825
|
),
|
|
826
|
+
// Every money movement writes its balance transaction (semantics/ledger.ts): a captured charge credits its
|
|
827
|
+
// amount less Stripe's standard fee, pending until available_on; a refund and a payout debit at once; a
|
|
828
|
+
// platform payout beyond the available balance is balance_insufficient.
|
|
829
|
+
done('stripe.balance_transactions.from_money_movement', 'balance', 'BalanceTransactions written by charges, refunds and payouts; the balance their sum; payouts held to it', 'api', 'common', () =>
|
|
830
|
+
withRoot(async (h) => {
|
|
831
|
+
const ch = await h({ m: 'POST', p: '/v1/charges', b: 'amount=10000¤cy=usd&source=tok_visa' });
|
|
832
|
+
const btId = field(ch, 'balance_transaction') as string;
|
|
833
|
+
const bt = await h({ m: 'GET', p: `/v1/balance_transactions/${btId}` });
|
|
834
|
+
if (!(ok(bt) && field(bt, 'type') === 'charge' && field(bt, 'fee') === 320 && field(bt, 'net') === 9680 && field(bt, 'status') === 'pending')) return false;
|
|
835
|
+
// pending funds cannot be paid out; funds that settle at once can
|
|
836
|
+
if ((await h({ m: 'POST', p: '/v1/payouts', b: 'amount=5000¤cy=usd' })).status !== 400) return false;
|
|
837
|
+
await h({ m: 'POST', p: '/v1/charges', b: 'amount=10000¤cy=usd&source=tok_bypassPending' });
|
|
838
|
+
const po = await h({ m: 'POST', p: '/v1/payouts', b: 'amount=5000¤cy=usd' });
|
|
839
|
+
const bal = await h({ m: 'GET', p: '/v1/balance' });
|
|
840
|
+
const available = ((bal.body as { available: Array<{ amount: number }> }).available[0]!).amount;
|
|
841
|
+
return ok(po) && available === 9680 - 5000;
|
|
842
|
+
}),
|
|
843
|
+
),
|
|
522
844
|
done('stripe.payouts.crud', 'payouts', 'Payouts: create + retrieve + list + cancel', 'api', 'core', () =>
|
|
523
845
|
withRoot(async (h) => {
|
|
846
|
+
await h({ m: 'POST', p: '/v1/charges', b: 'amount=2000¤cy=usd&source=tok_bypassPending' });
|
|
524
847
|
const p = await h({ m: 'POST', p: '/v1/payouts', b: 'amount=1000¤cy=usd' });
|
|
525
848
|
if (!ok(p) || field(p, 'object') !== 'payout') return false;
|
|
526
849
|
const g = await h({ m: 'GET', p: `/v1/payouts/${id(p)}` });
|
|
@@ -528,22 +851,39 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
528
851
|
return ok(g) && id(g) === id(p) && ok(c);
|
|
529
852
|
}),
|
|
530
853
|
),
|
|
531
|
-
// Payout reverse:
|
|
532
|
-
//
|
|
533
|
-
//
|
|
534
|
-
done('stripe.payouts.reverse', 'payouts', 'Payout reverse', 'api', 'niche', () =>
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
const
|
|
541
|
-
|
|
542
|
-
const
|
|
854
|
+
// Payout reverse: a connected account's payout, once paid, is reversed as a NEW payout carrying original_payout;
|
|
855
|
+
// the original stays paid and names it (reversed_by). A pending payout is canceled instead (400), a platform payout
|
|
856
|
+
// cannot be reversed (400), reversing twice 400s, and an unknown id 404s.
|
|
857
|
+
done('stripe.payouts.reverse', 'payouts', 'Payout reverse (a connected account\'s paid payout)', 'api', 'niche', async () => {
|
|
858
|
+
const root = mkdtempSync(join(tmpdir(), 'stp-rev-'));
|
|
859
|
+
try {
|
|
860
|
+
// at noon: accounts are paid out automatically at midnight UTC on their schedule's days (5ab14297c), so a charge
|
|
861
|
+
// made at the stroke of midnight is swept into that same instant's automatic payout and nothing is left to send
|
|
862
|
+
const h = (s: Step & { acct?: string; day?: number }) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: `2026-06-${String(s.day ?? 1).padStart(2, '0')}T12:00:00Z`, ...(s.acct ? { stripeAccount: s.acct } : {}) });
|
|
863
|
+
const seller = await onboardedSeller(h);
|
|
864
|
+
await h({ m: 'POST', p: '/v1/transfers', b: `amount=5000¤cy=usd&destination=${seller}` });
|
|
865
|
+
const p = await h({ m: 'POST', p: '/v1/payouts', b: 'amount=4000¤cy=usd', acct: seller });
|
|
866
|
+
if (!ok(p) || (await h({ m: 'POST', p: `/v1/payouts/${id(p)}/reverse`, acct: seller })).status !== 400) return false;
|
|
867
|
+
// the platform's own payout, made the same day, before its automatic payout sweeps what is left (5ab14297c)
|
|
868
|
+
const plat = await h({ m: 'POST', p: '/v1/payouts', b: 'amount=1000¤cy=usd' });
|
|
869
|
+
if (!ok(plat)) return false;
|
|
870
|
+
const rev = await h({ m: 'POST', p: `/v1/payouts/${id(p)}/reverse`, acct: seller, day: 5 });
|
|
871
|
+
// the reversal is a payout the other way, "a negative amount" as the reverse page's example answers it
|
|
872
|
+
// (docs.stripe.com/api/payouts/reverse; 8848ebfaa)
|
|
873
|
+
if (!ok(rev) || field(rev, 'original_payout') !== id(p) || field(rev, 'amount') !== -4000) return false;
|
|
874
|
+
const orig = await h({ m: 'GET', p: `/v1/payouts/${id(p)}`, acct: seller, day: 5 });
|
|
875
|
+
if (field(orig, 'status') !== 'paid' || field(orig, 'reversed_by') !== id(rev)) return false;
|
|
876
|
+
const platRev = await h({ m: 'POST', p: `/v1/payouts/${id(plat)}/reverse`, day: 5 });
|
|
877
|
+
const twice = await h({ m: 'POST', p: `/v1/payouts/${id(p)}/reverse`, acct: seller, day: 5 });
|
|
543
878
|
const nope = await h({ m: 'POST', p: '/v1/payouts/po_nope/reverse' });
|
|
544
|
-
return twice.status === 400 && nope.status === 404;
|
|
545
|
-
})
|
|
546
|
-
|
|
879
|
+
return platRev.status === 400 && twice.status === 400 && nope.status === 404;
|
|
880
|
+
} catch (err) {
|
|
881
|
+
if (isInfrastructureError(err)) throw harnessError('stripe.payouts.reverse', err);
|
|
882
|
+
return false;
|
|
883
|
+
} finally {
|
|
884
|
+
rmSync(root, { recursive: true, force: true });
|
|
885
|
+
}
|
|
886
|
+
}),
|
|
547
887
|
// Payout schedule: the automatic-payout cadence lives in account.settings.payouts.schedule.
|
|
548
888
|
// GET /v1/account returns the platform account with a default daily schedule; POST updates
|
|
549
889
|
// it to e.g. weekly+anchor (anchors normalized — weekly_anchor set, monthly_anchor null) and
|
|
@@ -555,7 +895,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
555
895
|
if (!ok(acct)) return false;
|
|
556
896
|
const sched0 = ((field(acct, 'settings') as Body)?.payouts as Body)?.schedule as Body;
|
|
557
897
|
if (!sched0 || sched0.interval !== 'daily') return false;
|
|
558
|
-
const upd = await h({ m: 'POST', p: '/
|
|
898
|
+
const upd = await h({ m: 'POST', p: '/_twin/account', b: 'settings[payouts][schedule][interval]=weekly&settings[payouts][schedule][weekly_anchor]=friday' });
|
|
559
899
|
const sched1 = ((field(upd, 'settings') as Body)?.payouts as Body)?.schedule as Body;
|
|
560
900
|
if (!ok(upd) || sched1.interval !== 'weekly' || sched1.weekly_anchor !== 'friday' || sched1.monthly_anchor !== null) return false;
|
|
561
901
|
// persists across a re-read.
|
|
@@ -569,6 +909,211 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
569
909
|
}),
|
|
570
910
|
),
|
|
571
911
|
|
|
912
|
+
// Automatic payouts: on its schedule an account is paid out what has become available, and the ledger lists the
|
|
913
|
+
// entries each automatic payout paid (docs.stripe.com/payouts#payout-schedule,
|
|
914
|
+
// docs.stripe.com/api/balance_transactions/list#balance_transaction_list-payout).
|
|
915
|
+
done('stripe.payouts.automatic', 'payouts', 'Automatic payouts on the account\'s schedule, with the entries each paid out', 'api', 'core', async () => {
|
|
916
|
+
const root = mkdtempSync(join(tmpdir(), 'stp-auto-'));
|
|
917
|
+
try {
|
|
918
|
+
const h = (s: Step & { day?: number }) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: `2026-06-${String(s.day ?? 1).padStart(2, '0')}T12:00:00Z` });
|
|
919
|
+
const ch = await h({ m: 'POST', p: '/v1/charges', b: 'amount=2000¤cy=usd&source=tok_visa' });
|
|
920
|
+
const early = await h({ m: 'GET', p: '/v1/payouts', day: 2 });
|
|
921
|
+
const later = await h({ m: 'GET', p: '/v1/payouts?created[gte]=1780272000', day: 6 });
|
|
922
|
+
const po = (((later.body as Body).data as Body[]) ?? [])[0];
|
|
923
|
+
const entries = await h({ m: 'GET', p: `/v1/balance_transactions?payout=${String(po?.id)}`, day: 6 });
|
|
924
|
+
const sources = (((entries.body as Body).data as Body[]) ?? []).map((t) => t.source);
|
|
925
|
+
return ((early.body as Body).data as Body[]).length === 0 && po?.automatic === true && po.amount === 2000 - 88
|
|
926
|
+
&& sources.includes(id(ch)) && sources.includes(po.id);
|
|
927
|
+
} finally {
|
|
928
|
+
rmSync(root, { recursive: true, force: true });
|
|
929
|
+
}
|
|
930
|
+
}),
|
|
931
|
+
|
|
932
|
+
// Time's events (stripe-server.ts, the drain door): funds coming due send their account balance.available, a payout's
|
|
933
|
+
// arrival sends payout.paid, each once, a connected account's scoped to it (docs.stripe.com/api/events/types;
|
|
934
|
+
// docs.stripe.com/connect/webhooks). Dub pays a partner's connected account out on balance.available and completes
|
|
935
|
+
// the partner's payouts on payout.paid. The webhooks are read where an application reads them, a local receiver behind
|
|
936
|
+
// a platform endpoint and a `connect` one; the stored events are listed as each account (the Stripe-Account header).
|
|
937
|
+
done('stripe.events.time_drain', 'webhooks', 'Events World time produces (balance.available, payout.paid) sent through the drain door, once each, scoped to their account', 'api', 'common', async () => {
|
|
938
|
+
const root = mkdtempSync(join(tmpdir(), 'stp-drain-'));
|
|
939
|
+
const rx = await webhookReceiver();
|
|
940
|
+
try {
|
|
941
|
+
let now = '2026-06-01T12:00:00.000Z';
|
|
942
|
+
const h = fetcher(createStripeTwinFetch({ root, clock: () => now }));
|
|
943
|
+
await h('POST', '/v1/webhook_endpoints', `url=${rx.url('/platform')}&enabled_events[]=balance.available&enabled_events[]=payout.paid`);
|
|
944
|
+
await h('POST', '/v1/webhook_endpoints', `url=${rx.url('/connect')}&enabled_events[]=balance.available&enabled_events[]=payout.paid&connect=true`);
|
|
945
|
+
const seller = String((await h('POST', '/v1/accounts', 'type=custom&country=US&capabilities[transfers][requested]=true&business_profile[mcc]=5462&business_profile[url]=https://seller.test&tos_acceptance[date]=1767225600&tos_acceptance[ip]=203.0.113.9&individual[first_name]=Sam&individual[last_name]=Seller&settings[payouts][schedule][interval]=manual')).id);
|
|
946
|
+
await h('POST', `/v1/accounts/${seller}/external_accounts`, 'external_account[object]=bank_account&external_account[country]=US&external_account[currency]=usd&external_account[routing_number]=110000000&external_account[account_number]=000123456789');
|
|
947
|
+
await h('POST', '/_twin/account', 'settings[payouts][schedule][interval]=manual');
|
|
948
|
+
const pi = await h('POST', '/v1/payment_intents', 'amount=2592¤cy=usd&payment_method=pm_card_visa&confirm=true');
|
|
949
|
+
await h('POST', '/v1/transfers', `amount=2400¤cy=usd&destination=${seller}&source_transaction=${String(pi.latest_charge)}`);
|
|
950
|
+
const sent = async (): Promise<string[]> => ((await h('POST', '/_twin/drain')).delivered as Body[]).map((d) => `${String(d.type)}@${String(d.account ?? 'platform')}`);
|
|
951
|
+
const early = await sent();
|
|
952
|
+
now = '2026-06-04T12:00:00.000Z';
|
|
953
|
+
const due = await sent();
|
|
954
|
+
const again = await sent();
|
|
955
|
+
const po = await h('POST', '/v1/payouts', 'amount=2400¤cy=usd', seller);
|
|
956
|
+
now = '2026-06-07T12:00:00.000Z';
|
|
957
|
+
const arrived = await sent();
|
|
958
|
+
// the connected account's webhooks reach only the connect endpoint, naming it; the platform's only the other
|
|
959
|
+
const theirs = rx.got('/connect');
|
|
960
|
+
const ours = rx.got('/platform');
|
|
961
|
+
const balance = theirs.find((e) => e.type === 'balance.available');
|
|
962
|
+
const paid = theirs.find((e) => e.type === 'payout.paid');
|
|
963
|
+
const hook = balance?.account === seller && ((balance.data.object as Body).available as Body[])?.[0]?.amount === 2400
|
|
964
|
+
&& paid?.account === seller && (paid.data.object as Body).id === po.id && theirs.length === 2
|
|
965
|
+
&& ours.length === 1 && ours[0]!.type === 'balance.available' && !('account' in ours[0]!);
|
|
966
|
+
const listed = (acct?: string) => h('GET', '/v1/events?limit=50', undefined, acct).then((l) => ((l.data as Body[]) ?? []).map((e) => `${String(e.type)}@${String(e.account ?? 'platform')}`));
|
|
967
|
+
const sellerEvents = await listed(seller);
|
|
968
|
+
const platformEvents = await listed();
|
|
969
|
+
return early.length === 0 && due.includes(`balance.available@${seller}`) && due.includes('balance.available@platform') && again.length === 0
|
|
970
|
+
&& po.status === 'pending' && arrived.includes(`payout.paid@${seller}`) && hook
|
|
971
|
+
&& sellerEvents.includes(`balance.available@${seller}`) && sellerEvents.includes(`payout.paid@${seller}`)
|
|
972
|
+
&& platformEvents.includes('balance.available@platform') && !platformEvents.some((e) => e.endsWith(`@${seller}`));
|
|
973
|
+
} finally {
|
|
974
|
+
rx.close();
|
|
975
|
+
rmSync(root, { recursive: true, force: true });
|
|
976
|
+
}
|
|
977
|
+
}),
|
|
978
|
+
|
|
979
|
+
// Test mode's clock, as Stripe documents it (docs.stripe.com/testing#available-balance; semantics/ledger.ts): a card
|
|
980
|
+
// that bypasses the pending balance (4000000000000077, 4000003720000278, their pm_card_/tok_ names) puts its funds in
|
|
981
|
+
// the available balance at once, a PaymentMethod saved from it too when a later charge names it by id, and a
|
|
982
|
+
// source_transaction transfer from that charge is available on the connected account at once ("takes on the pending
|
|
983
|
+
// status of the associated charge", docs.stripe.com/connect/separate-charges-and-transfers); the drain in that same
|
|
984
|
+
// instant sends each account balance.available. Every other card keeps the delay (4242 pending), and a test payout
|
|
985
|
+
// keeps live timing ("Test payouts simulate a live payout", docs.stripe.com/payouts#test-payouts): paid at its
|
|
986
|
+
// arrival_date, not before. Dub's partner payout reaches Completed this way without World time for the funds. A US
|
|
987
|
+
// bank account debit settles at once too ("Test transactions settle instantly and are added to your available test
|
|
988
|
+
// balance", docs.stripe.com/testing), and every credit available at once is sent as balance.available.
|
|
989
|
+
done('stripe.balance.test_mode_bypass_pending', 'balance', 'Test-mode funds timing: bypass cards (raw, by test name, saved as a PaymentMethod) and bank debits available at once with balance.available sent, source_transaction transfers inheriting it; 4242 and test payouts keep their timing', 'api', 'common', async () => {
|
|
990
|
+
const root = mkdtempSync(join(tmpdir(), 'stp-bypass-'));
|
|
991
|
+
const rx = await webhookReceiver();
|
|
992
|
+
try {
|
|
993
|
+
let now = '2026-06-01T12:00:00.000Z';
|
|
994
|
+
const h = fetcher(createStripeTwinFetch({ root, clock: () => now }));
|
|
995
|
+
await h('POST', '/v1/webhook_endpoints', `url=${rx.url('/connect')}&enabled_events[]=balance.available&enabled_events[]=payout.paid&connect=true`);
|
|
996
|
+
const seller = String((await h('POST', '/v1/accounts', 'type=custom&country=US&capabilities[transfers][requested]=true&business_profile[mcc]=5462&business_profile[url]=https://seller.test&tos_acceptance[date]=1767225600&tos_acceptance[ip]=203.0.113.9&business_type=individual&individual[first_name]=Jordan&individual[last_name]=Lee&settings[payouts][schedule][interval]=manual')).id);
|
|
997
|
+
await h('POST', `/v1/accounts/${seller}/external_accounts`, 'external_account[object]=bank_account&external_account[country]=US&external_account[currency]=usd&external_account[routing_number]=110000000&external_account[account_number]=000123456789');
|
|
998
|
+
await h('POST', '/_twin/account', 'settings[payouts][schedule][interval]=manual');
|
|
999
|
+
const btOf = async (charge: unknown) => h('GET', `/v1/balance_transactions/${String((await h('GET', `/v1/charges/${String(charge)}`)).balance_transaction)}`);
|
|
1000
|
+
const amounts = async (acct?: string) => {
|
|
1001
|
+
const b = await h('GET', '/v1/balance', undefined, acct);
|
|
1002
|
+
return { available: ((b.available as Body[])[0]?.amount as number) ?? 0, pending: ((b.pending as Body[])[0]?.amount as number) ?? 0 };
|
|
1003
|
+
};
|
|
1004
|
+
// 4242: the delay stands in test mode
|
|
1005
|
+
const visa = await h('POST', '/v1/payment_intents', 'amount=1000¤cy=usd&payment_method=pm_card_visa&confirm=true');
|
|
1006
|
+
if ((await btOf(visa.latest_charge)).status !== 'pending') return false;
|
|
1007
|
+
const before = await amounts();
|
|
1008
|
+
// a card saved from 4000000000000077, charged later by its id
|
|
1009
|
+
const customer = String((await h('POST', '/v1/customers', 'email=maya@acme.test')).id);
|
|
1010
|
+
const pm = String((await h('POST', '/v1/payment_methods', 'type=card&card[number]=4000000000000077&card[exp_month]=4&card[exp_year]=2030&card[cvc]=314')).id);
|
|
1011
|
+
await h('POST', `/v1/payment_methods/${pm}/attach`, `customer=${customer}`);
|
|
1012
|
+
const pi = await h('POST', '/v1/payment_intents', `amount=2592¤cy=usd&customer=${customer}&payment_method=${pm}&confirm=true&off_session=true&expand[]=latest_charge.balance_transaction`);
|
|
1013
|
+
const charge = String((pi.latest_charge as Body | undefined)?.id);
|
|
1014
|
+
const bt = await btOf(charge);
|
|
1015
|
+
const after = await amounts();
|
|
1016
|
+
// the entry reads available however it is read: its own retrieve, the balance, an expansion
|
|
1017
|
+
const embedded = ((pi.latest_charge as Body | undefined)?.balance_transaction as Body | undefined)?.status;
|
|
1018
|
+
const platformAtOnce = bt.status === 'available' && embedded === 'available' && after.available - before.available === Number(bt.net) && after.pending === before.pending;
|
|
1019
|
+
// its source_transaction transfer is available on the connected account at once, and is sent as such
|
|
1020
|
+
await h('POST', '/v1/transfers', `amount=2400¤cy=usd&destination=${seller}&source_transaction=${charge}`);
|
|
1021
|
+
const theirs = await amounts(seller);
|
|
1022
|
+
const sent = async (): Promise<string[]> => ((await h('POST', '/_twin/drain')).delivered as Body[]).map((d) => `${String(d.type)}@${String(d.account ?? 'platform')}`);
|
|
1023
|
+
const drained = await sent();
|
|
1024
|
+
const again = await sent();
|
|
1025
|
+
const hook = rx.got('/connect').find((e) => e.type === 'balance.available');
|
|
1026
|
+
const announced = drained.includes(`balance.available@${seller}`) && drained.includes('balance.available@platform') && again.length === 0
|
|
1027
|
+
&& hook?.account === seller && ((hook.data.object as Body).available as Body[])?.[0]?.amount === 2400;
|
|
1028
|
+
// a test payout to Stripe's succeeding test bank account keeps live timing: pending until its arrival_date
|
|
1029
|
+
const po = await h('POST', '/v1/payouts', 'amount=2400¤cy=usd', seller);
|
|
1030
|
+
if (!(platformAtOnce && theirs.available === 2400 && theirs.pending === 0 && announced && typeof po.id === 'string')) return false;
|
|
1031
|
+
const early = await sent();
|
|
1032
|
+
now = new Date(Number(po.arrival_date) * 1000 + 1000).toISOString();
|
|
1033
|
+
const arrived = await sent();
|
|
1034
|
+
const paid = (await h('GET', `/v1/payouts/${String(po.id)}`, undefined, seller)).status;
|
|
1035
|
+
// the international bypass card, raw and by test name
|
|
1036
|
+
const intl = await h('POST', '/v1/charges', 'amount=500¤cy=usd&card[number]=4000003720000278&card[exp_month]=4&card[exp_year]=2030');
|
|
1037
|
+
const named = String((await h('POST', `/v1/payment_methods/pm_card_bypassPendingInternational/attach`, `customer=${customer}`)).id);
|
|
1038
|
+
const byName = await h('POST', '/v1/payment_intents', `amount=500¤cy=usd&customer=${customer}&payment_method=${named}&confirm=true&off_session=true`);
|
|
1039
|
+
const intlAtOnce = (await btOf(intl.id)).status === 'available' && (await btOf(byName.latest_charge)).status === 'available';
|
|
1040
|
+
// a US bank account debit settles into the available balance at once ("Test transactions settle instantly")
|
|
1041
|
+
const ach = await h('POST', '/v1/payment_intents', 'amount=700¤cy=usd&payment_method=pm_usBankAccount_success&confirm=true');
|
|
1042
|
+
const achCharge = (await h('GET', `/v1/payment_intents/${String(ach.id)}`)).latest_charge;
|
|
1043
|
+
// and a stored us_bank_account PaymentMethod's, as an application saves one
|
|
1044
|
+
const bank = String((await h('POST', '/v1/payment_methods', 'type=us_bank_account&us_bank_account[account_number]=000123456789&us_bank_account[routing_number]=110000000&us_bank_account[account_holder_type]=individual&billing_details[name]=Maya')).id);
|
|
1045
|
+
const stored = await h('POST', '/v1/payment_intents', `amount=800¤cy=usd&payment_method=${bank}&confirm=true`);
|
|
1046
|
+
const storedCharge = (await h('GET', `/v1/payment_intents/${String(stored.id)}`)).latest_charge;
|
|
1047
|
+
const achAtOnce = typeof achCharge === 'string' && (await btOf(achCharge)).status === 'available'
|
|
1048
|
+
&& typeof storedCharge === 'string' && (await btOf(storedCharge)).status === 'available';
|
|
1049
|
+
// a plain transfer's credit is sent as the connected account's balance.available too
|
|
1050
|
+
await h('POST', '/v1/transfers', `amount=100¤cy=usd&destination=${seller}`);
|
|
1051
|
+
const plain = (await sent()).includes(`balance.available@${seller}`);
|
|
1052
|
+
// a card made from payment_method_data carries what follows its success (a dispute, docs.stripe.com/testing#disputes)
|
|
1053
|
+
const fromData = await h('POST', '/v1/payment_intents', 'amount=300¤cy=usd&payment_method_data[type]=card&payment_method_data[card][number]=4000000000000259&payment_method_data[card][exp_month]=4&payment_method_data[card][exp_year]=2030&confirm=true');
|
|
1054
|
+
const disputed = (await h('GET', `/v1/charges/${String(fromData.latest_charge)}`)).disputed === true;
|
|
1055
|
+
// a 4242 charge, three days on and before any drain records its move, reads available however it is read, an
|
|
1056
|
+
// expansion included
|
|
1057
|
+
const later = await h('POST', '/v1/payment_intents', 'amount=400¤cy=usd&payment_method=pm_card_visa&confirm=true');
|
|
1058
|
+
now = new Date(Date.parse(now) + 3 * 86_400_000).toISOString();
|
|
1059
|
+
const laterRead = await h('GET', `/v1/charges/${String(later.latest_charge)}?expand[]=balance_transaction`);
|
|
1060
|
+
const visaAvailable = (laterRead.balance_transaction as Body | undefined)?.status === 'available';
|
|
1061
|
+
return po.status === 'pending' && Number(po.arrival_date) > Number(po.created) && !early.includes(`payout.paid@${seller}`)
|
|
1062
|
+
&& arrived.includes(`payout.paid@${seller}`) && paid === 'paid' && intlAtOnce && achAtOnce && plain && visaAvailable && disputed;
|
|
1063
|
+
} finally {
|
|
1064
|
+
rx.close();
|
|
1065
|
+
rmSync(root, { recursive: true, force: true });
|
|
1066
|
+
}
|
|
1067
|
+
}),
|
|
1068
|
+
|
|
1069
|
+
// One event, one id: the webhook a consumer receives and the event the Events API stores carry the same id, and no two
|
|
1070
|
+
// events share one — Dub's queue deduplicates by event.id, and a Balance has no id of its own to tell two accounts'
|
|
1071
|
+
// balance.available apart. Two partners' funds coming due in one drain are two events, each retrievable by its id as
|
|
1072
|
+
// its account (the Stripe-Account header).
|
|
1073
|
+
done('stripe.events.ids', 'webhooks', 'Event ids: unique per event (two accounts\' balance.available in one drain), and a webhook\'s id is the stored event\'s', 'api', 'common', async () => {
|
|
1074
|
+
const root = mkdtempSync(join(tmpdir(), 'stp-evid-'));
|
|
1075
|
+
const rx = await webhookReceiver();
|
|
1076
|
+
try {
|
|
1077
|
+
let now = '2026-06-01T12:00:00.000Z';
|
|
1078
|
+
const h = fetcher(createStripeTwinFetch({ root, clock: () => now }));
|
|
1079
|
+
await h('POST', '/v1/webhook_endpoints', `url=${rx.url('/platform')}&enabled_events[]=*`);
|
|
1080
|
+
await h('POST', '/v1/webhook_endpoints', `url=${rx.url('/connect')}&enabled_events[]=balance.available&connect=true`);
|
|
1081
|
+
await h('POST', '/_twin/account', 'settings[payouts][schedule][interval]=manual');
|
|
1082
|
+
const partner = async (): Promise<string> => {
|
|
1083
|
+
const a = String((await h('POST', '/v1/accounts', 'type=custom&country=US&capabilities[transfers][requested]=true&business_profile[mcc]=5462&business_profile[url]=https://seller.test&tos_acceptance[date]=1767225600&tos_acceptance[ip]=203.0.113.9&individual[first_name]=Sam&individual[last_name]=Seller&settings[payouts][schedule][interval]=manual')).id);
|
|
1084
|
+
await h('POST', `/v1/accounts/${a}/external_accounts`, 'external_account[object]=bank_account&external_account[country]=US&external_account[currency]=usd&external_account[routing_number]=110000000&external_account[account_number]=000123456789');
|
|
1085
|
+
const pi = await h('POST', '/v1/payment_intents', 'amount=2000¤cy=usd&payment_method=pm_card_visa&confirm=true');
|
|
1086
|
+
await h('POST', '/v1/transfers', `amount=1500¤cy=usd&destination=${a}&source_transaction=${String(pi.latest_charge)}`);
|
|
1087
|
+
return a;
|
|
1088
|
+
};
|
|
1089
|
+
const [first, second] = [await partner(), await partner()];
|
|
1090
|
+
const customer = await h('POST', '/v1/customers', 'email=ids@twin.test');
|
|
1091
|
+
// two writes at once: each event takes its own number, though both are delivered before either answers
|
|
1092
|
+
const [c1, c2] = await Promise.all([h('POST', '/v1/customers', 'email=ids1@twin.test'), h('POST', '/v1/customers', 'email=ids2@twin.test')]);
|
|
1093
|
+
now = '2026-06-04T12:00:00.000Z';
|
|
1094
|
+
await h('POST', '/_twin/drain');
|
|
1095
|
+
const due = rx.got('/connect').filter((e) => e.type === 'balance.available');
|
|
1096
|
+
const of = (acct: string) => due.find((e) => e.account === acct);
|
|
1097
|
+
const [a, b] = [of(first), of(second)];
|
|
1098
|
+
if (!a || !b || a.id === b.id || due.length !== 2) return false;
|
|
1099
|
+
const [ga, gb] = [await h('GET', `/v1/events/${a.id}`, undefined, first), await h('GET', `/v1/events/${b.id}`, undefined, second)];
|
|
1100
|
+
const hook = rx.got('/platform').find((e) => e.type === 'customer.created');
|
|
1101
|
+
const stored = await h('GET', `/v1/events/${String(hook?.id)}`);
|
|
1102
|
+
const ids = rx.got('/platform').concat(rx.got('/connect')).map((e) => e.id);
|
|
1103
|
+
return ga.id === a.id && ga.account === first && ga.type === 'balance.available' && gb.id === b.id && gb.account === second
|
|
1104
|
+
&& stored.id === hook?.id && ((stored.data as Body)?.object as Body)?.id === customer.id && new Set(ids).size === ids.length
|
|
1105
|
+
&& await (async () => {
|
|
1106
|
+
const both = rx.got('/platform').filter((e) => e.type === 'customer.created' && [c1.id, c2.id].includes(((e.data as Body)?.object as Body)?.id));
|
|
1107
|
+
const looked = await Promise.all(both.map((e) => h('GET', `/v1/events/${String(e.id)}`)));
|
|
1108
|
+
return both.length === 2 && both[0]!.id !== both[1]!.id
|
|
1109
|
+
&& looked.every((g, i) => ((g.data as Body)?.object as Body)?.id === ((both[i]!.data as Body)?.object as Body)?.id);
|
|
1110
|
+
})();
|
|
1111
|
+
} finally {
|
|
1112
|
+
rx.close();
|
|
1113
|
+
rmSync(root, { recursive: true, force: true });
|
|
1114
|
+
}
|
|
1115
|
+
}),
|
|
1116
|
+
|
|
572
1117
|
// ── Products / Prices ─────────────────────────────────────────────────────────────
|
|
573
1118
|
done('stripe.products.crud', 'catalog', 'Products: create + retrieve + update + list', 'api', 'core', () =>
|
|
574
1119
|
withRoot(async (h) => {
|
|
@@ -592,6 +1137,53 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
592
1137
|
return ok(g) && field(l, 'object') === 'list' && bad.status >= 400;
|
|
593
1138
|
}),
|
|
594
1139
|
),
|
|
1140
|
+
// Plans: the Prices API "replaces the Plans API and is backwards compatible" (docs.stripe.com/api/plans/create), so a
|
|
1141
|
+
// plan made with the id its creator chose ("You can optionally override this ID") is a recurring price under that id:
|
|
1142
|
+
// read as a price, found by a lookup_key set on the price, and listed, updated and deleted as a plan. "Only one of
|
|
1143
|
+
// `amount` and `amount_decimal` can be set"; plan writes send plan.created / plan.updated / plan.deleted carrying the
|
|
1144
|
+
// plan; and "Existing subscribers aren't affected" by a deletion (docs.stripe.com/api/plans/delete).
|
|
1145
|
+
done('stripe.plans.crud', 'catalog', 'Plans: create (with a chosen id) + retrieve + list + update + delete, each plan the recurring price it is', 'api', 'common', () =>
|
|
1146
|
+
withRoot(async (h) => {
|
|
1147
|
+
const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Business' });
|
|
1148
|
+
const plan = await h({ m: 'POST', p: '/v1/plans', b: `id=price_business_monthly&amount=9000¤cy=usd&interval=month&product=${id(prod)}` });
|
|
1149
|
+
if (!ok(plan) || field(plan, 'object') !== 'plan' || id(plan) !== 'price_business_monthly' || field(plan, 'amount') !== 9000) return false;
|
|
1150
|
+
const asPrice = await h({ m: 'GET', p: '/v1/prices/price_business_monthly' });
|
|
1151
|
+
const keyed = await h({ m: 'POST', p: '/v1/prices/price_business_monthly', b: 'lookup_key=business_monthly' });
|
|
1152
|
+
const found = await h({ m: 'GET', p: '/v1/prices?lookup_keys[]=business_monthly' });
|
|
1153
|
+
const again = await h({ m: 'POST', p: '/v1/plans', b: `id=price_business_monthly&amount=1¤cy=usd&interval=month&product=${id(prod)}` });
|
|
1154
|
+
const renamed = await h({ m: 'POST', p: '/v1/plans/price_business_monthly', b: 'nickname=Business%20monthly' });
|
|
1155
|
+
const listed = await h({ m: 'GET', p: '/v1/plans' });
|
|
1156
|
+
// a subscriber on the plan is not affected by its deletion: the price its item names stays, inactive
|
|
1157
|
+
const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=plan@twin.test' });
|
|
1158
|
+
const sub = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&items[0][price]=price_business_monthly&payment_behavior=default_incomplete` });
|
|
1159
|
+
const gone = await h({ m: 'DELETE', p: '/v1/plans/price_business_monthly' });
|
|
1160
|
+
const after = await h({ m: 'GET', p: '/v1/plans/price_business_monthly' });
|
|
1161
|
+
const twice = await h({ m: 'DELETE', p: '/v1/plans/price_business_monthly' });
|
|
1162
|
+
const kept = await h({ m: 'GET', p: '/v1/prices/price_business_monthly' });
|
|
1163
|
+
const subAfter = await h({ m: 'GET', p: `/v1/subscriptions/${id(sub)}` });
|
|
1164
|
+
const item = ((field(subAfter, 'items') as Body)?.data as Body[] | undefined)?.[0];
|
|
1165
|
+
// amount_decimal is kept as the decimal given, and only one of amount and amount_decimal may be set
|
|
1166
|
+
const decimal = await h({ m: 'POST', p: '/v1/plans', b: `amount_decimal=1250.5¤cy=usd&interval=month&product=${id(prod)}` });
|
|
1167
|
+
const both = await h({ m: 'POST', p: '/v1/plans', b: `amount=100&amount_decimal=100¤cy=usd&interval=month&product=${id(prod)}` });
|
|
1168
|
+
// a decimal plan bills the decimal times the quantity, rounded after multiplying (docs.stripe.com/products-prices/manage-prices): 1250.5 × 3 = 3751.5 → 3752
|
|
1169
|
+
const onDecimal = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&items[0][price]=${id(decimal)}&items[0][quantity]=3&payment_behavior=default_incomplete` });
|
|
1170
|
+
const decimalInvoice = await h({ m: 'GET', p: `/v1/invoices/${String(field(onDecimal, 'latest_invoice'))}` });
|
|
1171
|
+
// plan writes send plan events carrying the plan
|
|
1172
|
+
const events = (((await h({ m: 'GET', p: '/v1/events?limit=100' })).body as Body).data as Body[]) ?? [];
|
|
1173
|
+
const planEvent = (type: string) => events.find((e) => e.type === type && ((e.data as Body).object as Body).id === 'price_business_monthly');
|
|
1174
|
+
const sent = ['plan.created', 'plan.updated', 'plan.deleted'].every((t) => ((planEvent(t)?.data as Body | undefined)?.object as Body | undefined)?.object === 'plan')
|
|
1175
|
+
&& (((planEvent('plan.created')?.data as Body).object as Body).amount === 9000);
|
|
1176
|
+
const data = (field(found, 'data') ?? []) as Array<{ id?: string }>;
|
|
1177
|
+
return ok(asPrice) && field(asPrice, 'object') === 'price' && field(asPrice, 'unit_amount') === 9000
|
|
1178
|
+
&& ok(keyed) && data.length === 1 && data[0]!.id === 'price_business_monthly'
|
|
1179
|
+
&& again.status === 400
|
|
1180
|
+
&& field(renamed, 'nickname') === 'Business monthly'
|
|
1181
|
+
&& field(listed, 'object') === 'list' && ok(gone) && field(gone, 'deleted') === true && after.status === 404 && twice.status === 404
|
|
1182
|
+
&& ok(kept) && field(kept, 'active') === false && (item?.price as Body | undefined)?.id === 'price_business_monthly'
|
|
1183
|
+
&& ok(decimal) && field(decimal, 'amount_decimal') === '1250.5' && field(decimal, 'amount') === null
|
|
1184
|
+
&& both.status === 400 && sent && field(decimalInvoice, 'total') === 3752;
|
|
1185
|
+
}),
|
|
1186
|
+
),
|
|
595
1187
|
// Tiered pricing: a billing_scheme=tiered price requires tiers_mode (graduated|volume) +
|
|
596
1188
|
// tiers[] (each up_to + unit_amount/flat_amount); the twin normalizes the tiers, forces
|
|
597
1189
|
// unit_amount null, and round-trips them. currency_options pass through (a per-currency
|
|
@@ -671,8 +1263,11 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
671
1263
|
done('stripe.promotion_codes.create', 'discounts', 'PromotionCode create + activate/deactivate', 'api', 'common', () =>
|
|
672
1264
|
withRoot(async (h) => {
|
|
673
1265
|
const coupon = await h({ m: 'POST', p: '/v1/coupons', b: 'percent_off=10&duration=once' });
|
|
674
|
-
const pc = await h({ m: 'POST', p: '/v1/promotion_codes', b: `coupon=${id(coupon)}&code=SAVE10` });
|
|
675
|
-
|
|
1266
|
+
const pc = await h({ m: 'POST', p: '/v1/promotion_codes', b: `promotion[type]=coupon&promotion[coupon]=${id(coupon)}&code=SAVE10` });
|
|
1267
|
+
// what it promotes is `promotion: {type: 'coupon', coupon}` in the served version, with no top-level coupon
|
|
1268
|
+
// (2025-09-30.clover, docs.stripe.com/changelog/clover/2025-09-30/polymorphic-coupon; stripe-version.ts renders it)
|
|
1269
|
+
if (!ok(pc) || field(pc, 'object') !== 'promotion_code' || field(pc, 'code') !== 'SAVE10' || field(pc, 'active') !== true) return false;
|
|
1270
|
+
if ((field(pc, 'promotion') as Body)?.type !== 'coupon' || (field(pc, 'promotion') as Body)?.coupon !== id(coupon) || field(pc, 'coupon') !== undefined) return false;
|
|
676
1271
|
const g = await h({ m: 'GET', p: `/v1/promotion_codes/${id(pc)}` });
|
|
677
1272
|
const off = await h({ m: 'POST', p: `/v1/promotion_codes/${id(pc)}`, b: 'active=false' });
|
|
678
1273
|
const on = await h({ m: 'POST', p: `/v1/promotion_codes/${id(pc)}`, b: 'active=true' });
|
|
@@ -680,7 +1275,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
680
1275
|
if (!ok(g) || id(g) !== id(pc) || !ok(off) || field(off, 'active') !== false || !ok(on) || field(on, 'active') !== true) return false;
|
|
681
1276
|
if (((lst.body as Body).data as Body[]).length !== 1) return false;
|
|
682
1277
|
const noCoupon = await h({ m: 'POST', p: '/v1/promotion_codes', b: 'code=X' });
|
|
683
|
-
const badCoupon = await h({ m: 'POST', p: '/v1/promotion_codes', b: 'coupon=coupon_nope&code=Y' });
|
|
1278
|
+
const badCoupon = await h({ m: 'POST', p: '/v1/promotion_codes', b: 'promotion[type]=coupon&promotion[coupon]=coupon_nope&code=Y' });
|
|
684
1279
|
return noCoupon.status === 400 && badCoupon.status === 400 && ((badCoupon.body as Body).error as Body)?.code === 'resource_missing';
|
|
685
1280
|
}),
|
|
686
1281
|
),
|
|
@@ -707,7 +1302,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
707
1302
|
if (!card || card.last4 !== '4242') return false;
|
|
708
1303
|
// last4 FIDELITY (not just presence): a raw test PAN must surface its TRUE last4 —
|
|
709
1304
|
// the '4242' check above can't prove that, since '4242' equals the synthesized
|
|
710
|
-
// fallback.
|
|
1305
|
+
// fallback. The form reader coerces an all-digit card[number] to a JS number (like
|
|
711
1306
|
// any numeric-looking form field), so cover BOTH wire shapes: the numeric-coerced
|
|
712
1307
|
// bare PAN and a string PAN (spaces keep it a string through the form parser).
|
|
713
1308
|
const pmNum = await h({ m: 'POST', p: '/v1/payment_methods', b: 'type=card&card[number]=5555555555554444&card[exp_month]=4&card[exp_year]=2030&card[cvc]=314' });
|
|
@@ -785,13 +1380,54 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
785
1380
|
const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=sub@twin.test' });
|
|
786
1381
|
const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Plan' });
|
|
787
1382
|
const price = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=1500¤cy=usd&recurring[interval]=month&product=${id(prod)}` });
|
|
788
|
-
|
|
1383
|
+
// the customer's card pays the first invoice, so the subscription starts active
|
|
1384
|
+
const card = await h({ m: 'POST', p: '/v1/payment_methods/pm_card_visa/attach', b: `customer=${id(cust)}` });
|
|
1385
|
+
const sub = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&items[0][price]=${id(price)}&default_payment_method=${id(card)}` });
|
|
789
1386
|
if (!ok(sub) || field(sub, 'status') !== 'active') return false;
|
|
790
1387
|
const g = await h({ m: 'GET', p: `/v1/subscriptions/${id(sub)}` });
|
|
791
1388
|
const cancel = await h({ m: 'DELETE', p: `/v1/subscriptions/${id(sub)}` });
|
|
792
1389
|
return ok(g) && ok(cancel) && field(cancel, 'status') === 'canceled';
|
|
793
1390
|
}),
|
|
794
1391
|
),
|
|
1392
|
+
// An update's `items` changes the subscription's items (docs.stripe.com/api/subscriptions/update): an entry with an
|
|
1393
|
+
// `id` swaps that item's price ("`quantity` is set to 1 unless a `quantity` parameter is provided") or its quantity, or
|
|
1394
|
+
// with `deleted` removes it; one without adds an item, from a price or from `price_data`, which generates a Price
|
|
1395
|
+
// inline, archived (active=false; docs.stripe.com/products-prices/manage-prices). A refused entry refuses the whole
|
|
1396
|
+
// update, leaving the items as they were. Dub's trial switch to Advanced replaced the price of its one item.
|
|
1397
|
+
done('stripe.subscriptions.update_items', 'subscriptions', 'Subscription update items: swap a price, change a quantity, add (price or price_data), delete; all or nothing', 'api', 'core', () =>
|
|
1398
|
+
withRoot(async (h) => {
|
|
1399
|
+
const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=items@twin.test' });
|
|
1400
|
+
const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Pro' });
|
|
1401
|
+
const pro = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=1000¤cy=usd&recurring[interval]=month&product=${id(prod)}` });
|
|
1402
|
+
const advanced = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=3000¤cy=usd&recurring[interval]=month&product=${id(prod)}` });
|
|
1403
|
+
const sub = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&items[0][price]=${id(pro)}&items[0][quantity]=3&payment_behavior=default_incomplete` });
|
|
1404
|
+
const items = (r: StripeResponse): Body[] => ((field(r, 'items') as Body)?.data as Body[]) ?? [];
|
|
1405
|
+
const si = String(items(sub)[0]?.id);
|
|
1406
|
+
const upd = (b: string) => h({ m: 'POST', p: `/v1/subscriptions/${id(sub)}`, b });
|
|
1407
|
+
const swapped = await upd(`items[0][id]=${si}&items[0][price]=${id(advanced)}`);
|
|
1408
|
+
const s0 = items(swapped);
|
|
1409
|
+
if (s0.length !== 1 || s0[0]?.id !== si || (s0[0]?.price as Body)?.id !== id(advanced) || s0[0]?.quantity !== 1) return false;
|
|
1410
|
+
const more = items(await upd(`items[0][id]=${si}&items[0][quantity]=5`));
|
|
1411
|
+
if (more.length !== 1 || more[0]?.quantity !== 5 || (more[0]?.price as Body)?.id !== id(advanced)) return false;
|
|
1412
|
+
// naming the item's own price is no change of price: the quantity stays
|
|
1413
|
+
const same = items(await upd(`items[0][id]=${si}&items[0][price]=${id(advanced)}`));
|
|
1414
|
+
if (same[0]?.quantity !== 5) return false;
|
|
1415
|
+
const added = items(await upd(`items[0][price_data][currency]=usd&items[0][price_data][product]=${id(prod)}&items[0][price_data][unit_amount]=250&items[0][price_data][recurring][interval]=month`));
|
|
1416
|
+
const inline = added.find((i) => i.id !== si)?.price as Body | undefined;
|
|
1417
|
+
if (added.length !== 2 || inline?.unit_amount !== 250 || inline?.active !== false || inline?.product !== id(prod)) return false;
|
|
1418
|
+
// one bad entry refuses the update: the delete beside it is not applied
|
|
1419
|
+
const refused = await upd(`items[0][id]=${si}&items[0][deleted]=true&items[1][price]=price_nope`);
|
|
1420
|
+
const kept = items(await h({ m: 'GET', p: `/v1/subscriptions/${id(sub)}` }));
|
|
1421
|
+
if (refused.status !== 400 || kept.length !== 2 || !kept.some((i) => i.id === si)) return false;
|
|
1422
|
+
const removed = items(await upd(`items[0][id]=${si}&items[0][deleted]=true`));
|
|
1423
|
+
const listed = await h({ m: 'GET', p: `/v1/subscription_items?subscription=${id(sub)}` });
|
|
1424
|
+
// a price_data amount that is not a whole number of cents is kept as the decimal, never truncated
|
|
1425
|
+
const decimal = items(await upd(`items[0][price_data][currency]=usd&items[0][price_data][product]=${id(prod)}&items[0][price_data][unit_amount_decimal]=12.5&items[0][price_data][recurring][interval]=month`));
|
|
1426
|
+
const decimalPrice = decimal.map((i) => i.price as Body).find((p) => p?.unit_amount_decimal === '12.5');
|
|
1427
|
+
return removed.length === 1 && removed[0]?.id !== si && ((listed.body as Body).data as Body[]).length === 1
|
|
1428
|
+
&& decimalPrice !== undefined && decimalPrice.unit_amount === null;
|
|
1429
|
+
}),
|
|
1430
|
+
),
|
|
795
1431
|
done('stripe.subscriptions.list', 'subscriptions', 'Subscription list + filter by customer', 'api', 'core', () =>
|
|
796
1432
|
withRoot(async (h) => {
|
|
797
1433
|
const r = await h({ m: 'GET', p: '/v1/subscriptions' });
|
|
@@ -810,13 +1446,218 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
810
1446
|
const sub = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&items[0][price]=${id(price)}&trial_period_days=14` });
|
|
811
1447
|
if (!ok(sub) || field(sub, 'status') !== 'trialing' || typeof field(sub, 'trial_end') !== 'number') return false;
|
|
812
1448
|
// upcoming invoice WITH a proration_date splits the period (credit + charge proration lines).
|
|
813
|
-
const up = await h({ m: '
|
|
814
|
-
|
|
1449
|
+
const up = await h({ m: 'POST', p: '/v1/invoices/create_preview', b: `subscription=${id(sub)}&subscription_details[proration_date]=100` });
|
|
1450
|
+
// a preview's id is prefixed `upcoming_in` ("For preview invoices created using the create preview endpoint, this id
|
|
1451
|
+
// will be prefixed with `upcoming_in`", served spec invoice.id), and a line's proration flag is on its parent's
|
|
1452
|
+
// details in the served version (basil, docs.stripe.com/changelog/basil; stripe-version.ts line_item, 24e757f60)
|
|
1453
|
+
if (!ok(up) || field(up, 'object') !== 'invoice' || !String(field(up, 'id')).startsWith('upcoming_in_')) return false;
|
|
815
1454
|
const lines = (field(up, 'lines') as Body).data as Body[];
|
|
816
|
-
const
|
|
1455
|
+
const prorationOf = (l: Body): unknown => ((l.parent as Body | undefined)?.subscription_item_details as Body | undefined)?.proration ?? ((l.parent as Body | undefined)?.invoice_item_details as Body | undefined)?.proration;
|
|
1456
|
+
if (lines.some((l) => 'proration' in l)) return false;
|
|
1457
|
+
const prorations = lines.filter((l) => prorationOf(l) === true);
|
|
817
1458
|
return prorations.length === 2 && prorations.some((l) => Number(l.amount) < 0) && prorations.some((l) => Number(l.amount) > 0);
|
|
818
1459
|
}),
|
|
819
1460
|
),
|
|
1461
|
+
// Renewal over time: at the period's end a subscription_cycle invoice is drafted and, an hour on, charged to the
|
|
1462
|
+
// default payment method; a card that declines when charged leaves it open and the subscription past_due, and
|
|
1463
|
+
// paying it makes the subscription active (docs.stripe.com/billing/subscriptions/overview#payment-status).
|
|
1464
|
+
done('stripe.subscriptions.renewal', 'subscriptions', 'Subscriptions renew at each period end; a declined renewal is past_due until its invoice is paid', 'api', 'core', async () => {
|
|
1465
|
+
const root = mkdtempSync(join(tmpdir(), 'stp-renew-'));
|
|
1466
|
+
try {
|
|
1467
|
+
// February's reads come two hours into the renewed period: its invoice is drafted at the period's end and charged
|
|
1468
|
+
// "an hour on" (above; cab4389eb stamps each catch-up write at that moment), so a read at the period's very end
|
|
1469
|
+
// sees the draft, not the paid invoice this claim is about
|
|
1470
|
+
const h = (s: Step & { month?: number }) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: `2026-${String(s.month ?? 1).padStart(2, '0')}-10T${(s.month ?? 1) > 1 ? '02' : '00'}:00:00Z` });
|
|
1471
|
+
const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Box' });
|
|
1472
|
+
const price = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=3500¤cy=usd&recurring[interval]=month&product=${id(prod)}` });
|
|
1473
|
+
const payer = await h({ m: 'POST', p: '/v1/customers', b: 'email=renews@twin.test&payment_method=pm_card_visa&invoice_settings[default_payment_method]=pm_card_visa' });
|
|
1474
|
+
const good = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(payer)}&items[0][price]=${id(price)}&default_payment_method=pm_card_visa` });
|
|
1475
|
+
const failing = await h({ m: 'POST', p: '/v1/customers', b: 'email=declines@twin.test' });
|
|
1476
|
+
const bad = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(failing)}&items[0][price]=${id(price)}&default_payment_method=pm_card_chargeCustomerFail&trial_period_days=7` });
|
|
1477
|
+
const renewed = await h({ m: 'GET', p: `/v1/invoices?subscription=${id(good)}`, month: 2 });
|
|
1478
|
+
const cycle = ((renewed.body as Body).data as Body[]).find((i) => i.billing_reason === 'subscription_cycle');
|
|
1479
|
+
const pastDue = await h({ m: 'GET', p: `/v1/subscriptions/${id(bad)}`, month: 2 });
|
|
1480
|
+
const open = (((await h({ m: 'GET', p: `/v1/invoices?subscription=${id(bad)}&status=open`, month: 2 })).body as Body).data as Body[])[0];
|
|
1481
|
+
const paid = await h({ m: 'POST', p: `/v1/invoices/${String(open?.id)}/pay`, b: 'payment_method=pm_card_visa', month: 2 });
|
|
1482
|
+
const back = await h({ m: 'GET', p: `/v1/subscriptions/${id(bad)}`, month: 2 });
|
|
1483
|
+
// made when Stripe would have made it, not when the February request caught it up
|
|
1484
|
+
const stamped = cycle?.created === cycle?.period_start && (cycle?.status_transitions as Body | undefined)?.paid_at === Number(cycle?.period_start) + 3600;
|
|
1485
|
+
return stamped && cycle?.status === 'paid' && cycle.amount_paid === 3500 && field(pastDue, 'status') === 'past_due'
|
|
1486
|
+
&& open?.attempt_count === 1 && field(paid, 'status') === 'paid' && field(back, 'status') === 'active';
|
|
1487
|
+
} finally {
|
|
1488
|
+
rmSync(root, { recursive: true, force: true });
|
|
1489
|
+
}
|
|
1490
|
+
}),
|
|
1491
|
+
// A `once` coupon discounts one invoice: the one the subscription finalizes next after it was applied, which records it,
|
|
1492
|
+
// and is then removed from the subscription's discounts (docs.stripe.com/billing/subscriptions/coupons#coupon-duration),
|
|
1493
|
+
// however that invoice is finalized: by time, or through the Invoices API.
|
|
1494
|
+
done('stripe.subscriptions.once_coupon', 'subscriptions', 'A once coupon discounts the next invoice finalized after it is applied, then is removed from the subscription', 'api', 'common', async () => {
|
|
1495
|
+
const root = mkdtempSync(join(tmpdir(), 'stp-once-'));
|
|
1496
|
+
try {
|
|
1497
|
+
const h = (s: Step & { at?: string }) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: s.at ?? '2026-03-01T00:00:00Z' });
|
|
1498
|
+
const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Box' });
|
|
1499
|
+
const price = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=9000¤cy=usd&recurring[interval]=month&product=${id(prod)}` });
|
|
1500
|
+
const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=once%40twin.test&payment_method=pm_card_visa&invoice_settings[default_payment_method]=pm_card_visa' });
|
|
1501
|
+
const coupon = await h({ m: 'POST', p: '/v1/coupons', b: 'id=HALF&percent_off=50&duration=once' });
|
|
1502
|
+
const invoices = async (sub: StripeResponse, at: string) => ((((await h({ m: 'GET', p: `/v1/invoices?subscription=${id(sub)}`, at })).body as Body).data as Body[]) ?? []);
|
|
1503
|
+
const discounts = (r: StripeResponse) => ((field(r, 'discounts') as unknown[] | undefined) ?? []).length;
|
|
1504
|
+
// added after April's renewal was drafted (00:00) and before it is collected (01:00): April is full, May is halved
|
|
1505
|
+
const late = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&items[0][price]=${id(price)}` });
|
|
1506
|
+
await h({ m: 'GET', p: `/v1/subscriptions/${id(late)}`, at: '2026-04-01T00:10:00Z' });
|
|
1507
|
+
await h({ m: 'POST', p: `/v1/subscriptions/${id(late)}`, b: `discounts[0][coupon]=${id(coupon)}`, at: '2026-04-01T00:20:00Z' });
|
|
1508
|
+
const april = (await invoices(late, '2026-04-01T02:00:00Z')).find((i) => i.period_start === Date.parse('2026-04-01T00:00:00Z') / 1000);
|
|
1509
|
+
const stillThere = await h({ m: 'GET', p: `/v1/subscriptions/${id(late)}`, at: '2026-04-01T02:00:00Z' });
|
|
1510
|
+
const may = (await invoices(late, '2026-05-01T02:00:00Z')).find((i) => i.period_start === Date.parse('2026-05-01T00:00:00Z') / 1000);
|
|
1511
|
+
const gone = await h({ m: 'GET', p: `/v1/subscriptions/${id(late)}`, at: '2026-05-01T02:00:00Z' });
|
|
1512
|
+
if (april?.total !== 9000 || discounts(stillThere) !== 1 || may?.total !== 4500 || discounts(gone) !== 0) return false;
|
|
1513
|
+
// the renewal's draft finalized and paid through the Invoices API spends it too: June is full again
|
|
1514
|
+
const other = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&items[0][price]=${id(price)}` });
|
|
1515
|
+
await h({ m: 'POST', p: `/v1/subscriptions/${id(other)}`, b: `discounts[0][coupon]=${id(coupon)}`, at: '2026-03-15T00:00:00Z' });
|
|
1516
|
+
const draft = (await invoices(other, '2026-04-01T00:10:00Z')).find((i) => i.status === 'draft');
|
|
1517
|
+
await h({ m: 'POST', p: `/v1/invoices/${String(draft?.id)}/finalize`, at: '2026-04-01T00:20:00Z' });
|
|
1518
|
+
const afterFinalize = await h({ m: 'GET', p: `/v1/subscriptions/${id(other)}`, at: '2026-04-01T00:30:00Z' });
|
|
1519
|
+
const mayOther = (await invoices(other, '2026-05-01T02:00:00Z')).find((i) => i.period_start === Date.parse('2026-05-01T00:00:00Z') / 1000);
|
|
1520
|
+
return draft?.total === 4500 && discounts(afterFinalize) === 0 && mayOther?.total === 9000;
|
|
1521
|
+
} finally {
|
|
1522
|
+
rmSync(root, { recursive: true, force: true });
|
|
1523
|
+
}
|
|
1524
|
+
}),
|
|
1525
|
+
// Ending a trial early: `trial_end=now` on a trialing subscription starts a paid period now, invoices it and charges it
|
|
1526
|
+
// at once (active; a decline leaves it past_due, error_if_incomplete refuses with 402 and changes nothing); a future
|
|
1527
|
+
// timestamp moves the trial's end and the period with it; a past one is refused (semantics/subscriptions.ts
|
|
1528
|
+
// applyTrialEnd; docs.stripe.com/api/subscriptions/update#update_subscription-trial_end,
|
|
1529
|
+
// docs.stripe.com/billing/subscriptions/upgrade-downgrade#immediate-payment). Read in basil's shape, as Dub reads it.
|
|
1530
|
+
done('stripe.subscriptions.trial_end_now', 'subscriptions', 'Subscription update trial_end: `now` ends the trial and charges a new period at once; a timestamp moves it; a past one is refused', 'api', 'core', async () => {
|
|
1531
|
+
const root = mkdtempSync(join(tmpdir(), 'stp-trialend-'));
|
|
1532
|
+
try {
|
|
1533
|
+
const h = (s: Step & { day?: number }) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: `2026-03-${String(s.day ?? 1).padStart(2, '0')}T00:00:00Z` });
|
|
1534
|
+
const day = (d: number) => Date.parse(`2026-03-${String(d).padStart(2, '0')}T00:00:00Z`) / 1000;
|
|
1535
|
+
const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Business' });
|
|
1536
|
+
const price = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=9000¤cy=usd&recurring[interval]=month&product=${id(prod)}` });
|
|
1537
|
+
const trialing = async (email: string, pm: string) => {
|
|
1538
|
+
const c = await h({ m: 'POST', p: '/v1/customers', b: `email=${email}&payment_method=${pm}&invoice_settings[default_payment_method]=${pm}` });
|
|
1539
|
+
return h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(c)}&items[0][price]=${id(price)}&trial_period_days=14` });
|
|
1540
|
+
};
|
|
1541
|
+
const period = (r: StripeResponse) => ((field(r, 'items') as Body | undefined)?.data as Body[] | undefined)?.[0] ?? {};
|
|
1542
|
+
const sub = await trialing('trial%40twin.test', 'pm_card_visa');
|
|
1543
|
+
// a trial's end is its billing anchor
|
|
1544
|
+
if (field(sub, 'billing_cycle_anchor') !== field(sub, 'trial_end')) return false;
|
|
1545
|
+
// refused before anything changes: a past timestamp, a value that is no timestamp
|
|
1546
|
+
const past = await h({ m: 'POST', p: `/v1/subscriptions/${id(sub)}`, b: 'trial_end=1700000000', day: 3 });
|
|
1547
|
+
const junk = await h({ m: 'POST', p: `/v1/subscriptions/${id(sub)}`, b: 'trial_end=tomorrow', day: 3 });
|
|
1548
|
+
const pastError = (past.body as { error?: Body }).error;
|
|
1549
|
+
if (past.status !== 400 || pastError?.param !== 'trial_end' || pastError?.message !== 'Invalid timestamp: must be an integer Unix timestamp in the future.' || junk.status !== 400) return false;
|
|
1550
|
+
// a future timestamp moves the trial's end, the period's end and the billing anchor
|
|
1551
|
+
const moved = await h({ m: 'POST', p: `/v1/subscriptions/${id(sub)}`, b: `trial_end=${day(20)}`, day: 3 });
|
|
1552
|
+
if (field(moved, 'status') !== 'trialing' || field(moved, 'trial_end') !== day(20) || period(moved).current_period_end !== day(20) || field(moved, 'billing_cycle_anchor') !== day(20)) return false;
|
|
1553
|
+
// `now`: the trial ends, a month's period starts now and its invoice is paid by the default payment method
|
|
1554
|
+
const ended = await h({ m: 'POST', p: `/v1/subscriptions/${id(sub)}`, b: 'trial_end=now&cancel_at_period_end=false', day: 5 });
|
|
1555
|
+
const inv = await h({ m: 'GET', p: `/v1/invoices/${String(field(ended, 'latest_invoice'))}`, day: 5 });
|
|
1556
|
+
const invoices = (((await h({ m: 'GET', p: `/v1/invoices?customer=${String(field(sub, 'customer'))}`, day: 5 })).body as Body).data as Body[]) ?? [];
|
|
1557
|
+
const events = ((((await h({ m: 'GET', p: '/v1/events?limit=100', day: 5 })).body as Body).data as Body[]) ?? []).filter((e) => e.created === day(5)).map((e) => e.type);
|
|
1558
|
+
const endedOk = ok(ended) && field(ended, 'status') === 'active' && field(ended, 'trial_end') === day(5) && field(ended, 'billing_cycle_anchor') === day(5)
|
|
1559
|
+
&& period(ended).current_period_start === day(5) && period(ended).current_period_end === Date.parse('2026-04-05T00:00:00Z') / 1000;
|
|
1560
|
+
const invoiceOk = field(inv, 'status') === 'paid' && field(inv, 'billing_reason') === 'subscription_update' && field(inv, 'amount_paid') === 9000
|
|
1561
|
+
&& field(inv, 'period_start') === day(5) && invoices.some((i) => i.id === id(inv));
|
|
1562
|
+
if (!endedOk || !invoiceOk || !['customer.subscription.updated', 'invoice.created', 'invoice.paid'].every((t) => events.includes(t))) return false;
|
|
1563
|
+
// a cancel scheduled during the trial follows the new period; a `once` coupon added during the trial, or with the
|
|
1564
|
+
// update itself, takes the invoice ending it and is then spent
|
|
1565
|
+
const once = await h({ m: 'POST', p: '/v1/coupons', b: 'id=HALFOFF&percent_off=50&duration=once', day: 2 });
|
|
1566
|
+
const leaving = await trialing('leaving%40twin.test', 'pm_card_visa');
|
|
1567
|
+
await h({ m: 'POST', p: `/v1/subscriptions/${id(leaving)}`, b: `cancel_at_period_end=true&discounts[0][coupon]=${id(once)}`, day: 2 });
|
|
1568
|
+
const early = await h({ m: 'POST', p: `/v1/subscriptions/${id(leaving)}`, b: 'trial_end=now', day: 5 });
|
|
1569
|
+
const halved = await h({ m: 'GET', p: `/v1/invoices/${String(field(early, 'latest_invoice'))}`, day: 5 });
|
|
1570
|
+
const sameCall = await trialing('same%40twin.test', 'pm_card_visa');
|
|
1571
|
+
const withCoupon = await h({ m: 'POST', p: `/v1/subscriptions/${id(sameCall)}`, b: `trial_end=now&discounts[0][coupon]=${id(once)}`, day: 5 });
|
|
1572
|
+
const halvedToo = await h({ m: 'GET', p: `/v1/invoices/${String(field(withCoupon, 'latest_invoice'))}`, day: 5 });
|
|
1573
|
+
const spent = (r: StripeResponse) => ((field(r, 'discounts') as Body[] | undefined) ?? []).length === 0;
|
|
1574
|
+
if (field(early, 'cancel_at') !== period(early).current_period_end || field(early, 'cancel_at_period_end') !== true || field(halved, 'total') !== 4500
|
|
1575
|
+
|| !spent(early) || field(halvedToo, 'total') !== 4500 || !spent(withCoupon)) return false;
|
|
1576
|
+
// what the update leaves bills: a quantity of 0 bills nothing (paid, active, with no card); an unknown item is refused as such
|
|
1577
|
+
const nobody = await h({ m: 'POST', p: '/v1/customers', b: 'email=nobody%40twin.test' });
|
|
1578
|
+
const idle = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(nobody)}&items[0][price]=${id(price)}&trial_period_days=14` });
|
|
1579
|
+
const zero = await h({ m: 'POST', p: `/v1/subscriptions/${id(idle)}`, b: `trial_end=now&payment_behavior=error_if_incomplete&items[0][id]=${String(period(idle).id)}&items[0][quantity]=0`, day: 5 });
|
|
1580
|
+
const zeroInvoice = await h({ m: 'GET', p: `/v1/invoices/${String(field(zero, 'latest_invoice'))}`, day: 5 });
|
|
1581
|
+
if (field(zero, 'status') !== 'active' || field(zeroInvoice, 'total') !== 0 || field(zeroInvoice, 'status') !== 'paid') return false;
|
|
1582
|
+
const idle2 = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(nobody)}&items[0][price]=${id(price)}&trial_period_days=14` });
|
|
1583
|
+
const decimal = await h({ m: 'POST', p: `/v1/subscriptions/${id(idle2)}`, b: `trial_end=now&payment_behavior=error_if_incomplete&items[0][id]=${String(period(idle2).id)}&items[0][deleted]=true&items[1][price_data][currency]=usd&items[1][price_data][product]=${id(prod)}&items[1][price_data][unit_amount_decimal]=5000&items[1][price_data][recurring][interval]=month`, day: 5 });
|
|
1584
|
+
const unknown = await h({ m: 'POST', p: `/v1/subscriptions/${id(idle2)}`, b: 'trial_end=now&payment_behavior=error_if_incomplete&items[0][id]=si_nope&items[0][quantity]=2', day: 5 });
|
|
1585
|
+
const idleAfter = await h({ m: 'GET', p: `/v1/subscriptions/${id(idle2)}`, day: 5 });
|
|
1586
|
+
if (decimal.status !== 400 || !String(((unknown.body as { error?: Body }).error)?.message).includes("No such subscription_item: 'si_nope'")
|
|
1587
|
+
|| field(idleAfter, 'status') !== 'trialing' || period(idleAfter).id !== period(idle2).id) return false;
|
|
1588
|
+
// a trial made through the API opens its $0 invoice at once, which takes and spends a once coupon, as Checkout's does
|
|
1589
|
+
const withOnce = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(nobody)}&items[0][price]=${id(price)}&trial_period_days=14&discounts[0][coupon]=${id(once)}` });
|
|
1590
|
+
const trialInvoice = await h({ m: 'GET', p: `/v1/invoices/${String(field(withOnce, 'latest_invoice'))}` });
|
|
1591
|
+
if (field(trialInvoice, 'total') !== 0 || field(trialInvoice, 'status') !== 'paid' || field(trialInvoice, 'billing_reason') !== 'subscription_create'
|
|
1592
|
+
|| ((field(trialInvoice, 'discounts') as unknown[] | undefined) ?? []).length !== 1 || !spent(withOnce)) return false;
|
|
1593
|
+
// and the subscription names it from its own create: the customer.subscription.created event carries it
|
|
1594
|
+
const createdEvent = ((((await h({ m: 'GET', p: '/v1/events?type=customer.subscription.created&limit=100' })).body as Body).data as Body[]) ?? [])
|
|
1595
|
+
.find((e) => ((e.data as Body).object as Body).id === id(withOnce));
|
|
1596
|
+
if (((createdEvent?.data as Body | undefined)?.object as Body | undefined)?.latest_invoice !== field(withOnce, 'latest_invoice')) return false;
|
|
1597
|
+
// a card that declines when charged: the change stands, the invoice is open and the subscription past_due
|
|
1598
|
+
const declining = await trialing('declines%40twin.test', 'pm_card_chargeCustomerFail');
|
|
1599
|
+
const invoicesOf = async (s: StripeResponse) => ((((await h({ m: 'GET', p: `/v1/invoices?customer=${String(field(s, 'customer'))}`, day: 5 })).body as Body).data as Body[]) ?? []).length;
|
|
1600
|
+
const before = await invoicesOf(declining);
|
|
1601
|
+
const strict = await h({ m: 'POST', p: `/v1/subscriptions/${id(declining)}`, b: 'trial_end=now&payment_behavior=error_if_incomplete', day: 5 });
|
|
1602
|
+
const still = await h({ m: 'GET', p: `/v1/subscriptions/${id(declining)}`, day: 5 });
|
|
1603
|
+
if (await invoicesOf(declining) !== before) return false;
|
|
1604
|
+
// error_if_incomplete judges the period as the update leaves it: $0 items swapped for a paid price with no payment
|
|
1605
|
+
// method to charge is refused as the create refuses it (semantics/subscriptions.ts firstPaymentRefused)
|
|
1606
|
+
const freeProd = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=0¤cy=usd&recurring[interval]=month&product=${id(prod)}` });
|
|
1607
|
+
const bare = await h({ m: 'POST', p: '/v1/customers', b: 'email=bare%40twin.test' });
|
|
1608
|
+
const free = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(bare)}&items[0][price]=${id(freeProd)}&trial_period_days=14` });
|
|
1609
|
+
const freeItem = String(period(free).id);
|
|
1610
|
+
const upgrade = await h({ m: 'POST', p: `/v1/subscriptions/${id(free)}`, b: `trial_end=now&payment_behavior=error_if_incomplete&items[0][id]=${freeItem}&items[0][price]=${id(price)}`, day: 5 });
|
|
1611
|
+
const unchanged = await h({ m: 'GET', p: `/v1/subscriptions/${id(free)}`, day: 5 });
|
|
1612
|
+
if (upgrade.status !== 400 || field(unchanged, 'status') !== 'trialing' || (period(unchanged).price as Body | undefined)?.id !== id(freeProd)) return false;
|
|
1613
|
+
// default_incomplete: the period is invoiced and left open, no payment attempted
|
|
1614
|
+
const quiet = await trialing('quiet%40twin.test', 'pm_card_visa');
|
|
1615
|
+
const deferred = await h({ m: 'POST', p: `/v1/subscriptions/${id(quiet)}`, b: 'trial_end=now&payment_behavior=default_incomplete', day: 5 });
|
|
1616
|
+
const unpaid = await h({ m: 'GET', p: `/v1/invoices/${String(field(deferred, 'latest_invoice'))}`, day: 5 });
|
|
1617
|
+
if (field(deferred, 'status') !== 'past_due' || field(unpaid, 'status') !== 'open' || field(unpaid, 'attempted') !== false || field(unpaid, 'amount_paid') !== 0) return false;
|
|
1618
|
+
const lenient = await h({ m: 'POST', p: `/v1/subscriptions/${id(declining)}`, b: 'trial_end=now', day: 5 });
|
|
1619
|
+
const open = await h({ m: 'GET', p: `/v1/invoices/${String(field(lenient, 'latest_invoice'))}`, day: 5 });
|
|
1620
|
+
return strict.status === 402 && field(still, 'status') === 'trialing' && field(still, 'latest_invoice') === field(declining, 'latest_invoice')
|
|
1621
|
+
&& field(lenient, 'status') === 'past_due' && field(open, 'status') === 'open' && field(open, 'amount_remaining') === 9000;
|
|
1622
|
+
} finally {
|
|
1623
|
+
rmSync(root, { recursive: true, force: true });
|
|
1624
|
+
}
|
|
1625
|
+
}),
|
|
1626
|
+
// Automatic reconciliation: a bank transfer arriving in the customer's cash balance pays their open invoice that takes
|
|
1627
|
+
// transfers (docs.stripe.com/payments/customer-balance/reconciliation).
|
|
1628
|
+
done('stripe.customers.cash_balance_reconciliation', 'customers', 'Cash balance funding pays an open bank-transfer invoice (automatic reconciliation)', 'api', 'common', () =>
|
|
1629
|
+
withRoot(async (h) => {
|
|
1630
|
+
const cust = await h({ m: 'POST', p: '/v1/customers', b: 'name=Cowork&email=ap@cowork.test' });
|
|
1631
|
+
const inv = await h({ m: 'POST', p: '/v1/invoices', b: `customer=${id(cust)}&collection_method=send_invoice&days_until_due=30&payment_settings[payment_method_types][0]=customer_balance` });
|
|
1632
|
+
await h({ m: 'POST', p: '/v1/invoiceitems', b: `customer=${id(cust)}&invoice=${id(inv)}&amount=24000¤cy=usd` });
|
|
1633
|
+
await h({ m: 'POST', p: `/v1/invoices/${id(inv)}/finalize` });
|
|
1634
|
+
await h({ m: 'POST', p: `/v1/test_helpers/customers/${id(cust)}/fund_cash_balance`, b: 'amount=24000¤cy=usd' });
|
|
1635
|
+
const paid = await h({ m: 'GET', p: `/v1/invoices/${id(inv)}` });
|
|
1636
|
+
const bal = await h({ m: 'GET', p: `/v1/customers/${id(cust)}/cash_balance` });
|
|
1637
|
+
return field(paid, 'status') === 'paid' && field(paid, 'amount_paid') === 24000 && ((field(bal, 'available') as Body | null)?.usd ?? 0) === 0;
|
|
1638
|
+
}),
|
|
1639
|
+
),
|
|
1640
|
+
// A subscription set to cancel at its period's end is canceled when the period ends, with no renewal invoice
|
|
1641
|
+
// (docs.stripe.com/billing/subscriptions/cancel#cancel-at-end-of-cycle).
|
|
1642
|
+
done('stripe.subscriptions.cancel_at_period_end', 'subscriptions', 'cancel_at_period_end cancels the subscription when its period ends', 'api', 'common', async () => {
|
|
1643
|
+
const root = mkdtempSync(join(tmpdir(), 'stp-cape-'));
|
|
1644
|
+
try {
|
|
1645
|
+
// February's reads come two hours into the renewed period: its invoice is drafted at the period's end and charged
|
|
1646
|
+
// "an hour on" (above; cab4389eb stamps each catch-up write at that moment), so a read at the period's very end
|
|
1647
|
+
// sees the draft, not the paid invoice this claim is about
|
|
1648
|
+
const h = (s: Step & { month?: number }) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: `2026-${String(s.month ?? 1).padStart(2, '0')}-10T${(s.month ?? 1) > 1 ? '02' : '00'}:00:00Z` });
|
|
1649
|
+
const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Box' });
|
|
1650
|
+
const price = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=3500¤cy=usd&recurring[interval]=month&product=${id(prod)}` });
|
|
1651
|
+
const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=leaving@twin.test' });
|
|
1652
|
+
const sub = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&items[0][price]=${id(price)}&default_payment_method=pm_card_visa` });
|
|
1653
|
+
const set = await h({ m: 'POST', p: `/v1/subscriptions/${id(sub)}`, b: 'cancel_at_period_end=true' });
|
|
1654
|
+
const after = await h({ m: 'GET', p: `/v1/subscriptions/${id(sub)}`, month: 3 });
|
|
1655
|
+
const invoices = ((await h({ m: 'GET', p: `/v1/invoices?subscription=${id(sub)}`, month: 3 })).body as Body).data as Body[];
|
|
1656
|
+
return typeof field(set, 'cancel_at') === 'number' && field(after, 'status') === 'canceled' && typeof field(after, 'ended_at') === 'number' && invoices.length === 1;
|
|
1657
|
+
} finally {
|
|
1658
|
+
rmSync(root, { recursive: true, force: true });
|
|
1659
|
+
}
|
|
1660
|
+
}),
|
|
820
1661
|
// Pause/resume: set pause_collection[behavior]=void → the sub records pause_collection (and
|
|
821
1662
|
// the customer.subscription.paused event fires); clearing it (pause_collection="") resumes
|
|
822
1663
|
// (pause_collection null). cancel_at_period_end toggles a scheduled cancel and reactivation.
|
|
@@ -861,29 +1702,30 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
861
1702
|
ok(rel) && field(rel, 'status') === 'released' && relAgain.status === 400 && noTarget.status === 400;
|
|
862
1703
|
}),
|
|
863
1704
|
),
|
|
864
|
-
// Metered/usage billing: a Billing Meter defines event_name + aggregation; usage is reported
|
|
865
|
-
//
|
|
866
|
-
// display_name/event_name/default_aggregation
|
|
867
|
-
//
|
|
868
|
-
|
|
1705
|
+
// Metered/usage billing: a Billing Meter defines event_name + aggregation; usage is reported as meter events and read
|
|
1706
|
+
// back as the meter's event summaries (GET /v1/billing/meters/:id/event_summaries, docs.stripe.com/api/billing/meter-event-summary).
|
|
1707
|
+
// The twin requires display_name/event_name/default_aggregation, lists/retrieves meters, deactivate transitions
|
|
1708
|
+
// active→inactive, and a summary totals the customer's reported values. The legacy per-item usage records this claim
|
|
1709
|
+
// used to drive "left the API with basil" and were removed from the twin (5decb1d77; docs.stripe.com/changelog/basil).
|
|
1710
|
+
done('stripe.billing.metered_usage', 'subscriptions', 'Usage / metered billing (meters + meter events and their summaries)', 'api', 'common', () =>
|
|
869
1711
|
withRoot(async (h) => {
|
|
870
|
-
const
|
|
1712
|
+
const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=metered@twin.test' });
|
|
1713
|
+
const meter = await h({ m: 'POST', p: '/v1/billing/meters', b: 'display_name=API calls&event_name=api_request&default_aggregation[formula]=sum&value_settings[event_payload_key]=value&customer_mapping[event_payload_key]=stripe_customer_id&customer_mapping[type]=by_id' });
|
|
871
1714
|
if (!ok(meter) || field(meter, 'object') !== 'billing.meter' || field(meter, 'status') !== 'active' || field(meter, 'event_name') !== 'api_request') return false;
|
|
872
1715
|
const noAgg = await h({ m: 'POST', p: '/v1/billing/meters', b: 'display_name=X&event_name=y' });
|
|
873
1716
|
if (noAgg.status !== 400) return false;
|
|
874
|
-
|
|
1717
|
+
// two events (7 + 3) so the summary proves real aggregation (a single-value / canned impl would not total 10)
|
|
1718
|
+
const evt = await h({ m: 'POST', p: '/v1/billing/meter_events', b: `event_name=api_request&payload[value]=7&payload[stripe_customer_id]=${id(cust)}` });
|
|
875
1719
|
if (!ok(evt) || field(evt, 'object') !== 'billing.meter_event' || field(evt, 'event_name') !== 'api_request') return false;
|
|
1720
|
+
const evt2 = await h({ m: 'POST', p: '/v1/billing/meter_events', b: `event_name=api_request&payload[value]=3&payload[stripe_customer_id]=${id(cust)}` });
|
|
1721
|
+
// a window on whole days, as Stripe asks (its bounds are refused off minute boundaries), around the events just sent
|
|
1722
|
+
const now = Math.floor(Date.now() / 86_400_000) * 86_400;
|
|
1723
|
+
const summ = await h({ m: 'GET', p: `/v1/billing/meters/${id(meter)}/event_summaries?customer=${id(cust)}&start_time=${now - 86_400}&end_time=${now + 86_400}` });
|
|
1724
|
+
const sd = (summ.body as Body).data as Body[];
|
|
876
1725
|
const g = await h({ m: 'GET', p: `/v1/billing/meters/${id(meter)}` });
|
|
877
1726
|
const deact = await h({ m: 'POST', p: `/v1/billing/meters/${id(meter)}/deactivate` });
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
const ur = await h({ m: 'POST', p: '/v1/subscription_items/si_twin/usage_records', b: 'quantity=7' });
|
|
881
|
-
const ur2 = await h({ m: 'POST', p: '/v1/subscription_items/si_twin/usage_records', b: 'quantity=3' });
|
|
882
|
-
const noQty = await h({ m: 'POST', p: '/v1/subscription_items/si_twin/usage_records' });
|
|
883
|
-
const summ = await h({ m: 'GET', p: '/v1/subscription_items/si_twin/usage_record_summaries' });
|
|
884
|
-
const sd = (summ.body as Body).data as Body[];
|
|
885
|
-
return ok(g) && ok(deact) && field(deact, 'status') === 'inactive' && ok(ur) && ok(ur2) && noQty.status === 400 &&
|
|
886
|
-
sd.length === 1 && sd[0]!.total_usage === 10;
|
|
1727
|
+
return ok(evt2) && ok(summ) && sd.length === 1 && sd[0]!.object === 'billing.meter_event_summary' && sd[0]!.aggregated_value === 10
|
|
1728
|
+
&& ok(g) && ok(deact) && field(deact, 'status') === 'inactive';
|
|
887
1729
|
}),
|
|
888
1730
|
),
|
|
889
1731
|
// Subscription discounts: a `coupon` on create attaches a discount (validated to exist),
|
|
@@ -894,12 +1736,12 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
894
1736
|
withRoot(async (h) => {
|
|
895
1737
|
const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=disc@twin.test' });
|
|
896
1738
|
const coupon = await h({ m: 'POST', p: '/v1/coupons', b: 'percent_off=25&duration=forever' });
|
|
897
|
-
const sub = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&coupon=${id(coupon)}` });
|
|
1739
|
+
const sub = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&discounts[0][coupon]=${id(coupon)}` });
|
|
898
1740
|
if (!ok(sub)) return false;
|
|
899
1741
|
const discs = (field(sub, 'discounts') as Body[]) ?? [];
|
|
900
1742
|
if (discs.length !== 1 || discs[0]!.object !== 'discount' || (discs[0]!.coupon as Body)?.id !== id(coupon)) return false;
|
|
901
1743
|
// unknown coupon on a fresh sub 400s.
|
|
902
|
-
const bad = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&coupon=coupon_nope` });
|
|
1744
|
+
const bad = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&discounts[0][coupon]=coupon_nope` });
|
|
903
1745
|
if (bad.status !== 400) return false;
|
|
904
1746
|
// remove the discount.
|
|
905
1747
|
const del = await h({ m: 'DELETE', p: `/v1/subscriptions/${id(sub)}/discount` });
|
|
@@ -945,12 +1787,36 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
945
1787
|
return ok(payGood) && field(payGood, 'status') === 'paid';
|
|
946
1788
|
}),
|
|
947
1789
|
),
|
|
948
|
-
done('stripe.invoiceitems.crud', 'invoices', 'InvoiceItems: create + list', 'api', 'core', () =>
|
|
1790
|
+
done('stripe.invoiceitems.crud', 'invoices', 'InvoiceItems: create + retrieve + update + list + delete', 'api', 'core', () =>
|
|
949
1791
|
withRoot(async (h) => {
|
|
950
1792
|
const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=ii@twin.test' });
|
|
951
1793
|
const ii = await h({ m: 'POST', p: '/v1/invoiceitems', b: `customer=${id(cust)}&amount=400¤cy=usd` });
|
|
952
1794
|
const l = await h({ m: 'GET', p: '/v1/invoiceitems' });
|
|
953
|
-
|
|
1795
|
+
if (!ok(ii) || field(ii, 'object') !== 'invoiceitem' || field(l, 'object') !== 'list') return false;
|
|
1796
|
+
// an item is reachable by the id its own create handed back
|
|
1797
|
+
const g = await h({ m: 'GET', p: `/v1/invoiceitems/${id(ii)}` });
|
|
1798
|
+
const miss = await h({ m: 'GET', p: '/v1/invoiceitems/ii_nope' });
|
|
1799
|
+
if (!ok(g) || id(g) !== id(ii) || miss.status !== 404) return false;
|
|
1800
|
+
// The earlier pending 400 is swept onto the invoice at creation, when the create asks: pending_invoice_items_behavior
|
|
1801
|
+
// "Defaults to `exclude` if the parameter is omitted" (docs.stripe.com/api/invoices/create; e0e3b88c6).
|
|
1802
|
+
// An item on a DRAFT invoice moves the invoice's total when it changes, and again when
|
|
1803
|
+
// it is deleted — an invoice billing for a line that no longer says that is money in
|
|
1804
|
+
// two places at once.
|
|
1805
|
+
const inv = await h({ m: 'POST', p: '/v1/invoices', b: `customer=${id(cust)}&pending_invoice_items_behavior=include` });
|
|
1806
|
+
const on = await h({ m: 'POST', p: '/v1/invoiceitems', b: `customer=${id(cust)}&invoice=${id(inv)}&amount=900¤cy=usd` });
|
|
1807
|
+
const u = await h({ m: 'POST', p: `/v1/invoiceitems/${id(on)}`, b: 'amount=1500' });
|
|
1808
|
+
const afterUpdate = await h({ m: 'GET', p: `/v1/invoices/${id(inv)}` });
|
|
1809
|
+
if (!ok(u) || field(afterUpdate, 'amount_due') !== 1900) return false;
|
|
1810
|
+
const del = await h({ m: 'DELETE', p: `/v1/invoiceitems/${id(on)}` });
|
|
1811
|
+
const afterDelete = await h({ m: 'GET', p: `/v1/invoices/${id(inv)}` });
|
|
1812
|
+
const gone = await h({ m: 'GET', p: `/v1/invoiceitems/${id(on)}` });
|
|
1813
|
+
// and once the invoice is finalized its items are no longer deletable, like Stripe
|
|
1814
|
+
const inv2 = await h({ m: 'POST', p: '/v1/invoices', b: `customer=${id(cust)}` });
|
|
1815
|
+
const locked = await h({ m: 'POST', p: '/v1/invoiceitems', b: `customer=${id(cust)}&invoice=${id(inv2)}&amount=700¤cy=usd` });
|
|
1816
|
+
await h({ m: 'POST', p: `/v1/invoices/${id(inv2)}/finalize` });
|
|
1817
|
+
const refused = await h({ m: 'DELETE', p: `/v1/invoiceitems/${id(locked)}` });
|
|
1818
|
+
return ok(del) && field(del, 'deleted') === true && field(afterDelete, 'amount_due') === 400
|
|
1819
|
+
&& gone.status === 404 && refused.status === 400;
|
|
954
1820
|
}),
|
|
955
1821
|
),
|
|
956
1822
|
// Invoice send / mark_uncollectible / pay-out-of-band: send auto-finalizes a draft (→ open);
|
|
@@ -961,11 +1827,15 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
961
1827
|
withRoot(async (h) => {
|
|
962
1828
|
const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=send@twin.test' });
|
|
963
1829
|
await h({ m: 'POST', p: '/v1/invoiceitems', b: `customer=${id(cust)}&amount=4000¤cy=usd` });
|
|
964
|
-
|
|
1830
|
+
// the pending item is taken only when the create asks ("Defaults to `exclude`", docs.stripe.com/api/invoices/create)
|
|
1831
|
+
const inv = await h({ m: 'POST', p: '/v1/invoices', b: `customer=${id(cust)}&pending_invoice_items_behavior=include` });
|
|
1832
|
+
if (field(inv, 'amount_due') !== 4000) return false;
|
|
965
1833
|
const sent = await h({ m: 'POST', p: `/v1/invoices/${id(inv)}/send` });
|
|
966
1834
|
if (!ok(sent) || field(sent, 'status') !== 'open') return false;
|
|
967
1835
|
const oob = await h({ m: 'POST', p: `/v1/invoices/${id(inv)}/pay`, b: 'paid_out_of_band=true' });
|
|
968
|
-
|
|
1836
|
+
// out of band is `amount_paid_off_stripe` in the served version ("Amount ... paid on the invoice outside of
|
|
1837
|
+
// Stripe", served spec invoice); basil answers no `paid_out_of_band` (docs.stripe.com/changelog/basil; 24e757f60)
|
|
1838
|
+
if (!ok(oob) || field(oob, 'status') !== 'paid' || field(oob, 'amount_paid_off_stripe') !== 4000 || field(oob, 'amount_paid') !== 4000 || 'paid_out_of_band' in (oob.body as Body)) return false;
|
|
969
1839
|
// a second invoice can be marked uncollectible
|
|
970
1840
|
const inv2 = await h({ m: 'POST', p: '/v1/invoices', b: `customer=${id(cust)}` });
|
|
971
1841
|
await h({ m: 'POST', p: `/v1/invoices/${id(inv2)}/finalize` });
|
|
@@ -984,14 +1854,17 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
984
1854
|
const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Plan' });
|
|
985
1855
|
const price = await h({ m: 'POST', p: `/v1/prices`, b: `unit_amount=3000¤cy=usd&recurring[interval]=month&product=${id(prod)}` });
|
|
986
1856
|
const coupon = await h({ m: 'POST', p: '/v1/coupons', b: 'percent_off=10&duration=forever' });
|
|
987
|
-
const sub = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&items[0][price]=${id(price)}&coupon=${id(coupon)}` });
|
|
988
|
-
const up = await h({ m: '
|
|
989
|
-
|
|
1857
|
+
const sub = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&items[0][price]=${id(price)}&discounts[0][coupon]=${id(coupon)}` });
|
|
1858
|
+
const up = await h({ m: 'POST', p: '/v1/invoices/create_preview', b: `subscription=${id(sub)}` });
|
|
1859
|
+
// not persisted, and named as a preview: "For preview invoices created using the create preview endpoint, this id
|
|
1860
|
+
// will be prefixed with `upcoming_in`" (served spec, invoice.id; b75e48888)
|
|
1861
|
+
if (!ok(up) || field(up, 'object') !== 'invoice' || !String(field(up, 'id')).startsWith('upcoming_in_')) return false;
|
|
1862
|
+
if ((await h({ m: 'GET', p: `/v1/invoices/${String(field(up, 'id'))}` })).status !== 404) return false;
|
|
990
1863
|
// subtotal 3000, 10% off → total 2700.
|
|
991
1864
|
if (field(up, 'subtotal') !== 3000 || field(up, 'total') !== 2700) return false;
|
|
992
1865
|
const lines = (field(up, 'lines') as Body).data as Body[];
|
|
993
1866
|
if (lines.length !== 1 || lines[0]!.amount !== 3000) return false;
|
|
994
|
-
const noCust = await h({ m: '
|
|
1867
|
+
const noCust = await h({ m: 'POST', p: '/v1/invoices/create_preview', b: 'customer=cus_nope' });
|
|
995
1868
|
return noCust.status === 404;
|
|
996
1869
|
}),
|
|
997
1870
|
),
|
|
@@ -1004,13 +1877,19 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1004
1877
|
withRoot(async (h) => {
|
|
1005
1878
|
const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=cn@twin.test' });
|
|
1006
1879
|
await h({ m: 'POST', p: '/v1/invoiceitems', b: `customer=${id(cust)}&amount=5000¤cy=usd` });
|
|
1007
|
-
|
|
1880
|
+
// the pending item is taken only when the create asks ("Defaults to `exclude`", docs.stripe.com/api/invoices/create; e0e3b88c6)
|
|
1881
|
+
const inv = await h({ m: 'POST', p: '/v1/invoices', b: `customer=${id(cust)}&pending_invoice_items_behavior=include` });
|
|
1008
1882
|
await h({ m: 'POST', p: `/v1/invoices/${id(inv)}/finalize` });
|
|
1009
1883
|
await h({ m: 'POST', p: `/v1/invoices/${id(inv)}/pay`, b: 'payment_method=pm_card_visa' });
|
|
1884
|
+
// a paid invoice's credit says how it is paid back: "The sum of refunds, customer balance credits, and outside of
|
|
1885
|
+
// Stripe credits must equal the post_payment_amount" (docs.stripe.com/api/credit_notes/create; 1159092c7), so the
|
|
1886
|
+
// 2000 goes to the customer's balance (credit_amount), and a split that does not add up is refused
|
|
1887
|
+
const unsplit = await h({ m: 'GET', p: `/v1/credit_notes/preview?invoice=${id(inv)}&amount=2000` });
|
|
1888
|
+
if (unsplit.status !== 400) return false;
|
|
1010
1889
|
// preview computes the object WITHOUT persisting it
|
|
1011
|
-
const prev = await h({ m: 'GET', p: `/v1/credit_notes/preview?invoice=${id(inv)}&amount=2000` });
|
|
1890
|
+
const prev = await h({ m: 'GET', p: `/v1/credit_notes/preview?invoice=${id(inv)}&amount=2000&credit_amount=2000` });
|
|
1012
1891
|
if (!ok(prev) || field(prev, 'object') !== 'credit_note' || field(prev, 'amount') !== 2000) return false;
|
|
1013
|
-
const cn = await h({ m: 'POST', p: '/v1/credit_notes', b: `invoice=${id(inv)}&amount=2000&reason=order_change` });
|
|
1892
|
+
const cn = await h({ m: 'POST', p: '/v1/credit_notes', b: `invoice=${id(inv)}&amount=2000&credit_amount=2000&reason=order_change` });
|
|
1014
1893
|
if (!ok(cn) || field(cn, 'object') !== 'credit_note' || field(cn, 'status') !== 'issued' || field(cn, 'type') !== 'post_payment' || field(cn, 'amount') !== 2000) return false;
|
|
1015
1894
|
const g = await h({ m: 'GET', p: `/v1/credit_notes/${id(cn)}` });
|
|
1016
1895
|
const lines = await h({ m: 'GET', p: `/v1/credit_notes/${id(cn)}/lines` });
|
|
@@ -1040,29 +1919,345 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1040
1919
|
return ok(g) && ok(li) && field(li, 'object') === 'list' && ok(exp);
|
|
1041
1920
|
}),
|
|
1042
1921
|
),
|
|
1922
|
+
// A Checkout line item's description: "An arbitrary string attached to the object. Often useful for displaying to users.
|
|
1923
|
+
// Defaults to product name." (spec/openapi.json.gz, the `item` schema). The session create's line_items take no
|
|
1924
|
+
// description of their own (price, price_data, quantity, adjustable_quantity, metadata, tax_rates), so the name is the
|
|
1925
|
+
// price's product's, or the product_data an inline price names. Dub's Checkout showed its Business plan as "Item".
|
|
1926
|
+
done('stripe.checkout.line_item_description', 'checkout', 'Checkout line items are described by their product\'s name (a price\'s product, or price_data.product_data)', 'api', 'common', () =>
|
|
1927
|
+
withRoot(async (h) => {
|
|
1928
|
+
const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Business' });
|
|
1929
|
+
const price = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=9000¤cy=usd&product=${id(prod)}` });
|
|
1930
|
+
const cs = await h({ m: 'POST', p: '/v1/checkout/sessions', b: `mode=payment&success_url=https://x.test&line_items[0][price]=${id(price)}&line_items[0][quantity]=2&line_items[1][price_data][currency]=usd&line_items[1][price_data][unit_amount]=100&line_items[1][price_data][product_data][name]=Setup%20fee&line_items[1][quantity]=1` });
|
|
1931
|
+
const items = (((await h({ m: 'GET', p: `/v1/checkout/sessions/${id(cs)}/line_items` })).body as Body).data as Body[]) ?? [];
|
|
1932
|
+
return items.length === 2 && items[0]?.description === 'Business' && items[0]?.amount_total === 18000
|
|
1933
|
+
&& items[1]?.description === 'Setup fee' && items[1]?.amount_total === 100;
|
|
1934
|
+
}),
|
|
1935
|
+
),
|
|
1043
1936
|
// Completion copies the session's create-only subscription_data onto the created
|
|
1044
1937
|
// subscription (metadata is how apps bind the checkout attempt → subscription; trial
|
|
1045
1938
|
// becomes a real trialing window) — and the Session itself never echoes the param.
|
|
1046
1939
|
done('stripe.checkout.subscription_data', 'checkout', 'Checkout completion copies subscription_data (metadata + trial) onto the created subscription', 'api', 'core', () =>
|
|
1047
1940
|
withRoot(async (h) => {
|
|
1048
1941
|
const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=subdata@twin.test' });
|
|
1049
|
-
|
|
1942
|
+
// the inline price is a subscription's: a product and a recurring interval (docs.stripe.com/api/checkout/sessions/create,
|
|
1943
|
+
// line_items.price_data), so the subscription has an item to carry its period (c3a378808 bills inline prices)
|
|
1944
|
+
const cs = await h({ m: 'POST', p: '/v1/checkout/sessions', b: `mode=subscription&success_url=https://x.test&customer=${id(cust)}&line_items[0][price_data][currency]=usd&line_items[0][price_data][unit_amount]=999&line_items[0][price_data][product_data][name]=Plan&line_items[0][price_data][recurring][interval]=month&line_items[0][quantity]=1&subscription_data[metadata][attemptId]=att_cap_1&subscription_data[trial_period_days]=7` });
|
|
1050
1945
|
if (!ok(cs) || field(cs, 'subscription_data') !== undefined || field(cs, '_subscription_data') !== undefined) return false;
|
|
1051
|
-
|
|
1946
|
+
await h({ m: 'POST', p: `/c/pay/${id(cs)}`, b: 'email=pay%40twin.test&cardNumber=4242424242424242&cardExpiry=12%2F34&cardCvc=123&billingName=Twin&billingCountry=US&billingPostalCode=94105' });
|
|
1947
|
+
const completed = await h({ m: 'GET', p: `/v1/checkout/sessions/${id(cs)}` });
|
|
1052
1948
|
if (!ok(completed) || !field(completed, 'subscription')) return false;
|
|
1053
1949
|
const sub = await h({ m: 'GET', p: `/v1/subscriptions/${field(completed, 'subscription')}` });
|
|
1054
1950
|
const md = field(sub, 'metadata') as Body | undefined;
|
|
1951
|
+
// a subscription's billing period is its items' in the served version ("current_period_start and current_period_end
|
|
1952
|
+
// ... moved to the subscription item", docs.stripe.com/changelog/basil/2025-03-31/deprecate-subscription-current-period-start-and-end; 24e757f60)
|
|
1953
|
+
const item = (((field(sub, 'items') as Body | undefined)?.data as Body[] | undefined) ?? [])[0];
|
|
1055
1954
|
return ok(sub) && md?.attemptId === 'att_cap_1' && field(sub, 'status') === 'trialing'
|
|
1056
|
-
&& typeof field(sub, 'trial_end') === 'number' && field(sub, 'trial_end') ===
|
|
1057
|
-
|
|
1058
|
-
|
|
1955
|
+
&& typeof field(sub, 'trial_end') === 'number' && field(sub, 'trial_end') === item?.current_period_end
|
|
1956
|
+
&& field(sub, 'current_period_end') === undefined;
|
|
1957
|
+
}),
|
|
1958
|
+
),
|
|
1959
|
+
// A subscription session's customer pays its first invoice on the page: the invoice is paid through a succeeded
|
|
1960
|
+
// PaymentIntent and its charge, and the card is the subscription's default payment method
|
|
1961
|
+
// (docs.stripe.com/payments/checkout/how-checkout-works).
|
|
1962
|
+
done('stripe.checkout.subscription_first_invoice', 'checkout', 'Subscription Checkout pays the first invoice with the entered card, the subscription\'s default payment method', 'api', 'core', () =>
|
|
1963
|
+
withRoot(async (h) => {
|
|
1964
|
+
const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Box' });
|
|
1965
|
+
const price = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=3500¤cy=usd&product=${id(prod)}&recurring[interval]=month` });
|
|
1966
|
+
const cs = await h({ m: 'POST', p: '/v1/checkout/sessions', b: `mode=subscription&success_url=https://x.test&line_items[0][price]=${id(price)}&line_items[0][quantity]=1` });
|
|
1967
|
+
await h({ m: 'POST', p: `/c/pay/${id(cs)}`, b: 'email=pay%40twin.test&cardNumber=4242424242424242&cardExpiry=12%2F34&cardCvc=123&billingName=Twin&billingCountry=US&billingPostalCode=94105' });
|
|
1968
|
+
const done = await h({ m: 'GET', p: `/v1/checkout/sessions/${id(cs)}` });
|
|
1969
|
+
const sub = await h({ m: 'GET', p: `/v1/subscriptions/${String(field(done, 'subscription'))}` });
|
|
1970
|
+
const inv = await h({ m: 'GET', p: `/v1/invoices/${String(field(done, 'invoice'))}` });
|
|
1971
|
+
// the invoice's PaymentIntent is its payment in the served version: basil answers no `invoice.payment_intent` and
|
|
1972
|
+
// lists the invoice's payments (docs.stripe.com/changelog/basil/2025-03-31/add-support-for-multiple-partial-payments-on-invoices; 24e757f60)
|
|
1973
|
+
const payments = (((await h({ m: 'GET', p: `/v1/invoice_payments?invoice=${String(field(done, 'invoice'))}` })).body as Body).data as Body[]) ?? [];
|
|
1974
|
+
const pi = await h({ m: 'GET', p: `/v1/payment_intents/${String((payments[0]?.payment as Body | undefined)?.payment_intent)}` });
|
|
1975
|
+
if (payments.length !== 1 || payments[0]!.status !== 'paid' || payments[0]!.amount_paid !== 3500) return false;
|
|
1976
|
+
return field(sub, 'status') === 'active' && typeof field(sub, 'default_payment_method') === 'string'
|
|
1977
|
+
&& field(inv, 'status') === 'paid' && field(inv, 'billing_reason') === 'subscription_create' && field(inv, 'amount_paid') === 3500
|
|
1978
|
+
&& field(pi, 'status') === 'succeeded' && typeof field(pi, 'latest_charge') === 'string';
|
|
1979
|
+
}),
|
|
1980
|
+
),
|
|
1981
|
+
// Checkout attaches the card it saves to the customer and says so: payment_method.attached ("Occurs whenever a new payment
|
|
1982
|
+
// method is attached to a customer", docs.stripe.com/api/events/types) for a subscription's card, which is also the
|
|
1983
|
+
// subscription's default payment method (docs.stripe.com/payments/checkout/how-checkout-works), for a setup session's, and
|
|
1984
|
+
// for a payment's only under payment_intent_data.setup_future_usage (docs.stripe.com/api/checkout/sessions/create
|
|
1985
|
+
// #create_checkout_session-customer); the event comes before the subscription it pays for (the twin's order; Stripe's is
|
|
1986
|
+
// not guaranteed, docs.stripe.com/webhooks#event-ordering). Rallly walk 4: its billing page read no saved card.
|
|
1987
|
+
done('stripe.checkout.payment_method_attached', 'checkout', 'Checkout attaches the saved card to the customer and emits payment_method.attached (subscription, setup, and payment with setup_future_usage)', 'api', 'core', () =>
|
|
1988
|
+
withRoot(async (h) => {
|
|
1989
|
+
const card = 'cardNumber=4242424242424242&cardExpiry=12%2F34&cardCvc=123&billingName=Twin&billingCountry=US&billingPostalCode=94105';
|
|
1990
|
+
const attachedEvents = async () => (((await h({ m: 'GET', p: '/v1/events?type=payment_method.attached&limit=100' })).body as Body).data as Body[] | undefined) ?? [];
|
|
1991
|
+
const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=saved@twin.test' });
|
|
1992
|
+
const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Pro' });
|
|
1993
|
+
const price = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=1200¤cy=usd&product=${id(prod)}&recurring[interval]=month` });
|
|
1994
|
+
const sub = await h({ m: 'POST', p: '/v1/checkout/sessions', b: `mode=subscription&success_url=https://x.test&customer=${id(cust)}&line_items[0][price]=${id(price)}&line_items[0][quantity]=1` });
|
|
1995
|
+
await h({ m: 'POST', p: `/c/pay/${id(sub)}`, b: card });
|
|
1996
|
+
const done = await h({ m: 'GET', p: `/v1/checkout/sessions/${id(sub)}` });
|
|
1997
|
+
const subscription = await h({ m: 'GET', p: `/v1/subscriptions/${String(field(done, 'subscription'))}` });
|
|
1998
|
+
const pmId = field(subscription, 'default_payment_method');
|
|
1999
|
+
const pm = await h({ m: 'GET', p: `/v1/payment_methods/${String(pmId)}` });
|
|
2000
|
+
const attached = await attachedEvents();
|
|
2001
|
+
const all = (((await h({ m: 'GET', p: '/v1/events?limit=100' })).body as Body).data as Body[] | undefined) ?? [];
|
|
2002
|
+
// the list is newest first: the attach is older than the subscription's creation and the session's completion
|
|
2003
|
+
const at = (type: string) => all.findIndex((e) => e.type === type);
|
|
2004
|
+
const subscriptionOk = field(done, 'status') === 'complete' && typeof pmId === 'string' && field(pm, 'customer') === id(cust)
|
|
2005
|
+
&& attached.length === 1 && ((attached[0]!.data as Body).object as Body).id === pmId && ((attached[0]!.data as Body).object as Body).customer === id(cust)
|
|
2006
|
+
&& at('payment_method.attached') > at('customer.subscription.created') && at('customer.subscription.created') > at('checkout.session.completed');
|
|
2007
|
+
// a payment without setup_future_usage saves nothing on the customer; with it, the card is attached
|
|
2008
|
+
const pay = (extra: string) => h({ m: 'POST', p: '/v1/checkout/sessions', b: `mode=payment&success_url=https://x.test&customer=${id(cust)}&line_items[0][price_data][currency]=usd&line_items[0][price_data][unit_amount]=2500&line_items[0][price_data][product_data][name]=Pot&line_items[0][quantity]=1${extra}` });
|
|
2009
|
+
const once = await pay('');
|
|
2010
|
+
await h({ m: 'POST', p: `/c/pay/${id(once)}`, b: card });
|
|
2011
|
+
const oncePi = await h({ m: 'GET', p: `/v1/payment_intents/${String(field(await h({ m: 'GET', p: `/v1/checkout/sessions/${id(once)}` }), 'payment_intent'))}` });
|
|
2012
|
+
const oncePm = await h({ m: 'GET', p: `/v1/payment_methods/${String(field(oncePi, 'payment_method'))}` });
|
|
2013
|
+
const afterOnce = (await attachedEvents()).length;
|
|
2014
|
+
const keep = await pay('&payment_intent_data[setup_future_usage]=off_session');
|
|
2015
|
+
await h({ m: 'POST', p: `/c/pay/${id(keep)}`, b: card });
|
|
2016
|
+
const keepPi = await h({ m: 'GET', p: `/v1/payment_intents/${String(field(await h({ m: 'GET', p: `/v1/checkout/sessions/${id(keep)}` }), 'payment_intent'))}` });
|
|
2017
|
+
const keepPm = await h({ m: 'GET', p: `/v1/payment_methods/${String(field(keepPi, 'payment_method'))}` });
|
|
2018
|
+
const afterKeep = await attachedEvents();
|
|
2019
|
+
const paymentOk = ok(oncePm) && field(oncePm, 'customer') === null && afterOnce === 1 && field(keepPi, 'setup_future_usage') === 'off_session'
|
|
2020
|
+
&& field(keepPm, 'customer') === id(cust) && afterKeep.length === 2 && ((afterKeep[0]!.data as Body).object as Body).id === id(keepPm);
|
|
2021
|
+
// a setup session saves the card: its SetupIntent names it, and it is attached
|
|
2022
|
+
const setup = await h({ m: 'POST', p: '/v1/checkout/sessions', b: `mode=setup¤cy=usd&success_url=https://x.test&customer=${id(cust)}` });
|
|
2023
|
+
await h({ m: 'POST', p: `/c/pay/${id(setup)}`, b: card });
|
|
2024
|
+
const si = await h({ m: 'GET', p: `/v1/setup_intents/${String(field(await h({ m: 'GET', p: `/v1/checkout/sessions/${id(setup)}` }), 'setup_intent'))}` });
|
|
2025
|
+
const setupPm = await h({ m: 'GET', p: `/v1/payment_methods/${String(field(si, 'payment_method'))}` });
|
|
2026
|
+
const afterSetup = await attachedEvents();
|
|
2027
|
+
const setupOk = field(si, 'status') === 'succeeded' && field(setupPm, 'customer') === id(cust) && afterSetup.length === 3 && ((afterSetup[0]!.data as Body).object as Body).id === id(setupPm);
|
|
2028
|
+
return subscriptionOk && paymentOk && setupOk;
|
|
2029
|
+
}),
|
|
2030
|
+
),
|
|
2031
|
+
// A session made for a customer with an email shows that email on the page, prefilled and not editable, and the session
|
|
2032
|
+
// is the customer's email whatever is posted; a customer with none types one, which Checkout sets on the customer ("If the
|
|
2033
|
+
// Customer already has a valid email set, the email will be prefilled and not editable in Checkout. If the Customer does
|
|
2034
|
+
// not have a valid email, Checkout will set the email entered during the session on the Customer",
|
|
2035
|
+
// docs.stripe.com/api/checkout/sessions/create#create_checkout_session-customer). Rallly walk 4: the field was empty.
|
|
2036
|
+
done('stripe.checkout.customer_email', 'checkout', "Checkout for a session's customer shows the customer's email prefilled and not editable; a customer without one gets the typed email", 'ui', 'common', async () => {
|
|
2037
|
+
const root = mkdtempSync(join(tmpdir(), 'stp-cs-email-'));
|
|
2038
|
+
try {
|
|
2039
|
+
const f = createStripeTwinFetch({ root });
|
|
2040
|
+
const api = async (method: string, path: string, body?: string) => (await f(new Request(`http://stripe.test${path}`, { method, headers: { 'content-type': 'application/x-www-form-urlencoded' }, ...(body ? { body } : {}) }))).json() as Promise<Body>;
|
|
2041
|
+
const post = (url: string, email: string) => f(new Request(url, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ email, cardNumber: '4242424242424242', cardExpiry: '12 / 34', cardCvc: '123', billingName: 'Twin', billingCountry: 'US', billingPostalCode: '94105' }).toString() }));
|
|
2042
|
+
const session = (customer: string) => api('POST', '/v1/checkout/sessions', `mode=subscription&success_url=https://x.test&customer=${customer}&line_items[0][price_data][currency]=usd&line_items[0][price_data][unit_amount]=1200&line_items[0][price_data][product_data][name]=Pro&line_items[0][price_data][recurring][interval]=month&line_items[0][quantity]=1`);
|
|
2043
|
+
const known = await api('POST', '/v1/customers', 'email=known@twin.test');
|
|
2044
|
+
const cs = await session(String(known.id));
|
|
2045
|
+
const html = await (await f(new Request(String(cs.url)))).text();
|
|
2046
|
+
const input = /<input[^>]*id="email"[^>]*>/.exec(html)?.[0] ?? '';
|
|
2047
|
+
const paid = await post(String(cs.url), 'other@twin.test');
|
|
2048
|
+
const done = await api('GET', `/v1/checkout/sessions/${String(cs.id)}`);
|
|
2049
|
+
const knownAfter = await api('GET', `/v1/customers/${String(known.id)}`);
|
|
2050
|
+
const prefilled = input.includes('value="known@twin.test"') && /\sreadonly(=""|\s|\/|>)/i.test(input)
|
|
2051
|
+
&& paid.status === 303 && (done.customer_details as Body | undefined)?.email === 'known@twin.test' && knownAfter.email === 'known@twin.test';
|
|
2052
|
+
// a customer with no email: the field is empty and editable, and the typed email becomes the customer's
|
|
2053
|
+
const bare = await api('POST', '/v1/customers', 'name=Bare');
|
|
2054
|
+
const cs2 = await session(String(bare.id));
|
|
2055
|
+
const bareInput = /<input[^>]*id="email"[^>]*>/.exec(await (await f(new Request(String(cs2.url)))).text())?.[0] ?? '';
|
|
2056
|
+
const paid2 = await post(String(cs2.url), 'typed@twin.test');
|
|
2057
|
+
const bareAfter = await api('GET', `/v1/customers/${String(bare.id)}`);
|
|
2058
|
+
const updated = ((await api('GET', '/v1/events?type=customer.updated')).data as Body[] | undefined) ?? [];
|
|
2059
|
+
const typed = bareInput !== '' && !/readonly/i.test(bareInput) && paid2.status === 303 && bareAfter.email === 'typed@twin.test'
|
|
2060
|
+
&& updated.length === 1 && ((updated[0]!.data as Body).object as Body).id === bare.id && ((updated[0]!.data as Body).object as Body).email === 'typed@twin.test';
|
|
2061
|
+
return prefilled && typed;
|
|
2062
|
+
} finally {
|
|
2063
|
+
rmSync(root, { recursive: true, force: true });
|
|
2064
|
+
}
|
|
2065
|
+
}),
|
|
2066
|
+
// Checkout's expiry field takes the digits typed straight through, as Stripe's formats `1230` into `12 / 30`: the page
|
|
2067
|
+
// marks the field for its formatter, and the posted digits pay and are the card's expiry (screens/checkout.tsx
|
|
2068
|
+
// parseExpiry); two digits are still incomplete.
|
|
2069
|
+
done('stripe.checkout.card_expiry_digits', 'checkout', 'Checkout card expiry accepts digits typed straight through (1230 is 12 / 30), formatted as typed', 'ui', 'common', async () => {
|
|
2070
|
+
const root = mkdtempSync(join(tmpdir(), 'stp-expiry-'));
|
|
2071
|
+
try {
|
|
2072
|
+
const f = createStripeTwinFetch({ root, clock: () => '2026-03-01T00:00:00Z' });
|
|
2073
|
+
const api = async (method: string, path: string, body?: string) => (await f(new Request(`http://stripe.test${path}`, { method, headers: { 'content-type': 'application/x-www-form-urlencoded' }, ...(body ? { body } : {}) }))).json() as Promise<Body>;
|
|
2074
|
+
const pay = (cs: Body, expiry: string) => f(new Request(String(cs.url), { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ email: 'pay@twin.test', cardNumber: '4242424242424242', cardExpiry: expiry, cardCvc: '123', billingName: 'Twin', billingCountry: 'US', billingPostalCode: '94105' }).toString() }));
|
|
2075
|
+
const session = () => api('POST', '/v1/checkout/sessions', 'mode=payment&success_url=https://x.test&line_items[0][price_data][currency]=usd&line_items[0][price_data][unit_amount]=2500&line_items[0][price_data][product_data][name]=Pro&line_items[0][quantity]=1');
|
|
2076
|
+
const cs = await session();
|
|
2077
|
+
const html = await (await f(new Request(String(cs.url)))).text();
|
|
2078
|
+
const script = /<script>([\s\S]*?)<\/script>/.exec(html)?.[1] ?? '';
|
|
2079
|
+
const marked = /<input[^>]*id="cardExpiry"[^>]*data-format="card-expiry"/.test(html) && script === FORMAT_SCRIPT;
|
|
2080
|
+
// the page's own script, run over a field as a person types, deletes, pastes or fills it
|
|
2081
|
+
const typed = expiryField(script);
|
|
2082
|
+
const formats = typed.type('1230') === '12 / 30' && expiryField(script).type('5') === '05 / ' && expiryField(script).type('1/3') === '01 / 3'
|
|
2083
|
+
&& expiryField(script).fill('12/2030') === '12 / 30' && expiryField(script).fill('122030') === '12 / 30'
|
|
2084
|
+
&& (typed.back(), typed.back()) === '12 / ' && typed.back() === '12' && typed.back() === '1';
|
|
2085
|
+
const short = await pay(cs, '12');
|
|
2086
|
+
const shortText = await short.text();
|
|
2087
|
+
const paid = await pay(cs, '1230');
|
|
2088
|
+
const done = await api('GET', `/v1/checkout/sessions/${String(cs.id)}`);
|
|
2089
|
+
const pi = await api('GET', `/v1/payment_intents/${String(done.payment_intent)}`);
|
|
2090
|
+
const card = (await api('GET', `/v1/payment_methods/${String(pi.payment_method)}`)).card as Body | undefined;
|
|
2091
|
+
// a first digit above 1 is the month on its own: 530 is May 2030
|
|
2092
|
+
const other = await session();
|
|
2093
|
+
const may = await pay(other, '530');
|
|
2094
|
+
const pmMay = (await api('GET', `/v1/payment_methods/${String((await api('GET', `/v1/payment_intents/${String((await api('GET', `/v1/checkout/sessions/${String(other.id)}`)).payment_intent)}`)).payment_method)}`)).card as Body | undefined;
|
|
2095
|
+
return marked && formats && short.status === 402 && shortText.includes('expiration date is incomplete')
|
|
2096
|
+
&& paid.status === 303 && done.status === 'complete' && card?.exp_month === 12 && card?.exp_year === 2030
|
|
2097
|
+
&& may.status === 303 && pmMay?.exp_month === 5 && pmMay?.exp_year === 2030;
|
|
2098
|
+
} finally {
|
|
2099
|
+
rmSync(root, { recursive: true, force: true });
|
|
2100
|
+
}
|
|
2101
|
+
}),
|
|
2102
|
+
// A subscription session with a trial shows the trial as Checkout does: "14 days free", "Then $90.00 per month", a
|
|
2103
|
+
// "Start trial" button and, under it, when the trial's end charges; one without a trial says Subscribe and its amount
|
|
2104
|
+
// (screens/checkout.tsx trialSummary). The amount and date come from the seeded price and the session's trial.
|
|
2105
|
+
done('stripe.checkout.trial_display', 'checkout', 'Checkout shows a subscription\'s trial: N days free, then the price per interval from the trial\'s end, Start trial', 'ui', 'common', async () => {
|
|
2106
|
+
const root = mkdtempSync(join(tmpdir(), 'stp-trialpage-'));
|
|
2107
|
+
try {
|
|
2108
|
+
const f = createStripeTwinFetch({ root, clock: () => '2026-03-01T00:00:00Z' });
|
|
2109
|
+
const api = async (method: string, path: string, body?: string) => (await f(new Request(`http://stripe.test${path}`, { method, headers: { 'content-type': 'application/x-www-form-urlencoded' }, ...(body ? { body } : {}) }))).json() as Promise<Body>;
|
|
2110
|
+
const text = async (cs: Body) => (await (await f(new Request(String(cs.url)))).text()).replace(/<script[\s\S]*?<\/script>/g, '').replace(/<[^>]+>/g, '|');
|
|
2111
|
+
const prod = await api('POST', '/v1/products', 'name=Business');
|
|
2112
|
+
const price = await api('POST', '/v1/prices', `unit_amount=9000¤cy=usd&recurring[interval]=month&product=${String(prod.id)}`);
|
|
2113
|
+
const yearly = await api('POST', '/v1/prices', `unit_amount=90000¤cy=usd&recurring[interval]=year&product=${String(prod.id)}`);
|
|
2114
|
+
const line = (p: Body) => `mode=subscription&success_url=https://x.test&customer_email=t%40twin.test&line_items[0][price]=${String(p.id)}&line_items[0][quantity]=1`;
|
|
2115
|
+
const trial = await text(await api('POST', '/v1/checkout/sessions', `${line(price)}&subscription_data[trial_period_days]=14`));
|
|
2116
|
+
// a trial set by its end: 2026-03-08 is a week on
|
|
2117
|
+
const byEnd = await text(await api('POST', '/v1/checkout/sessions', `${line(yearly)}&subscription_data[trial_end]=${Date.parse('2026-03-08T00:00:00Z') / 1000}`));
|
|
2118
|
+
const plain = await text(await api('POST', '/v1/checkout/sessions', line(price)));
|
|
2119
|
+
// after the trial a `once` coupon is spent (the trial's $0 invoice took it) and a one-time item is not billed again;
|
|
2120
|
+
// a `forever` coupon still applies
|
|
2121
|
+
const once = await api('POST', '/v1/coupons', 'id=ONCE50&percent_off=50&duration=once');
|
|
2122
|
+
const forever = await api('POST', '/v1/coupons', 'id=EVER10&percent_off=10&duration=forever');
|
|
2123
|
+
const setup = await api('POST', '/v1/prices', `unit_amount=2500¤cy=usd&product=${String(prod.id)}`);
|
|
2124
|
+
const spent = await text(await api('POST', '/v1/checkout/sessions', `${line(price)}&subscription_data[trial_period_days]=14&discounts[0][coupon]=${String(once.id)}&line_items[1][price]=${String(setup.id)}&line_items[1][quantity]=1`));
|
|
2125
|
+
const kept = await text(await api('POST', '/v1/checkout/sessions', `${line(price)}&subscription_data[trial_period_days]=14&discounts[0][coupon]=${String(forever.id)}`));
|
|
2126
|
+
// Checkout refuses a trial_end under 48 hours away
|
|
2127
|
+
const soon = await f(new Request('http://stripe.test/v1/checkout/sessions', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: `${line(price)}&subscription_data[trial_end]=${Date.parse('2026-03-02T12:00:00Z') / 1000}` }));
|
|
2128
|
+
if (!spent.includes('|Then $90.00 per month|') || !kept.includes('|Then $81.00 per month|') || soon.status !== 400) return false;
|
|
2129
|
+
// and the twin bills what the page says: the one-time price on the first invoice, then $90.00 a period
|
|
2130
|
+
const paid = await api('POST', '/v1/checkout/sessions', `${line(price)}&subscription_data[trial_period_days]=14&line_items[1][price]=${String(setup.id)}&line_items[1][quantity]=1`);
|
|
2131
|
+
await f(new Request(String(paid.url), { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'email=t%40twin.test&cardNumber=4242424242424242&cardExpiry=1230&cardCvc=123&billingName=T&billingCountry=US&billingPostalCode=94105' }));
|
|
2132
|
+
const later = createStripeTwinFetch({ root, clock: () => '2026-03-20T00:00:00Z' });
|
|
2133
|
+
const read = async (path: string) => (await later(new Request(`http://stripe.test${path}`))).json() as Promise<Body>;
|
|
2134
|
+
const done = await read(`/v1/checkout/sessions/${String(paid.id)}`);
|
|
2135
|
+
const first = await read(`/v1/invoices/${String(done.invoice)}`);
|
|
2136
|
+
const bills = ((await read(`/v1/invoices?subscription=${String(done.subscription)}`)).data as Body[] | undefined) ?? [];
|
|
2137
|
+
const cycle = bills.find((b) => b.billing_reason === 'subscription_cycle');
|
|
2138
|
+
if (first.total !== 2500 || first.status !== 'paid' || cycle?.total !== 9000) return false;
|
|
2139
|
+
// the same sale in inline price_data: the page and the invoices agree ($25 now, $90 a period)
|
|
2140
|
+
const inlineLines = 'mode=subscription&success_url=https://x.test&customer_email=t%40twin.test&subscription_data[trial_period_days]=14'
|
|
2141
|
+
+ `&line_items[0][price_data][currency]=usd&line_items[0][price_data][unit_amount]=9000&line_items[0][price_data][recurring][interval]=month&line_items[0][price_data][product]=${String(prod.id)}&line_items[0][quantity]=1`
|
|
2142
|
+
+ '&line_items[1][price_data][currency]=usd&line_items[1][price_data][unit_amount]=2500&line_items[1][price_data][product_data][name]=Setup&line_items[1][quantity]=1';
|
|
2143
|
+
const inlineCs = await api('POST', '/v1/checkout/sessions', inlineLines);
|
|
2144
|
+
const inlinePage = await text(inlineCs);
|
|
2145
|
+
await f(new Request(String(inlineCs.url), { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'email=t%40twin.test&cardNumber=4242424242424242&cardExpiry=1230&cardCvc=123&billingName=T&billingCountry=US&billingPostalCode=94105' }));
|
|
2146
|
+
const inlineDone = await read(`/v1/checkout/sessions/${String(inlineCs.id)}`);
|
|
2147
|
+
const inlineFirst = await read(`/v1/invoices/${String(inlineDone.invoice)}`);
|
|
2148
|
+
const inlineBills = ((await read(`/v1/invoices?subscription=${String(inlineDone.subscription)}`)).data as Body[] | undefined) ?? [];
|
|
2149
|
+
if (!inlinePage.includes('|Then $90.00 per month|') || inlineFirst.total !== 2500 || inlineBills.find((b) => b.billing_reason === 'subscription_cycle')?.total !== 9000) return false;
|
|
2150
|
+
return trial.includes('|14 days free|') && trial.includes('|Then $90.00 per month|') && trial.includes('|Start trial|')
|
|
2151
|
+
&& trial.includes('After your trial ends, you will be charged $90.00 per month starting March 15, 2026. You can always cancel before then.')
|
|
2152
|
+
&& !trial.includes('|Subscribe|')
|
|
2153
|
+
&& byEnd.includes('|7 days free|') && byEnd.includes('|Then $900.00 per year|') && byEnd.includes('starting March 8, 2026')
|
|
2154
|
+
&& plain.includes('|Subscribe|') && plain.includes('|$90.00|') && !plain.includes('days free') && !plain.includes('Start trial');
|
|
2155
|
+
} finally {
|
|
2156
|
+
rmSync(root, { recursive: true, force: true });
|
|
2157
|
+
}
|
|
2158
|
+
}),
|
|
2159
|
+
// A session's promotion code discounts it and the subscription it makes; an unknown code and an expired one are
|
|
2160
|
+
// refused at create (docs.stripe.com/api/checkout/sessions/create#create_checkout_session-discounts).
|
|
2161
|
+
done('stripe.checkout.promotion_codes', 'checkout', 'Checkout discounts[].promotion_code: applied, or refused when unknown or expired', 'api', 'common', async () => {
|
|
2162
|
+
const root = mkdtempSync(join(tmpdir(), 'stp-promo-'));
|
|
2163
|
+
try {
|
|
2164
|
+
const h = (s: Step & { day?: number }) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: `2026-05-${String(s.day ?? 1).padStart(2, '0')}T12:00:00Z` });
|
|
2165
|
+
const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Box' });
|
|
2166
|
+
const price = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=3500¤cy=usd&recurring[interval]=month&product=${id(prod)}` });
|
|
2167
|
+
const coupon = await h({ m: 'POST', p: '/v1/coupons', b: 'percent_off=50&duration=once' });
|
|
2168
|
+
const code = await h({ m: 'POST', p: '/v1/promotion_codes', b: `promotion[type]=coupon&promotion[coupon]=${id(coupon)}&code=SPRING&expires_at=1780272000` });
|
|
2169
|
+
const make = (day: number, pc: string) => h({ m: 'POST', p: '/v1/checkout/sessions', b: `mode=subscription&success_url=https://x.test&line_items[0][price]=${id(price)}&line_items[0][quantity]=1&discounts[0][promotion_code]=${pc}`, day });
|
|
2170
|
+
const good = await make(2, id(code));
|
|
2171
|
+
const unknown = await make(2, 'promo_nope');
|
|
2172
|
+
const late = await make(30, id(code));
|
|
2173
|
+
const lateOk = late.status === 200;
|
|
2174
|
+
const june = await handleStripeTwinRequest({ method: 'POST', path: '/v1/checkout/sessions', body: `mode=subscription&success_url=https://x.test&line_items[0][price]=${id(price)}&line_items[0][quantity]=1&discounts[0][promotion_code]=${id(code)}`, root, occurredAt: '2026-06-02T12:00:00Z' });
|
|
2175
|
+
return field(good, 'amount_total') === 1750 && unknown.status === 400 && lateOk && june.status === 400 && ((june.body as Body).error as Body)?.code === 'coupon_expired';
|
|
2176
|
+
} finally {
|
|
2177
|
+
rmSync(root, { recursive: true, force: true });
|
|
2178
|
+
}
|
|
2179
|
+
}),
|
|
2180
|
+
// A payment session's payment_intent_data makes a destination charge: the payment transfers to the connected account
|
|
2181
|
+
// less the platform's application fee (docs.stripe.com/connect/destination-charges).
|
|
2182
|
+
done('stripe.checkout.destination_charge', 'checkout', 'Checkout payment_intent_data: destination charge with an application fee', 'api', 'common', () =>
|
|
2183
|
+
withRoot(async (h) => {
|
|
2184
|
+
const seller = await onboardedSeller(h);
|
|
2185
|
+
const cs = await h({ m: 'POST', p: '/v1/checkout/sessions', b: `mode=payment&success_url=https://x.test&line_items[0][price_data][currency]=usd&line_items[0][price_data][unit_amount]=5000&line_items[0][quantity]=1&payment_intent_data[application_fee_amount]=750&payment_intent_data[transfer_data][destination]=${seller}` });
|
|
2186
|
+
await h({ m: 'POST', p: `/c/pay/${id(cs)}`, b: 'email=pay%40twin.test&cardNumber=4242424242424242&cardExpiry=12%2F34&cardCvc=123&billingName=Twin&billingCountry=US&billingPostalCode=94105' });
|
|
2187
|
+
const done = await h({ m: 'GET', p: `/v1/checkout/sessions/${id(cs)}?expand[]=payment_intent.latest_charge` });
|
|
2188
|
+
const charge = ((field(done, 'payment_intent') as Body)?.latest_charge ?? {}) as Body;
|
|
2189
|
+
const transfer = await h({ m: 'GET', p: `/v1/transfers/${String(charge.transfer)}` });
|
|
2190
|
+
return field(transfer, 'destination') === seller && field(transfer, 'amount') === 4250 && charge.application_fee_amount === 750;
|
|
2191
|
+
}),
|
|
2192
|
+
),
|
|
2193
|
+
// Refunding a destination charge with reverse_transfer and refund_application_fee reverses the transfer and refunds the
|
|
2194
|
+
// application fee (docs.stripe.com/connect/destination-charges#issue-refunds).
|
|
2195
|
+
done('stripe.refunds.reverse_transfer', 'payments', 'Refund reverse_transfer + refund_application_fee on a destination charge', 'api', 'common', () =>
|
|
2196
|
+
withRoot(async (h) => {
|
|
2197
|
+
const seller = await onboardedSeller(h);
|
|
2198
|
+
const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: `amount=5000¤cy=usd&payment_method=pm_card_visa&confirm=true&application_fee_amount=750&transfer_data[destination]=${seller}` });
|
|
2199
|
+
const refund = await h({ m: 'POST', p: '/v1/refunds', b: `payment_intent=${id(pi)}&reverse_transfer=true&refund_application_fee=true` });
|
|
2200
|
+
const fees = ((await h({ m: 'GET', p: '/v1/application_fees' })).body as Body).data as Body[];
|
|
2201
|
+
const reversal = typeof field(refund, 'transfer_reversal') === 'string';
|
|
2202
|
+
return ok(refund) && reversal && field(refund, 'reverse_transfer') === undefined && fees[0]?.refunded === true && fees[0]?.amount_refunded === 750;
|
|
2203
|
+
}),
|
|
2204
|
+
),
|
|
2205
|
+
// Hosted Express onboarding: an Account Link is a single-use entry point to the onboarding session
|
|
2206
|
+
// (docs.stripe.com/connect/hosted-onboarding), which collects what a US account owes before card payments
|
|
2207
|
+
// (docs.stripe.com/connect/required-verification-information: eighteen requirements for an individual), the business, its representative and the payout bank account, refuses a
|
|
2208
|
+
// submit that leaves one out, and stores what it was given on the account (docs.stripe.com/connect/express-accounts).
|
|
2209
|
+
done('stripe.connect.hosted_onboarding', 'connect', 'Hosted onboarding collects and stores the business, representative and bank account', 'api', 'common', async () => {
|
|
2210
|
+
const root = mkdtempSync(join(tmpdir(), 'stp-onb-'));
|
|
2211
|
+
try {
|
|
2212
|
+
const h = (s: Step) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root });
|
|
2213
|
+
const acct = await h({ m: 'POST', p: '/v1/accounts', b: 'type=express&country=US&business_type=individual&email=grower@twin.test' });
|
|
2214
|
+
const owed = (((field(acct, 'requirements') as Body)?.currently_due as string[]) ?? []).length;
|
|
2215
|
+
const link = await h({ m: 'POST', p: '/v1/account_links', b: `account=${id(acct)}&type=account_onboarding&refresh_url=https://x.test/r&return_url=https://x.test/d` });
|
|
2216
|
+
// visiting the link consumes it and opens the onboarding session; a second visit is sent to refresh_url
|
|
2217
|
+
const url = String(field(link, 'url'));
|
|
2218
|
+
const visited = await stripeOnboardingFlow({ root })(new Request(url));
|
|
2219
|
+
const session = visited?.headers.get('location') ?? '';
|
|
2220
|
+
const again = await stripeOnboardingFlow({ root })(new Request(url));
|
|
2221
|
+
// the page offers Stripe's supported industries (a creator or marketer finds Advertising Services) and every state
|
|
2222
|
+
const shown = await (await stripeOnboardingFlow({ root })(new Request(session)))?.text() ?? '';
|
|
2223
|
+
const offers = shown.includes('value="7311"') && shown.includes('Advertising Services') && shown.includes('value="5815"')
|
|
2224
|
+
&& shown.includes('value="WY"') && shown.includes('value="DC"') && !shown.includes('value="5967"')
|
|
2225
|
+
&& (shown.match(/<option /g) ?? []).length >= 256 + 51;
|
|
2226
|
+
// no industry is chosen for the person, and a padded code is kept as the code
|
|
2227
|
+
const unchosen = shown.includes('<select id="mcc" name="mcc"><option value="" selected="">Select your industry</option>');
|
|
2228
|
+
// an account the platform gave an industry the page offers opens on it; one it does not offer, on no industry
|
|
2229
|
+
const opened = async (mcc: string): Promise<string> => {
|
|
2230
|
+
const a = await h({ m: 'POST', p: '/v1/accounts', b: `type=express&country=US&business_type=individual&business_profile[mcc]=${mcc}` });
|
|
2231
|
+
const l = await h({ m: 'POST', p: '/v1/account_links', b: `account=${id(a)}&type=account_onboarding&refresh_url=https://x.test/r&return_url=https://x.test/d` });
|
|
2232
|
+
const s = (await stripeOnboardingFlow({ root })(new Request(String(field(l, 'url')))))?.headers.get('location') ?? '';
|
|
2233
|
+
return await (await stripeOnboardingFlow({ root })(new Request(s)))?.text() ?? '';
|
|
2234
|
+
};
|
|
2235
|
+
const prefilled = (await opened('7311')).includes('<option value="7311" selected="">') && (await opened('5912')).includes('<option value="" selected="">Select your industry</option>');
|
|
2236
|
+
// an industry the page does not offer (a restricted code) leaves the MCC due
|
|
2237
|
+
const unlisted = currentlyDue('individual', { mcc: '5967' }).includes('business_profile.mcc') && !currentlyDue('individual', { mcc: '7311' }).includes('business_profile.mcc');
|
|
2238
|
+
const partial = await hostedSubmit(root, stripeOnboardingFlow, session, { business_type: 'individual', first_name: 'Ada' });
|
|
2239
|
+
const full = await hostedSubmit(root, stripeOnboardingFlow, session, {
|
|
2240
|
+
mcc: ' 5261 ', url: 'https://grower.test', statement_descriptor: 'GROWER TEST', line1: 'address_full_match', city: 'Portland', state: 'OR', postal_code: '97201',
|
|
2241
|
+
first_name: 'Ada', last_name: 'Grower', email: 'grower@twin.test', phone: '0000000000', dob: '1901-01-01', ssn_last_4: '0000', routing_number: '110000000', account_number: '000123456789', tos: 'accepted',
|
|
2242
|
+
});
|
|
2243
|
+
const after = await h({ m: 'GET', p: `/v1/accounts/${id(acct)}` });
|
|
2244
|
+
const banks = await h({ m: 'GET', p: `/v1/accounts/${id(acct)}/external_accounts` });
|
|
2245
|
+
return owed === 18 && offers && unlisted && unchosen && prefilled && (field(after, 'business_profile') as Body)?.mcc === '5261' && ((field(after, 'requirements') as Body)?.currently_due as string[] | undefined)?.length === 0 && field(after, 'charges_enabled') === true
|
|
2246
|
+
&& session.startsWith('https://connect.stripe.com/setup/s/') && again?.headers.get('location') === 'https://x.test/r'
|
|
2247
|
+
&& partial?.status === 400 && full?.status === 302 && field(after, 'details_submitted') === true
|
|
2248
|
+
&& ((field(after, 'individual') as Body)?.last_name === 'Grower') && ((banks.body as Body).data as Body[])[0]?.last4 === '6789';
|
|
2249
|
+
} finally {
|
|
2250
|
+
rmSync(root, { recursive: true, force: true });
|
|
2251
|
+
}
|
|
2252
|
+
}),
|
|
1059
2253
|
// Completing a session fires checkout.session.completed (data.object = the completed
|
|
1060
2254
|
// Session) and stores it in the Events API — the event most billing flows fulfill from.
|
|
1061
2255
|
done('stripe.checkout.completed_event', 'checkout', 'Checkout completion emits checkout.session.completed with the session payload', 'api', 'core', () =>
|
|
1062
2256
|
withRoot(async (h) => {
|
|
1063
2257
|
const cs = await h({ m: 'POST', p: '/v1/checkout/sessions', b: 'mode=payment&success_url=https://x.test&line_items[0][price_data][currency]=usd&line_items[0][price_data][unit_amount]=2500&line_items[0][quantity]=1' });
|
|
1064
2258
|
if (!ok(cs)) return false;
|
|
1065
|
-
|
|
2259
|
+
await h({ m: 'POST', p: `/c/pay/${id(cs)}`, b: 'email=pay%40twin.test&cardNumber=4242424242424242&cardExpiry=12%2F34&cardCvc=123&billingName=Twin&billingCountry=US&billingPostalCode=94105' });
|
|
2260
|
+
const completed = await h({ m: 'GET', p: `/v1/checkout/sessions/${id(cs)}` });
|
|
1066
2261
|
if (!ok(completed) || field(completed, 'status') !== 'complete') return false;
|
|
1067
2262
|
const ev = await h({ m: 'GET', p: '/v1/events?type=checkout.session.completed' });
|
|
1068
2263
|
const rows = ((ev.body as Body)?.data ?? []) as Body[];
|
|
@@ -1071,6 +2266,105 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1071
2266
|
&& payload?.id === id(cs) && payload?.status === 'complete' && payload?.payment_status === 'paid';
|
|
1072
2267
|
}),
|
|
1073
2268
|
),
|
|
2269
|
+
// Stripe.js (stripe-js.ts): the @stripe/stripe-js loader injects or accepts a script whose src matches
|
|
2270
|
+
// `/^https:\/\/js\.stripe\.com\/v3\/?(\?.*)?$/` or `/^https:\/\/js\.stripe\.com\/(v3|[a-z]+)\/stripe\.js(\?.*)?$/`
|
|
2271
|
+
// (@stripe/stripe-js 7.x dist/index.mjs) and reads `Stripe.version` (3 for v3, else the train's name). Each such path the
|
|
2272
|
+
// pack's js.stripe.com host claims is served a script defining Stripe with that version, and redirectToCheckout sends
|
|
2273
|
+
// the page to the session's hosted page, checkout.stripe.com/c/pay/{id}.
|
|
2274
|
+
done('stripe.js.loader', 'checkout', 'Stripe.js served at /v3, /v3/, /v3/stripe.js and /<train>/stripe.js; redirectToCheckout opens the hosted Checkout page', 'api', 'common', async () => {
|
|
2275
|
+
const root = mkdtempSync(join(tmpdir(), 'stp-js-'));
|
|
2276
|
+
try {
|
|
2277
|
+
const f = createStripeTwinFetch({ root });
|
|
2278
|
+
const host = pack.hosts?.find((x) => 'host' in x && x.host === 'js.stripe.com');
|
|
2279
|
+
const claims = (p: string) => !!host?.pathPattern && new RegExp(host.pathPattern).test(p);
|
|
2280
|
+
const load = async (p: string): Promise<{ version: unknown; opened: string[] } | undefined> => {
|
|
2281
|
+
const res = await f(new Request(`https://js.stripe.com${p}`));
|
|
2282
|
+
if (res.status !== 200 || !claims(p)) return undefined;
|
|
2283
|
+
const opened: string[] = [];
|
|
2284
|
+
const win: Record<string, unknown> = { location: { assign: (u: string) => opened.push(u) } };
|
|
2285
|
+
new Function('window', await res.text())(win);
|
|
2286
|
+
const Stripe = win.Stripe as ((key: string) => { redirectToCheckout: (o: { sessionId: string }) => unknown }) & { version: unknown };
|
|
2287
|
+
void Stripe('pk_test_twin').redirectToCheckout({ sessionId: 'cs_test_1' });
|
|
2288
|
+
return { version: Stripe.version, opened };
|
|
2289
|
+
};
|
|
2290
|
+
const [v3, v3slash, v3file, basil] = [await load('/v3'), await load('/v3/'), await load('/v3/stripe.js'), await load('/basil/stripe.js')];
|
|
2291
|
+
const other = await f(new Request('https://js.stripe.com/v3/other.js'));
|
|
2292
|
+
return [v3, v3slash, v3file].every((x) => x?.version === 3) && basil?.version === 'basil'
|
|
2293
|
+
&& [v3, v3slash, v3file, basil].every((x) => x?.opened.length === 1 && x.opened[0] === 'https://checkout.stripe.com/c/pay/cs_test_1')
|
|
2294
|
+
&& other.status === 404;
|
|
2295
|
+
} finally {
|
|
2296
|
+
rmSync(root, { recursive: true, force: true });
|
|
2297
|
+
}
|
|
2298
|
+
}),
|
|
2299
|
+
// The name customers see: the operator saves the business name on the Dashboard's Public details page
|
|
2300
|
+
// (dashboard.stripe.com/settings/public), which is the platform account's business_profile.name ("The customer-facing
|
|
2301
|
+
// business name", docs.stripe.com/api/accounts/object), and Checkout and the customer portal show it ("You can change a
|
|
2302
|
+
// Checkout page's name by modifying the Business name field", docs.stripe.com/payments/checkout/customization/appearance).
|
|
2303
|
+
done('stripe.dashboard.public_details', 'checkout', 'Dashboard Public details: the business name saved there is the account\'s business_profile.name, shown on Checkout and the customer portal', 'ui', 'common', async () => {
|
|
2304
|
+
const root = mkdtempSync(join(tmpdir(), 'stp-public-'));
|
|
2305
|
+
try {
|
|
2306
|
+
const f = createStripeTwinFetch({ root });
|
|
2307
|
+
const api = async (method: string, path: string, body?: string, account?: string) => (await f(new Request(`http://stripe.test${path}`, { method, headers: { 'content-type': 'application/x-www-form-urlencoded', ...(account ? { 'stripe-account': account } : {}) }, ...(body ? { body } : {}) }))).json() as Promise<Body>;
|
|
2308
|
+
const page = async (url: string) => (await f(new Request(url))).text();
|
|
2309
|
+
const save = (name: string) => f(new Request('https://dashboard.stripe.com/settings/public', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ business_name: name }).toString() }));
|
|
2310
|
+
const cs = await api('POST', '/v1/checkout/sessions', 'mode=payment&success_url=https://x.test&cancel_url=https://x.test/c&line_items[0][price_data][currency]=usd&line_items[0][price_data][unit_amount]=2500&line_items[0][price_data][product_data][name]=Pro&line_items[0][quantity]=1');
|
|
2311
|
+
const before = await page(`https://checkout.stripe.com/c/pay/${String(cs.id)}`);
|
|
2312
|
+
const empty = await save(' ');
|
|
2313
|
+
const saved = await save('Dub');
|
|
2314
|
+
const form = await page('https://dashboard.stripe.com/settings/public');
|
|
2315
|
+
const account = await api('GET', '/v1/account');
|
|
2316
|
+
const checkout = await page(`https://checkout.stripe.com/c/pay/${String(cs.id)}`);
|
|
2317
|
+
const cust = await api('POST', '/v1/customers', 'email=public@twin.test');
|
|
2318
|
+
const bps = await api('POST', '/v1/billing_portal/sessions', `customer=${String(cust.id)}&return_url=https://x.test/account`);
|
|
2319
|
+
const portal = await page(String(bps.url));
|
|
2320
|
+
// a second save changes the name in place and keeps the account's other public details
|
|
2321
|
+
await save('Dub Technologies');
|
|
2322
|
+
const again = await api('GET', '/v1/account');
|
|
2323
|
+
// each save is the account's update, the first included
|
|
2324
|
+
const updated = ((await api('GET', '/v1/events?type=account.updated')).data as Body[] | undefined) ?? [];
|
|
2325
|
+
// a session's own branding_settings.display_name wins; a direct charge's page shows the connected account's name
|
|
2326
|
+
const line = 'mode=payment&success_url=https://x.test&line_items[0][price_data][currency]=usd&line_items[0][price_data][unit_amount]=900&line_items[0][price_data][product_data][name]=Fern&line_items[0][quantity]=1';
|
|
2327
|
+
const branded = await page(String((await api('POST', '/v1/checkout/sessions', `${line}&branding_settings[display_name]=Dub Links`)).url));
|
|
2328
|
+
const grower = await api('POST', '/v1/accounts', 'type=standard&country=US&business_profile[name]=Grower Co');
|
|
2329
|
+
const directSession = await api('POST', '/v1/checkout/sessions', line, String(grower.id));
|
|
2330
|
+
const direct = await page(String(directSession.url));
|
|
2331
|
+
// the name is the page's only use of the account a session was made on: paying it keeps its events where they were
|
|
2332
|
+
// (the twin makes a hosted page's payment on the platform's books), all of them together
|
|
2333
|
+
await f(new Request(String(directSession.url), { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'email=fern%40x.test&cardNumber=4242424242424242&cardExpiry=12%2F34&cardCvc=123&billingName=Fern&billingCountry=US&billingPostalCode=94105' }));
|
|
2334
|
+
const completedHere = ((((await api('GET', '/v1/events?type=checkout.session.completed')).data as Body[] | undefined) ?? [])).filter((e) => ((e.data as Body)?.object as Body)?.id === directSession.id);
|
|
2335
|
+
const completedThere = ((await api('GET', '/v1/events?type=checkout.session.completed', undefined, String(grower.id))).data as Body[] | undefined) ?? [];
|
|
2336
|
+
const behalf = await page(String((await api('POST', '/v1/billing_portal/sessions', `customer=${String(cust.id)}&return_url=https://x.test/account&on_behalf_of=${String(grower.id)}`)).url));
|
|
2337
|
+
const subBehalf = await page(String((await api('POST', '/v1/checkout/sessions', `mode=subscription&success_url=https://x.test&line_items[0][price_data][currency]=usd&line_items[0][price_data][unit_amount]=900&line_items[0][price_data][recurring][interval]=month&line_items[0][price_data][product_data][name]=Fern&line_items[0][quantity]=1&subscription_data[on_behalf_of]=${String(grower.id)}`)).url));
|
|
2338
|
+
const onBehalf = await page(String((await api('POST', '/v1/checkout/sessions', `${line}&payment_intent_data[on_behalf_of]=${String(grower.id)}&payment_intent_data[transfer_data][destination]=${String(grower.id)}`)).url));
|
|
2339
|
+
const portalAsGrower = await page(String((await api('POST', '/v1/billing_portal/sessions', `customer=${String(cust.id)}&return_url=https://x.test/account`, String(grower.id))).url));
|
|
2340
|
+
// the page's trailing-slash address posts to itself and comes back to itself
|
|
2341
|
+
const slashed = await page('https://dashboard.stripe.com/settings/public/');
|
|
2342
|
+
const slashSave = await f(new Request('https://dashboard.stripe.com/settings/public/', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'business_name=Dub+Technologies' }));
|
|
2343
|
+
const landed = new URL(slashSave.headers.get('location') ?? '', 'https://dashboard.stripe.com/settings/public/').pathname;
|
|
2344
|
+
// the /_twin/account door, written first, starts the platform's record from the same default and is an update too
|
|
2345
|
+
const door = createStripeTwinFetch({ root: join(root, 'door') });
|
|
2346
|
+
await door(new Request('http://stripe.test/_twin/account', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'settings[payouts][schedule][interval]=manual&business_profile[support_email]=help@dub.test' }));
|
|
2347
|
+
// and a save on the page keeps the public details it does not show, as the door keeps the name the page saved
|
|
2348
|
+
await door(new Request('https://dashboard.stripe.com/settings/public', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'business_name=Dub' }));
|
|
2349
|
+
await door(new Request('http://stripe.test/_twin/account', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'business_profile[url]=https://dub.test' }));
|
|
2350
|
+
const doorAccount = await (await door(new Request('http://stripe.test/v1/account'))).json() as Body;
|
|
2351
|
+
const doorEvents = (((await (await door(new Request('http://stripe.test/v1/events?type=account.updated'))).json()) as Body).data as Body[] | undefined) ?? [];
|
|
2352
|
+
const long = await save('x'.repeat(5001));
|
|
2353
|
+
return before.includes('Twin Inc.') && !before.includes('Dub') && empty.status === 400 && saved.status === 303 && long.status === 400
|
|
2354
|
+
&& updated.length === 2 && branded.includes('Pay Dub Links') && direct.includes('Pay Grower Co') && completedHere.length === 1 && !completedHere[0]!.account && completedThere.length === 0 && !direct.includes('Dub Technologies') && behalf.includes('Return to Grower Co')
|
|
2355
|
+
&& saved.headers.get('location') === '?saved=1' && form.includes('value="Dub"') && !form.includes('action=')
|
|
2356
|
+
&& onBehalf.includes('Pay Grower Co') && subBehalf.includes('<div class="pay-merchant">Grower Co</div>') && portalAsGrower.includes('Return to Grower Co')
|
|
2357
|
+
&& slashed.includes('name="business_name"') && slashSave.status === 303 && landed === '/settings/public/'
|
|
2358
|
+
&& doorAccount.created === 1767225600 && (doorAccount.capabilities as Body)?.card_payments === 'active' && doorEvents.length === 3
|
|
2359
|
+
&& (doorAccount.business_profile as Body)?.support_email === 'help@dub.test' && (doorAccount.business_profile as Body)?.name === 'Dub' && (doorAccount.business_profile as Body)?.url === 'https://dub.test'
|
|
2360
|
+
&& (((doorAccount.settings as Body)?.payouts as Body)?.schedule as Body)?.interval === 'manual'
|
|
2361
|
+
&& (account.business_profile as Body)?.name === 'Dub' && account.id === 'acct_twin_self' && account.charges_enabled === true
|
|
2362
|
+
&& checkout.includes('<div class="pay-merchant">Dub</div>') && checkout.includes('Pay Dub') && !checkout.includes('Twin Inc.')
|
|
2363
|
+
&& portal.includes('Return to Dub') && portal.includes('<div class="portal-merchant">Dub</div>') && (again.business_profile as Body)?.name === 'Dub Technologies';
|
|
2364
|
+
} finally {
|
|
2365
|
+
rmSync(root, { recursive: true, force: true });
|
|
2366
|
+
}
|
|
2367
|
+
}),
|
|
1074
2368
|
done('stripe.billing_portal.session', 'checkout', 'Billing Portal Session create (referential to customer)', 'api', 'common', () =>
|
|
1075
2369
|
withRoot(async (h) => {
|
|
1076
2370
|
const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=portal@twin.test' });
|
|
@@ -1078,6 +2372,34 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1078
2372
|
return ok(bps) && field(bps, 'object') === 'billing_portal.session' && !!field(bps, 'url');
|
|
1079
2373
|
}),
|
|
1080
2374
|
),
|
|
2375
|
+
// The portal's payment method and invoices: the customer saves a new card (the default their subscription charges)
|
|
2376
|
+
// and pays the open invoice a declined renewal left, which makes the subscription active
|
|
2377
|
+
// (docs.stripe.com/customer-management/configure-portal).
|
|
2378
|
+
done('stripe.billing_portal.payment_method_and_pay', 'checkout', 'Billing Portal: update the payment method and pay an open invoice', 'api', 'common', async () => {
|
|
2379
|
+
const root = mkdtempSync(join(tmpdir(), 'stp-portal-'));
|
|
2380
|
+
try {
|
|
2381
|
+
// February's reads come two hours into the renewed period: its invoice is drafted at the period's end and charged
|
|
2382
|
+
// "an hour on" (above; cab4389eb stamps each catch-up write at that moment), so a read at the period's very end
|
|
2383
|
+
// sees the draft, not the paid invoice this claim is about
|
|
2384
|
+
const h = (s: Step & { month?: number }) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: `2026-${String(s.month ?? 1).padStart(2, '0')}-10T${(s.month ?? 1) > 1 ? '02' : '00'}:00:00Z` });
|
|
2385
|
+
const prod = await h({ m: 'POST', p: '/v1/products', b: 'name=Box' });
|
|
2386
|
+
const price = await h({ m: 'POST', p: '/v1/prices', b: `unit_amount=3500¤cy=usd&recurring[interval]=month&product=${id(prod)}` });
|
|
2387
|
+
const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=portal-pay@twin.test' });
|
|
2388
|
+
const sub = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&items[0][price]=${id(price)}&default_payment_method=pm_card_chargeCustomerFail&trial_period_days=7` });
|
|
2389
|
+
const open = (((await h({ m: 'GET', p: `/v1/invoices?subscription=${id(sub)}&status=open`, month: 2 })).body as Body).data as Body[])[0];
|
|
2390
|
+
const bps = await h({ m: 'POST', p: '/v1/billing_portal/sessions', b: `customer=${id(cust)}`, month: 2 });
|
|
2391
|
+
const flow = stripePortalFlow({ root, clock: () => '2026-02-10T00:00:00Z' });
|
|
2392
|
+
const post = (to: string, form: Record<string, string>) => flow(new Request(`https://billing.stripe.com/p/session/${id(bps)}/${to}`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(form).toString() }));
|
|
2393
|
+
const saved = await post('payment_method', { cardNumber: '4242424242424242', cardExpiry: '12/34', cardCvc: '123', billingName: 'Twin', billingCountry: 'US', billingPostalCode: '94105' });
|
|
2394
|
+
const paid = await post('pay', { invoice: String(open?.id) });
|
|
2395
|
+
const after = await h({ m: 'GET', p: `/v1/subscriptions/${id(sub)}`, month: 2 });
|
|
2396
|
+
const inv = await h({ m: 'GET', p: `/v1/invoices/${String(open?.id)}`, month: 2 });
|
|
2397
|
+
return saved?.status === 303 && paid?.status === 303 && field(inv, 'status') === 'paid'
|
|
2398
|
+
&& field(after, 'status') === 'active' && field(after, 'default_payment_method') !== 'pm_card_chargeCustomerFail';
|
|
2399
|
+
} finally {
|
|
2400
|
+
rmSync(root, { recursive: true, force: true });
|
|
2401
|
+
}
|
|
2402
|
+
}),
|
|
1081
2403
|
done('stripe.billing_portal.configuration', 'checkout', 'Billing Portal Configurations: create + retrieve + update + list', 'api', 'common', () =>
|
|
1082
2404
|
withRoot(async (h) => {
|
|
1083
2405
|
const c = await h({ m: 'POST', p: '/v1/billing_portal/configurations', b: 'business_profile[headline]=Hi&features[customer_update][enabled]=true' });
|
|
@@ -1087,8 +2409,6 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1087
2409
|
return ok(g) && id(g) === id(c) && field(l, 'object') === 'list';
|
|
1088
2410
|
}),
|
|
1089
2411
|
),
|
|
1090
|
-
outOfScope('stripe.checkout.hosted_page', 'checkout', 'Hosted Checkout PAGE pixel rendering', 'ui', 'niche', 'Out of scope: the Session/API is modeled; the hosted PAGE pixels are out of scope.'),
|
|
1091
|
-
outOfScope('stripe.billing_portal.hosted_page', 'checkout', 'Hosted Customer Portal PAGE pixel rendering', 'ui', 'niche', 'Out of scope: the Session/API is modeled; the hosted PAGE pixels are out of scope.'),
|
|
1092
2412
|
|
|
1093
2413
|
// ── Payment Links / Quotes ────────────────────────────────────────────────────────
|
|
1094
2414
|
// Payment Links: create (referential to a Price) → active link with a share url + line_items
|
|
@@ -1150,10 +2470,13 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1150
2470
|
return ok(g) && ok(u) && ok(ll) && field(ll, 'object') === 'login_link' && ok(del) && field(del, 'deleted') === true;
|
|
1151
2471
|
}),
|
|
1152
2472
|
),
|
|
1153
|
-
done('stripe.connect.transfers', 'connect', 'Connect Transfers:
|
|
2473
|
+
done('stripe.connect.transfers', 'connect', 'Connect Transfers: to an account whose transfers capability is active, from the platform\'s balance + retrieve + list', 'api', 'niche', () =>
|
|
1154
2474
|
withRoot(async (h) => {
|
|
1155
|
-
const
|
|
1156
|
-
|
|
2475
|
+
const seller = await onboardedSeller(h);
|
|
2476
|
+
// an account still onboarding cannot receive transfers
|
|
2477
|
+
const fresh = await h({ m: 'POST', p: '/v1/accounts', b: 'type=express&capabilities[transfers][requested]=true' });
|
|
2478
|
+
if ((await h({ m: 'POST', p: '/v1/transfers', b: `amount=500¤cy=usd&destination=${id(fresh)}` })).status !== 400) return false;
|
|
2479
|
+
const t = await h({ m: 'POST', p: '/v1/transfers', b: `amount=500¤cy=usd&destination=${seller}` });
|
|
1157
2480
|
if (!ok(t) || field(t, 'object') !== 'transfer') return false;
|
|
1158
2481
|
const g = await h({ m: 'GET', p: `/v1/transfers/${id(t)}` });
|
|
1159
2482
|
const l = await h({ m: 'GET', p: '/v1/transfers' });
|
|
@@ -1162,6 +2485,48 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1162
2485
|
return ok(g) && field(l, 'object') === 'list' && bad.status >= 400;
|
|
1163
2486
|
}),
|
|
1164
2487
|
),
|
|
2488
|
+
// docs.stripe.com/connect/separate-charges-and-transfers, "Transfer availability": a transfer tied to a charge succeeds
|
|
2489
|
+
// while the charge's funds are pending ("if the related charge hasn't settled yet"; once they have, it is held to the
|
|
2490
|
+
// available balance), never takes more than the charge (less what was reversed of it), takes the charge's
|
|
2491
|
+
// transfer_group over one the request names or, the charge having none, `group_` plus its PaymentIntent id, which the
|
|
2492
|
+
// charge takes too, and pays the destination when the charge's funds become available. (Dub pays its partners this
|
|
2493
|
+
// way: a card charge, then source_transaction.)
|
|
2494
|
+
done('stripe.connect.transfer_source_transaction', 'connect', 'Connect Transfers from a charge\'s pending funds (source_transaction)', 'api', 'common', async () => {
|
|
2495
|
+
const root = mkdtempSync(join(tmpdir(), 'stp-src-'));
|
|
2496
|
+
try {
|
|
2497
|
+
const h = (s: Step & { acct?: string; day?: number }) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: `2026-06-${String(s.day ?? 1).padStart(2, '0')}T12:00:00Z`, ...(s.acct ? { stripeAccount: s.acct } : {}) });
|
|
2498
|
+
const a = await h({ m: 'POST', p: '/v1/accounts', b: 'type=custom&country=US&capabilities[transfers][requested]=true&business_profile[mcc]=5462&business_profile[url]=https://seller.test&tos_acceptance[date]=1767225600&tos_acceptance[ip]=203.0.113.9&individual[first_name]=Sam&individual[last_name]=Seller&settings[payouts][schedule][interval]=manual' });
|
|
2499
|
+
const seller = id(a);
|
|
2500
|
+
await h({ m: 'POST', p: `/v1/accounts/${seller}/external_accounts`, b: 'external_account[object]=bank_account&external_account[country]=US&external_account[currency]=usd&external_account[routing_number]=110000000&external_account[account_number]=000123456789' });
|
|
2501
|
+
await h({ m: 'POST', p: '/_twin/account', b: 'settings[payouts][schedule][interval]=manual' });
|
|
2502
|
+
const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=2592¤cy=usd&payment_method=pm_card_visa&confirm=true&transfer_group=inv_7' });
|
|
2503
|
+
const charge = field(pi, 'latest_charge') as string;
|
|
2504
|
+
const t = await h({ m: 'POST', p: '/v1/transfers', b: `amount=2400¤cy=usd&destination=${seller}&source_transaction=${charge}&transfer_group=mine` });
|
|
2505
|
+
const over = await h({ m: 'POST', p: '/v1/transfers', b: `amount=500¤cy=usd&destination=${seller}&source_transaction=${charge}` });
|
|
2506
|
+
const sellerBalance = await h({ m: 'GET', p: '/v1/balance', acct: seller });
|
|
2507
|
+
const pending = ((sellerBalance.body as Body).pending as Array<{ amount: number }> | undefined)?.[0]?.amount;
|
|
2508
|
+
if (!ok(pi) || !ok(t) || field(t, 'source_transaction') !== charge || field(t, 'transfer_group') !== 'inv_7' || over.status !== 400 || pending !== 2400) return false;
|
|
2509
|
+
// what was reversed of a transfer is room again
|
|
2510
|
+
await h({ m: 'POST', p: `/v1/transfers/${id(t)}/reversals`, b: 'amount=400' });
|
|
2511
|
+
const again = await h({ m: 'POST', p: '/v1/transfers', b: `amount=500¤cy=usd&destination=${seller}&source_transaction=${charge}` });
|
|
2512
|
+
// no transfer_group on the charge: group_ plus its PaymentIntent id, on the transfer and on the charge
|
|
2513
|
+
const plain = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=2000¤cy=usd&payment_method=pm_card_visa&confirm=true' });
|
|
2514
|
+
const plainCharge = field(plain, 'latest_charge') as string;
|
|
2515
|
+
const grouped = await h({ m: 'POST', p: '/v1/transfers', b: `amount=500¤cy=usd&destination=${seller}&source_transaction=${plainCharge}` });
|
|
2516
|
+
const chargeAfter = await h({ m: 'GET', p: `/v1/charges/${plainCharge}` });
|
|
2517
|
+
if (!ok(again) || field(grouped, 'transfer_group') !== `group_${id(plain)}` || field(chargeAfter, 'transfer_group') !== `group_${id(plain)}`) return false;
|
|
2518
|
+
// day 5: both charges have settled, so a transfer from one is held to the platform's available balance
|
|
2519
|
+
// (2487 + 1912 − 2400 + 400 − 500 − 500 = 1399)
|
|
2520
|
+
const tooMuch = await h({ m: 'POST', p: '/v1/transfers', b: `amount=1500¤cy=usd&destination=${seller}&source_transaction=${plainCharge}`, day: 5 });
|
|
2521
|
+
const fits = await h({ m: 'POST', p: '/v1/transfers', b: `amount=1000¤cy=usd&destination=${seller}&source_transaction=${plainCharge}`, day: 5 });
|
|
2522
|
+
// an authorization is no source of funds until it is captured (the twin's rule: the docs are silent)
|
|
2523
|
+
const held = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=900¤cy=usd&capture_method=manual&payment_method=pm_card_visa&confirm=true', day: 5 });
|
|
2524
|
+
const fromHold = await h({ m: 'POST', p: '/v1/transfers', b: `amount=100¤cy=usd&destination=${seller}&source_transaction=${String(field(held, 'latest_charge'))}`, day: 5 });
|
|
2525
|
+
const sellerAfter = (((await h({ m: 'GET', p: '/v1/transfers', day: 5 })).body as Body).data as Body[]) ?? [];
|
|
2526
|
+
return tooMuch.status === 400 && ((tooMuch.body as Body).error as Body)?.code === 'balance_insufficient' && ok(fits)
|
|
2527
|
+
&& fromHold.status === 400 && !sellerAfter.some((x) => x.source_transaction === field(held, 'latest_charge'));
|
|
2528
|
+
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
2529
|
+
}),
|
|
1165
2530
|
// (account_links, persons, external_accounts, transfer_reversals, application_fees,
|
|
1166
2531
|
// capabilities upgraded to done() in the AUDIT GROWTH block below.)
|
|
1167
2532
|
// Connect payouts (Stripe-Account): a payout created WITH a Stripe-Account header is attributed
|
|
@@ -1171,8 +2536,10 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1171
2536
|
const root = mkdtempSync(join(tmpdir(), 'stp-cpo-'));
|
|
1172
2537
|
try {
|
|
1173
2538
|
const h = (s: Step & { acct?: string }) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, ...(s.acct ? { stripeAccount: s.acct } : {}) });
|
|
1174
|
-
const
|
|
1175
|
-
|
|
2539
|
+
const seller = await onboardedSeller(h);
|
|
2540
|
+
const acct = { status: 200, body: { id: seller } } as StripeResponse;
|
|
2541
|
+
await h({ m: 'POST', p: '/v1/transfers', b: `amount=3000¤cy=usd&destination=${seller}` });
|
|
2542
|
+
// a platform payout (no header) and a connected-account payout (with header), each from its own balance.
|
|
1176
2543
|
const plat = await h({ m: 'POST', p: '/v1/payouts', b: 'amount=1000¤cy=usd' });
|
|
1177
2544
|
const conn = await h({ m: 'POST', p: '/v1/payouts', b: 'amount=2000¤cy=usd', acct: id(acct) });
|
|
1178
2545
|
if (!ok(plat) || !ok(conn)) return false;
|
|
@@ -1196,27 +2563,106 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1196
2563
|
// Top-ups: POST /v1/topups funds the platform balance (amount + currency required); the twin
|
|
1197
2564
|
// succeeds a test top-up immediately. retrieve/list/update round-trip; a succeeded top-up
|
|
1198
2565
|
// cannot be canceled (400); missing money 400; unknown id 404.
|
|
1199
|
-
|
|
1200
|
-
|
|
2566
|
+
// Connect webhooks (docs.stripe.com/connect/webhooks): an endpoint created with `connect` true receives the Connected
|
|
2567
|
+
// accounts scope, one without it Your account; "Each event for a connected account contains a top-level `account`
|
|
2568
|
+
// property that identifies the connected account". A write made as the account (the Stripe-Account header) and the
|
|
2569
|
+
// account's own account.updated are its events; the platform's own writes are the platform's.
|
|
2570
|
+
done('stripe.connect.webhook_scope', 'connect', 'Connect webhooks: a connected account\'s events reach only connect endpoints, with top-level account; the platform\'s only the others', 'api', 'common', async () => {
|
|
2571
|
+
const root = mkdtempSync(join(tmpdir(), 'stp-scope-'));
|
|
2572
|
+
const rx = await webhookReceiver();
|
|
2573
|
+
try {
|
|
2574
|
+
const h = fetcher(createStripeTwinFetch({ root }));
|
|
2575
|
+
await h('POST', '/v1/webhook_endpoints', `url=${rx.url('/platform')}&enabled_events[]=*`);
|
|
2576
|
+
await h('POST', '/v1/webhook_endpoints', `url=${rx.url('/connect')}&enabled_events[]=*&connect=true`);
|
|
2577
|
+
const seller = String((await h('POST', '/v1/accounts', 'type=express&country=US')).id);
|
|
2578
|
+
// the account's own event: an update "Occurs whenever an account status or property has changed"
|
|
2579
|
+
// (account.updated, docs.stripe.com/api/events/types). Its creation was asserted to send one when this claim was
|
|
2580
|
+
// written (788199389), which no version of the twin did; an Express account's creation changes nothing Stripe
|
|
2581
|
+
// reviews here (semantics/connect.ts review: an Express account is onboarded on Stripe's pages).
|
|
2582
|
+
await h('POST', `/v1/accounts/${seller}`, 'metadata[tier]=gold');
|
|
2583
|
+
const theirs = String((await h('POST', '/v1/customers', 'email=buyer@seller.test', seller)).id);
|
|
2584
|
+
const ours = String((await h('POST', '/v1/customers', 'email=buyer@platform.test')).id);
|
|
2585
|
+
const at = (path: string, type: string, object: string) => rx.got(path).filter((e) => e.type === type && (e.data.object as Body).id === object);
|
|
2586
|
+
const theirsC = at('/connect', 'customer.created', theirs);
|
|
2587
|
+
const oursP = at('/platform', 'customer.created', ours);
|
|
2588
|
+
return theirsC.length === 1 && theirsC[0]!.account === seller && at('/platform', 'customer.created', theirs).length === 0
|
|
2589
|
+
&& oursP.length === 1 && !('account' in oursP[0]!) && at('/connect', 'customer.created', ours).length === 0
|
|
2590
|
+
&& rx.got('/connect').some((e) => e.type === 'account.updated' && e.account === seller)
|
|
2591
|
+
&& !rx.got('/platform').some((e) => e.type === 'account.updated');
|
|
2592
|
+
} finally {
|
|
2593
|
+
rx.close();
|
|
2594
|
+
rmSync(root, { recursive: true, force: true });
|
|
2595
|
+
}
|
|
2596
|
+
}),
|
|
2597
|
+
// A connected account's event is its own even when no request acted as it: the automatic payout World time makes for
|
|
2598
|
+
// it (semantics/balance.ts) goes to connect endpoints with `account`. The Events API answers each account its own
|
|
2599
|
+
// events: with the Stripe-Account header that account's, without it the platform's (semantics/webhook-endpoints.ts).
|
|
2600
|
+
done('stripe.connect.event_scope', 'connect', 'A connected account\'s automatic payout event is scoped to it; the Events API lists and retrieves per account', 'api', 'common', async () => {
|
|
2601
|
+
const root = mkdtempSync(join(tmpdir(), 'stp-evscope-'));
|
|
2602
|
+
const rx = await webhookReceiver();
|
|
2603
|
+
try {
|
|
2604
|
+
let now = '2026-06-01T12:00:00.000Z';
|
|
2605
|
+
const h = fetcher(createStripeTwinFetch({ root, clock: () => now }));
|
|
2606
|
+
await h('POST', '/v1/webhook_endpoints', `url=${rx.url('/platform')}&enabled_events[]=*`);
|
|
2607
|
+
await h('POST', '/v1/webhook_endpoints', `url=${rx.url('/connect')}&enabled_events[]=*&connect=true`);
|
|
2608
|
+
const seller = String((await h('POST', '/v1/accounts', 'type=custom&country=US&capabilities[transfers][requested]=true&business_profile[mcc]=5462&business_profile[url]=https://seller.test&tos_acceptance[date]=1767225600&tos_acceptance[ip]=203.0.113.9&individual[first_name]=Sam&individual[last_name]=Seller')).id);
|
|
2609
|
+
await h('POST', `/v1/accounts/${seller}/external_accounts`, 'external_account[object]=bank_account&external_account[country]=US&external_account[currency]=usd&external_account[routing_number]=110000000&external_account[account_number]=000123456789');
|
|
2610
|
+
await h('POST', '/v1/charges', 'amount=100000¤cy=usd&source=tok_bypassPending');
|
|
2611
|
+
await h('POST', '/v1/transfers', `amount=5000¤cy=usd&destination=${seller}`);
|
|
2612
|
+
now = '2026-06-03T12:00:00.000Z';
|
|
2613
|
+
await h('GET', '/v1/balance');
|
|
2614
|
+
const theirPayout = rx.got('/connect').find((e) => e.type === 'payout.created');
|
|
2615
|
+
if (theirPayout?.account !== seller || (theirPayout.data.object as Body).amount !== 5000) return false;
|
|
2616
|
+
if (rx.got('/platform').some((e) => e.type === 'payout.created' && (e.data.object as Body).id === (theirPayout.data.object as Body).id)) return false;
|
|
2617
|
+
const theirs = ((await h('GET', '/v1/events?limit=100', undefined, seller)).data as Body[]) ?? [];
|
|
2618
|
+
const ours = ((await h('GET', '/v1/events?limit=100')).data as Body[]) ?? [];
|
|
2619
|
+
const listed = theirs.find((e) => e.type === 'payout.created');
|
|
2620
|
+
const hidden = await h('GET', `/v1/events/${String(listed?.id)}`);
|
|
2621
|
+
const shown = await h('GET', `/v1/events/${String(listed?.id)}`, undefined, seller);
|
|
2622
|
+
return theirs.length > 0 && theirs.every((e) => e.account === seller) && listed !== undefined
|
|
2623
|
+
&& ours.length > 0 && ours.every((e) => !('account' in e)) && ours.some((e) => e.type === 'transfer.created')
|
|
2624
|
+
&& (hidden.error as Body | undefined)?.code === 'resource_missing' && shown.id === listed.id && shown.account === seller;
|
|
2625
|
+
} finally {
|
|
2626
|
+
rx.close();
|
|
2627
|
+
rmSync(root, { recursive: true, force: true });
|
|
2628
|
+
}
|
|
2629
|
+
}),
|
|
2630
|
+
// A top-up is pending until its funds arrive five days on ("USA (USD) ACH Debit Transfer 5 days",
|
|
2631
|
+
// docs.stripe.com/connect/top-ups; semantics/terminal.ts, c3519a064), and only a pending one can be canceled; a
|
|
2632
|
+
// succeeded one cannot. The verify runs on a World clock it moves, so the arrival is chosen, not waited for.
|
|
2633
|
+
done('stripe.connect.top_ups', 'connect', 'Top-ups (fund platform balance)', 'api', 'niche', async () => {
|
|
2634
|
+
const root = mkdtempSync(join(tmpdir(), 'stp-topup-'));
|
|
2635
|
+
try {
|
|
2636
|
+
const h = (s: Step & { day?: number }) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: `2026-06-${String(s.day ?? 1).padStart(2, '0')}T12:00:00Z` });
|
|
1201
2637
|
const tu = await h({ m: 'POST', p: '/v1/topups', b: 'amount=50000¤cy=usd&statement_descriptor=Top up' });
|
|
1202
|
-
if (!ok(tu) || field(tu, 'object') !== 'topup' || field(tu, 'status') !== '
|
|
2638
|
+
if (!ok(tu) || field(tu, 'object') !== 'topup' || field(tu, 'status') !== 'pending' || field(tu, 'expected_availability_date') !== Number(field(tu, 'created')) + 5 * 86_400) return false;
|
|
1203
2639
|
const g = await h({ m: 'GET', p: `/v1/topups/${id(tu)}` });
|
|
1204
2640
|
const u = await h({ m: 'POST', p: `/v1/topups/${id(tu)}`, b: 'metadata[ref]=q3' });
|
|
1205
|
-
const
|
|
1206
|
-
// a
|
|
1207
|
-
const
|
|
2641
|
+
const pendingList = await h({ m: 'GET', p: '/v1/topups?status=pending' });
|
|
2642
|
+
// a second one, canceled while it is still pending
|
|
2643
|
+
const other = await h({ m: 'POST', p: '/v1/topups', b: 'amount=100¤cy=usd' });
|
|
2644
|
+
const canceled = await h({ m: 'POST', p: `/v1/topups/${id(other)}/cancel` });
|
|
2645
|
+
// six days on the first has arrived: succeeded, and past canceling
|
|
2646
|
+
const arrived = await h({ m: 'GET', p: `/v1/topups/${id(tu)}`, day: 7 });
|
|
2647
|
+
const l = await h({ m: 'GET', p: '/v1/topups?status=succeeded', day: 7 });
|
|
2648
|
+
const cancel = await h({ m: 'POST', p: `/v1/topups/${id(tu)}/cancel`, day: 7 });
|
|
1208
2649
|
const noMoney = await h({ m: 'POST', p: '/v1/topups', b: 'currency=usd' });
|
|
1209
2650
|
const nope = await h({ m: 'GET', p: '/v1/topups/tu_nope' });
|
|
1210
|
-
return ok(g) && id(g) === id(tu) && ok(u) && ((
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
2651
|
+
return ok(g) && id(g) === id(tu) && ok(u) && ((pendingList.body as Body).data as Body[]).length === 1
|
|
2652
|
+
&& ok(canceled) && field(canceled, 'status') === 'canceled'
|
|
2653
|
+
&& field(arrived, 'status') === 'succeeded' && typeof field(arrived, 'balance_transaction') === 'string'
|
|
2654
|
+
&& ((l.body as Body).data as Body[]).length === 1 && cancel.status === 400 && noMoney.status === 400 && nope.status === 404;
|
|
2655
|
+
} finally {
|
|
2656
|
+
rmSync(root, { recursive: true, force: true });
|
|
2657
|
+
}
|
|
2658
|
+
}),
|
|
1214
2659
|
|
|
1215
2660
|
// ── Identity / Files ──────────────────────────────────────────────────────────────
|
|
1216
2661
|
done('stripe.identity.verification_sessions', 'identity', 'Identity VerificationSessions: create + retrieve', 'api', 'niche', () =>
|
|
1217
2662
|
withRoot(async (h) => {
|
|
1218
2663
|
const v = await h({ m: 'POST', p: '/v1/identity/verification_sessions', b: 'type=document' });
|
|
1219
|
-
|
|
2664
|
+
// the object is named by its namespace: "object": "identity.verification_session" (docs.stripe.com/api/identity/verification_sessions/object)
|
|
2665
|
+
if (!ok(v) || field(v, 'object') !== 'identity.verification_session' || field(v, 'status') !== 'requires_input') return false;
|
|
1220
2666
|
const g = await h({ m: 'GET', p: `/v1/identity/verification_sessions/${id(v)}` });
|
|
1221
2667
|
return ok(g) && id(g) === id(v);
|
|
1222
2668
|
}),
|
|
@@ -1226,19 +2672,24 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1226
2672
|
// the old fabricating path returned 200 for a missing file and would fail this assertion.
|
|
1227
2673
|
done('stripe.file_links.create', 'files', 'FileLinks: create (requires file)', 'api', 'niche', () =>
|
|
1228
2674
|
withRoot(async (h) => {
|
|
1229
|
-
const
|
|
1230
|
-
|
|
2675
|
+
const up = await h({ m: 'POST', p: '/v1/files', b: 'purpose=dispute_evidence&file=evidence.png' });
|
|
2676
|
+
const f = await h({ m: 'POST', p: '/v1/file_links', b: `file=${id(up)}` });
|
|
2677
|
+
if (!ok(f) || field(f, 'object') !== 'file_link' || field(f, 'file') !== id(up)) return false;
|
|
2678
|
+
// a file of a purpose the link's file parameter does not list is refused
|
|
2679
|
+
const logo = await h({ m: 'POST', p: '/v1/files', b: 'purpose=issuing_logo&file=logo.png' });
|
|
2680
|
+
if ((await h({ m: 'POST', p: '/v1/file_links', b: `file=${id(logo)}` })).status !== 400) return false;
|
|
1231
2681
|
const missing = await h({ m: 'POST', p: '/v1/file_links', b: 'expires_at=0' });
|
|
1232
2682
|
return missing.status === 400 && ((missing.body as Body).error as Body)?.code === 'parameter_missing';
|
|
1233
2683
|
}),
|
|
1234
2684
|
),
|
|
1235
2685
|
// (files.upload upgraded to done() in the AUDIT GROWTH block below.)
|
|
1236
|
-
// Identity VerificationReports:
|
|
1237
|
-
//
|
|
2686
|
+
// Identity VerificationReports: the person verifies on verify.stripe.com, which makes the report; then list
|
|
2687
|
+
// (filterable by verification_session) + retrieve. Unknown id 404.
|
|
1238
2688
|
done('stripe.identity.verification_reports', 'identity', 'Identity VerificationReports', 'api', 'niche', () =>
|
|
1239
|
-
withRoot(async (h) => {
|
|
2689
|
+
withRoot(async (h, root) => {
|
|
1240
2690
|
const vs = await h({ m: 'POST', p: '/v1/identity/verification_sessions', b: 'type=document' });
|
|
1241
|
-
|
|
2691
|
+
await hostedSubmit(root, stripeIdentityFlow, `https://verify.stripe.com/start/${id(vs)}`, { outcome: 'verified' });
|
|
2692
|
+
const ver = await h({ m: 'GET', p: `/v1/identity/verification_sessions/${id(vs)}` });
|
|
1242
2693
|
if (!ok(ver) || field(ver, 'status') !== 'verified') return false;
|
|
1243
2694
|
const reportId = field(ver, 'last_verification_report') as string;
|
|
1244
2695
|
if (!reportId?.startsWith('vr_')) return false;
|
|
@@ -1253,10 +2704,12 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1253
2704
|
),
|
|
1254
2705
|
|
|
1255
2706
|
// ── Events / Webhooks / Idempotency ───────────────────────────────────────────────
|
|
1256
|
-
done('stripe.events.list', 'events', 'Events:
|
|
2707
|
+
done('stripe.events.list', 'events', 'Events: a write records its event; retrieve + list', 'api', 'core', () =>
|
|
1257
2708
|
withRoot(async (h) => {
|
|
1258
|
-
|
|
1259
|
-
|
|
2709
|
+
await h({ m: 'POST', p: '/v1/customers', b: 'email=events@twin.test' });
|
|
2710
|
+
const first = (((await h({ m: 'GET', p: '/v1/events' })).body as Body).data as Body[])[0]!;
|
|
2711
|
+
const e = { status: 200, body: first } as StripeResponse;
|
|
2712
|
+
if (field(e, 'object') !== 'event' || field(e, 'type') !== 'customer.created') return false;
|
|
1260
2713
|
const g = await h({ m: 'GET', p: `/v1/events/${id(e)}` });
|
|
1261
2714
|
const l = await h({ m: 'GET', p: '/v1/events' });
|
|
1262
2715
|
return ok(g) && id(g) === id(e) && field(l, 'object') === 'list';
|
|
@@ -1284,7 +2737,17 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1284
2737
|
if (!report.ok || posts.length !== 1) return false;
|
|
1285
2738
|
// verified with the same scheme stripe.webhooks.constructEvent implements
|
|
1286
2739
|
const evt = constructEvent(posts[0]!.body, posts[0]!.headers['stripe-signature']!, secret);
|
|
1287
|
-
if (evt.type !== 'invoice.paid' || (evt.data.object as Body).id !== id(inv)) return false;
|
|
2740
|
+
if (evt.type !== 'invoice.paid' || (evt.data.object as Body).id !== id(inv) || 'account' in evt) return false;
|
|
2741
|
+
// a connected account's object re-fires as its event: a payout kept on its books carries the account
|
|
2742
|
+
// (at noon: an automatic payout, made at midnight, would take the funds first)
|
|
2743
|
+
const noon = (m: string, p: string, b?: string, acct?: string) => handleStripeTwinRequest({ method: m, path: p, body: b, root, occurredAt: '2026-08-01T12:00:00Z', ...(acct ? { stripeAccount: acct } : {}) });
|
|
2744
|
+
const seller = await onboardedSeller((s) => noon(s.m, s.p, s.b));
|
|
2745
|
+
await noon('POST', '/v1/transfers', `amount=5000¤cy=usd&destination=${seller}`);
|
|
2746
|
+
const po = await noon('POST', '/v1/payouts', 'amount=4000¤cy=usd', seller);
|
|
2747
|
+
await H('POST', '/v1/webhook_endpoints', `url=${encodeURIComponent('http://127.0.0.1:1/connect')}&enabled_events[0]=payout.*&connect=true`);
|
|
2748
|
+
const scoped: string[] = [];
|
|
2749
|
+
await emitTwinEvent(stripeEmitter, { type: 'payout.created', subjectId: id(po), root, fetchFn: async (_url, init) => { scoped.push(init.body); return { status: 200 }; } });
|
|
2750
|
+
if (scoped.length === 0 || !scoped.every((b) => (JSON.parse(b) as Body).account === seller)) return false;
|
|
1288
2751
|
// unknown subject → LOUD, listing what exists
|
|
1289
2752
|
return await emitTwinEvent(stripeEmitter, { type: 'invoice.paid', subjectId: 'in_twin_999', root }).then(() => false, (e: unknown) => /no invoice "in_twin_999"/.test(String(e)));
|
|
1290
2753
|
});
|
|
@@ -1412,7 +2875,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1412
2875
|
withRoot(async (h) => {
|
|
1413
2876
|
const cus = await h({ m: 'POST', p: '/v1/customers', b: 'email=ev@example.com' });
|
|
1414
2877
|
if (!ok(cus)) return false;
|
|
1415
|
-
const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=2000¤cy=usd' });
|
|
2878
|
+
const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=2000¤cy=usd&transfer_group=inv_42' });
|
|
1416
2879
|
const conf = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/confirm`, b: 'payment_method=pm_card_visa' });
|
|
1417
2880
|
const pm = await h({ m: 'POST', p: '/v1/payment_methods', b: 'type=card&card[number]=4242424242424242&card[exp_month]=4&card[exp_year]=2030' });
|
|
1418
2881
|
const att = await h({ m: 'POST', p: `/v1/payment_methods/${id(pm)}/attach`, b: `customer=${id(cus)}` });
|
|
@@ -1421,8 +2884,11 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1421
2884
|
if (!ok(list) || (list.body as Body).object !== 'list') return false;
|
|
1422
2885
|
const events = (list.body as Body).data as Array<Record<string, unknown>>;
|
|
1423
2886
|
const types = new Set(events.map((e) => e.type as string));
|
|
1424
|
-
const wanted = ['customer.created', 'payment_intent.created', 'payment_intent.succeeded', 'payment_method.attached'];
|
|
1425
|
-
if (!wanted.every((t) => types.has(t))) return false;
|
|
2887
|
+
const wanted = ['customer.created', 'payment_intent.created', 'payment_intent.succeeded', 'charge.succeeded', 'payment_method.attached'];
|
|
2888
|
+
if (!wanted.every((t) => types.has(t)) || types.has('charge.created')) return false;
|
|
2889
|
+
// the payment's charge is the event's object and carries the intent's transfer_group (Dub reads its payout invoice from it)
|
|
2890
|
+
const charged = (events.find((e) => e.type === 'charge.succeeded')?.data as Body)?.object as Body | undefined;
|
|
2891
|
+
if (charged?.object !== 'charge' || charged.payment_intent !== id(pi) || charged.transfer_group !== 'inv_42') return false;
|
|
1426
2892
|
// envelope shape: object 'event', carries the resource snapshot under data.object
|
|
1427
2893
|
const succeeded = events.find((e) => e.type === 'payment_intent.succeeded');
|
|
1428
2894
|
if (!succeeded || succeeded.object !== 'event' || ((succeeded.data as Body)?.object as Body)?.status !== 'succeeded') return false;
|
|
@@ -1447,8 +2913,8 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1447
2913
|
const e1 = await h({ m: 'GET', p: `/v1/payment_intents/${id(pi)}?expand[]=customer` });
|
|
1448
2914
|
if (!ok(e1) || ((field(e1, 'customer') as Body)?.object) !== 'customer' || (field(e1, 'customer') as Body)?.id !== id(cust)) return false;
|
|
1449
2915
|
// a charge whose customer + payment_intent chain expands deeply (charge.payment_intent.customer).
|
|
1450
|
-
const ch = await h({ m: 'POST', p:
|
|
1451
|
-
const e2 = await h({ m: 'GET', p: `/v1/charges/${
|
|
2916
|
+
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` });
|
|
2917
|
+
const e2 = await h({ m: 'GET', p: `/v1/charges/${String(field(ch, 'latest_charge'))}?expand[]=payment_intent.customer` });
|
|
1452
2918
|
const expandedPi = field(e2, 'payment_intent') as Body;
|
|
1453
2919
|
if (!expandedPi || expandedPi.object !== 'payment_intent' || (expandedPi.customer as Body)?.id !== id(cust)) return false;
|
|
1454
2920
|
// list-level expand (expand[]=data.customer) expands the field on every row.
|
|
@@ -1511,54 +2977,63 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1511
2977
|
const events = await handleStripeTwinRequest({ method: 'GET', path: '/v1/events', root });
|
|
1512
2978
|
const ev = ((events.body as Body).data as Body[]).find((e) => e.type === 'payment_intent.created');
|
|
1513
2979
|
if (!ev || ev.api_version !== '2022-11-15') return false;
|
|
1514
|
-
// no version → the
|
|
2980
|
+
// no version → the version the pack serves, its vendored spec's (stripe-version.ts SERVED_VERSION; "answers
|
|
2981
|
+
// render in the served API version", 24e757f60), as a request that pins none gets the account's
|
|
1515
2982
|
const pi2 = await handleStripeTwinRequest({ method: 'POST', path: '/v1/payment_intents', body: 'amount=500¤cy=usd', root });
|
|
1516
2983
|
const ev2 = (((await handleStripeTwinRequest({ method: 'GET', path: '/v1/events', root })).body as Body).data as Body[])
|
|
1517
2984
|
.find((e) => e.type === 'payment_intent.created' && ((e.data as Body)?.object as Body)?.id === id(pi2));
|
|
1518
|
-
return ev2 ? ev2.api_version === '
|
|
2985
|
+
return ev2 ? ev2.api_version === SERVED_VERSION && SERVED_VERSION > '2025-03-31' : false;
|
|
1519
2986
|
} catch (err) { if (isInfrastructureError(err)) throw harnessError('stripe.api.versioning', err); return false; } finally { rmSync(root, { recursive: true, force: true }); }
|
|
1520
2987
|
}),
|
|
1521
2988
|
|
|
1522
2989
|
// ── Test helpers ──────────────────────────────────────────────────────────────────
|
|
1523
2990
|
// Test clocks: create a clock at a frozen_time, then advance it; advancing past a trialing
|
|
1524
2991
|
// subscription's trial_end transitions that sub trialing→active (the real clock-tick effect).
|
|
1525
|
-
// Advancing backwards 400s; missing frozen_time 400s; delete
|
|
1526
|
-
//
|
|
2992
|
+
// Advancing backwards 400s; advancing while advancing 400s test_clock_not_ready; missing frozen_time 400s; delete
|
|
2993
|
+
// removes it.
|
|
1527
2994
|
done('stripe.test_clocks', 'test-helpers', 'Test clocks (advance time for subscription/invoice)', 'api', 'common', () =>
|
|
1528
|
-
withRoot(async (h) => {
|
|
2995
|
+
withRoot(async (h, root) => {
|
|
1529
2996
|
const clock = await h({ m: 'POST', p: '/v1/test_helpers/test_clocks', b: 'frozen_time=1000&name=T' });
|
|
1530
2997
|
if (!ok(clock) || field(clock, 'object') !== 'test_helpers.test_clock' || field(clock, 'frozen_time') !== 1000) return false;
|
|
1531
|
-
// a trialing subscription whose trial_end is 5000
|
|
1532
|
-
|
|
2998
|
+
// a trialing subscription whose trial_end is 5000, for a customer ON the clock: a clock moves only what is attached
|
|
2999
|
+
// to it ("create a customer with the test_clock parameter", docs.stripe.com/billing/testing/test-clocks/api-advanced-usage),
|
|
3000
|
+
// and a trial_end is refused unless it is in the future (subscriptions.ts trialEndRefused) — for a clock's customer,
|
|
3001
|
+
// the clock's future. The customer was left off the clock when this was written, which its 1970 trial_end cannot survive.
|
|
3002
|
+
const cust = await h({ m: 'POST', p: '/v1/customers', b: `email=clock@twin.test&test_clock=${id(clock)}` });
|
|
1533
3003
|
const sub = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&trial_end=5000` });
|
|
1534
3004
|
if (field(sub, 'status') !== 'trialing') return false;
|
|
1535
|
-
// advancing PAST trial_end
|
|
3005
|
+
// advancing PAST trial_end: the clock advances for a few seconds (a second advance meanwhile is refused), then the
|
|
3006
|
+
// trial has ended and the sub is active
|
|
1536
3007
|
const adv = await h({ m: 'POST', p: `/v1/test_helpers/test_clocks/${id(clock)}/advance`, b: 'frozen_time=6000' });
|
|
1537
|
-
if (!ok(adv) || field(adv, '
|
|
1538
|
-
const
|
|
3008
|
+
if (!ok(adv) || field(adv, 'status') !== 'advancing') return false;
|
|
3009
|
+
const busy = await h({ m: 'POST', p: `/v1/test_helpers/test_clocks/${id(clock)}/advance`, b: 'frozen_time=7000' });
|
|
3010
|
+
if (busy.status !== 400 || ((busy.body as Body).error as Body)?.code !== 'test_clock_not_ready') return false;
|
|
3011
|
+
const later = new Date(Date.now() + 10_000).toISOString();
|
|
3012
|
+
const subAfter = await handleStripeTwinRequest({ method: 'GET', path: `/v1/subscriptions/${id(sub)}`, root, occurredAt: later });
|
|
1539
3013
|
if (field(subAfter, 'status') !== 'active') return false;
|
|
1540
|
-
const back = await
|
|
3014
|
+
const back = await handleStripeTwinRequest({ method: 'POST', path: `/v1/test_helpers/test_clocks/${id(clock)}/advance`, body: 'frozen_time=1', root, occurredAt: later });
|
|
1541
3015
|
const noTime = await h({ m: 'POST', p: '/v1/test_helpers/test_clocks' });
|
|
1542
3016
|
const del = await h({ m: 'DELETE', p: `/v1/test_helpers/test_clocks/${id(clock)}` });
|
|
1543
3017
|
const gone = await h({ m: 'GET', p: `/v1/test_helpers/test_clocks/${id(clock)}` });
|
|
1544
3018
|
return back.status === 400 && noTime.status === 400 && ok(del) && field(del, 'deleted') === true && gone.status === 404;
|
|
1545
3019
|
}),
|
|
1546
3020
|
),
|
|
1547
|
-
//
|
|
3021
|
+
// Issuing: a top-up to the Issuing balance funds it; the test-helper endpoints present an
|
|
1548
3022
|
// authorization (the test-mode way to simulate card usage) → a 'pending' authorization
|
|
1549
3023
|
// awaiting approve/decline; create_force_capture lands a settled transaction directly.
|
|
1550
3024
|
// Missing required params 400; unknown card 400. (Exercised together with the issuing family.)
|
|
1551
|
-
done('stripe.test_helpers.issuing', 'test-helpers', 'Test-helper endpoints (
|
|
1552
|
-
withRoot(async (h) => {
|
|
1553
|
-
const fund = await
|
|
1554
|
-
|
|
3025
|
+
done('stripe.test_helpers.issuing', 'test-helpers', 'Test-helper endpoints (present authorization, force capture), on an Issuing balance a top-up funded', 'api', 'niche', () =>
|
|
3026
|
+
withRoot(async (h, root) => {
|
|
3027
|
+
const fund = await fundIssuing(root, 100000);
|
|
3028
|
+
const bal = await h({ m: 'GET', p: '/v1/balance' });
|
|
3029
|
+
if (!ok(fund) || ((field(bal, 'issuing') as Body)?.available as Body[])[0]?.amount !== 100000) return false;
|
|
1555
3030
|
const ch = await h({ m: 'POST', p: '/v1/issuing/cardholders', b: 'name=Jane&type=individual&billing[address][line1]=1 Main&billing[address][city]=SF&billing[address][country]=US&billing[address][postal_code]=94105&billing[address][state]=CA' });
|
|
1556
3031
|
const card = await h({ m: 'POST', p: `/v1/issuing/cards`, b: `cardholder=${id(ch)}¤cy=usd&type=virtual` });
|
|
1557
3032
|
const auth = await h({ m: 'POST', p: '/v1/test_helpers/issuing/authorizations', b: `card=${id(card)}&amount=2500` });
|
|
1558
3033
|
if (!ok(auth) || field(auth, 'status') !== 'pending' || (field(auth, 'card') as Body)?.id !== id(card) || field(auth, 'cardholder') !== id(ch)) return false;
|
|
1559
3034
|
const forced = await h({ m: 'POST', p: '/v1/test_helpers/issuing/transactions/create_force_capture', b: `card=${id(card)}&amount=1000` });
|
|
1560
3035
|
if (!ok(forced) || field(forced, 'type') !== 'capture' || field(forced, 'amount') !== -1000) return false;
|
|
1561
|
-
const noAmt = await h({ m: 'POST', p: '/v1/
|
|
3036
|
+
const noAmt = await h({ m: 'POST', p: '/v1/topups', b: 'currency=usd&destination_balance=issuing' });
|
|
1562
3037
|
const badCard = await h({ m: 'POST', p: '/v1/test_helpers/issuing/authorizations', b: 'card=ic_nope&amount=100' });
|
|
1563
3038
|
return noAmt.status === 400 && badCard.status === 400;
|
|
1564
3039
|
}),
|
|
@@ -1611,20 +3086,18 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1611
3086
|
// the running mirror server → render the mirror's OWN ListPane over both → assert one
|
|
1612
3087
|
// list-row per seeded object carrying the seeded name. Failable: an empty workspace yields
|
|
1613
3088
|
// zero rows → zero list-rows.
|
|
1614
|
-
|
|
1615
|
-
|
|
3089
|
+
// Tax Rates only: Stripe publishes no list of tax calculations (a calculation is retrieved by id), so the mirror's
|
|
3090
|
+
// Tax Calculations screen, which read the invented GET /v1/tax/calculations, left with it (5decb1d77).
|
|
3091
|
+
done('stripe.ui.tax', 'ui-dashboard', 'Dashboard: Tax (Tax Rates) screen (data-coupled)', 'ui', 'common', uiDataCoupled({
|
|
3092
|
+
markers: ['Tax Rates', 'list-row'],
|
|
1616
3093
|
seed: async (h) => {
|
|
1617
3094
|
await h({ m: 'POST', p: '/v1/tax_rates', b: 'display_name=UI Check Tax&percentage=12.5&inclusive=false&jurisdiction=US' });
|
|
1618
|
-
await h({ m: 'POST', p: '/v1/tax/calculations', b: 'currency=usd&line_items[0][amount]=1000&line_items[0][reference]=ui_check_sku&customer_details[address][country]=US' });
|
|
1619
3095
|
},
|
|
1620
3096
|
check: async ({ get }) => {
|
|
1621
3097
|
const rates = ((await get('/v1/tax_rates')).data as Body[]) as unknown as StripeRow[];
|
|
1622
|
-
|
|
1623
|
-
if (!rates.length || !calcs.length) return false;
|
|
3098
|
+
if (!rates.length) return false;
|
|
1624
3099
|
const ratesMarkup = renderSectionList('tax_rates', rates);
|
|
1625
|
-
|
|
1626
|
-
return listRows(ratesMarkup) === rates.length && ratesMarkup.includes('UI Check Tax') &&
|
|
1627
|
-
listRows(calcsMarkup) === calcs.length;
|
|
3100
|
+
return listRows(ratesMarkup) === rates.length && ratesMarkup.includes('UI Check Tax');
|
|
1628
3101
|
},
|
|
1629
3102
|
})),
|
|
1630
3103
|
|
|
@@ -1665,18 +3138,27 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1665
3138
|
((l.body as Body).data as Body[]).length === 2 && badCh.status === 400 && badStatus.status === 400;
|
|
1666
3139
|
}),
|
|
1667
3140
|
),
|
|
1668
|
-
// Authorizations (+approve/decline): an authorization is PRESENTED via the test helper (pending)
|
|
1669
|
-
//
|
|
1670
|
-
// closes it (approved:false).
|
|
3141
|
+
// Authorizations (+approve/decline): an authorization is PRESENTED via the test helper (pending);
|
|
3142
|
+
// /approve keeps it pending (approved:true, funds held) until the merchant's capture closes it and
|
|
3143
|
+
// makes the transaction; /decline closes it (approved:false). Deciding twice 400s; unknown id 404.
|
|
1671
3144
|
done('stripe.issuing.authorizations', 'issuing', 'Issuing Authorizations (+approve/decline)', 'api', 'niche', () =>
|
|
1672
|
-
withRoot(async (h) => {
|
|
3145
|
+
withRoot(async (h, root) => {
|
|
3146
|
+
await fundIssuing(root, 10000000); // the Issuing balance authorizations are paid from
|
|
1673
3147
|
const ch = await h({ m: 'POST', p: '/v1/issuing/cardholders', b: 'name=Jane&type=individual&billing[address][country]=US' });
|
|
1674
3148
|
const card = await h({ m: 'POST', p: `/v1/issuing/cards`, b: `cardholder=${id(ch)}¤cy=usd&type=virtual` });
|
|
3149
|
+
// with no real-time endpoint an authorization is approved at once (card_active)
|
|
3150
|
+
const plain = await h({ m: 'POST', p: '/v1/test_helpers/issuing/authorizations', b: `card=${id(card)}&amount=1000` });
|
|
3151
|
+
if (field(plain, 'approved') !== true || ((field(plain, 'request_history') as Body[])?.[0] as Body | undefined)?.reason !== 'card_active') return false;
|
|
3152
|
+
// an endpoint that does not answer leaves it pending for the deprecated approve and decline
|
|
3153
|
+
await h({ m: 'POST', p: '/v1/webhook_endpoints', b: `url=${encodeURIComponent('https://rt.twin.test/auth')}&enabled_events[]=issuing_authorization.request` });
|
|
1675
3154
|
const auth = await h({ m: 'POST', p: '/v1/test_helpers/issuing/authorizations', b: `card=${id(card)}&amount=3000` });
|
|
1676
3155
|
if (field(auth, 'status') !== 'pending') return false;
|
|
1677
3156
|
const appr = await h({ m: 'POST', p: `/v1/issuing/authorizations/${id(auth)}/approve` });
|
|
1678
|
-
if (!ok(appr) || field(appr, 'status') !== '
|
|
1679
|
-
//
|
|
3157
|
+
if (!ok(appr) || field(appr, 'status') !== 'pending' || field(appr, 'approved') !== true) return false;
|
|
3158
|
+
// nothing is transacted until the merchant captures
|
|
3159
|
+
if ((((await h({ m: 'GET', p: '/v1/issuing/transactions' })).body as Body).data as Body[]).length !== 0) return false;
|
|
3160
|
+
const cap = await h({ m: 'POST', p: `/v1/test_helpers/issuing/authorizations/${id(auth)}/capture` });
|
|
3161
|
+
if (!ok(cap) || field(cap, 'status') !== 'closed') return false;
|
|
1680
3162
|
const txns = await h({ m: 'GET', p: '/v1/issuing/transactions' });
|
|
1681
3163
|
const txnData = (txns.body as Body).data as Body[];
|
|
1682
3164
|
if (txnData.length !== 1 || txnData[0]!.authorization !== id(auth) || txnData[0]!.amount !== -3000) return false;
|
|
@@ -1696,13 +3178,20 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1696
3178
|
// retrieved/updated and disputed. A dispute requires transaction + evidence[reason]; /submit
|
|
1697
3179
|
// transitions unsubmitted→submitted; submitting twice 400s; unknown transaction 400.
|
|
1698
3180
|
done('stripe.issuing.transactions', 'issuing', 'Issuing Transactions + disputes', 'api', 'niche', () =>
|
|
1699
|
-
withRoot(async (h) => {
|
|
3181
|
+
withRoot(async (h, root) => {
|
|
3182
|
+
await fundIssuing(root, 10000000); // the Issuing balance authorizations are paid from
|
|
1700
3183
|
const ch = await h({ m: 'POST', p: '/v1/issuing/cardholders', b: 'name=Jane&type=individual&billing[address][country]=US' });
|
|
1701
3184
|
const card = await h({ m: 'POST', p: `/v1/issuing/cards`, b: `cardholder=${id(ch)}¤cy=usd&type=virtual` });
|
|
3185
|
+
// with no real-time webhook the authorization is approved at once ("If you don't have a real-time authorization
|
|
3186
|
+
// webhook, we approve the authorization without sending the issuing_authorization.request",
|
|
3187
|
+
// docs.stripe.com/issuing/purchases/authorizations), so the approve API refuses it as already decided; the merchant's
|
|
3188
|
+
// capture (the test helper) is what makes the transaction
|
|
1702
3189
|
const auth = await h({ m: 'POST', p: '/v1/test_helpers/issuing/authorizations', b: `card=${id(card)}&amount=4200` });
|
|
1703
|
-
await h({ m: 'POST', p: `/v1/issuing/authorizations/${id(auth)}/approve` });
|
|
3190
|
+
if (field(auth, 'approved') !== true || (await h({ m: 'POST', p: `/v1/issuing/authorizations/${id(auth)}/approve` })).status !== 400) return false;
|
|
3191
|
+
await h({ m: 'POST', p: `/v1/test_helpers/issuing/authorizations/${id(auth)}/capture` });
|
|
1704
3192
|
const txns = await h({ m: 'GET', p: '/v1/issuing/transactions' });
|
|
1705
3193
|
const txn = ((txns.body as Body).data as Body[])[0]!;
|
|
3194
|
+
if (((txns.body as Body).data as Body[]).length !== 1 || txn.amount !== -4200 || txn.authorization !== id(auth)) return false;
|
|
1706
3195
|
const g = await h({ m: 'GET', p: `/v1/issuing/transactions/${txn.id}` });
|
|
1707
3196
|
if (!ok(g) || field(g, 'object') !== 'issuing.transaction') return false;
|
|
1708
3197
|
const u = await h({ m: 'POST', p: `/v1/issuing/transactions/${txn.id}`, b: 'metadata[note]=checked' });
|
|
@@ -1729,6 +3218,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1729
3218
|
const root = mkdtempSync(join(tmpdir(), 'stp-cap-'));
|
|
1730
3219
|
try {
|
|
1731
3220
|
const h = (s: Step) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: '2026-06-14T00:00:00Z' });
|
|
3221
|
+
await fundIssuing(root, 10000000, '2026-06-14T00:00:00Z'); // the Issuing balance authorizations are paid from
|
|
1732
3222
|
const delivered: StripeEvent[] = [];
|
|
1733
3223
|
setStripeEventDelivery((_url, event) => {
|
|
1734
3224
|
delivered.push(event);
|
|
@@ -1759,6 +3249,9 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1759
3249
|
const root2 = mkdtempSync(join(tmpdir(), 'stp-cap-'));
|
|
1760
3250
|
try {
|
|
1761
3251
|
const h2 = (s: Step) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root: root2, occurredAt: '2026-06-14T00:00:00Z' });
|
|
3252
|
+
// funded as the first World is: an authorization the Issuing balance cannot cover is declined insufficient_funds
|
|
3253
|
+
// (docs.stripe.com/issuing/funding/balance), which would decide it before any endpoint could be consulted
|
|
3254
|
+
await fundIssuing(root2, 10000000, '2026-06-14T00:00:00Z');
|
|
1762
3255
|
let consulted = false;
|
|
1763
3256
|
setStripeEventDelivery((_url, event) => {
|
|
1764
3257
|
if (event.type === 'issuing_authorization.request') consulted = true;
|
|
@@ -1767,8 +3260,14 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1767
3260
|
const ch2 = await h2({ m: 'POST', p: '/v1/issuing/cardholders', b: 'name=RT2&type=individual&billing[address][country]=US' });
|
|
1768
3261
|
const card2 = await h2({ m: 'POST', p: '/v1/issuing/cards', b: `cardholder=${id(ch2)}¤cy=usd&type=virtual` });
|
|
1769
3262
|
await h2({ m: 'POST', p: '/v1/webhook_endpoints', b: `url=${encodeURIComponent('https://other.twin.test/hook')}&enabled_events[]=invoice.paid` });
|
|
3263
|
+
// ...and, not enrolled, it is approved at once by the account's default settings: "If you don't have a real-time
|
|
3264
|
+
// authorization webhook, we approve the authorization without sending the issuing_authorization.request"
|
|
3265
|
+
// (docs.stripe.com/issuing/purchases/authorizations; semantics/issuing.ts, reason card_active). It used to stay
|
|
3266
|
+
// pending for the approve API, which is the deprecated path.
|
|
1770
3267
|
const auth2 = await h2({ m: 'POST', p: '/v1/test_helpers/issuing/authorizations', b: `card=${id(card2)}&amount=100` });
|
|
1771
|
-
|
|
3268
|
+
const history2 = (field(auth2, 'request_history') as Body[] | undefined) ?? [];
|
|
3269
|
+
return !consulted && field(auth2, 'status') === 'pending' && field(auth2, 'approved') === true
|
|
3270
|
+
&& history2.length === 1 && history2[0]!.reason === 'card_active';
|
|
1772
3271
|
} finally {
|
|
1773
3272
|
rmSync(root2, { recursive: true, force: true });
|
|
1774
3273
|
}
|
|
@@ -1781,15 +3280,16 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1781
3280
|
rmSync(root, { recursive: true, force: true });
|
|
1782
3281
|
}
|
|
1783
3282
|
}),
|
|
1784
|
-
// Real-time authorization fallbacks (the 2s
|
|
1785
|
-
//
|
|
1786
|
-
// reason webhook_timeout; an invalid response (non-2xx, non-JSON, missing `approved`)
|
|
3283
|
+
// Real-time authorization fallbacks (the 2s window): a webhook decline closes the
|
|
3284
|
+
// authorization (reason webhook_declined); a TIMEOUT leaves it pending for the deprecated
|
|
3285
|
+
// approve/decline until the window ends, then declines it with reason webhook_timeout; an invalid response (non-2xx, non-JSON, missing `approved`)
|
|
1787
3286
|
// declines with reason webhook_error + a reason_message; a partial `amount` in the
|
|
1788
3287
|
// response is held ONLY when the presentment was is_amount_controllable.
|
|
1789
|
-
done('stripe.issuing.realtime_auth_fallbacks', 'issuing', 'Real-time authorization: decline honored;
|
|
3288
|
+
done('stripe.issuing.realtime_auth_fallbacks', 'issuing', 'Real-time authorization: decline honored; unanswered → pending, approve in the window, webhook_timeout after it; invalid response → webhook_error; partial amount only when controllable', 'api', 'niche', async () => {
|
|
1790
3289
|
const root = mkdtempSync(join(tmpdir(), 'stp-cap-'));
|
|
1791
3290
|
try {
|
|
1792
3291
|
const h = (s: Step) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: '2026-06-14T00:00:00Z' });
|
|
3292
|
+
await fundIssuing(root, 10000000, '2026-06-14T00:00:00Z'); // the Issuing balance authorizations are paid from
|
|
1793
3293
|
const ch = await h({ m: 'POST', p: '/v1/issuing/cardholders', b: 'name=FB&type=individual&billing[address][country]=US' });
|
|
1794
3294
|
const card = await h({ m: 'POST', p: '/v1/issuing/cards', b: `cardholder=${id(ch)}¤cy=usd&type=virtual` });
|
|
1795
3295
|
await h({ m: 'POST', p: '/v1/webhook_endpoints', b: `url=${encodeURIComponent('https://rt.twin.test/auth')}&enabled_events[]=issuing_authorization.request` });
|
|
@@ -1805,10 +3305,17 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1805
3305
|
respond({ status: 200, body: '{"approved":false}' });
|
|
1806
3306
|
const dec = await present(`card=${id(card)}&amount=900`);
|
|
1807
3307
|
if (field(dec, 'approved') !== false || field(dec, 'status') !== 'closed' || reasonOf(dec).reason !== 'webhook_declined') return false;
|
|
1808
|
-
//
|
|
3308
|
+
// an unanswered request (the exact TimeoutError AbortSignal.timeout raises) is no decision: pending with its request
|
|
1809
3309
|
respond(() => { const e = new Error('timed out'); e.name = 'TimeoutError'; throw e; });
|
|
1810
3310
|
const to = await present(`card=${id(card)}&amount=900`);
|
|
1811
|
-
if (field(to, '
|
|
3311
|
+
if (field(to, 'status') !== 'pending' || field(to, 'approved') !== false || !field(to, 'pending_request')) return false;
|
|
3312
|
+
// the deprecated approve inside the window decides it
|
|
3313
|
+
const held = await present(`card=${id(card)}&amount=800`);
|
|
3314
|
+
const approved = await h({ m: 'POST', p: `/v1/issuing/authorizations/${id(held)}/approve` });
|
|
3315
|
+
if (field(approved, 'approved') !== true || reasonOf(approved).reason !== 'webhook_approved') return false;
|
|
3316
|
+
// once the 2-second window has passed, the one left unanswered is declined with reason webhook_timeout
|
|
3317
|
+
const later = await handleStripeTwinRequest({ method: 'GET', path: `/v1/issuing/authorizations/${id(to)}`, root, occurredAt: '2026-06-14T00:00:03Z' });
|
|
3318
|
+
if (field(later, 'status') !== 'closed' || field(later, 'approved') !== false || reasonOf(later).reason !== 'webhook_timeout') return false;
|
|
1812
3319
|
// invalid responses → webhook_error with a reason_message
|
|
1813
3320
|
for (const bad of [{ status: 500, body: 'oops' }, { status: 200, body: 'not-json' }, { status: 200, body: '{"ok":true}' }]) {
|
|
1814
3321
|
respond(bad);
|
|
@@ -1838,6 +3345,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1838
3345
|
const root = mkdtempSync(join(tmpdir(), 'stp-cap-'));
|
|
1839
3346
|
try {
|
|
1840
3347
|
const h = (s: Step) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: '2026-06-14T00:00:00Z' });
|
|
3348
|
+
await fundIssuing(root, 10000000, '2026-06-14T00:00:00Z'); // the Issuing balance authorizations are paid from
|
|
1841
3349
|
setStripeEventDelivery((_url, event) => {
|
|
1842
3350
|
if (event.type === 'issuing_authorization.request') return { status: 200, body: '{"approved":true}' };
|
|
1843
3351
|
});
|
|
@@ -1876,7 +3384,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1876
3384
|
rmSync(root, { recursive: true, force: true });
|
|
1877
3385
|
}
|
|
1878
3386
|
}),
|
|
1879
|
-
// Capture debits the issuing balance:
|
|
3387
|
+
// Capture debits the issuing balance: a top-up to Issuing funds the float, a webhook-approved
|
|
1880
3388
|
// authorization is captured via POST /v1/test_helpers/issuing/authorizations/:id/capture
|
|
1881
3389
|
// (partial capture_amount honored, refused above the held amount / on a non-approved or
|
|
1882
3390
|
// closed authorization), the materialized transaction is type 'capture' with a NEGATIVE
|
|
@@ -1897,8 +3405,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1897
3405
|
const ch = await h({ m: 'POST', p: '/v1/issuing/cardholders', b: 'name=Cap&type=individual&billing[address][country]=US' });
|
|
1898
3406
|
const card = await h({ m: 'POST', p: '/v1/issuing/cards', b: `cardholder=${id(ch)}¤cy=usd&type=virtual` });
|
|
1899
3407
|
await h({ m: 'POST', p: '/v1/webhook_endpoints', b: `url=${encodeURIComponent('https://rt.twin.test/auth')}&enabled_events[]=issuing_authorization.request` });
|
|
1900
|
-
|
|
1901
|
-
if (((field(fund, 'issuing') as Body).available as Body[])[0]!.amount !== 100000) return false;
|
|
3408
|
+
await fundIssuing(root, 100000, '2026-06-14T00:00:00Z');
|
|
1902
3409
|
if (await issuingAvailable() !== 100000) return false;
|
|
1903
3410
|
const auth = await h({ m: 'POST', p: '/v1/test_helpers/issuing/authorizations', b: `card=${id(card)}&amount=30000` });
|
|
1904
3411
|
if (field(auth, 'approved') !== true) return false;
|
|
@@ -1941,6 +3448,29 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1941
3448
|
// Readers (+process_payment_intent): a reader registers with a registration_code (status
|
|
1942
3449
|
// 'online'); handing it a PaymentIntent sets reader.action to process_payment_intent
|
|
1943
3450
|
// in_progress; cancel_action clears it. Connection tokens mint a secret. Missing code 400.
|
|
3451
|
+
// A simulated reader processing a PaymentIntent is presented a card by the test helper: the intent is paid and the
|
|
3452
|
+
// reader's action succeeds (docs.stripe.com/api/terminal/readers/present_payment_method).
|
|
3453
|
+
done('stripe.terminal.present_payment_method', 'terminal', 'Terminal test helper: present a card to a reader processing a PaymentIntent', 'api', 'common', () =>
|
|
3454
|
+
withRoot(async (h) => {
|
|
3455
|
+
const loc = await h({ m: 'POST', p: '/v1/terminal/locations', b: 'display_name=Market&address[line1]=1 Main&address[city]=Portland&address[state]=OR&address[postal_code]=97201&address[country]=US' });
|
|
3456
|
+
const rdr = await h({ m: 'POST', p: '/v1/terminal/readers', b: `registration_code=simulated-wpe&location=${id(loc)}` });
|
|
3457
|
+
const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=2400¤cy=usd&allowed_payment_method_types[0]=card_present' });
|
|
3458
|
+
await h({ m: 'POST', p: `/v1/terminal/readers/${id(rdr)}/process_payment_intent`, b: `payment_intent=${id(pi)}` });
|
|
3459
|
+
const done = await h({ m: 'POST', p: `/v1/test_helpers/terminal/readers/${id(rdr)}/present_payment_method` });
|
|
3460
|
+
const paid = await h({ m: 'GET', p: `/v1/payment_intents/${id(pi)}` });
|
|
3461
|
+
// under manual capture the card holds the amount as an uncaptured charge, which the intent's capture takes
|
|
3462
|
+
const held = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=1800¤cy=usd&capture_method=manual&allowed_payment_method_types[0]=card_present' });
|
|
3463
|
+
await h({ m: 'POST', p: `/v1/terminal/readers/${id(rdr)}/process_payment_intent`, b: `payment_intent=${id(held)}` });
|
|
3464
|
+
await h({ m: 'POST', p: `/v1/test_helpers/terminal/readers/${id(rdr)}/present_payment_method` });
|
|
3465
|
+
const hold = await h({ m: 'GET', p: `/v1/payment_intents/${id(held)}` });
|
|
3466
|
+
const auth = await h({ m: 'GET', p: `/v1/charges/${String(field(hold, 'latest_charge'))}` });
|
|
3467
|
+
const cap = await h({ m: 'POST', p: `/v1/payment_intents/${id(held)}/capture` });
|
|
3468
|
+
const taken = await h({ m: 'GET', p: `/v1/charges/${String(field(hold, 'latest_charge'))}` });
|
|
3469
|
+
return ((field(done, 'action') as Body)?.status === 'succeeded') && field(paid, 'status') === 'succeeded' && typeof field(paid, 'latest_charge') === 'string'
|
|
3470
|
+
&& field(hold, 'status') === 'requires_capture' && field(auth, 'captured') === false && field(auth, 'amount') === 1800
|
|
3471
|
+
&& field(cap, 'latest_charge') === id(auth) && field(taken, 'captured') === true && field(taken, 'amount_captured') === 1800;
|
|
3472
|
+
}),
|
|
3473
|
+
),
|
|
1944
3474
|
done('stripe.terminal.readers', 'terminal', 'Terminal Readers (+process_payment_intent)', 'api', 'niche', () =>
|
|
1945
3475
|
withRoot(async (h) => {
|
|
1946
3476
|
const loc = await h({ m: 'POST', p: '/v1/terminal/locations', b: 'display_name=HQ&address[country]=US&address[line1]=1 Main&address[city]=SF&address[postal_code]=94105&address[state]=CA' });
|
|
@@ -1989,10 +3519,17 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1989
3519
|
// not a public create). open is the only approvable state → /approve closes it (open:false,
|
|
1990
3520
|
// closed_reason 'approved'); a second approve 400s. retrieve + list round-trip; unknown id 404.
|
|
1991
3521
|
// (the open→closed approve transition is produced ONLY by this feature.)
|
|
3522
|
+
// A review is opened by Radar, never by the API (Stripe's API has no review create; the twin's seeding route for it was
|
|
3523
|
+
// removed with the invented endpoints, 5decb1d77): an elevated-risk card's charge is placed in review
|
|
3524
|
+
// (docs.stripe.com/radar/reviews; the test card 4000000000009235, pm_card_riskLevelElevated, docs.stripe.com/testing).
|
|
1992
3525
|
done('stripe.radar.reviews', 'radar', 'Radar Reviews (approve/list)', 'api', 'niche', () =>
|
|
1993
3526
|
withRoot(async (h) => {
|
|
1994
|
-
const
|
|
1995
|
-
if (!ok(
|
|
3527
|
+
const risky = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=2000¤cy=usd&payment_method=pm_card_riskLevelElevated&confirm=true' });
|
|
3528
|
+
if (!ok(risky) || field(risky, 'status') !== 'succeeded') return false;
|
|
3529
|
+
const opened = (((await h({ m: 'GET', p: '/v1/reviews' })).body as Body).data as Body[]) ?? [];
|
|
3530
|
+
if (opened.length !== 1) return false;
|
|
3531
|
+
const rv = await h({ m: 'GET', p: `/v1/reviews/${String(opened[0]!.id)}` });
|
|
3532
|
+
if (!ok(rv) || field(rv, 'object') !== 'review' || field(rv, 'open') !== true || field(rv, 'payment_intent') !== id(risky)) return false;
|
|
1996
3533
|
const g = await h({ m: 'GET', p: `/v1/reviews/${id(rv)}` });
|
|
1997
3534
|
const l = await h({ m: 'GET', p: '/v1/reviews' });
|
|
1998
3535
|
if (!ok(g) || id(g) !== id(rv) || field(l, 'object') !== 'list') return false;
|
|
@@ -2029,83 +3566,58 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2029
3566
|
noAlias.status === 400 && badList.status === 400 && noValue.status === 400;
|
|
2030
3567
|
}),
|
|
2031
3568
|
),
|
|
2032
|
-
// Radar
|
|
2033
|
-
//
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
const badAction = await h({ m: 'POST', p: '/v1/radar/rules', b: 'action=nuke&predicate=x' });
|
|
2045
|
-
const noPred = await h({ m: 'POST', p: '/v1/radar/rules', b: 'action=block' });
|
|
2046
|
-
return ok(del) && field(del, 'deleted') === true && gone.status === 404 &&
|
|
2047
|
-
noAction.status === 400 && badAction.status === 400 && noPred.status === 400;
|
|
2048
|
-
}),
|
|
2049
|
-
),
|
|
2050
|
-
// Dashboard: Radar (Reviews + Value Lists + Rules) screens — rendered surfaces in the mirror.
|
|
2051
|
-
// Radar (Reviews + Value Lists + Rules) — RUNG-5 data-coupled: the three section markers are
|
|
2052
|
-
// bundled (the screens are wired in) AND, for each of the three Radar collections, we seed
|
|
2053
|
-
// real twin state (a review, a value-list + item, a rule), fetch the SAME /v1 endpoint the
|
|
2054
|
-
// mirror's React client reads off the running mirror server, render the mirror's OWN ListPane
|
|
2055
|
-
// component over those rows, and assert the rendered DOM emits exactly one `list-row` landmark
|
|
2056
|
-
// per seeded object carrying that object's id. Failable: an empty workspace yields zero rows →
|
|
2057
|
-
// zero list-rows → false; and a marker-only/hardcoded render could not surface the seeded ids.
|
|
2058
|
-
done('stripe.ui.radar', 'ui-dashboard', 'Dashboard: Radar (Reviews + Value Lists + Rules) screens', 'ui', 'niche', uiDataCoupled({
|
|
2059
|
-
markers: ['Radar Reviews', 'Radar Value Lists', 'Radar Rules', 'list-row'],
|
|
3569
|
+
// Radar rules are not in Stripe's API: rules are written in the Dashboard (docs.stripe.com/radar/rules), and the
|
|
3570
|
+
// published spec has no /v1/radar/rules. The twin's routes for them were invented and are gone (5decb1d77), so the
|
|
3571
|
+
// `stripe.radar.rules` claim they carried is withdrawn rather than kept as a todo: it is not vendor API surface.
|
|
3572
|
+
// Dashboard: Radar (Reviews + Value Lists) screens — RUNG-5 data-coupled: the section markers are bundled (the screens
|
|
3573
|
+
// are wired in) AND, for each Radar collection, we seed real twin state (a review Radar opens on an elevated-risk
|
|
3574
|
+
// charge, a value-list + item), fetch the SAME /v1 endpoint the mirror's React client reads off the running mirror
|
|
3575
|
+
// server, render the mirror's OWN ListPane component over those rows, and assert the rendered DOM emits exactly one
|
|
3576
|
+
// `list-row` landmark per seeded object carrying it. Failable: an empty workspace yields zero rows → false. The Rules
|
|
3577
|
+
// screen left the mirror with the invented /v1/radar/rules it read (5decb1d77: "The Dashboard mirror no longer lists
|
|
3578
|
+
// ... radar rules"), and the review is no longer seeded through the invented POST /v1/radar/reviews.
|
|
3579
|
+
done('stripe.ui.radar', 'ui-dashboard', 'Dashboard: Radar (Reviews + Value Lists) screens', 'ui', 'niche', uiDataCoupled({
|
|
3580
|
+
markers: ['Radar Reviews', 'Radar Value Lists', 'list-row'],
|
|
2060
3581
|
seed: async (h) => {
|
|
2061
|
-
|
|
2062
|
-
await h({ m: 'POST', p: '/v1/radar/reviews', b: 'charge=ch_radar_ui&payment_intent=pi_radar_ui' });
|
|
3582
|
+
await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=2000¤cy=usd&payment_method=pm_card_riskLevelElevated&confirm=true' });
|
|
2063
3583
|
const vl = await h({ m: 'POST', p: '/v1/radar/value_lists', b: 'alias=ui_block_ips&name=UI Blocked IPs&item_type=ip_address' });
|
|
2064
3584
|
await h({ m: 'POST', p: '/v1/radar/value_list_items', b: `value_list=${(vl.body as Body).id}&value=9.9.9.9` });
|
|
2065
|
-
await h({ m: 'POST', p: '/v1/radar/rules', b: 'action=block&predicate=:risk_level: = "highest"' });
|
|
2066
3585
|
},
|
|
2067
3586
|
check: async ({ get }) => {
|
|
2068
3587
|
const reviews = ((await get('/v1/reviews')).data as Body[]) ?? [];
|
|
2069
3588
|
const valueLists = ((await get('/v1/radar/value_lists')).data as Body[]) ?? [];
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
const review = reviews.find((r) => r.charge === '
|
|
3589
|
+
// the review is the one Radar opened on the seeded elevated-risk payment
|
|
3590
|
+
const risky = (((await get('/v1/payment_intents')).data as Body[]) ?? [])[0];
|
|
3591
|
+
const review = reviews.find((r) => r.open === true && r.payment_intent === risky?.id && typeof r.charge === 'string' && r.charge === risky?.latest_charge);
|
|
2073
3592
|
const vl = valueLists.find((l) => l.alias === 'ui_block_ips');
|
|
2074
|
-
|
|
2075
|
-
if (!review || !vl || !rule) return false;
|
|
2076
|
-
// RENDER the mirror's own ListPane over the fetched rows and assert the DOM emits one
|
|
2077
|
-
// list-row per object, each carrying the seeded object's id (subtitle renders r.id).
|
|
3593
|
+
if (!review || !vl) return false;
|
|
2078
3594
|
const reviewsMarkup = renderSectionList('reviews', reviews as StripeRow[]);
|
|
2079
3595
|
const valueListsMarkup = renderSectionList('radar/value_lists', valueLists as StripeRow[]);
|
|
2080
|
-
|
|
2081
|
-
return (
|
|
2082
|
-
|
|
2083
|
-
// name + alias (the id is not surfaced in that section's row), so assert those instead.
|
|
2084
|
-
listRows(reviewsMarkup) === reviews.length && reviewsMarkup.includes(String(review.id)) &&
|
|
2085
|
-
listRows(valueListsMarkup) === valueLists.length && valueListsMarkup.includes('UI Blocked IPs') && valueListsMarkup.includes('ui_block_ips') &&
|
|
2086
|
-
listRows(rulesMarkup) === rules.length && rulesMarkup.includes(String(rule.id)) && rulesMarkup.includes('block')
|
|
2087
|
-
);
|
|
3596
|
+
// a review's row names its charge; the value-list row renders its name + alias
|
|
3597
|
+
return listRows(reviewsMarkup) === reviews.length && reviewsMarkup.includes(String(review.charge))
|
|
3598
|
+
&& listRows(valueListsMarkup) === valueLists.length && valueListsMarkup.includes('UI Blocked IPs') && valueListsMarkup.includes('ui_block_ips');
|
|
2088
3599
|
},
|
|
2089
3600
|
})),
|
|
2090
3601
|
|
|
2091
3602
|
// ── Reporting ────────────────────────────────────────────────────────────────────
|
|
2092
3603
|
// Reporting report_runs + report_types: POST /v1/reporting/report_runs requires
|
|
2093
|
-
//
|
|
2094
|
-
//
|
|
3604
|
+
// report_type (a valid type from the catalog); a run starts pending and completes a
|
|
3605
|
+
// minute later (status 'succeeded') with a result File ref. Unknown/missing report_type 400.
|
|
2095
3606
|
// The report-type catalog is listable/retrievable.
|
|
2096
3607
|
done('stripe.reporting.report_runs', 'reporting', 'Reporting (report_runs + report_types)', 'api', 'niche', () =>
|
|
2097
|
-
withRoot(async (h) => {
|
|
3608
|
+
withRoot(async (h, root) => {
|
|
2098
3609
|
const types = await h({ m: 'GET', p: '/v1/reporting/report_types' });
|
|
2099
3610
|
if (!ok(types) || ((types.body as Body).data as Body[]).length === 0) return false;
|
|
2100
3611
|
const one = await h({ m: 'GET', p: '/v1/reporting/report_types/balance.summary.1' });
|
|
2101
3612
|
if (!ok(one) || field(one, 'object') !== 'reporting.report_type') return false;
|
|
2102
|
-
const run = await h({ m: 'POST', p: '/v1/reporting/report_runs', b: '
|
|
2103
|
-
if (!ok(run) || field(run, 'object') !== 'reporting.report_run' || field(run, 'status') !== '
|
|
2104
|
-
|
|
2105
|
-
const g = await
|
|
3613
|
+
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' });
|
|
3614
|
+
if (!ok(run) || field(run, 'object') !== 'reporting.report_run' || field(run, 'status') !== 'pending' || field(run, 'report_type') !== 'balance.summary.1') return false;
|
|
3615
|
+
// "Most runs complete within a few minutes" (docs.stripe.com/reports/api): read two minutes on, it has succeeded
|
|
3616
|
+
const g = await handleStripeTwinRequest({ method: 'GET', path: `/v1/reporting/report_runs/${id(run)}`, root, occurredAt: new Date(Date.now() + 120_000).toISOString() });
|
|
3617
|
+
if (field(g, 'status') !== 'succeeded' || (field(g, 'result') as Body)?.object !== 'file') return false;
|
|
2106
3618
|
const l = await h({ m: 'GET', p: '/v1/reporting/report_runs' });
|
|
2107
3619
|
const noType = await h({ m: 'POST', p: '/v1/reporting/report_runs', b: 'parameters[interval_start]=0' });
|
|
2108
|
-
const badType = await h({ m: 'POST', p: '/v1/reporting/report_runs', b: '
|
|
3620
|
+
const badType = await h({ m: 'POST', p: '/v1/reporting/report_runs', b: 'report_type=bogus.report' });
|
|
2109
3621
|
const nope = await h({ m: 'GET', p: '/v1/reporting/report_runs/frr_nope' });
|
|
2110
3622
|
const badTypeRetrieve = await h({ m: 'GET', p: '/v1/reporting/report_types/bogus.report' });
|
|
2111
3623
|
return ok(g) && id(g) === id(run) && ((l.body as Body).data as Body[]).length === 1 &&
|
|
@@ -2113,25 +3625,24 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2113
3625
|
}),
|
|
2114
3626
|
),
|
|
2115
3627
|
// Reporting API (the modellable part of Sigma/Financial reports): report_types catalog +
|
|
2116
|
-
// report_runs create→result. A run requires a valid
|
|
2117
|
-
// starts and completes to 'succeeded' with a result File ref. The Sigma
|
|
2118
|
-
//
|
|
3628
|
+
// report_runs create→result. A run requires a valid report_type (else 400),
|
|
3629
|
+
// starts pending and completes to 'succeeded' with a result File ref a minute later. The Sigma scheduled-query
|
|
3630
|
+
// surface is filed separately as a todo. Unknown type 400.
|
|
2119
3631
|
done('stripe.sigma_financial', 'reporting', 'Financial reports / Sigma (Reporting API)', 'api', 'niche', () =>
|
|
2120
|
-
withRoot(async (h) => {
|
|
3632
|
+
withRoot(async (h, root) => {
|
|
2121
3633
|
const types = await h({ m: 'GET', p: '/v1/reporting/report_types' });
|
|
2122
3634
|
if (!ok(types) || field(types, 'object') !== 'list' || ((types.body as Body).data as Body[]).length === 0) return false;
|
|
2123
|
-
const run = await h({ m: 'POST', p: '/v1/reporting/report_runs', b: '
|
|
2124
|
-
if (!ok(run) || field(run, 'object') !== 'reporting.report_run' || field(run, 'status') !== '
|
|
2125
|
-
|
|
2126
|
-
|
|
3635
|
+
const run = await h({ m: 'POST', p: '/v1/reporting/report_runs', b: 'report_type=balance.summary.1' });
|
|
3636
|
+
if (!ok(run) || field(run, 'object') !== 'reporting.report_run' || field(run, 'status') !== 'pending') return false;
|
|
3637
|
+
const get = await handleStripeTwinRequest({ method: 'GET', path: `/v1/reporting/report_runs/${id(run)}`, root, occurredAt: new Date(Date.now() + 120_000).toISOString() });
|
|
3638
|
+
if (field(get, 'status') !== 'succeeded' || (field(get, 'result') as Body)?.object !== 'file') return false;
|
|
2127
3639
|
const list = await h({ m: 'GET', p: '/v1/reporting/report_runs' });
|
|
2128
|
-
const badType = await h({ m: 'POST', p: '/v1/reporting/report_runs', b: '
|
|
2129
|
-
const missing = await h({ m: 'POST', p: '/v1/reporting/report_runs', b: '
|
|
3640
|
+
const badType = await h({ m: 'POST', p: '/v1/reporting/report_runs', b: 'report_type=not.a.type' });
|
|
3641
|
+
const missing = await h({ m: 'POST', p: '/v1/reporting/report_runs', b: 'parameters[interval_start]=0' });
|
|
2130
3642
|
return ok(get) && field(list, 'object') === 'list' && badType.status === 400 && missing.status === 400;
|
|
2131
3643
|
}),
|
|
2132
3644
|
),
|
|
2133
|
-
|
|
2134
|
-
'Out of scope: Sigma is a hosted SQL analytics warehouse over your account data — running arbitrary SQL against a managed data lake is not locally reproducible. The Reporting API (report_types + report_runs) IS modeled (stripe.sigma_financial).'),
|
|
3645
|
+
todo('stripe.sigma.scheduled_query_runs', 'reporting', 'Sigma scheduled query runs: GET /v1/sigma/scheduled_query_runs list + retrieve, with the run object\'s status/result-file lifecycle (the Reporting API half is already modeled by stripe.sigma_financial)', 'api', 'niche'),
|
|
2135
3646
|
|
|
2136
3647
|
// ── Connector (full pull/push) ────────────────────────────────────────────────────
|
|
2137
3648
|
done('stripe.connector.read_surface', 'connector', 'Connector read surface (list core collections in one pass)', 'connector', 'core', () =>
|
|
@@ -2165,8 +3676,11 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2165
3676
|
pushed.push(`${method} ${path}`);
|
|
2166
3677
|
return { id: 'ext_pushed', object: 'customer' };
|
|
2167
3678
|
}) as unknown as Parameters<typeof fullSyncStripe>[0];
|
|
3679
|
+
const { performPending } = await import('./stripe-perform-harness.ts');
|
|
3680
|
+
// protocol 2: the head performs the pending write, the refresh folds the account — two halves, not one
|
|
3681
|
+
const sent = await performPending(execute as never, { root, occurredAt: '2024-01-01T00:00:00.000Z' });
|
|
2168
3682
|
const res = await fullSyncStripe(execute, { root, occurredAt: '2024-01-01T00:00:00.000Z' });
|
|
2169
|
-
if (
|
|
3683
|
+
if (sent.pushed < 1 || pushed.length < 1) return false; // the pending create was performed
|
|
2170
3684
|
if (res.observed < 1 || res.collections < 11) return false; // all collections + webhooks pulled
|
|
2171
3685
|
// the pulled real customer is now in the twin's projection.
|
|
2172
3686
|
const list = await handleStripeTwinRequest({ method: 'GET', path: '/v1/customers', root });
|
|
@@ -2225,12 +3739,15 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2225
3739
|
// screen reads → render the mirror's OWN ListPane → assert the seeded charge id survives.
|
|
2226
3740
|
done('stripe.ui.refunds', 'ui-dashboard', 'Dashboard: Refunds screen (data-coupled)', 'ui', 'core', uiDataCoupled({
|
|
2227
3741
|
markers: ['Refunds', 'list-row'],
|
|
2228
|
-
seed: async (h) => {
|
|
3742
|
+
seed: async (h) => {
|
|
3743
|
+
const charge = await h({ m: 'POST', p: '/v1/charges', b: 'amount=1000¤cy=usd&description=ui_check_refund' });
|
|
3744
|
+
await h({ m: 'POST', p: '/v1/refunds', b: `charge=${id(charge)}&amount=555` });
|
|
3745
|
+
},
|
|
2229
3746
|
check: async ({ get }) => {
|
|
2230
3747
|
const rows = ((await get('/v1/refunds')).data as Body[]) as unknown as StripeRow[];
|
|
2231
3748
|
if (!rows.length) return false;
|
|
2232
3749
|
const markup = renderSectionList('refunds', rows);
|
|
2233
|
-
return listRows(markup) === rows.length && rows.some((r) => r.charge === '
|
|
3750
|
+
return listRows(markup) === rows.length && rows.some((r) => typeof r.charge === 'string' && r.amount === 555);
|
|
2234
3751
|
},
|
|
2235
3752
|
})),
|
|
2236
3753
|
// TWIN-14 (B4) migration — was a marker-grep over the literal 'Subscriptions'; now
|
|
@@ -2308,12 +3825,12 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2308
3825
|
// screen reads → render the mirror's OWN ListPane → assert the seeded charge id survives.
|
|
2309
3826
|
done('stripe.ui.disputes', 'ui-dashboard', 'Dashboard: Disputes screen (data-coupled)', 'ui', 'common', uiDataCoupled({
|
|
2310
3827
|
markers: ['Disputes', 'list-row'],
|
|
2311
|
-
seed: async (h) => { await h
|
|
3828
|
+
seed: async (h) => { await disputed(h, 1234); },
|
|
2312
3829
|
check: async ({ get }) => {
|
|
2313
3830
|
const rows = ((await get('/v1/disputes')).data as Body[]) as unknown as StripeRow[];
|
|
2314
3831
|
if (!rows.length) return false;
|
|
2315
3832
|
const markup = renderSectionList('disputes', rows);
|
|
2316
|
-
return listRows(markup) === rows.length && rows.some((d) => d.
|
|
3833
|
+
return listRows(markup) === rows.length && rows.some((d) => d.amount === 1234);
|
|
2317
3834
|
},
|
|
2318
3835
|
})),
|
|
2319
3836
|
// TWIN-14 (B4) migration — was a marker-grep over the literal 'Payouts'; now data-coupled:
|
|
@@ -2321,7 +3838,10 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2321
3838
|
// reads → render the mirror's OWN ListPane → assert the seeded amount survives.
|
|
2322
3839
|
done('stripe.ui.payouts', 'ui-dashboard', 'Dashboard: Payouts screen (data-coupled)', 'ui', 'core', uiDataCoupled({
|
|
2323
3840
|
markers: ['Payouts', 'list-row'],
|
|
2324
|
-
seed: async (h) => {
|
|
3841
|
+
seed: async (h) => {
|
|
3842
|
+
await h({ m: 'POST', p: '/v1/charges', b: 'amount=10000¤cy=usd&source=tok_bypassPending' });
|
|
3843
|
+
await h({ m: 'POST', p: '/v1/payouts', b: 'amount=7777¤cy=usd' });
|
|
3844
|
+
},
|
|
2325
3845
|
check: async ({ get }) => {
|
|
2326
3846
|
const rows = ((await get('/v1/payouts')).data as Body[]) as unknown as StripeRow[];
|
|
2327
3847
|
if (!rows.length) return false;
|
|
@@ -2338,15 +3858,17 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2338
3858
|
// empty workspace's balance has no buckets → `BalanceSummary` renders nothing.
|
|
2339
3859
|
done('stripe.ui.balance', 'ui-dashboard', 'Dashboard: Balance + Balance Transactions screens (data-coupled)', 'ui', 'core', uiDataCoupled({
|
|
2340
3860
|
markers: ['Balance', 'Balance Transactions', 'balance-summary'],
|
|
2341
|
-
seed: async (h) => { await h({ m: 'POST', p: '/v1/
|
|
3861
|
+
seed: async (h) => { await h({ m: 'POST', p: '/v1/charges', b: 'amount=424200¤cy=usd&source=tok_bypassPending' }); },
|
|
2342
3862
|
check: async ({ get }) => {
|
|
2343
3863
|
const balance = await get('/v1/balance');
|
|
2344
3864
|
const txns = ((await get('/v1/balance_transactions')).data as Body[]) as unknown as StripeRow[];
|
|
2345
3865
|
if (!txns.length) return false;
|
|
2346
3866
|
const summaryMarkup = renderToStaticMarkup(createElement(BalanceSummary, { row: { id: 'balance', ...balance } as StripeRow }));
|
|
2347
3867
|
const txnsMarkup = renderSectionList('balance_transactions', txns);
|
|
2348
|
-
|
|
2349
|
-
|
|
3868
|
+
// the balance is the charge less Stripe's fee, 2.9% + 30¢ (stripe.com/pricing; the ledger, d5c8f9a70):
|
|
3869
|
+
// 424200 - (12302 + 30) = 411868 available, while the transaction keeps the gross amount
|
|
3870
|
+
return summaryMarkup.includes('balance-summary') && summaryMarkup.includes(formatStripeAmount(411868, 'usd')) &&
|
|
3871
|
+
listRows(txnsMarkup) === txns.length && txns.some((t) => t.amount === 424200 && t.fee === 12332 && t.net === 411868);
|
|
2350
3872
|
},
|
|
2351
3873
|
})),
|
|
2352
3874
|
// TWIN-14 (B4) migration — was a marker-grep over the literals 'Connected Accounts',
|
|
@@ -2358,8 +3880,8 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2358
3880
|
done('stripe.ui.connect', 'ui-dashboard', 'Dashboard: Connect (Connected Accounts + Transfers) screens (data-coupled)', 'ui', 'niche', uiDataCoupled({
|
|
2359
3881
|
markers: ['Connected Accounts', 'Transfers', 'connect-flag'],
|
|
2360
3882
|
seed: async (h) => {
|
|
2361
|
-
const
|
|
2362
|
-
await h({ m: 'POST', p: '/v1/transfers', b: `amount=999¤cy=usd&destination=${
|
|
3883
|
+
const seller = await onboardedSeller(h);
|
|
3884
|
+
await h({ m: 'POST', p: '/v1/transfers', b: `amount=999¤cy=usd&destination=${seller}` });
|
|
2363
3885
|
},
|
|
2364
3886
|
check: async ({ get }) => {
|
|
2365
3887
|
const accounts = ((await get('/v1/accounts')).data as Body[]) as unknown as StripeRow[];
|
|
@@ -2377,40 +3899,34 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2377
3899
|
// screen reads → render the mirror's OWN ListPane → assert the seeded type survives.
|
|
2378
3900
|
done('stripe.ui.events', 'ui-dashboard', 'Dashboard: Events log screen (data-coupled)', 'ui', 'core', uiDataCoupled({
|
|
2379
3901
|
markers: ['Events', 'list-row'],
|
|
2380
|
-
seed: async (h) => { await h({ m: 'POST', p: '/v1/
|
|
3902
|
+
seed: async (h) => { await h({ m: 'POST', p: '/v1/coupons', b: 'percent_off=10&duration=once' }); },
|
|
2381
3903
|
check: async ({ get }) => {
|
|
2382
3904
|
const rows = ((await get('/v1/events')).data as Body[]) as unknown as StripeRow[];
|
|
2383
3905
|
if (!rows.length) return false;
|
|
2384
3906
|
const markup = renderSectionList('events', rows);
|
|
2385
|
-
return listRows(markup) === rows.length && rows.some((e) => e.type === '
|
|
3907
|
+
return listRows(markup) === rows.length && rows.some((e) => e.type === 'coupon.created') && markup.includes('coupon.created');
|
|
2386
3908
|
},
|
|
2387
3909
|
})),
|
|
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'],
|
|
3910
|
+
// Dashboard: credit notes and the account's tax IDs, data-coupled: seed a credit note and the account's own VAT
|
|
3911
|
+
// number, fetch the same `/v1/credit_notes` and `/v1/tax_ids` lists those screens read, render the mirror's own
|
|
3912
|
+
// ListPane over each and assert the seeded rows survive.
|
|
3913
|
+
done('stripe.ui.billing_credit', 'ui-dashboard', 'Dashboard: Credit Notes + Tax IDs screens (data-coupled)', 'ui', 'common', uiDataCoupled({
|
|
3914
|
+
markers: ['Credit Notes', 'Tax IDs', 'list-row'],
|
|
2395
3915
|
seed: async (h) => {
|
|
2396
3916
|
const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=billing-credit-check@twin.test' });
|
|
2397
3917
|
const custId = (cust.body as Body).id;
|
|
2398
3918
|
const inv = await h({ m: 'POST', p: '/v1/invoices', b: `customer=${custId}` });
|
|
2399
3919
|
await h({ m: 'POST', p: '/v1/credit_notes', b: `invoice=${(inv.body as Body).id}&amount=2500&reason=order_change` });
|
|
2400
|
-
await h({ m: 'POST', p:
|
|
2401
|
-
await h({ m: 'POST', p: `/v1/customers/${custId}/balance_transactions`, b: 'amount=-750¤cy=usd' });
|
|
3920
|
+
await h({ m: 'POST', p: '/v1/tax_ids', b: 'type=eu_vat&value=DE999999999' });
|
|
2402
3921
|
},
|
|
2403
3922
|
check: async ({ get }) => {
|
|
2404
3923
|
const notes = ((await get('/v1/credit_notes')).data as Body[]) as unknown as StripeRow[];
|
|
2405
3924
|
const taxIds = ((await get('/v1/tax_ids')).data as Body[]) as unknown as StripeRow[];
|
|
2406
|
-
|
|
2407
|
-
if (!notes.length || !taxIds.length || !custBal.length) return false;
|
|
3925
|
+
if (!notes.length || !taxIds.length) return false;
|
|
2408
3926
|
const notesMarkup = renderSectionList('credit_notes', notes);
|
|
2409
3927
|
const taxMarkup = renderSectionList('tax_ids', taxIds);
|
|
2410
|
-
const balMarkup = renderSectionList('customer_balance_transactions', custBal);
|
|
2411
3928
|
return listRows(notesMarkup) === notes.length && notes.some((n) => n.amount === 2500) &&
|
|
2412
|
-
listRows(taxMarkup) === taxIds.length && taxIds.some((t) => t.value === 'DE999999999')
|
|
2413
|
-
listRows(balMarkup) === custBal.length && custBal.some((b) => b.amount === -750);
|
|
3929
|
+
listRows(taxMarkup) === taxIds.length && taxIds.some((t) => t.value === 'DE999999999');
|
|
2414
3930
|
},
|
|
2415
3931
|
})),
|
|
2416
3932
|
// TWIN-14 (B4) migration — was a marker-grep over the literal 'Payment error'; now
|
|
@@ -2468,7 +3984,12 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2468
3984
|
// per seeded run carrying its report_type (fails on an empty workspace).
|
|
2469
3985
|
done('stripe.ui.reports', 'ui-dashboard', 'Dashboard: Reports / analytics screen', 'ui', 'niche', uiDataCoupled({
|
|
2470
3986
|
markers: ['Reports', 'reporting/report_runs', 'list-row'],
|
|
2471
|
-
|
|
3987
|
+
// made ten minutes ago on the World's clock: a run starts pending and succeeds a minute on ("Most runs complete within a
|
|
3988
|
+
// few minutes", docs.stripe.com/reports/api; stripe.reporting.report_runs), so one made at the instant the screen reads
|
|
3989
|
+
// it is still pending
|
|
3990
|
+
seed: async (_h, root) => {
|
|
3991
|
+
await handleStripeTwinRequest({ method: 'POST', path: '/v1/reporting/report_runs', body: 'report_type=balance.summary.1¶meters[interval_start]=0¶meters[interval_end]=100', root, occurredAt: new Date(Date.now() - 10 * 60_000).toISOString() });
|
|
3992
|
+
},
|
|
2472
3993
|
check: async ({ get }) => {
|
|
2473
3994
|
const rows = ((await get('/v1/reporting/report_runs')).data as Body[]) as unknown as StripeRow[];
|
|
2474
3995
|
if (!rows || rows.length < 1) return false;
|
|
@@ -2482,7 +4003,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2482
4003
|
done('stripe.ui.settings', 'ui-dashboard', 'Dashboard: Settings (account/team/api keys/webhooks)', 'ui', 'common', uiDataCoupled({
|
|
2483
4004
|
markers: ['settings-block', 'data-settings', 'API keys', 'settings-payout-schedule', 'payoutScheduleText'],
|
|
2484
4005
|
seed: async (h) => {
|
|
2485
|
-
await h({ m: 'POST', p: '/
|
|
4006
|
+
await h({ m: 'POST', p: '/_twin/account', b: 'settings[payouts][schedule][interval]=weekly&settings[payouts][schedule][weekly_anchor]=monday' });
|
|
2486
4007
|
await h({ m: 'POST', p: '/v1/webhook_endpoints', b: 'url=https://twin.test/hook&enabled_events[]=charge.succeeded' });
|
|
2487
4008
|
},
|
|
2488
4009
|
check: async ({ get }) => {
|
|
@@ -2541,14 +4062,19 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2541
4062
|
// ── SetupIntents full lifecycle (only confirm was tracked before) ──
|
|
2542
4063
|
done('stripe.setup_intents.lifecycle', 'payment_methods', 'SetupIntents: create / retrieve / list / cancel', 'api', 'common', () =>
|
|
2543
4064
|
withRoot(async (h) => {
|
|
4065
|
+
// made without a payment method it waits for one: "When the SetupIntent is created, it has a status of
|
|
4066
|
+
// requires_payment_method until a payment method is attached" (docs.stripe.com/payments/setupintents/lifecycle;
|
|
4067
|
+
// 45d8b30d1); with one it waits for confirmation
|
|
2544
4068
|
const c = await h({ m: 'POST', p: '/v1/setup_intents', b: 'usage=off_session' });
|
|
2545
|
-
if (!ok(c) || field(c, 'status') !== '
|
|
4069
|
+
if (!ok(c) || field(c, 'status') !== 'requires_payment_method') return false;
|
|
2546
4070
|
const g = await h({ m: 'GET', p: `/v1/setup_intents/${id(c)}` });
|
|
2547
4071
|
const l = await h({ m: 'GET', p: '/v1/setup_intents' });
|
|
2548
4072
|
const x = await h({ m: 'POST', p: `/v1/setup_intents/${id(c)}/cancel` });
|
|
2549
4073
|
// cancelling a succeeded SI is rejected (negative path)
|
|
2550
|
-
const conf = await h({ m: 'POST', p: '/v1/setup_intents', b: 'usage=off_session' });
|
|
2551
|
-
|
|
4074
|
+
const conf = await h({ m: 'POST', p: '/v1/setup_intents', b: 'usage=off_session&payment_method=pm_card_visa' });
|
|
4075
|
+
if (field(conf, 'status') !== 'requires_confirmation') return false;
|
|
4076
|
+
const confirmed = await h({ m: 'POST', p: `/v1/setup_intents/${id(conf)}/confirm` });
|
|
4077
|
+
if (field(confirmed, 'status') !== 'succeeded') return false;
|
|
2552
4078
|
const badCancel = await h({ m: 'POST', p: `/v1/setup_intents/${id(conf)}/cancel` });
|
|
2553
4079
|
return ok(g) && id(g) === id(c) && field(l, 'object') === 'list'
|
|
2554
4080
|
&& ok(x) && field(x, 'status') === 'canceled' && badCancel.status === 400;
|
|
@@ -2574,24 +4100,40 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2574
4100
|
// ── Mandates retrieve ──
|
|
2575
4101
|
done('stripe.mandates.retrieve', 'payment_methods', 'Mandates: retrieve (404 unknown)', 'api', 'niche', () =>
|
|
2576
4102
|
withRoot(async (h) => {
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
4103
|
+
// Stripe's API makes no mandate on request (the twin's seeding POST /v1/mandates was invented and is gone,
|
|
4104
|
+
// 5decb1d77): a mandate is the customer's acceptance a verified bank account carries, and a SetupIntent verified by
|
|
4105
|
+
// micro-deposits names its multi-use one (docs.stripe.com/api/mandates, docs.stripe.com/api/setup_intents/object mandate)
|
|
4106
|
+
const si = await h({ m: 'POST', p: '/v1/setup_intents', b: 'allowed_payment_method_types[]=us_bank_account' });
|
|
4107
|
+
await h({ m: 'POST', p: `/v1/setup_intents/${id(si)}/confirm`, b: 'payment_method=pm_us_bank_account' });
|
|
4108
|
+
const verified = await h({ m: 'POST', p: `/v1/setup_intents/${id(si)}/verify_microdeposits`, b: 'descriptor_code=SM11AA' });
|
|
4109
|
+
const mandateId = String(field(verified, 'mandate'));
|
|
4110
|
+
const g = await h({ m: 'GET', p: `/v1/mandates/${mandateId}` });
|
|
2580
4111
|
const miss = await h({ m: 'GET', p: '/v1/mandates/mandate_nope' });
|
|
2581
|
-
return ok(g) &&
|
|
4112
|
+
return ok(g) && field(g, 'object') === 'mandate' && id(g) === mandateId && field(g, 'status') === 'active' && field(g, 'type') === 'multi_use' && miss.status === 404;
|
|
2582
4113
|
}),
|
|
2583
4114
|
),
|
|
2584
4115
|
|
|
2585
4116
|
// ── Refund cancel ──
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
const
|
|
2592
|
-
|
|
4117
|
+
// A bank-transfer payment's refund waits in requires_action for the customer's bank details (Stripe emails them), and
|
|
4118
|
+
// only such a refund can be canceled, which gives the charge back its amount; a succeeded refund cannot be canceled
|
|
4119
|
+
// (docs.stripe.com/payments/customer-balance/refunding, docs.stripe.com/api/refunds/cancel).
|
|
4120
|
+
done('stripe.refunds.cancel', 'refunds', 'Refunds: a bank-transfer refund awaiting bank details is canceled (400 once succeeded)', 'api', 'niche', () =>
|
|
4121
|
+
withRoot(async (h) => {
|
|
4122
|
+
const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=wire@twin.test' });
|
|
4123
|
+
await h({ m: 'POST', p: `/v1/test_helpers/customers/${id(cust)}/fund_cash_balance`, b: 'amount=2000¤cy=usd' });
|
|
4124
|
+
const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: `amount=2000¤cy=usd&customer=${id(cust)}&allowed_payment_method_types[]=customer_balance` });
|
|
4125
|
+
await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/apply_customer_balance` });
|
|
4126
|
+
const waiting = await h({ m: 'POST', p: '/v1/refunds', b: `payment_intent=${id(pi)}&amount=500&instructions_email=wire@twin.test` });
|
|
4127
|
+
if (!ok(waiting) || field(waiting, 'status') !== 'requires_action') return false;
|
|
4128
|
+
const charge = String(field(waiting, 'charge'));
|
|
4129
|
+
const held = await h({ m: 'GET', p: `/v1/charges/${charge}` });
|
|
4130
|
+
const x = await h({ m: 'POST', p: `/v1/refunds/${id(waiting)}/cancel` });
|
|
4131
|
+
const released = await h({ m: 'GET', p: `/v1/charges/${charge}` });
|
|
4132
|
+
const back = await h({ m: 'POST', p: '/v1/refunds', b: `payment_intent=${id(pi)}&amount=500&origin=customer_balance` });
|
|
4133
|
+
const bad = await h({ m: 'POST', p: `/v1/refunds/${id(back)}/cancel` });
|
|
2593
4134
|
const miss = await h({ m: 'POST', p: '/v1/refunds/re_nope/cancel' });
|
|
2594
|
-
return ok(x) && field(x, 'status') === 'canceled' && bad.status === 400 && miss.status === 404
|
|
4135
|
+
return ok(x) && field(x, 'status') === 'canceled' && field(back, 'status') === 'succeeded' && bad.status === 400 && miss.status === 404
|
|
4136
|
+
&& field(held, 'amount_refunded') === 500 && field(released, 'amount_refunded') === 0;
|
|
2595
4137
|
}),
|
|
2596
4138
|
),
|
|
2597
4139
|
|
|
@@ -2604,10 +4146,313 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2604
4146
|
const inc = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/increment_authorization`, b: 'amount=1500' });
|
|
2605
4147
|
// must be greater than current → reject lowering
|
|
2606
4148
|
const bad = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/increment_authorization`, b: 'amount=1000' });
|
|
2607
|
-
|
|
4149
|
+
// the charge holds the grown authorization, so capturing all of it releases nothing
|
|
4150
|
+
const held = await h({ m: 'GET', p: `/v1/charges/${String(field(inc, 'latest_charge'))}` });
|
|
4151
|
+
await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/capture` });
|
|
4152
|
+
const taken = await h({ m: 'GET', p: `/v1/charges/${String(field(inc, 'latest_charge'))}` });
|
|
4153
|
+
return ok(inc) && field(inc, 'amount') === 1500 && field(inc, 'amount_capturable') === 1500 && field(inc, 'status') === 'requires_capture' && bad.status === 400
|
|
4154
|
+
&& field(held, 'amount') === 1500 && field(taken, 'amount_captured') === 1500 && field(taken, 'amount_refunded') === 0 && field(taken, 'refunded') === false;
|
|
2608
4155
|
}),
|
|
2609
4156
|
),
|
|
2610
4157
|
|
|
4158
|
+
// ── Connect OAuth for Standard accounts (screens/connect-oauth.tsx; docs.stripe.com/connect/oauth-reference) ──
|
|
4159
|
+
// The platform's OAuth settings: "Enable onboarding accounts with OAuth", "Copy your client_id", "Set your redirect_uri
|
|
4160
|
+
// ... If you don't include the redirect_uri parameter in your request, Stripe defaults to using the first address you've
|
|
4161
|
+
// configured" (docs.stripe.com/connect/oauth-standard-accounts). The page shows the client_id and what was saved, and
|
|
4162
|
+
// authorize holds the platform to it.
|
|
4163
|
+
done('stripe.connect.oauth_settings', 'connect', 'Dashboard Connect OAuth settings: the client_id shown, OAuth enabled and redirect URIs registered, which authorize holds the platform to', 'ui', 'common', () =>
|
|
4164
|
+
withOAuth(async (w) => {
|
|
4165
|
+
const read = async (path: string) => (await w.f(new Request(`https://dashboard.stripe.com${path}`))).text();
|
|
4166
|
+
const first = await read('/settings/connect');
|
|
4167
|
+
const shownId = /data-testid="connect-client-id">(ca_[^<]+)</.exec(first)?.[1];
|
|
4168
|
+
// a new platform has OAuth off: authorize refuses it, in JSON, with the state
|
|
4169
|
+
const off = await w.authorize(authorizeQuery());
|
|
4170
|
+
const offBody = (await off.json()) as Body;
|
|
4171
|
+
// a redirect URI that is not an http(s) URL is refused on the page, and nothing is saved
|
|
4172
|
+
const bad = await w.settings(true, 'ftp://files.test/cb');
|
|
4173
|
+
const fragment = await w.settings(true, 'https://app.test/cb#tab');
|
|
4174
|
+
const stillOff = ((await (await w.authorize(authorizeQuery())).json()) as Body).error === 'invalid_client';
|
|
4175
|
+
// OAuth on with no redirect URI: a request naming none has nowhere to go
|
|
4176
|
+
await w.settings(true, '');
|
|
4177
|
+
const nowhere = (await (await w.authorize(authorizeQuery('', null))).json()) as Body;
|
|
4178
|
+
const saved = await w.settings(true, `https://app.test/first\n${OAUTH_CALLBACK}?tab=stripe`);
|
|
4179
|
+
const listed = await read('/settings/connect/onboarding-options/oauth');
|
|
4180
|
+
const uris = [...listed.matchAll(/data-testid="connect-redirect-uri">([^<]+)</g)].map((m) => m[1]!.replace(/&/g, '&'));
|
|
4181
|
+
// no redirect_uri: the first registered; a registered one with its own query keeps it; an unregistered one is refused
|
|
4182
|
+
const defaulted = (await w.authorize(authorizeQuery('', null), 'skip')).headers.get('location') ?? '';
|
|
4183
|
+
const named = (await w.authorize(authorizeQuery('', `${OAUTH_CALLBACK}?tab=stripe`), 'skip')).headers.get('location') ?? '';
|
|
4184
|
+
const unregistered = await w.authorize(authorizeQuery('', OAUTH_CALLBACK));
|
|
4185
|
+
const unregisteredBody = (await unregistered.json()) as Body;
|
|
4186
|
+
// turned off again, authorize refuses again
|
|
4187
|
+
await w.settings(false, OAUTH_CALLBACK);
|
|
4188
|
+
const offAgain = ((await (await w.authorize(authorizeQuery())).json()) as Body).error === 'invalid_client';
|
|
4189
|
+
const testLink = (await read('/test/settings/connect/onboarding-options/oauth')).includes('OAuth is disabled.');
|
|
4190
|
+
return shownId === 'ca_twin_self' && first.includes('OAuth is disabled.') && off.status === 400 && offBody.error === 'invalid_client' && offBody.state === 'st8'
|
|
4191
|
+
&& bad.status === 400 && fragment.status === 400 && stillOff && nowhere.error === 'invalid_redirect_uri' && saved.status === 303 && uris.length === 2 && uris[0] === 'https://app.test/first' && uris[1] === `${OAUTH_CALLBACK}?tab=stripe`
|
|
4192
|
+
&& listed.includes('OAuth is enabled.') && defaulted.startsWith('https://app.test/first?scope=read_write&code=ac_') && named.startsWith(`${OAUTH_CALLBACK}?tab=stripe&scope=read_write&code=ac_`)
|
|
4193
|
+
&& unregistered.status === 400 && unregisteredBody.error === 'invalid_redirect_uri' && String(unregisteredBody.error_description).includes(OAUTH_CALLBACK)
|
|
4194
|
+
&& offAgain && testLink;
|
|
4195
|
+
}, false),
|
|
4196
|
+
),
|
|
4197
|
+
// GET connect.stripe.com/oauth/authorize: the request checked as the reference lists its errors (a JSON dictionary with
|
|
4198
|
+
// error, error_description and state, never a redirect), the page the person answers, "Skip this form" in test mode
|
|
4199
|
+
// creating a new Standard account from the prefill ("Any parameters with invalid values are silently ignored") and
|
|
4200
|
+
// redirecting with code, scope and state; a denial redirects with access_denied and creates nothing.
|
|
4201
|
+
done('stripe.connect.oauth_authorize', 'connect', 'Connect OAuth authorize: request errors, the connect page, Skip this form creates a prefilled Standard account and redirects with code/scope/state; Deny redirects access_denied', 'api', 'common', () =>
|
|
4202
|
+
withOAuth(async (w) => {
|
|
4203
|
+
const err = async (query: string) => { const r = await w.authorize(query); return { status: r.status, ...((await r.json()) as Body) } as Body; };
|
|
4204
|
+
const noClient = await err(authorizeQuery().replace('client_id=ca_twin_self&', ''));
|
|
4205
|
+
const unknown = await err(authorizeQuery().replace('ca_twin_self', 'ca_nope'));
|
|
4206
|
+
const noType = await err(authorizeQuery().replace('&response_type=code', ''));
|
|
4207
|
+
const tokenType = await err(authorizeQuery().replace('response_type=code', 'response_type=token'));
|
|
4208
|
+
const badScope = await err(authorizeQuery().replace('scope=read_write', 'scope=admin'));
|
|
4209
|
+
const errorsRight = noClient.status === 400 && noClient.error === 'invalid_request' && unknown.error === 'invalid_client' && unknown.error_description === 'No application matches the supplied client identifier'
|
|
4210
|
+
&& noType.error === 'invalid_request' && tokenType.error === 'unsupported_response_type' && badScope.error === 'invalid_scope' && [noClient, unknown, noType, tokenType, badScope].every((e) => e.state === 'st8' && e.status === 400);
|
|
4211
|
+
const prefillQuery = '&stripe_user%5Bemail%5D=org%40cal.test&stripe_user%5Bfirst_name%5D=Ada&stripe_user%5Bbusiness_name%5D=Ada%20Consulting&stripe_user%5Bcountry%5D=CA&stripe_user%5Burl%5D=not-a-url&stripe_user%5Bbusiness_type%5D=llc&stripe_user%5Bproduct_description%5D=Consulting%20sessions&stripe_user%5Bphone_number%5D=6045550123';
|
|
4212
|
+
const shown = await (await w.authorize(authorizeQuery(prefillQuery))).text();
|
|
4213
|
+
const pageRight = shown.includes('Skip this form') && shown.includes('Deny access') && shown.includes('Read and write access') && shown.includes('org@cal.test') && shown.includes('Twin Inc.');
|
|
4214
|
+
// Deny: back to the platform with access_denied and the state, and no account made
|
|
4215
|
+
const denied = (await w.authorize(authorizeQuery(prefillQuery), 'deny')).headers.get('location');
|
|
4216
|
+
const noneMade = ((await w.api('GET', '/v1/accounts')).body.data as Body[]).length === 0;
|
|
4217
|
+
const { location, code, token } = await w.connect(prefillQuery);
|
|
4218
|
+
const acct = (await w.api('GET', `/v1/accounts/${String(token.stripe_user_id)}`)).body;
|
|
4219
|
+
const profile = acct.business_profile as Body;
|
|
4220
|
+
// scope defaults to read_only
|
|
4221
|
+
const readOnly = new URL((await w.authorize(authorizeQuery().replace('scope=read_write&', ''), 'skip')).headers.get('location') ?? 'about:blank').searchParams.get('scope');
|
|
4222
|
+
// an empty scope is no scope: the default, not invalid_scope
|
|
4223
|
+
const emptyScope = new URL((await w.authorize(authorizeQuery().replace('scope=read_write', 'scope='), 'skip')).headers.get('location') ?? 'about:blank').searchParams.get('scope');
|
|
4224
|
+
// two people connecting at once get two codes, each for its own account (a regression guard: in one process a
|
|
4225
|
+
// count and its write never interleave; the atomic mint is for Worlds whose store two processes write)
|
|
4226
|
+
const both = await Promise.all([w.authorize(authorizeQuery(), 'skip'), w.authorize(authorizeQuery(), 'skip')]);
|
|
4227
|
+
const codes = both.map((r) => new URL(r.headers.get('location') ?? 'about:blank').searchParams.get('code'));
|
|
4228
|
+
const bothTokens = await Promise.all(codes.map(async (c) => (await w.endpoint('token', `grant_type=authorization_code&code=${c}`)).body));
|
|
4229
|
+
return errorsRight && pageRight && noneMade
|
|
4230
|
+
&& denied === `${OAUTH_CALLBACK}?error=access_denied&error_description=The%20user%20denied%20your%20request&state=st8`
|
|
4231
|
+
&& `${location.origin}${location.pathname}` === OAUTH_CALLBACK && [...location.searchParams.keys()].join(',') === 'scope,code,state' && location.searchParams.get('scope') === 'read_write' && location.searchParams.get('state') === 'st8' && /^ac_/.test(code)
|
|
4232
|
+
&& acct.id === token.stripe_user_id && acct.type === 'standard' && (acct.controller as Body)?.type === 'account' && acct.email === 'org@cal.test' && acct.country === 'CA' && acct.default_currency === 'cad'
|
|
4233
|
+
&& profile?.name === 'Ada Consulting' && profile?.url === null && profile?.product_description === 'Consulting sessions' && profile?.support_phone === '6045550123'
|
|
4234
|
+
&& acct.business_type === 'company' && ((acct.settings as Body)?.dashboard as Body)?.display_name === 'Ada Consulting'
|
|
4235
|
+
&& ((acct.requirements as Body)?.currently_due as unknown[])?.length === 0 && acct.details_submitted === true && acct.payouts_enabled === true
|
|
4236
|
+
&& acct.charges_enabled === true && (acct.capabilities as Body)?.card_payments === 'active'
|
|
4237
|
+
&& readOnly === 'read_only' && emptyScope === 'read_only'
|
|
4238
|
+
&& codes[0] !== codes[1] && bothTokens.every((t) => /^acct_/.test(String(t.stripe_user_id))) && bothTokens[0]!.stripe_user_id !== bothTokens[1]!.stripe_user_id && bothTokens[0]!.refresh_token !== bothTokens[1]!.refresh_token;
|
|
4239
|
+
}),
|
|
4240
|
+
),
|
|
4241
|
+
// POST connect.stripe.com/oauth/token with grant_type=authorization_code, as stripe-node's oauth.token sends it (the
|
|
4242
|
+
// platform's secret key as Bearer; curl's -u as Basic): the reference's response, the connected account usable with the
|
|
4243
|
+
// Stripe-Account header at once; a code "can only be used once and expires in 5 minutes", and "Consuming an
|
|
4244
|
+
// authorization code more than once revokes the account connection"; the reference's error codes.
|
|
4245
|
+
done('stripe.connect.oauth_token', 'connect', 'Connect OAuth token: an authorization code becomes the connection (stripe_user_id, tokens), once, within 5 minutes; reuse revokes; invalid_request / invalid_grant / unsupported_grant_type', 'api', 'common', () =>
|
|
4246
|
+
withOAuth(async (w) => {
|
|
4247
|
+
const { code, token } = await w.connect();
|
|
4248
|
+
const account = String(token.stripe_user_id);
|
|
4249
|
+
const shape = Object.keys(token).sort().join(',') === 'access_token,livemode,refresh_token,scope,stripe_publishable_key,stripe_user_id,token_type'
|
|
4250
|
+
&& token.token_type === 'bearer' && token.livemode === false && token.scope === 'read_write' && /^sk_test_/.test(String(token.access_token))
|
|
4251
|
+
&& /^pk_test_/.test(String(token.stripe_publishable_key)) && /^rt_/.test(String(token.refresh_token)) && /^acct_/.test(account);
|
|
4252
|
+
// the platform acts as the account (Cal.com: a PaymentIntent with stripeAccount), and reads its default currency
|
|
4253
|
+
const pi = await w.api('POST', '/v1/payment_intents', 'amount=2500¤cy=usd', account);
|
|
4254
|
+
const retrieved = (await w.api('GET', `/v1/accounts/${account}`)).body;
|
|
4255
|
+
const e = async (body: string, auth?: string) => { const r = await w.endpoint('token', body, auth); return { status: r.status, ...r.body } as Body; };
|
|
4256
|
+
const noKey = await e(`grant_type=authorization_code&code=${code}`, '');
|
|
4257
|
+
const publishable = await e(`grant_type=authorization_code&code=${code}`, 'Bearer pk_test_platform');
|
|
4258
|
+
const noGrant = await e(`code=${code}`);
|
|
4259
|
+
const password = await e('grant_type=password');
|
|
4260
|
+
const noCode = await e('grant_type=authorization_code');
|
|
4261
|
+
const missing = await e('grant_type=authorization_code&code=ac_nope');
|
|
4262
|
+
// curl -u sk_...: (Basic) works as Bearer does
|
|
4263
|
+
const second = await w.authorize(authorizeQuery(), 'skip');
|
|
4264
|
+
const basic = await e(`grant_type=authorization_code&code=${new URL(second.headers.get('location')!).searchParams.get('code')}`, `Basic ${btoa('sk_test_platform:')}`);
|
|
4265
|
+
// five minutes: a code 299 s old is exchanged, one 300 s old is not
|
|
4266
|
+
const fresh = new URL((await w.authorize(authorizeQuery(), 'skip')).headers.get('location')!).searchParams.get('code');
|
|
4267
|
+
const stale = new URL((await w.authorize(authorizeQuery(), 'skip')).headers.get('location')!).searchParams.get('code');
|
|
4268
|
+
w.at('2026-03-01T00:04:59.000Z');
|
|
4269
|
+
const inTime = await e(`grant_type=authorization_code&code=${fresh}`);
|
|
4270
|
+
w.at('2026-03-01T00:05:00.000Z');
|
|
4271
|
+
const late = await e(`grant_type=authorization_code&code=${stale}`);
|
|
4272
|
+
const liveCode = new URL((await w.authorize(authorizeQuery(), 'skip')).headers.get('location')!).searchParams.get('code');
|
|
4273
|
+
const liveKey = await e(`grant_type=authorization_code&code=${liveCode}`, 'Bearer sk_live_platform');
|
|
4274
|
+
// a JSON body is read as the form is
|
|
4275
|
+
const jsonCode = new URL((await w.authorize(authorizeQuery(), 'skip')).headers.get('location')!).searchParams.get('code');
|
|
4276
|
+
const asJson = await w.f(new Request('https://connect.stripe.com/oauth/token', { method: 'POST', headers: { authorization: 'Bearer sk_test_platform', 'content-type': 'application/json' }, body: JSON.stringify({ grant_type: 'authorization_code', code: jsonCode }) }));
|
|
4277
|
+
const asJsonBody = (await asJson.json()) as Body;
|
|
4278
|
+
// the first code again: refused, and the connection it made is revoked
|
|
4279
|
+
const reused = await e(`grant_type=authorization_code&code=${code}`);
|
|
4280
|
+
const after = await w.api('POST', '/v1/payment_intents', 'amount=2500¤cy=usd', account);
|
|
4281
|
+
const others = await w.api('POST', '/v1/payment_intents', 'amount=2500¤cy=usd', String(basic.stripe_user_id));
|
|
4282
|
+
// the same code exchanged twice at once (a callback run twice): one exchange wins, and still the connection is revoked
|
|
4283
|
+
// (the order the loser marks first is pinned below, `midway`)
|
|
4284
|
+
const ids = async () => ((await w.api('GET', '/v1/accounts?limit=100')).body.data as Body[]).map((a) => String(a.id));
|
|
4285
|
+
const known = await ids();
|
|
4286
|
+
const raced = new URL((await w.authorize(authorizeQuery(), 'skip')).headers.get('location')!).searchParams.get('code');
|
|
4287
|
+
const racedAccount = (await ids()).find((a) => !known.includes(a)) ?? '';
|
|
4288
|
+
const pair = await Promise.all([e(`grant_type=authorization_code&code=${raced}`), e(`grant_type=authorization_code&code=${raced}`)]);
|
|
4289
|
+
const racedAfter = racedAccount ? await w.api('GET', '/v1/customers', undefined, racedAccount) : { status: 0, body: {} };
|
|
4290
|
+
// the order a race can take the other way: the second use marks the code while the first exchange is still writing
|
|
4291
|
+
// its connection (the state that second use leaves, seeded as it is written), so the first exchange, finding the
|
|
4292
|
+
// mark after its write, revokes the connection itself
|
|
4293
|
+
const midway = new URL((await w.authorize(authorizeQuery(), 'skip')).headers.get('location')!).searchParams.get('code')!;
|
|
4294
|
+
const midAccount = (await ids()).find((a) => !known.includes(a) && a !== racedAccount) ?? '';
|
|
4295
|
+
await applyTwinWrite('stripe', { operation: 'oauth_code.reuse', subjectType: '_oauth_code', subjectId: midway, fields: { reused: true }, occurredAt: '2026-03-01T00:05:00.000Z', actor: { kind: 'agent' } }, w.root);
|
|
4296
|
+
const midExchange = await e(`grant_type=authorization_code&code=${midway}`);
|
|
4297
|
+
const midAfter = midAccount ? await w.api('GET', '/v1/customers', undefined, midAccount) : { status: 0, body: {} };
|
|
4298
|
+
return shape && pi.status === 200 && pi.body.object === 'payment_intent' && retrieved.default_currency === 'usd'
|
|
4299
|
+
&& midExchange.status === 400 && midExchange.error === 'invalid_grant' && midAfter.status === 403
|
|
4300
|
+
&& noKey.status === 401 && publishable.status === 401 && noGrant.status === 400 && noGrant.error === 'invalid_request' && password.error === 'unsupported_grant_type'
|
|
4301
|
+
&& noCode.error === 'invalid_request' && missing.status === 400 && missing.error === 'invalid_grant' && missing.error_description === 'Authorization code does not exist: ac_nope'
|
|
4302
|
+
&& basic.status === 200 && basic.token_type === 'bearer' && basic.stripe_user_id !== account
|
|
4303
|
+
&& inTime.status === 200 && late.status === 400 && late.error === 'invalid_grant' && String(late.error_description).includes('expired')
|
|
4304
|
+
&& liveKey.error === 'invalid_grant' && asJson.status === 200 && /^acct_/.test(String(asJsonBody.stripe_user_id))
|
|
4305
|
+
&& reused.status === 400 && reused.error === 'invalid_grant' && String(reused.error_description).includes('already been used')
|
|
4306
|
+
&& after.status === 403 && (after.body.error as Body)?.code === 'account_invalid' && others.status === 200
|
|
4307
|
+
&& pair.filter((p) => p.status === 200).length <= 1 && pair.some((p) => p.error === 'invalid_grant') && racedAfter.status === 403;
|
|
4308
|
+
}),
|
|
4309
|
+
),
|
|
4310
|
+
// grant_type=refresh_token: "a new access token of an equal or lesser scope"; "Any existing access token with the same
|
|
4311
|
+
// scope and mode ... is revoked"; a refresh token of a revoked connection is refused.
|
|
4312
|
+
done('stripe.connect.oauth_refresh', 'connect', 'Connect OAuth refresh_token grant: a new access token of equal or lesser scope; invalid_scope / invalid_grant', 'api', 'niche', () =>
|
|
4313
|
+
withOAuth(async (w) => {
|
|
4314
|
+
const { token } = await w.connect();
|
|
4315
|
+
const e = async (body: string) => { const r = await w.endpoint('token', body); return { status: r.status, ...r.body } as Body; };
|
|
4316
|
+
const same = await e(`grant_type=refresh_token&refresh_token=${String(token.refresh_token)}`);
|
|
4317
|
+
const lesser = await e(`grant_type=refresh_token&refresh_token=${String(token.refresh_token)}&scope=read_only`);
|
|
4318
|
+
const bogus = await e(`grant_type=refresh_token&refresh_token=${String(token.refresh_token)}&scope=admin`);
|
|
4319
|
+
const liveRefresh = await w.endpoint('token', `grant_type=refresh_token&refresh_token=${String(token.refresh_token)}`, 'Bearer sk_live_platform');
|
|
4320
|
+
const noToken = await e('grant_type=refresh_token');
|
|
4321
|
+
const unknown = await e('grant_type=refresh_token&refresh_token=rt_nope');
|
|
4322
|
+
// a read_only connection cannot refresh into read_write
|
|
4323
|
+
const ro = (await w.connect('').then(async () => {
|
|
4324
|
+
const loc = new URL((await w.authorize(authorizeQuery().replace('scope=read_write', 'scope=read_only'), 'skip')).headers.get('location')!);
|
|
4325
|
+
return (await w.endpoint('token', `grant_type=authorization_code&code=${loc.searchParams.get('code')}`)).body;
|
|
4326
|
+
}));
|
|
4327
|
+
const greater = await e(`grant_type=refresh_token&refresh_token=${String(ro.refresh_token)}&scope=read_write`);
|
|
4328
|
+
await w.endpoint('deauthorize', `client_id=ca_twin_self&stripe_user_id=${String(token.stripe_user_id)}`);
|
|
4329
|
+
const revoked = await e(`grant_type=refresh_token&refresh_token=${String(token.refresh_token)}`);
|
|
4330
|
+
return same.status === 200 && same.scope === 'read_write' && same.refresh_token === token.refresh_token && same.stripe_user_id === token.stripe_user_id
|
|
4331
|
+
&& same.access_token !== token.access_token && /^sk_test_/.test(String(same.access_token))
|
|
4332
|
+
&& lesser.status === 200 && lesser.scope === 'read_only' && lesser.access_token !== same.access_token
|
|
4333
|
+
&& bogus.error === 'invalid_scope' && liveRefresh.body.error === 'invalid_grant' && noToken.error === 'invalid_request' && unknown.status === 400 && unknown.error === 'invalid_grant'
|
|
4334
|
+
&& ro.scope === 'read_only' && greater.error === 'invalid_scope' && revoked.error === 'invalid_grant';
|
|
4335
|
+
}),
|
|
4336
|
+
),
|
|
4337
|
+
// POST connect.stripe.com/oauth/deauthorize: `{ stripe_user_id }`, and then "the account can't be accessed by your
|
|
4338
|
+
// platform ... through the API" (the Stripe-Account header, /v1/accounts/{account}); invalid_request and invalid_client
|
|
4339
|
+
// as the reference lists them. "You can only revoke a Standard account's access": an account the platform made itself
|
|
4340
|
+
// was never connected by OAuth, so it is not connected to the application.
|
|
4341
|
+
done('stripe.connect.oauth_deauthorize', 'connect', 'Connect OAuth deauthorize: revokes the connection; the account is then refused with the Stripe-Account header; invalid_request / invalid_client', 'api', 'common', () =>
|
|
4342
|
+
withOAuth(async (w) => {
|
|
4343
|
+
const { token } = await w.connect();
|
|
4344
|
+
const other = (await w.connect()).token;
|
|
4345
|
+
const account = String(token.stripe_user_id);
|
|
4346
|
+
const e = async (body: string, auth?: string) => { const r = await w.endpoint('deauthorize', body, auth); return { status: r.status, ...r.body } as Body; };
|
|
4347
|
+
const made = String((await w.api('POST', '/v1/accounts', 'type=standard&country=US')).body.id);
|
|
4348
|
+
const noClient = await e(`stripe_user_id=${account}`);
|
|
4349
|
+
const noKey = await e(`client_id=ca_twin_self&stripe_user_id=${account}`, '');
|
|
4350
|
+
const noAccount = await e('client_id=ca_twin_self');
|
|
4351
|
+
const wrongClient = await e(`client_id=ca_nope&stripe_user_id=${account}`);
|
|
4352
|
+
const liveKey = await e(`client_id=ca_twin_self&stripe_user_id=${account}`, 'Bearer sk_live_platform');
|
|
4353
|
+
const notConnected = await e(`client_id=ca_twin_self&stripe_user_id=${made}`);
|
|
4354
|
+
const before = await w.api('GET', '/v1/customers', undefined, account);
|
|
4355
|
+
const revoked = await e(`client_id=ca_twin_self&stripe_user_id=${account}`);
|
|
4356
|
+
const asIt = await w.api('GET', '/v1/customers', undefined, account);
|
|
4357
|
+
const named = await w.api('GET', `/v1/accounts/${account}`);
|
|
4358
|
+
const again = await e(`client_id=ca_twin_self&stripe_user_id=${account}`);
|
|
4359
|
+
const listed = ((await w.api('GET', '/v1/accounts?limit=100')).body.data as Body[]).map((a) => a.id);
|
|
4360
|
+
const stillOthers = await w.api('GET', '/v1/customers', undefined, String(other.stripe_user_id));
|
|
4361
|
+
const stillMade = await w.api('GET', '/v1/customers', undefined, made);
|
|
4362
|
+
return before.status === 200 && revoked.status === 200 && Object.keys(revoked).sort().join(',') === 'status,stripe_user_id' && revoked.stripe_user_id === account
|
|
4363
|
+
&& asIt.status === 403 && (asIt.body.error as Body)?.code === 'account_invalid' && String((asIt.body.error as Body)?.message).includes(account)
|
|
4364
|
+
&& named.status === 403 && again.status === 400 && again.error === 'invalid_client'
|
|
4365
|
+
&& noKey.status === 401 && noClient.error === 'invalid_request' && noAccount.error === 'invalid_request' && wrongClient.error === 'invalid_client' && liveKey.error === 'invalid_client'
|
|
4366
|
+
&& notConnected.error === 'invalid_client' && stillOthers.status === 200 && stillMade.status === 200
|
|
4367
|
+
&& !listed.includes(account) && listed.includes(other.stripe_user_id) && listed.includes(made);
|
|
4368
|
+
}),
|
|
4369
|
+
),
|
|
4370
|
+
// account.application.authorized "Occurs whenever a user authorizes an application" and account.application.deauthorized
|
|
4371
|
+
// "whenever a user deauthorizes an application" (docs.stripe.com/api/events/types), each the connected account's event
|
|
4372
|
+
// (top-level `account`, Connect endpoints: docs.stripe.com/connect/webhooks) carrying the application.
|
|
4373
|
+
done('stripe.connect.oauth_events', 'connect', 'Connect OAuth events: account.application.authorized and .deauthorized reach Connect endpoints with the account and the application', 'api', 'common', async () => {
|
|
4374
|
+
const rx = await webhookReceiver();
|
|
4375
|
+
try {
|
|
4376
|
+
return await withOAuth(async (w) => {
|
|
4377
|
+
await w.api('POST', '/v1/webhook_endpoints', `url=${rx.url('/platform')}&enabled_events[]=*`);
|
|
4378
|
+
const connectEndpoint = await w.api('POST', '/v1/webhook_endpoints', `url=${rx.url('/connect')}&enabled_events[]=*&connect=true`);
|
|
4379
|
+
const { token } = await w.connect();
|
|
4380
|
+
const account = String(token.stripe_user_id);
|
|
4381
|
+
// a charge on the account, whose funds come due after the connection is revoked
|
|
4382
|
+
await w.api('POST', '/v1/charges', 'amount=5000¤cy=usd&source=tok_visa', account);
|
|
4383
|
+
const before = rx.got('/connect').filter((e) => e.account === account && e.type === 'charge.succeeded').length;
|
|
4384
|
+
const authorized = rx.got('/connect').filter((e) => e.type === 'account.application.authorized');
|
|
4385
|
+
// two deauthorizations at once: one revokes (and one event is sent), the other finds it no longer connected (a
|
|
4386
|
+
// regression guard: in one process the check and the revoke never interleave)
|
|
4387
|
+
const twice = await Promise.all([1, 2].map(() => w.endpoint('deauthorize', `client_id=ca_twin_self&stripe_user_id=${account}`)));
|
|
4388
|
+
const deauthorized = rx.got('/connect').filter((e) => e.type === 'account.application.deauthorized');
|
|
4389
|
+
const seen = rx.got('/connect').length;
|
|
4390
|
+
// days later its funds are available: the account's own balance.available no longer reaches the platform
|
|
4391
|
+
w.at('2026-03-06T00:00:00.000Z');
|
|
4392
|
+
const drained = await w.f(new Request('http://stripe.test/_twin/drain', { method: 'POST' }));
|
|
4393
|
+
const drainedBody = (await drained.json()) as { delivered: Array<{ type: string; account?: string }> };
|
|
4394
|
+
const leaked = rx.got('/connect').slice(seen).filter((e) => e.account === account);
|
|
4395
|
+
const app = authorized[0]?.data.object as Body | undefined;
|
|
4396
|
+
return connectEndpoint.status === 200 && connectEndpoint.body.connect === true
|
|
4397
|
+
&& authorized.length === 1 && authorized[0]!.account === account && app?.object === 'application' && app?.id === 'ca_twin_self' && typeof app?.name === 'string'
|
|
4398
|
+
&& before === 1 && drainedBody.delivered.some((d) => d.account === account && d.type === 'balance.available') && leaked.length === 0
|
|
4399
|
+
&& twice.filter((r) => r.status === 200).length === 1 && twice.some((r) => r.body.error === 'invalid_client')
|
|
4400
|
+
&& deauthorized.length === 1 && deauthorized[0]!.account === account && (deauthorized[0]!.data.object as Body).id === 'ca_twin_self'
|
|
4401
|
+
&& !rx.got('/platform').some((e) => e.type.startsWith('account.application.'));
|
|
4402
|
+
});
|
|
4403
|
+
} finally {
|
|
4404
|
+
rx.close();
|
|
4405
|
+
}
|
|
4406
|
+
}),
|
|
4407
|
+
// The token answer's access_token and stripe_publishable_key act as the connected account ("Use the Stripe-Account
|
|
4408
|
+
// header with your platform's secret key", the reference, for the deprecated keys): a request made with one is the
|
|
4409
|
+
// account's (its events are its own, GET /v1/account answers it), and it is not the platform's key at the OAuth
|
|
4410
|
+
// endpoints; a key no live connection holds, after a refresh
|
|
4411
|
+
// ("Any existing access token with the same scope and mode ... is revoked") or a deauthorize, is an invalid API key.
|
|
4412
|
+
done('stripe.connect.oauth_access_token_auth', 'connect', 'Connect OAuth keys: the access_token and stripe_publishable_key act as the connected account; a refreshed-away or revoked token is refused 401', 'api', 'niche', () =>
|
|
4413
|
+
withOAuth(async (w) => {
|
|
4414
|
+
const { token } = await w.connect();
|
|
4415
|
+
const account = String(token.stripe_user_id);
|
|
4416
|
+
const as = async (m: string, p: string, key: unknown, b?: string, acct?: string) => {
|
|
4417
|
+
const r = await w.f(new Request(`http://stripe.test${p}`, { method: m, headers: { authorization: `Bearer ${String(key)}`, 'content-type': 'application/x-www-form-urlencoded', ...(acct ? { 'stripe-account': acct } : {}) }, ...(b !== undefined ? { body: b } : {}) }));
|
|
4418
|
+
return { status: r.status, body: (await r.json()) as Body };
|
|
4419
|
+
};
|
|
4420
|
+
const me = await as('GET', '/v1/account', token.access_token);
|
|
4421
|
+
// curl -u sk_…: (Basic) is the same key
|
|
4422
|
+
const basicMe = await w.f(new Request('http://stripe.test/v1/account', { headers: { authorization: `Basic ${btoa(`${String(token.access_token)}:`)}` } }));
|
|
4423
|
+
const basicBody = (await basicMe.json()) as Body;
|
|
4424
|
+
const unknownAccount = await w.api('GET', '/v1/account', undefined, 'acct_twin_nope');
|
|
4425
|
+
// the account's key is not the platform's: it cannot exchange, refresh or deauthorize
|
|
4426
|
+
const asPlatform = await w.endpoint('token', `grant_type=refresh_token&refresh_token=${String(token.refresh_token)}`, `Bearer ${String(token.access_token)}`);
|
|
4427
|
+
const deauthAsIt = await w.endpoint('deauthorize', `client_id=ca_twin_self&stripe_user_id=${account}`, `Bearer ${String(token.access_token)}`);
|
|
4428
|
+
const platformSelf = await w.api('GET', '/v1/account');
|
|
4429
|
+
const viaHeader = await w.api('GET', '/v1/account', undefined, account);
|
|
4430
|
+
const made = await as('POST', '/v1/customers', token.access_token, 'email=buyer@connected.test');
|
|
4431
|
+
const theirs = ((await w.api('GET', '/v1/events?type=customer.created', undefined, account)).body.data as Body[]).map((e) => ((e.data as Body).object as Body).id);
|
|
4432
|
+
const ours = ((await w.api('GET', '/v1/events?type=customer.created')).body.data as Body[]).map((e) => ((e.data as Body).object as Body).id);
|
|
4433
|
+
// the publishable key acts as the account and is still only a publishable key
|
|
4434
|
+
const pkRead = await as('GET', '/v1/customers', token.stripe_publishable_key);
|
|
4435
|
+
const elsewhere = await as('GET', '/v1/customers', token.access_token, undefined, 'acct_twin_other');
|
|
4436
|
+
const refreshed = (await w.endpoint('token', `grant_type=refresh_token&refresh_token=${String(token.refresh_token)}`)).body;
|
|
4437
|
+
const oldToken = await as('GET', '/v1/customers', token.access_token);
|
|
4438
|
+
const newToken = await as('GET', '/v1/customers', refreshed.access_token);
|
|
4439
|
+
const forged = await as('GET', '/v1/customers', `sk_test_oauth_${account}_9`);
|
|
4440
|
+
await w.endpoint('deauthorize', `client_id=ca_twin_self&stripe_user_id=${account}`);
|
|
4441
|
+
const afterRevoke = await as('GET', '/v1/customers', refreshed.access_token);
|
|
4442
|
+
return me.status === 200 && me.body.id === account && me.body.type === 'standard' && platformSelf.body.id === 'acct_twin_self' && viaHeader.body.id === account
|
|
4443
|
+
&& basicMe.status === 200 && basicBody.id === account && unknownAccount.status === 403 && (unknownAccount.body.error as Body)?.code === 'account_invalid'
|
|
4444
|
+
&& asPlatform.status === 401 && deauthAsIt.status === 401
|
|
4445
|
+
&& made.status === 200 && theirs.includes(made.body.id) && !ours.includes(made.body.id)
|
|
4446
|
+
&& pkRead.status === 403 && (pkRead.body.error as Body)?.code === 'secret_key_required'
|
|
4447
|
+
&& elsewhere.status === 403 && (elsewhere.body.error as Body)?.code === 'account_invalid'
|
|
4448
|
+
&& oldToken.status === 401 && newToken.status === 200 && ((newToken.body.data as Body[]) ?? []).some((c) => c.id === made.body.id)
|
|
4449
|
+
&& forged.status === 401 && afterRevoke.status === 401;
|
|
4450
|
+
}),
|
|
4451
|
+
),
|
|
4452
|
+
todo('stripe.connect.oauth_scope_enforcement', 'connect', 'Connect OAuth: a read_only connection (or access token) is refused writes as the account', 'api', 'niche'),
|
|
4453
|
+
todo('stripe.connect.oauth_existing_account', 'connect', 'Connect OAuth: sign in and connect an existing Stripe account instead of creating one', 'api', 'niche'),
|
|
4454
|
+
todo('stripe.connect.oauth_account_application', 'connect', 'Connect OAuth: the full account application form (the page offers only the test-mode Skip this form)', 'ui', 'niche'),
|
|
4455
|
+
|
|
2611
4456
|
// ── Connect: account links (hosted onboarding) ──
|
|
2612
4457
|
done('stripe.connect.account_links', 'connect', 'Connect: account_links (onboarding URL)', 'api', 'niche', () =>
|
|
2613
4458
|
withRoot(async (h) => {
|
|
@@ -2667,15 +4512,20 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2667
4512
|
const g = await h({ m: 'GET', p: `/v1/accounts/${id(acct)}/capabilities/card_payments` });
|
|
2668
4513
|
const req = await h({ m: 'POST', p: `/v1/accounts/${id(acct)}/capabilities/card_payments`, b: 'requested=true' });
|
|
2669
4514
|
const miss = await h({ m: 'GET', p: `/v1/accounts/${id(acct)}/capabilities/bogus` });
|
|
2670
|
-
|
|
4515
|
+
// requested on an account that still owes its information, Stripe's review leaves it inactive with what is due
|
|
4516
|
+
// (the capability's `requirements`; "inactive ... Check requirements", docs.stripe.com/api/capabilities/object;
|
|
4517
|
+
// semantics/connect.ts review, 44dea3cb3), where it used to answer a pending that nothing ever moved
|
|
4518
|
+
const due = ((field(req, 'requirements') as Body | undefined)?.currently_due as string[] | undefined) ?? [];
|
|
4519
|
+
return ok(g) && field(g, 'object') === 'capability' && ok(req) && field(req, 'requested') === true && field(req, 'status') === 'inactive'
|
|
4520
|
+
&& due.includes('business_profile.mcc') && miss.status === 404;
|
|
2671
4521
|
}),
|
|
2672
4522
|
),
|
|
2673
4523
|
|
|
2674
4524
|
// ── Connect: transfer reversals ──
|
|
2675
4525
|
done('stripe.connect.transfer_reversals', 'connect', 'Connect: transfer reversals (partial + full)', 'api', 'niche', () =>
|
|
2676
4526
|
withRoot(async (h) => {
|
|
2677
|
-
const
|
|
2678
|
-
const tr = await h({ m: 'POST', p: '/v1/transfers', b: `amount=1000¤cy=usd&destination=${
|
|
4527
|
+
const seller = await onboardedSeller(h);
|
|
4528
|
+
const tr = await h({ m: 'POST', p: '/v1/transfers', b: `amount=1000¤cy=usd&destination=${seller}` });
|
|
2679
4529
|
if (!ok(tr)) return false;
|
|
2680
4530
|
const rev1 = await h({ m: 'POST', p: `/v1/transfers/${id(tr)}/reversals`, b: 'amount=400' });
|
|
2681
4531
|
if (!ok(rev1) || field(rev1, 'object') !== 'transfer_reversal') return false;
|
|
@@ -2692,8 +4542,11 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2692
4542
|
// ── Connect: application fees + refunds ──
|
|
2693
4543
|
done('stripe.connect.application_fees', 'connect', 'Connect: application fees + fee refunds', 'api', 'niche', () =>
|
|
2694
4544
|
withRoot(async (h) => {
|
|
2695
|
-
|
|
2696
|
-
|
|
4545
|
+
// a destination charge with an application fee earns the platform its fee
|
|
4546
|
+
const acct = await h({ m: 'POST', p: '/v1/accounts', b: 'type=express&country=US' });
|
|
4547
|
+
await h({ m: 'POST', p: '/v1/charges', b: `amount=5000¤cy=usd&source=tok_visa&application_fee_amount=1000&transfer_data[destination]=${id(acct)}` });
|
|
4548
|
+
const fee = { status: 200, body: (((await h({ m: 'GET', p: '/v1/application_fees' })).body as Body).data as Body[])[0]! } as StripeResponse;
|
|
4549
|
+
if (!fee.body || field(fee, 'object') !== 'application_fee' || field(fee, 'amount') !== 1000) return false;
|
|
2697
4550
|
const g = await h({ m: 'GET', p: `/v1/application_fees/${id(fee)}` });
|
|
2698
4551
|
const l = await h({ m: 'GET', p: '/v1/application_fees' });
|
|
2699
4552
|
const ref = await h({ m: 'POST', p: `/v1/application_fees/${id(fee)}/refunds`, b: 'amount=400' });
|
|
@@ -2706,11 +4559,11 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2706
4559
|
// ── Files (uploads) + retrieve ──
|
|
2707
4560
|
done('stripe.files.upload', 'files', 'Files: upload (purpose required) + retrieve/list', 'api', 'niche', () =>
|
|
2708
4561
|
withRoot(async (h) => {
|
|
2709
|
-
const f = await h({ m: 'POST', p: '/v1/files', b: 'purpose=dispute_evidence&
|
|
4562
|
+
const f = await h({ m: 'POST', p: '/v1/files', b: 'purpose=dispute_evidence&file=evidence.png' });
|
|
2710
4563
|
if (!ok(f) || field(f, 'object') !== 'file' || field(f, 'purpose') !== 'dispute_evidence') return false;
|
|
2711
4564
|
const g = await h({ m: 'GET', p: `/v1/files/${id(f)}` });
|
|
2712
4565
|
const l = await h({ m: 'GET', p: '/v1/files' });
|
|
2713
|
-
const bad = await h({ m: 'POST', p: '/v1/files', b: '
|
|
4566
|
+
const bad = await h({ m: 'POST', p: '/v1/files', b: 'file=x.png' });
|
|
2714
4567
|
return ok(g) && id(g) === id(f) && field(l, 'object') === 'list' && bad.status === 400;
|
|
2715
4568
|
}),
|
|
2716
4569
|
),
|
|
@@ -2741,13 +4594,17 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2741
4594
|
// scoped overwrite + find/delete are produced ONLY by this feature.)
|
|
2742
4595
|
done('stripe.connect.secrets', 'connect', 'Connect: apps secret store', 'api', 'niche', () =>
|
|
2743
4596
|
withRoot(async (h) => {
|
|
4597
|
+
// a secret's value is "nullable string Includable": answered only when the request expands it
|
|
4598
|
+
// (docs.stripe.com/api/secret_management; stripe-version.ts INCLUDABLE, 1d786078c), never on a plain answer
|
|
2744
4599
|
const set = await h({ m: 'POST', p: '/v1/apps/secrets', b: 'name=api_key&scope[type]=account&payload=sk_secret_1' });
|
|
2745
|
-
if (!ok(set) || field(set, 'object') !== 'apps.secret' || field(set, 'payload') !==
|
|
2746
|
-
const
|
|
4600
|
+
if (!ok(set) || field(set, 'object') !== 'apps.secret' || field(set, 'payload') !== undefined) return false;
|
|
4601
|
+
const plainFind = await h({ m: 'GET', p: '/v1/apps/secrets/find?name=api_key&scope[type]=account' });
|
|
4602
|
+
if (!ok(plainFind) || field(plainFind, 'payload') !== undefined) return false;
|
|
4603
|
+
const find = await h({ m: 'GET', p: '/v1/apps/secrets/find?name=api_key&scope[type]=account&expand[]=payload' });
|
|
2747
4604
|
if (!ok(find) || field(find, 'payload') !== 'sk_secret_1') return false;
|
|
2748
4605
|
// overwrite: same name+scope updates in place (no duplicate in the list).
|
|
2749
4606
|
const set2 = await h({ m: 'POST', p: '/v1/apps/secrets', b: 'name=api_key&scope[type]=account&payload=sk_secret_2' });
|
|
2750
|
-
const find2 = await h({ m: 'GET', p: '/v1/apps/secrets/find?name=api_key&scope[type]=account' });
|
|
4607
|
+
const find2 = await h({ m: 'GET', p: '/v1/apps/secrets/find?name=api_key&scope[type]=account&expand[]=payload' });
|
|
2751
4608
|
const list = await h({ m: 'GET', p: '/v1/apps/secrets?scope[type]=account' });
|
|
2752
4609
|
if (!ok(set2) || field(find2, 'payload') !== 'sk_secret_2' || ((list.body as Body).data as Body[]).length !== 1) return false;
|
|
2753
4610
|
// delete → find 404s.
|
|
@@ -2770,16 +4627,17 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2770
4627
|
done('stripe.payment_intents.apply_customer_balance', 'payment_intents', 'PaymentIntents: apply_customer_balance', 'api', 'niche', () =>
|
|
2771
4628
|
withRoot(async (h) => {
|
|
2772
4629
|
const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=acb@twin.test' });
|
|
2773
|
-
// partial funds first
|
|
2774
|
-
|
|
2775
|
-
|
|
4630
|
+
// partial funds first, through the test helper ("Create an incoming testmode bank transfer",
|
|
4631
|
+
// docs.stripe.com/api/cash_balance/fund_cash_balance): the twin-invented POST .../cash_balance_transactions is gone (5decb1d77)
|
|
4632
|
+
await h({ m: 'POST', p: `/v1/test_helpers/customers/${id(cust)}/fund_cash_balance`, b: 'amount=600¤cy=usd' });
|
|
4633
|
+
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
4634
|
if (!ok(pi)) return false;
|
|
2777
4635
|
const partial = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/apply_customer_balance` });
|
|
2778
4636
|
if (!ok(partial) || field(partial, 'status') !== 'requires_action') return false;
|
|
2779
4637
|
const na = field(partial, 'next_action') as Body;
|
|
2780
4638
|
if (na?.type !== 'display_bank_transfer_instructions' || (na.display_bank_transfer_instructions as Body).amount_remaining !== 400) return false;
|
|
2781
4639
|
// top up the rest → succeeds.
|
|
2782
|
-
await h({ m: 'POST', p: `/v1/customers/${id(cust)}/
|
|
4640
|
+
await h({ m: 'POST', p: `/v1/test_helpers/customers/${id(cust)}/fund_cash_balance`, b: 'amount=400¤cy=usd' });
|
|
2783
4641
|
const full = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/apply_customer_balance` });
|
|
2784
4642
|
if (!ok(full) || field(full, 'status') !== 'succeeded' || field(full, 'amount_received') !== 1000) return false;
|
|
2785
4643
|
// a succeeded PI 400s; unknown id 404.
|
|
@@ -2795,7 +4653,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2795
4653
|
// + the amount-match check are produced ONLY by this feature.)
|
|
2796
4654
|
done('stripe.payment_intents.verify_microdeposits', 'payment_intents', 'PaymentIntents: verify_microdeposits (ACH)', 'api', 'common', () =>
|
|
2797
4655
|
withRoot(async (h) => {
|
|
2798
|
-
const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=5000¤cy=usd&
|
|
4656
|
+
const pi = await h({ m: 'POST', p: '/v1/payment_intents', b: 'amount=5000¤cy=usd&allowed_payment_method_types[]=us_bank_account' });
|
|
2799
4657
|
if (!ok(pi)) return false;
|
|
2800
4658
|
const conf = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/confirm`, b: 'payment_method=pm_us_bank_account' });
|
|
2801
4659
|
if (!ok(conf) || field(conf, 'status') !== 'requires_action') return false;
|
|
@@ -2806,10 +4664,14 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2806
4664
|
const both = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/verify_microdeposits`, b: 'amounts[]=32&amounts[]=45&descriptor_code=SM11AA' });
|
|
2807
4665
|
const none = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/verify_microdeposits` });
|
|
2808
4666
|
if (both.status !== 400 || none.status !== 400) return false;
|
|
2809
|
-
// correct amounts →
|
|
4667
|
+
// correct amounts → processing, next_action cleared: "When the bank account is successfully verified, Stripe returns
|
|
4668
|
+
// the PaymentIntent object with a status of `processing`" (docs.stripe.com/payments/ach-direct-debit/accept-a-payment;
|
|
4669
|
+
// 242035d1c); a test-mode debit then settles ("Test transactions settle instantly"), so the next read has succeeded
|
|
2810
4670
|
const good = await h({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/verify_microdeposits`, b: 'amounts[]=32&amounts[]=45' });
|
|
4671
|
+
const settled = await h({ m: 'GET', p: `/v1/payment_intents/${id(pi)}` });
|
|
2811
4672
|
const missing = await h({ m: 'POST', p: '/v1/payment_intents/pi_nope/verify_microdeposits', b: 'amounts[]=32&amounts[]=45' });
|
|
2812
|
-
return ok(good) && field(good, 'status') === '
|
|
4673
|
+
return ok(good) && field(good, 'status') === 'processing' && field(good, 'next_action') === null
|
|
4674
|
+
&& field(settled, 'status') === 'succeeded' && missing.status === 404;
|
|
2813
4675
|
}),
|
|
2814
4676
|
),
|
|
2815
4677
|
// SetupIntent micro-deposit verification: same flow for saving an ACH bank account — confirm
|
|
@@ -2817,7 +4679,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2817
4679
|
// wrong descriptor 400s. (the requires_action microdeposit state is produced ONLY by this feature.)
|
|
2818
4680
|
done('stripe.setup_intents.verify_microdeposits', 'payment_methods', 'SetupIntents: verify_microdeposits (ACH)', 'api', 'common', () =>
|
|
2819
4681
|
withRoot(async (h) => {
|
|
2820
|
-
const si = await h({ m: 'POST', p: '/v1/setup_intents', b: '
|
|
4682
|
+
const si = await h({ m: 'POST', p: '/v1/setup_intents', b: 'allowed_payment_method_types[]=us_bank_account' });
|
|
2821
4683
|
if (!ok(si)) return false;
|
|
2822
4684
|
const conf = await h({ m: 'POST', p: `/v1/setup_intents/${id(si)}/confirm`, b: 'payment_method=pm_us_bank_account' });
|
|
2823
4685
|
if (!ok(conf) || field(conf, 'status') !== 'requires_action' || (field(conf, 'next_action') as Body)?.type !== 'verify_with_microdeposits') return false;
|
|
@@ -2957,7 +4819,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2957
4819
|
// editing a finalized (non-draft) invoice 400s; missing lines 400; unknown invoice 404.
|
|
2958
4820
|
await h({ m: 'POST', p: `/v1/invoices/${id(inv)}/finalize` });
|
|
2959
4821
|
const afterFinal = await h({ m: 'POST', p: `/v1/invoices/${id(inv)}/add_lines`, b: 'lines[0][amount]=100' });
|
|
2960
|
-
const noLines = await h({ m: 'POST', p: `/v1/invoices/${id(inv)}/add_lines`, b: '
|
|
4822
|
+
const noLines = await h({ m: 'POST', p: `/v1/invoices/${id(inv)}/add_lines`, b: 'invoice_metadata[x]=1' });
|
|
2961
4823
|
const missing = await h({ m: 'GET', p: '/v1/invoices/in_nope/lines' });
|
|
2962
4824
|
return afterFinal.status === 400 && (noLines.status === 400) && missing.status === 404;
|
|
2963
4825
|
}),
|
|
@@ -3026,28 +4888,35 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
3026
4888
|
// summary are produced ONLY by this feature.)
|
|
3027
4889
|
done('stripe.billing.credit_grants', 'billing', 'Billing credit grants (prepaid credits)', 'api', 'niche', () =>
|
|
3028
4890
|
withRoot(async (h) => {
|
|
4891
|
+
// the served spec requires what a grant applies to (applicability_config) and what a balance summary filters on
|
|
4892
|
+
// (filter), which the parameter check enforces (e6bfff516: "a missing required one [answers] 400
|
|
4893
|
+
// parameter_missing", docs.stripe.com/error-codes#parameter-missing); every request gives them, so each refusal
|
|
4894
|
+
// below is still the one it names
|
|
4895
|
+
const APPLIES = 'applicability_config[scope][price_type]=metered';
|
|
4896
|
+
const METERED = 'filter[type]=applicability_scope&filter[applicability_scope][price_type]=metered';
|
|
3029
4897
|
const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=credgr@twin.test' });
|
|
3030
|
-
const g = await h({ m: 'POST', p: '/v1/billing/credit_grants', b: `customer=${id(cust)}&category=promotional&name=Welcome&amount[type]=monetary&amount[monetary][value]=1000&amount[monetary][currency]=usd` });
|
|
4898
|
+
const g = await h({ m: 'POST', p: '/v1/billing/credit_grants', b: `customer=${id(cust)}&category=promotional&name=Welcome&amount[type]=monetary&amount[monetary][value]=1000&amount[monetary][currency]=usd&${APPLIES}` });
|
|
3031
4899
|
if (!ok(g) || field(g, 'object') !== 'billing.credit_grant' || field(g, 'category') !== 'promotional') return false;
|
|
3032
4900
|
if (((field(g, 'amount') as Body)?.monetary as Body)?.value !== 1000) return false;
|
|
3033
4901
|
const get = await h({ m: 'GET', p: `/v1/billing/credit_grants/${id(g)}` });
|
|
3034
4902
|
const list = await h({ m: 'GET', p: `/v1/billing/credit_grants?customer=${id(cust)}` });
|
|
3035
4903
|
if (!ok(get) || id(get) !== id(g) || ((list.body as Body).data as Body[]).length !== 1) return false;
|
|
3036
4904
|
// balance summary reflects the active grant.
|
|
3037
|
-
const sum = await h({ m: 'GET', p: `/v1/billing/credit_balance_summary?customer=${id(cust)}` });
|
|
4905
|
+
const sum = await h({ m: 'GET', p: `/v1/billing/credit_balance_summary?customer=${id(cust)}&${METERED}` });
|
|
3038
4906
|
const balances = (sum.body as Body).balances as Body[];
|
|
3039
4907
|
if (!ok(sum) || (((balances[0]!.available_balance as Body).monetary as Body).value) !== 1000) return false;
|
|
3040
4908
|
// void clears it from the balance.
|
|
3041
4909
|
const voided = await h({ m: 'POST', p: `/v1/billing/credit_grants/${id(g)}/void` });
|
|
3042
4910
|
if (!ok(voided) || field(voided, 'voided_at') === null) return false;
|
|
3043
|
-
const sum2 = await h({ m: 'GET', p: `/v1/billing/credit_balance_summary?customer=${id(cust)}` });
|
|
4911
|
+
const sum2 = await h({ m: 'GET', p: `/v1/billing/credit_balance_summary?customer=${id(cust)}&${METERED}` });
|
|
3044
4912
|
if (((sum2.body as Body).balances as Body[]).length !== 0) return false;
|
|
3045
4913
|
// vendor errors.
|
|
3046
|
-
const noCust = await h({ m: 'POST', p: '/v1/billing/credit_grants', b: 'customer=cus_nope&category=paid&amount[type]=monetary&amount[monetary][value]=10&amount[monetary][currency]=usd' });
|
|
3047
|
-
const badCat = await h({ m: 'POST', p: '/v1/billing/credit_grants', b: `customer=${id(cust)}&category=bogus&amount[type]=monetary&amount[monetary][value]=10&amount[monetary][currency]=usd` });
|
|
3048
|
-
const noAmt = await h({ m: 'POST', p: '/v1/billing/credit_grants', b: `customer=${id(cust)}&category=paid` });
|
|
4914
|
+
const noCust = await h({ m: 'POST', p: '/v1/billing/credit_grants', b: 'customer=cus_nope&category=paid&amount[type]=monetary&amount[monetary][value]=10&amount[monetary][currency]=usd&' + APPLIES });
|
|
4915
|
+
const badCat = await h({ m: 'POST', p: '/v1/billing/credit_grants', b: `customer=${id(cust)}&category=bogus&amount[type]=monetary&amount[monetary][value]=10&amount[monetary][currency]=usd&${APPLIES}` });
|
|
4916
|
+
const noAmt = await h({ m: 'POST', p: '/v1/billing/credit_grants', b: `customer=${id(cust)}&category=paid&${APPLIES}` });
|
|
3049
4917
|
const missing = await h({ m: 'GET', p: '/v1/billing/credit_grants/credgr_nope' });
|
|
3050
|
-
return noCust.status === 404 && badCat.status === 400 &&
|
|
4918
|
+
return noCust.status === 404 && badCat.status === 400 && ((badCat.body as Body).error as Body)?.code === 'parameter_invalid_string_enum'
|
|
4919
|
+
&& noAmt.status === 400 && ((noAmt.body as Body).error as Body)?.param === 'amount' && missing.status === 404;
|
|
3051
4920
|
}),
|
|
3052
4921
|
),
|
|
3053
4922
|
// Billing meters lifecycle: create→list (status filter)→deactivate (active→inactive)→
|
|
@@ -3064,12 +4933,14 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
3064
4933
|
const e1 = await h({ m: 'POST', p: '/v1/billing/meter_events', b: `event_name=api_request×tamp=100&payload[value]=3&payload[stripe_customer_id]=${id(cust)}` });
|
|
3065
4934
|
const e2 = await h({ m: 'POST', p: '/v1/billing/meter_events', b: `event_name=api_request×tamp=200&payload[value]=4&payload[stripe_customer_id]=${id(cust)}` });
|
|
3066
4935
|
if (!ok(e1) || !ok(e2) || field(e1, 'object') !== 'billing.meter_event') return false;
|
|
3067
|
-
const summary = await h({ m: 'GET', p: `/v1/billing/meters/${id(m)}/event_summaries?customer=${id(cust)}&start_time=0&end_time=
|
|
4936
|
+
const summary = await h({ m: 'GET', p: `/v1/billing/meters/${id(m)}/event_summaries?customer=${id(cust)}&start_time=0&end_time=1020` });
|
|
3068
4937
|
const sData = (summary.body as Body).data as Body[];
|
|
3069
4938
|
if (!ok(summary) || sData[0]!.aggregated_value !== 7) return false;
|
|
3070
4939
|
// an out-of-window event is excluded.
|
|
3071
|
-
const summaryNarrow = await h({ m: 'GET', p: `/v1/billing/meters/${id(m)}/event_summaries?customer=${id(cust)}&start_time=
|
|
4940
|
+
const summaryNarrow = await h({ m: 'GET', p: `/v1/billing/meters/${id(m)}/event_summaries?customer=${id(cust)}&start_time=120&end_time=1020` });
|
|
3072
4941
|
if (((summaryNarrow.body as Body).data as Body[])[0]!.aggregated_value !== 4) return false;
|
|
4942
|
+
// the window must be aligned with minute boundaries
|
|
4943
|
+
if ((await h({ m: 'GET', p: `/v1/billing/meters/${id(m)}/event_summaries?customer=${id(cust)}&start_time=150&end_time=1020` })).status !== 400) return false;
|
|
3073
4944
|
// deactivate → inactive; the active filter excludes it; reactivate → active.
|
|
3074
4945
|
const off = await h({ m: 'POST', p: `/v1/billing/meters/${id(m)}/deactivate` });
|
|
3075
4946
|
if (!ok(off) || field(off, 'status') !== 'inactive' || (field(off, 'status_transitions') as Body).deactivated_at === null) return false;
|
|
@@ -3088,7 +4959,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
3088
4959
|
done('stripe.billing.alerts', 'billing', 'Billing usage alerts (thresholds)', 'api', 'niche', () =>
|
|
3089
4960
|
withRoot(async (h) => {
|
|
3090
4961
|
const m = await h({ m: 'POST', p: '/v1/billing/meters', b: 'display_name=Calls&event_name=calls&default_aggregation[formula]=count' });
|
|
3091
|
-
const a = await h({ m: 'POST', p: `/v1/billing/alerts`, b: `alert_type=usage_threshold&title=Heavy use&usage_threshold[gte]=100&usage_threshold[meter]=${id(m)}` });
|
|
4962
|
+
const a = await h({ m: 'POST', p: `/v1/billing/alerts`, b: `alert_type=usage_threshold&title=Heavy use&usage_threshold[gte]=100&usage_threshold[meter]=${id(m)}&usage_threshold[recurrence]=one_time` });
|
|
3092
4963
|
if (!ok(a) || field(a, 'object') !== 'billing.alert' || field(a, 'status') !== 'active') return false;
|
|
3093
4964
|
if ((field(a, 'usage_threshold') as Body).gte !== 100) return false;
|
|
3094
4965
|
const get = await h({ m: 'GET', p: `/v1/billing/alerts/${id(a)}` });
|
|
@@ -3100,7 +4971,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
3100
4971
|
const arch = await h({ m: 'POST', p: `/v1/billing/alerts/${id(a)}/archive` });
|
|
3101
4972
|
// vendor errors.
|
|
3102
4973
|
const badType = await h({ m: 'POST', p: '/v1/billing/alerts', b: 'alert_type=bogus&title=x&usage_threshold[gte]=1&usage_threshold[meter]=' + id(m) });
|
|
3103
|
-
const badMeter = await h({ m: 'POST', p: '/v1/billing/alerts', b: 'alert_type=usage_threshold&title=x&usage_threshold[gte]=1&usage_threshold[meter]=mtr_nope' });
|
|
4974
|
+
const badMeter = await h({ m: 'POST', p: '/v1/billing/alerts', b: 'alert_type=usage_threshold&title=x&usage_threshold[gte]=1&usage_threshold[meter]=mtr_nope&usage_threshold[recurrence]=one_time' });
|
|
3104
4975
|
const missing = await h({ m: 'GET', p: '/v1/billing/alerts/alert_nope' });
|
|
3105
4976
|
return ok(on) && field(on, 'status') === 'active' && ok(arch) && field(arch, 'status') === 'archived' &&
|
|
3106
4977
|
badType.status === 400 && badMeter.status === 400 && missing.status === 404;
|
|
@@ -3155,8 +5026,9 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
3155
5026
|
// before subscribing: no active entitlements.
|
|
3156
5027
|
const before = await h({ m: 'GET', p: `/v1/entitlements/active_entitlements?customer=${id(cust)}` });
|
|
3157
5028
|
if (!ok(before) || ((before.body as Body).data as Body[]).length !== 0) return false;
|
|
3158
|
-
// subscribe → entitled to the product's feature.
|
|
3159
|
-
const
|
|
5029
|
+
// subscribe (the card pays the first invoice) → entitled to the product's feature.
|
|
5030
|
+
const card = await h({ m: 'POST', p: '/v1/payment_methods/pm_card_visa/attach', b: `customer=${id(cust)}` });
|
|
5031
|
+
const sub = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&items[0][price]=${id(price)}&default_payment_method=${id(card)}` });
|
|
3160
5032
|
if (!ok(sub)) return false;
|
|
3161
5033
|
const after = await h({ m: 'GET', p: `/v1/entitlements/active_entitlements?customer=${id(cust)}` });
|
|
3162
5034
|
const data = (after.body as Body).data as Body[];
|
|
@@ -3176,7 +5048,9 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
3176
5048
|
withRoot(async (h) => {
|
|
3177
5049
|
const calc = await h({ m: 'POST', p: '/v1/tax/calculations', b: 'currency=usd&line_items[0][amount]=1000&line_items[0][reference]=sku_1&customer_details[address][country]=US' });
|
|
3178
5050
|
if (!ok(calc)) return false;
|
|
3179
|
-
|
|
5051
|
+
// a transaction's line_items is "nullable object Includable" (docs.stripe.com/api/tax/transactions/object; 107591e5f):
|
|
5052
|
+
// absent unless the request expands it, as it is here and on the reversal
|
|
5053
|
+
const txn = await h({ m: 'POST', p: '/v1/tax/transactions/create_from_calculation', b: `calculation=${id(calc)}&reference=order_123&expand[]=line_items` });
|
|
3180
5054
|
if (!ok(txn) || field(txn, 'object') !== 'tax.transaction' || field(txn, 'type') !== 'transaction' || field(txn, 'reference') !== 'order_123') return false;
|
|
3181
5055
|
const txnLines = ((field(txn, 'line_items') as Body).data as Body[]) ?? [];
|
|
3182
5056
|
if (txnLines.length !== 1 || txnLines[0]!.amount_tax !== 100) return false;
|
|
@@ -3184,7 +5058,8 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
3184
5058
|
const li = await h({ m: 'GET', p: `/v1/tax/transactions/${id(txn)}/line_items` });
|
|
3185
5059
|
if (!ok(g) || id(g) !== id(txn) || ((li.body as Body).data as Body[]).length !== 1) return false;
|
|
3186
5060
|
// a reversal NEGATES the amounts.
|
|
3187
|
-
|
|
5061
|
+
if (field(g, 'line_items') !== undefined) return false;
|
|
5062
|
+
const rev = await h({ m: 'POST', p: '/v1/tax/transactions/create_reversal', b: `original_transaction=${id(txn)}&reference=refund_123&mode=full&expand[]=line_items` });
|
|
3188
5063
|
if (!ok(rev) || field(rev, 'type') !== 'reversal') return false;
|
|
3189
5064
|
const revLines = ((field(rev, 'line_items') as Body).data as Body[]) ?? [];
|
|
3190
5065
|
if (revLines[0]!.amount_tax !== -100 || (field(rev, 'reversal') as Body)?.original_transaction !== id(txn)) return false;
|
|
@@ -3243,25 +5118,19 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
3243
5118
|
ok(li) && ((li.body as Body).data as Body[]).length === 1;
|
|
3244
5119
|
}),
|
|
3245
5120
|
),
|
|
3246
|
-
// Discount delete:
|
|
3247
|
-
//
|
|
3248
|
-
//
|
|
3249
|
-
//
|
|
3250
|
-
done('stripe.discounts.delete', 'discounts', '
|
|
5121
|
+
// Discount delete: a subscription's coupon (discounts[0][coupon]) is removed by DELETE …/discount
|
|
5122
|
+
// (→ {object:'discount',deleted:true}) and the subscription's discounts are cleared. A customer's discount has no
|
|
5123
|
+
// create parameter in the served version (the customer-level coupon was removed), so an unknown customer's DELETE
|
|
5124
|
+
// answers 404.
|
|
5125
|
+
done('stripe.discounts.delete', 'discounts', 'Subscription discount delete', 'api', 'common', () =>
|
|
3251
5126
|
withRoot(async (h) => {
|
|
3252
5127
|
const coupon = await h({ m: 'POST', p: '/v1/coupons', b: 'percent_off=20&duration=forever' });
|
|
3253
|
-
const cust = await h({ m: 'POST', p: '/v1/customers', b:
|
|
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)}` });
|
|
5128
|
+
const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=disc-del@twin.test' });
|
|
5129
|
+
const sub = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&discounts[0][coupon]=${id(coupon)}` });
|
|
3261
5130
|
if (((field(sub, 'discounts') as Body[]) ?? []).length !== 1) return false;
|
|
3262
5131
|
const delSub = await h({ m: 'DELETE', p: `/v1/subscriptions/${id(sub)}/discount` });
|
|
3263
5132
|
const s = await h({ m: 'GET', p: `/v1/subscriptions/${id(sub)}` });
|
|
3264
|
-
const badCoupon = await h({ m: 'POST', p: '/v1/
|
|
5133
|
+
const badCoupon = await h({ m: 'POST', p: '/v1/subscriptions', b: `customer=${id(cust)}&discounts[0][coupon]=coupon_nope` });
|
|
3265
5134
|
const missing = await h({ m: 'DELETE', p: '/v1/customers/cus_nope/discount' });
|
|
3266
5135
|
return ok(delSub) && field(delSub, 'deleted') === true && ((field(s, 'discounts') as Body[]) ?? []).length === 0 &&
|
|
3267
5136
|
badCoupon.status === 400 && missing.status === 404;
|
|
@@ -3272,7 +5141,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
3272
5141
|
done('stripe.promotion_codes.update', 'discounts', 'Promotion codes: update (active toggle)', 'api', 'common', () =>
|
|
3273
5142
|
withRoot(async (h) => {
|
|
3274
5143
|
const coupon = await h({ m: 'POST', p: '/v1/coupons', b: 'percent_off=10&duration=once' });
|
|
3275
|
-
const pc = await h({ m: 'POST', p: '/v1/promotion_codes', b: `coupon=${id(coupon)}&code=UPD10` });
|
|
5144
|
+
const pc = await h({ m: 'POST', p: '/v1/promotion_codes', b: `promotion[type]=coupon&promotion[coupon]=${id(coupon)}&code=UPD10` });
|
|
3276
5145
|
if (!ok(pc) || field(pc, 'active') !== true) return false;
|
|
3277
5146
|
const off = await h({ m: 'POST', p: `/v1/promotion_codes/${id(pc)}`, b: 'active=false&metadata[campaign]=spring' });
|
|
3278
5147
|
if (!ok(off) || field(off, 'active') !== false || ((field(off, 'metadata') as Body)?.campaign) !== 'spring') return false;
|
|
@@ -3327,21 +5196,23 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
3327
5196
|
// retrieve returned nothing, if a status update didn't persist, or if filtering were broken.
|
|
3328
5197
|
done('stripe.issuing.tokens', 'issuing', 'Issuing: network tokens', 'api', 'niche', () =>
|
|
3329
5198
|
withRoot(async (h) => {
|
|
3330
|
-
|
|
3331
|
-
const
|
|
5199
|
+
const ch = await h({ m: 'POST', p: '/v1/issuing/cardholders', b: 'name=Jane&type=individual&billing[address][country]=US' });
|
|
5200
|
+
const card = await h({ m: 'POST', p: '/v1/issuing/cards', b: `cardholder=${id(ch)}¤cy=usd&type=virtual` });
|
|
5201
|
+
// a card with no wallet yet lists none; the list is always of one card's tokens (the served spec requires `card`,
|
|
5202
|
+
// which the parameter check enforces, e6bfff516), so the bare list is refused; unknown ids 404 on retrieve + update.
|
|
5203
|
+
const empty = await h({ m: 'GET', p: `/v1/issuing/tokens?card=${id(card)}` });
|
|
3332
5204
|
if (!ok(empty) || field(empty, 'object') !== 'list' || ((empty.body as Body).data as Body[]).length !== 0) return false;
|
|
5205
|
+
const unscoped = await h({ m: 'GET', p: '/v1/issuing/tokens' });
|
|
5206
|
+
if (unscoped.status !== 400 || ((unscoped.body as Body).error as Body)?.param !== 'card') return false;
|
|
3333
5207
|
const missing = await h({ m: 'GET', p: '/v1/issuing/tokens/iss_tok_nope' });
|
|
3334
5208
|
const badUpd = await h({ m: 'POST', p: '/v1/issuing/tokens/iss_tok_nope', b: 'status=active' });
|
|
3335
5209
|
if (missing.status !== 404 || badUpd.status !== 404) return false;
|
|
3336
|
-
//
|
|
3337
|
-
const
|
|
3338
|
-
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: '/v1/test_helpers/issuing/tokens', b: `card=${id(card)}` });
|
|
5210
|
+
// a token is network-minted when the cardholder adds a real card to a phone's wallet (the twin's stand-in).
|
|
5211
|
+
const seeded = await h({ m: 'POST', p: `/_twin/issuing/cards/${id(card)}/wallets`, b: 'wallet_provider=apple_pay' });
|
|
3340
5212
|
if (!ok(seeded) || field(seeded, 'object') !== 'issuing.token' || field(seeded, 'status') !== 'active' || field(seeded, 'card') !== id(card)) return false;
|
|
3341
5213
|
// minting requires a real card.
|
|
3342
|
-
const badCard = await h({ m: 'POST', p: '/
|
|
3343
|
-
|
|
3344
|
-
if (badCard.status !== 400 || noCard.status !== 400) return false;
|
|
5214
|
+
const badCard = await h({ m: 'POST', p: '/_twin/issuing/cards/ic_nope/wallets', b: 'wallet_provider=apple_pay' });
|
|
5215
|
+
if (badCard.status !== 400) return false;
|
|
3345
5216
|
// retrieve the seeded token.
|
|
3346
5217
|
const get = await h({ m: 'GET', p: `/v1/issuing/tokens/${id(seeded)}` });
|
|
3347
5218
|
if (!ok(get) || id(get) !== id(seeded) || field(get, 'card') !== id(card) || field(get, 'status') !== 'active') return false;
|
|
@@ -3373,8 +5244,12 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
3373
5244
|
// (the design object + review status machine are produced ONLY by this feature.)
|
|
3374
5245
|
done('stripe.issuing.personalization_designs', 'issuing', 'Issuing: card personalization designs', 'api', 'niche', () =>
|
|
3375
5246
|
withRoot(async (h) => {
|
|
3376
|
-
|
|
3377
|
-
|
|
5247
|
+
// the card logo must be a file of purpose issuing_logo; another purpose is refused
|
|
5248
|
+
const logo = await h({ m: 'POST', p: '/v1/files', b: 'purpose=issuing_logo&file=logo.png' });
|
|
5249
|
+
const icon = await h({ m: 'POST', p: '/v1/files', b: 'purpose=business_logo&file=icon.png' });
|
|
5250
|
+
if ((await h({ m: 'POST', p: '/v1/issuing/personalization_designs', b: `physical_bundle=ics_NLuXJPDYSTjFON&card_logo=${id(icon)}` })).status !== 400) return false;
|
|
5251
|
+
const d = await h({ m: 'POST', p: '/v1/issuing/personalization_designs', b: `physical_bundle=ics_NLuXJPDYSTjFON&name=Gold card&card_logo=${id(logo)}` });
|
|
5252
|
+
if (!ok(d) || field(d, 'object') !== 'issuing.personalization_design' || field(d, 'status') !== 'review') return false;
|
|
3378
5253
|
const get = await h({ m: 'GET', p: `/v1/issuing/personalization_designs/${id(d)}` });
|
|
3379
5254
|
const upd = await h({ m: 'POST', p: `/v1/issuing/personalization_designs/${id(d)}`, b: 'name=Platinum' });
|
|
3380
5255
|
if (!ok(get) || !ok(upd) || field(upd, 'name') !== 'Platinum') return false;
|
|
@@ -3383,7 +5258,8 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
3383
5258
|
if (!ok(act) || field(act, 'status') !== 'active') return false;
|
|
3384
5259
|
const list = await h({ m: 'GET', p: '/v1/issuing/personalization_designs?status=active' });
|
|
3385
5260
|
if (((list.body as Body).data as Body[]).length !== 1) return false;
|
|
3386
|
-
|
|
5261
|
+
// a rejection says why: the served spec requires rejection_reasons (enforced by the parameter check, e6bfff516)
|
|
5262
|
+
const rej = await h({ m: 'POST', p: `/v1/test_helpers/issuing/personalization_designs/${id(d)}/reject`, b: 'rejection_reasons[card_logo][]=inappropriate' });
|
|
3387
5263
|
const noBundle = await h({ m: 'POST', p: '/v1/issuing/personalization_designs', b: 'name=x' });
|
|
3388
5264
|
const missing = await h({ m: 'GET', p: '/v1/issuing/personalization_designs/pd_nope' });
|
|
3389
5265
|
return ok(rej) && field(rej, 'status') === 'rejected' && noBundle.status === 400 && missing.status === 404;
|
|
@@ -3424,12 +5300,17 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
3424
5300
|
// produced ONLY by this feature.)
|
|
3425
5301
|
done('stripe.treasury.financial_accounts', 'treasury', 'Treasury: financial accounts', 'api', 'niche', () =>
|
|
3426
5302
|
withRoot(async (h) => {
|
|
3427
|
-
|
|
5303
|
+
// features as the served spec's treasury.financial_account_features gives them: a toggle (card_issuing) or one per
|
|
5304
|
+
// network (outbound_payments.ach); a requested one is active at once in test mode and one never requested is left
|
|
5305
|
+
// out, each being optional in the spec (semantics/treasury.ts featuresOf, dd5d4344f)
|
|
5306
|
+
const fa = await h({ m: 'POST', p: '/v1/treasury/financial_accounts', b: 'supported_currencies[]=usd&features[card_issuing][requested]=true&features[outbound_payments][ach][requested]=true' });
|
|
3428
5307
|
if (!ok(fa) || field(fa, 'object') !== 'treasury.financial_account' || field(fa, 'status') !== 'open') return false;
|
|
3429
5308
|
const bal = field(fa, 'balance') as Body;
|
|
3430
5309
|
if ((bal.cash as Body).usd !== 0) return false;
|
|
3431
|
-
|
|
3432
|
-
if ((
|
|
5310
|
+
const features = field(fa, 'features') as Body;
|
|
5311
|
+
if ((features.card_issuing as Body).status !== 'active' || ((features.outbound_payments as Body).ach as Body)?.status !== 'active') return false;
|
|
5312
|
+
if (features.deposit_insurance !== undefined) return false;
|
|
5313
|
+
if ((field(fa, 'active_features') as string[]).join(',') !== 'card_issuing,outbound_payments.ach') return false;
|
|
3433
5314
|
const get = await h({ m: 'GET', p: `/v1/treasury/financial_accounts/${id(fa)}` });
|
|
3434
5315
|
const list = await h({ m: 'GET', p: '/v1/treasury/financial_accounts?status=open' });
|
|
3435
5316
|
const feats = await h({ m: 'GET', p: `/v1/treasury/financial_accounts/${id(fa)}/features` });
|
|
@@ -3489,20 +5370,27 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
3489
5370
|
badFa.status === 400 && noDest.status === 400 && nope.status === 404;
|
|
3490
5371
|
}),
|
|
3491
5372
|
),
|
|
3492
|
-
// Treasury InboundTransfers: pull funds into a FinancialAccount. create→
|
|
3493
|
-
//
|
|
3494
|
-
// financial_account/origin_payment_method. Unknown id 404. id ibt_.
|
|
5373
|
+
// Treasury InboundTransfers: pull funds into a FinancialAccount. create→processing (funds inbound_pending), the
|
|
5374
|
+
// test helper's succeed→succeeded, posting its ledger Transaction (cash↑). Requires amount/currency/
|
|
5375
|
+
// financial_account/origin_payment_method, a bank account set up for inbound flows and verified. Unknown id 404. id ibt_.
|
|
3495
5376
|
done('stripe.treasury.inbound_transfers', 'treasury', 'Treasury: inbound transfers / received credits', 'api', 'niche', () =>
|
|
3496
5377
|
withRoot(async (h) => {
|
|
3497
5378
|
const fa = await h({ m: 'POST', p: '/v1/treasury/financial_accounts', b: 'supported_currencies[]=usd&features[inbound_transfers][requested]=true' });
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
5379
|
+
// the pulled account is set up for inbound flows and verified with a SetupIntent; one that is not is refused
|
|
5380
|
+
const si = await h({ m: 'POST', p: '/v1/setup_intents', b: 'attach_to_self=true&flow_directions[]=inbound&allowed_payment_method_types[]=us_bank_account&payment_method_options[us_bank_account][verification_method]=microdeposits' });
|
|
5381
|
+
const conf = await h({ m: 'POST', p: `/v1/setup_intents/${id(si)}/confirm`, b: 'payment_method_data[type]=us_bank_account&payment_method_data[us_bank_account][account_number]=000123456789&payment_method_data[us_bank_account][routing_number]=110000000&payment_method_data[us_bank_account][account_holder_type]=company&payment_method_data[billing_details][name]=Ops' });
|
|
5382
|
+
const pm = String(field(conf, 'payment_method'));
|
|
5383
|
+
if ((await h({ m: 'POST', p: `/v1/treasury/inbound_transfers`, b: `amount=3000¤cy=usd&financial_account=${id(fa)}&origin_payment_method=${pm}` })).status !== 400) return false;
|
|
5384
|
+
await h({ m: 'POST', p: `/v1/setup_intents/${id(si)}/verify_microdeposits`, b: 'amounts[]=32&amounts[]=45' });
|
|
5385
|
+
const it = await h({ m: 'POST', p: `/v1/treasury/inbound_transfers`, b: `amount=3000¤cy=usd&financial_account=${id(fa)}&origin_payment_method=${pm}` });
|
|
5386
|
+
if (!ok(it) || field(it, 'object') !== 'treasury.inbound_transfer' || field(it, 'status') !== 'processing' || !id(it).startsWith('ibt_')) return false;
|
|
5387
|
+
const done = await h({ m: 'POST', p: `/v1/test_helpers/treasury/inbound_transfers/${id(it)}/succeed` });
|
|
5388
|
+
if (!ok(done) || field(done, 'status') !== 'succeeded' || (field(done, 'status_transitions') as Body)?.succeeded_at == null) return false;
|
|
3501
5389
|
const get = await h({ m: 'GET', p: `/v1/treasury/inbound_transfers/${id(it)}` });
|
|
3502
5390
|
const list = await h({ m: 'GET', p: `/v1/treasury/inbound_transfers?financial_account=${id(fa)}` });
|
|
3503
5391
|
if (!ok(get) || ((list.body as Body).data as Body[]).length !== 1) return false;
|
|
3504
5392
|
// a ledger transaction was posted with cash↑.
|
|
3505
|
-
const txns = await h({ m: 'GET', p: `/v1/treasury/transactions?financial_account=${id(fa)}
|
|
5393
|
+
const txns = await h({ m: 'GET', p: `/v1/treasury/transactions?financial_account=${id(fa)}` });
|
|
3506
5394
|
const tdata = (txns.body as Body).data as Body[];
|
|
3507
5395
|
if (tdata.length !== 1 || (tdata[0]!.balance_impact as Body)?.cash !== 3000 || tdata[0]!.status !== 'posted') return false;
|
|
3508
5396
|
// received_credits/debits read surface exists (empty until a test flow materializes one).
|
|
@@ -3513,7 +5401,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
3513
5401
|
}),
|
|
3514
5402
|
),
|
|
3515
5403
|
// Climate (carbon removal): products/suppliers catalog (read-only) + orders. An order needs
|
|
3516
|
-
// a valid product + exactly one of amount/metric_tons (else 400);
|
|
5404
|
+
// a valid product + exactly one of amount/metric_tons (else 400); is confirmed when made;
|
|
3517
5405
|
// cancel→canceled. amount_total = subtotal+fees. Unknown product 400; unknown order 404.
|
|
3518
5406
|
done('stripe.climate.orders', 'climate', 'Climate: orders / products / suppliers', 'api', 'niche', () =>
|
|
3519
5407
|
withRoot(async (h) => {
|
|
@@ -3522,8 +5410,8 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
3522
5410
|
if (pdata.length === 0 || pdata[0]!.object !== 'climate.product') return false;
|
|
3523
5411
|
const suppliers = await h({ m: 'GET', p: '/v1/climate/suppliers' });
|
|
3524
5412
|
if (((suppliers.body as Body).data as Body[])[0]?.object !== 'climate.supplier') return false;
|
|
3525
|
-
const order = await h({ m: 'POST', p: '/v1/climate/orders', b: 'product=
|
|
3526
|
-
if (!ok(order) || field(order, 'object') !== 'climate.order' || field(order, 'status') !== '
|
|
5413
|
+
const order = await h({ m: 'POST', p: '/v1/climate/orders', b: 'product=climsku_frontier_offtake_portfolio_2027&metric_tons=2' });
|
|
5414
|
+
if (!ok(order) || field(order, 'object') !== 'climate.order' || field(order, 'status') !== 'confirmed' || !id(order).startsWith('climorder_')) return false;
|
|
3527
5415
|
if (field(order, 'amount_total') !== (field(order, 'amount_subtotal') as number) + (field(order, 'amount_fees') as number)) return false;
|
|
3528
5416
|
const get = await h({ m: 'GET', p: `/v1/climate/orders/${id(order)}` });
|
|
3529
5417
|
const list = await h({ m: 'GET', p: '/v1/climate/orders' });
|
|
@@ -3531,25 +5419,27 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
3531
5419
|
const cancel = await h({ m: 'POST', p: `/v1/climate/orders/${id(order)}/cancel` });
|
|
3532
5420
|
if (!ok(cancel) || field(cancel, 'status') !== 'canceled') return false;
|
|
3533
5421
|
const badProduct = await h({ m: 'POST', p: '/v1/climate/orders', b: 'product=climsku_nope&metric_tons=1' });
|
|
3534
|
-
const bothAmounts = await h({ m: 'POST', p: '/v1/climate/orders', b: 'product=
|
|
5422
|
+
const bothAmounts = await h({ m: 'POST', p: '/v1/climate/orders', b: 'product=climsku_frontier_offtake_portfolio_2027&metric_tons=1&amount=1000' });
|
|
3535
5423
|
const nope = await h({ m: 'GET', p: '/v1/climate/orders/climorder_nope' });
|
|
3536
5424
|
return badProduct.status === 400 && bothAmounts.status === 400 && nope.status === 404;
|
|
3537
5425
|
}),
|
|
3538
5426
|
),
|
|
3539
|
-
// Financial Connections: create a session (requires account_holder + permissions),
|
|
3540
|
-
//
|
|
3541
|
-
//
|
|
5427
|
+
// Financial Connections: create a session (requires account_holder + permissions), the person links the test
|
|
5428
|
+
// bank's accounts in the flow Stripe.js opens, then read accounts + the session's accounts sub-list. id fcsess_ /
|
|
5429
|
+
// fca_. Missing permissions 400; unknown id 404.
|
|
3542
5430
|
done('stripe.financial_connections.sessions', 'financial_connections', 'Financial Connections: sessions + accounts', 'api', 'niche', () =>
|
|
3543
|
-
withRoot(async (h) => {
|
|
5431
|
+
withRoot(async (h, root) => {
|
|
3544
5432
|
const sess = await h({ m: 'POST', p: '/v1/financial_connections/sessions', b: 'account_holder[type]=customer&account_holder[customer]=cus_1&permissions[]=transactions&permissions[]=balances' });
|
|
3545
5433
|
if (!ok(sess) || field(sess, 'object') !== 'financial_connections.session' || !id(sess).startsWith('fcsess_')) return false;
|
|
3546
5434
|
if (typeof field(sess, 'client_secret') !== 'string') return false;
|
|
3547
|
-
|
|
3548
|
-
if (!ok(acc) || field(acc, 'object') !== 'financial_connections.account' || !id(acc).startsWith('fca_') || field(acc, 'status') !== 'active') return false;
|
|
5435
|
+
await hostedSubmit(root, stripeFinancialConnectionsFlow, `https://js.stripe.com/v3/financial-connections/${String(field(sess, 'client_secret'))}`, { answer: 'link' });
|
|
3549
5436
|
const getSess = await h({ m: 'GET', p: `/v1/financial_connections/sessions/${id(sess)}` });
|
|
3550
|
-
|
|
5437
|
+
const linked = ((field(getSess, 'accounts') as Body)?.data as Body[]) ?? [];
|
|
5438
|
+
if (!linked.length) return false;
|
|
5439
|
+
const acc = { status: 200, body: linked[0]! } as StripeResponse;
|
|
5440
|
+
if (field(acc, 'object') !== 'financial_connections.account' || !id(acc).startsWith('fca_') || field(acc, 'status') !== 'active') return false;
|
|
3551
5441
|
const accounts = await h({ m: 'GET', p: '/v1/financial_connections/accounts' });
|
|
3552
|
-
if (((accounts.body as Body).data as Body[]).length !==
|
|
5442
|
+
if (((accounts.body as Body).data as Body[]).length !== linked.length) return false;
|
|
3553
5443
|
const getAcc = await h({ m: 'GET', p: `/v1/financial_connections/accounts/${id(acc)}` });
|
|
3554
5444
|
const disc = await h({ m: 'POST', p: `/v1/financial_connections/accounts/${id(acc)}/disconnect` });
|
|
3555
5445
|
const noPerms = await h({ m: 'POST', p: '/v1/financial_connections/sessions', b: 'account_holder[type]=customer&account_holder[customer]=cus_1' });
|
|
@@ -3557,20 +5447,17 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
3557
5447
|
return ok(getAcc) && ok(disc) && field(disc, 'status') === 'disconnected' && noPerms.status === 400 && nope.status === 404;
|
|
3558
5448
|
}),
|
|
3559
5449
|
),
|
|
3560
|
-
// Financial Connections
|
|
3561
|
-
// (also via /v1/financial_connections/transactions?account=). Requires account; 404 unknown.
|
|
5450
|
+
// Financial Connections transactions: a linked account's, listed by account. Requires account.
|
|
3562
5451
|
done('stripe.financial_connections.transactions', 'financial_connections', 'Financial Connections: account transactions', 'api', 'niche', () =>
|
|
3563
|
-
withRoot(async (h) => {
|
|
5452
|
+
withRoot(async (h, root) => {
|
|
3564
5453
|
const sess = await h({ m: 'POST', p: '/v1/financial_connections/sessions', b: 'account_holder[type]=customer&account_holder[customer]=cus_1&permissions[]=transactions' });
|
|
3565
|
-
|
|
3566
|
-
const
|
|
3567
|
-
const
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
if (((flat.body as Body).data as Body[]).length !== 2) return false;
|
|
5454
|
+
await hostedSubmit(root, stripeFinancialConnectionsFlow, `https://js.stripe.com/v3/financial-connections/${String(field(sess, 'client_secret'))}`, { answer: 'link' });
|
|
5455
|
+
const acc = ((field(await h({ m: 'GET', p: `/v1/financial_connections/sessions/${id(sess)}` }), 'accounts') as Body).data as Body[])[0]!;
|
|
5456
|
+
const flat = await h({ m: 'GET', p: `/v1/financial_connections/transactions?account=${String(acc.id)}` });
|
|
5457
|
+
const tdata = (flat.body as Body).data as Body[];
|
|
5458
|
+
if (!tdata.length || tdata[0]!.object !== 'financial_connections.transaction' || tdata[0]!.account !== acc.id) return false;
|
|
3571
5459
|
const noAcct = await h({ m: 'GET', p: '/v1/financial_connections/transactions' });
|
|
3572
|
-
|
|
3573
|
-
return noAcct.status === 400 && nope.status === 404;
|
|
5460
|
+
return noAcct.status === 400;
|
|
3574
5461
|
}),
|
|
3575
5462
|
),
|
|
3576
5463
|
// Forwarding: PAN forwarding requests. create requires payment_method + url; stores the
|
|
@@ -3589,19 +5476,10 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
3589
5476
|
return noPm.status === 400 && noUrl.status === 400 && nope.status === 404;
|
|
3590
5477
|
}),
|
|
3591
5478
|
),
|
|
3592
|
-
// Crypto onramp sessions
|
|
3593
|
-
//
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
const cos = await h({ m: 'POST', p: '/v1/crypto/onramp_sessions', b: 'transaction_details[destination_currency]=eth&transaction_details[destination_network]=ethereum&transaction_details[source_amount]=100' });
|
|
3597
|
-
if (!ok(cos) || field(cos, 'object') !== 'crypto.onramp_session' || field(cos, 'status') !== 'initialized' || !id(cos).startsWith('cos_')) return false;
|
|
3598
|
-
if (typeof field(cos, 'client_secret') !== 'string') return false;
|
|
3599
|
-
if ((field(cos, 'transaction_details') as Body)?.destination_currency !== 'eth') return false;
|
|
3600
|
-
const get = await h({ m: 'GET', p: `/v1/crypto/onramp_sessions/${id(cos)}` });
|
|
3601
|
-
const nope = await h({ m: 'GET', p: '/v1/crypto/onramp_sessions/cos_nope' });
|
|
3602
|
-
return ok(get) && field(get, 'id') === id(cos) && nope.status === 404;
|
|
3603
|
-
}),
|
|
3604
|
-
),
|
|
5479
|
+
// Crypto onramp sessions (POST/GET /v1/crypto/onramp_sessions, docs.stripe.com/crypto/onramp/api-reference) are Stripe
|
|
5480
|
+
// API, but not in the spec this pack vendors and serves; the twin's hand route for them was removed with the other
|
|
5481
|
+
// routes outside the served spec (5decb1d77), so the capability is a todo again until the lane models it.
|
|
5482
|
+
todo('stripe.crypto.onramp', 'core', 'Crypto onramp sessions', 'api', 'niche'),
|
|
3605
5483
|
|
|
3606
5484
|
// ── HONEST DENOMINATOR GROWTH (real Stripe surfaces NOT yet modeled) ────────────────
|
|
3607
5485
|
// The gaps above were closed this cycle; these enumerate genuine remaining surface so the
|