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