@volter/twin-stripe 0.1.0 → 0.1.2
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 +39 -5
- package/package.json +2 -2
- package/src/cli.ts +10 -3
- package/src/index.ts +44 -2
- package/src/stripe-budget.ts +181 -0
- package/src/stripe-capabilities.ts +338 -20
- package/src/stripe-conformance.ts +2 -0
- package/src/stripe-connector.ts +65 -4
- package/src/stripe-emit.ts +150 -0
- package/src/stripe-events.ts +176 -6
- package/src/stripe-mirror-ui.ts +25 -10
- package/src/stripe-server.ts +67 -32
- package/src/stripe-twin.ts +1046 -71
- package/test-fixtures/stripe-known-deviations.json +25 -0
- package/test-fixtures/stripe-openapi-operations.json +6384 -0
- package/test-fixtures/stripe-schemas.json +376 -0
|
@@ -6,11 +6,13 @@
|
|
|
6
6
|
// regression. Grow this toward Stripe's *full* surface every cycle — a missing entry is a
|
|
7
7
|
// hidden gap, and a high % against a thin list is a misleading metric (the bug this fixes).
|
|
8
8
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
9
|
+
import { emitTwinEvent } from '@volter/twin';
|
|
10
|
+
import { stripeEmitter } from './stripe-emit.ts';
|
|
9
11
|
import { tmpdir } from 'node:os';
|
|
10
12
|
import { join } from 'node:path';
|
|
11
13
|
import { createElement } from 'react';
|
|
12
14
|
import { renderToStaticMarkup } from 'react-dom/server';
|
|
13
|
-
import { checkCapabilities, type CapabilityReport, type CapabilitySpec } from '@volter/twin-tooling';
|
|
15
|
+
import { checkCapabilities, type CapabilityReport, type CapabilitySpec, verifyBoundary, isInfrastructureError, harnessError } from '@volter/twin-tooling';
|
|
14
16
|
import { ListPane, SECTIONS, PaymentErrorBanner, ConnectAccountPanel, BalanceSummary, type Section } from '../client/stripe-mirror.tsx';
|
|
15
17
|
import type { StripeRow } from './stripe-mirror-ui.ts';
|
|
16
18
|
import {
|
|
@@ -58,7 +60,8 @@ function uiDataCoupled(opts: {
|
|
|
58
60
|
const get: ServerFetch = (path) => fetch(`${base}${path}`).then((r) => r.json() as Promise<Body>);
|
|
59
61
|
const post = (path: string, body?: string) => fetch(`${base}${path}`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: body ?? '' }).then((r) => r.json() as Promise<Body>);
|
|
60
62
|
return await opts.check({ get, post });
|
|
61
|
-
} catch {
|
|
63
|
+
} catch (err) {
|
|
64
|
+
if (isInfrastructureError(err)) throw harnessError('stripe.uiDataCoupled', err);
|
|
62
65
|
return false;
|
|
63
66
|
} finally {
|
|
64
67
|
server.stop();
|
|
@@ -96,9 +99,7 @@ async function withRoot(steps: (h: (s: Step) => Promise<StripeResponse>) => Prom
|
|
|
96
99
|
const root = mkdtempSync(join(tmpdir(), 'stp-cap-'));
|
|
97
100
|
const h = (s: Step) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root });
|
|
98
101
|
try {
|
|
99
|
-
return await steps(h);
|
|
100
|
-
} catch {
|
|
101
|
-
return false;
|
|
102
|
+
return await verifyBoundary('stripe.withRoot', () => steps(h));
|
|
102
103
|
} finally {
|
|
103
104
|
rmSync(root, { recursive: true, force: true });
|
|
104
105
|
}
|
|
@@ -704,6 +705,15 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
704
705
|
if (!ok(cus) || !ok(pm) || field(pm, 'type') !== 'card' || field(pm, 'customer') !== null) return false;
|
|
705
706
|
const card = (pm.body as Body).card as Body | undefined;
|
|
706
707
|
if (!card || card.last4 !== '4242') return false;
|
|
708
|
+
// last4 FIDELITY (not just presence): a raw test PAN must surface its TRUE last4 —
|
|
709
|
+
// the '4242' check above can't prove that, since '4242' equals the synthesized
|
|
710
|
+
// fallback. parseStripeForm coerces an all-digit card[number] to a JS number (like
|
|
711
|
+
// any numeric-looking form field), so cover BOTH wire shapes: the numeric-coerced
|
|
712
|
+
// bare PAN and a string PAN (spaces keep it a string through the form parser).
|
|
713
|
+
const pmNum = await h({ m: 'POST', p: '/v1/payment_methods', b: 'type=card&card[number]=5555555555554444&card[exp_month]=4&card[exp_year]=2030&card[cvc]=314' });
|
|
714
|
+
if (!ok(pmNum) || ((pmNum.body as Body).card as Body)?.last4 !== '4444') return false;
|
|
715
|
+
const pmStr = await h({ m: 'POST', p: '/v1/payment_methods', b: 'type=card&card[number]=5555+5555+5555+4444&card[exp_month]=4&card[exp_year]=2030&card[cvc]=314' });
|
|
716
|
+
if (!ok(pmStr) || ((pmStr.body as Body).card as Body)?.last4 !== '4444') return false;
|
|
707
717
|
const att = await h({ m: 'POST', p: `/v1/payment_methods/${id(pm)}/attach`, b: `customer=${id(cus)}` });
|
|
708
718
|
if (!ok(att) || field(att, 'customer') !== id(cus)) return false;
|
|
709
719
|
const g = await h({ m: 'GET', p: `/v1/payment_methods/${id(pm)}` });
|
|
@@ -761,7 +771,8 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
761
771
|
// DELETE expires it early (retrievable shape; expires bumped).
|
|
762
772
|
const del = await hv({ m: 'DELETE', p: `/v1/ephemeral_keys/${id(key)}` });
|
|
763
773
|
return badCust.status === 400 && noScope.status === 400 && ok(del);
|
|
764
|
-
} catch {
|
|
774
|
+
} catch (err) {
|
|
775
|
+
if (isInfrastructureError(err)) throw harnessError('stripe.ephemeral_keys', err);
|
|
765
776
|
return false;
|
|
766
777
|
} finally {
|
|
767
778
|
rmSync(root, { recursive: true, force: true });
|
|
@@ -905,9 +916,33 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
905
916
|
const inv = await h({ m: 'POST', p: '/v1/invoices', b: `customer=${id(cust)}` });
|
|
906
917
|
if (!ok(inv) || field(inv, 'object') !== 'invoice') return false;
|
|
907
918
|
const fin = await h({ m: 'POST', p: `/v1/invoices/${id(inv)}/finalize` });
|
|
908
|
-
const pay = await h({ m: 'POST', p: `/v1/invoices/${id(inv)}/pay
|
|
919
|
+
const pay = await h({ m: 'POST', p: `/v1/invoices/${id(inv)}/pay`, b: 'payment_method=pm_card_visa' });
|
|
909
920
|
const l = await h({ m: 'GET', p: '/v1/invoices' });
|
|
910
|
-
|
|
921
|
+
if (!(ok(fin) && field(fin, 'status') === 'open' && ok(pay) && field(pay, 'status') === 'paid' && field(l, 'object') === 'list')) return false;
|
|
922
|
+
// Card-decline fidelity on the pay edge (the off-session / saved-card retry shape):
|
|
923
|
+
// a card created from a DECLINING test PAN, attached, then referenced ONLY by its
|
|
924
|
+
// PaymentMethod id (never the raw PAN again) must 402 card_error and leave the
|
|
925
|
+
// invoice OPEN — decline state must survive past the creating request. A different
|
|
926
|
+
// NON-declining saved card on a fresh invoice must still pay to 'paid', proving the
|
|
927
|
+
// decline is per-card, not a blanket pay failure.
|
|
928
|
+
const bad = await h({ m: 'POST', p: '/v1/payment_methods', b: 'type=card&card[number]=4000000000000002&card[exp_month]=4&card[exp_year]=2030&card[cvc]=314' });
|
|
929
|
+
const badAtt = await h({ m: 'POST', p: `/v1/payment_methods/${id(bad)}/attach`, b: `customer=${id(cust)}` });
|
|
930
|
+
if (!ok(bad) || !ok(badAtt)) return false;
|
|
931
|
+
await h({ m: 'POST', p: '/v1/invoiceitems', b: `customer=${id(cust)}&amount=1800¤cy=usd` });
|
|
932
|
+
const inv2 = await h({ m: 'POST', p: '/v1/invoices', b: `customer=${id(cust)}` });
|
|
933
|
+
await h({ m: 'POST', p: `/v1/invoices/${id(inv2)}/finalize` });
|
|
934
|
+
const payBad = await h({ m: 'POST', p: `/v1/invoices/${id(inv2)}/pay`, b: `payment_method=${id(bad)}` });
|
|
935
|
+
if (payBad.status !== 402 || ((payBad.body as Body).error as Body)?.type !== 'card_error') return false;
|
|
936
|
+
const after = await h({ m: 'GET', p: `/v1/invoices/${id(inv2)}` });
|
|
937
|
+
if (!ok(after) || field(after, 'status') !== 'open') return false;
|
|
938
|
+
const good = await h({ m: 'POST', p: '/v1/payment_methods', b: 'type=card&card[number]=4242424242424242&card[exp_month]=4&card[exp_year]=2030&card[cvc]=314' });
|
|
939
|
+
const goodAtt = await h({ m: 'POST', p: `/v1/payment_methods/${id(good)}/attach`, b: `customer=${id(cust)}` });
|
|
940
|
+
if (!ok(good) || !ok(goodAtt)) return false;
|
|
941
|
+
await h({ m: 'POST', p: '/v1/invoiceitems', b: `customer=${id(cust)}&amount=1900¤cy=usd` });
|
|
942
|
+
const inv3 = await h({ m: 'POST', p: '/v1/invoices', b: `customer=${id(cust)}` });
|
|
943
|
+
await h({ m: 'POST', p: `/v1/invoices/${id(inv3)}/finalize` });
|
|
944
|
+
const payGood = await h({ m: 'POST', p: `/v1/invoices/${id(inv3)}/pay`, b: `payment_method=${id(good)}` });
|
|
945
|
+
return ok(payGood) && field(payGood, 'status') === 'paid';
|
|
911
946
|
}),
|
|
912
947
|
),
|
|
913
948
|
done('stripe.invoiceitems.crud', 'invoices', 'InvoiceItems: create + list', 'api', 'core', () =>
|
|
@@ -971,7 +1006,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
971
1006
|
await h({ m: 'POST', p: '/v1/invoiceitems', b: `customer=${id(cust)}&amount=5000¤cy=usd` });
|
|
972
1007
|
const inv = await h({ m: 'POST', p: '/v1/invoices', b: `customer=${id(cust)}` });
|
|
973
1008
|
await h({ m: 'POST', p: `/v1/invoices/${id(inv)}/finalize` });
|
|
974
|
-
await h({ m: 'POST', p: `/v1/invoices/${id(inv)}/pay
|
|
1009
|
+
await h({ m: 'POST', p: `/v1/invoices/${id(inv)}/pay`, b: 'payment_method=pm_card_visa' });
|
|
975
1010
|
// preview computes the object WITHOUT persisting it
|
|
976
1011
|
const prev = await h({ m: 'GET', p: `/v1/credit_notes/preview?invoice=${id(inv)}&amount=2000` });
|
|
977
1012
|
if (!ok(prev) || field(prev, 'object') !== 'credit_note' || field(prev, 'amount') !== 2000) return false;
|
|
@@ -1005,6 +1040,37 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1005
1040
|
return ok(g) && ok(li) && field(li, 'object') === 'list' && ok(exp);
|
|
1006
1041
|
}),
|
|
1007
1042
|
),
|
|
1043
|
+
// Completion copies the session's create-only subscription_data onto the created
|
|
1044
|
+
// subscription (metadata is how apps bind the checkout attempt → subscription; trial
|
|
1045
|
+
// becomes a real trialing window) — and the Session itself never echoes the param.
|
|
1046
|
+
done('stripe.checkout.subscription_data', 'checkout', 'Checkout completion copies subscription_data (metadata + trial) onto the created subscription', 'api', 'core', () =>
|
|
1047
|
+
withRoot(async (h) => {
|
|
1048
|
+
const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=subdata@twin.test' });
|
|
1049
|
+
const cs = await h({ m: 'POST', p: '/v1/checkout/sessions', b: `mode=subscription&success_url=https://x.test&customer=${id(cust)}&line_items[0][price_data][currency]=usd&line_items[0][price_data][unit_amount]=999&line_items[0][quantity]=1&subscription_data[metadata][attemptId]=att_cap_1&subscription_data[trial_period_days]=7` });
|
|
1050
|
+
if (!ok(cs) || field(cs, 'subscription_data') !== undefined || field(cs, '_subscription_data') !== undefined) return false;
|
|
1051
|
+
const completed = await h({ m: 'POST', p: `/v1/checkout/sessions/${id(cs)}`, b: 'status=complete' });
|
|
1052
|
+
if (!ok(completed) || !field(completed, 'subscription')) return false;
|
|
1053
|
+
const sub = await h({ m: 'GET', p: `/v1/subscriptions/${field(completed, 'subscription')}` });
|
|
1054
|
+
const md = field(sub, 'metadata') as Body | undefined;
|
|
1055
|
+
return ok(sub) && md?.attemptId === 'att_cap_1' && field(sub, 'status') === 'trialing'
|
|
1056
|
+
&& typeof field(sub, 'trial_end') === 'number' && field(sub, 'trial_end') === field(sub, 'current_period_end');
|
|
1057
|
+
}),
|
|
1058
|
+
),
|
|
1059
|
+
// Completing a session fires checkout.session.completed (data.object = the completed
|
|
1060
|
+
// Session) and stores it in the Events API — the event most billing flows fulfill from.
|
|
1061
|
+
done('stripe.checkout.completed_event', 'checkout', 'Checkout completion emits checkout.session.completed with the session payload', 'api', 'core', () =>
|
|
1062
|
+
withRoot(async (h) => {
|
|
1063
|
+
const cs = await h({ m: 'POST', p: '/v1/checkout/sessions', b: 'mode=payment&success_url=https://x.test&line_items[0][price_data][currency]=usd&line_items[0][price_data][unit_amount]=2500&line_items[0][quantity]=1' });
|
|
1064
|
+
if (!ok(cs)) return false;
|
|
1065
|
+
const completed = await h({ m: 'POST', p: `/v1/checkout/sessions/${id(cs)}`, b: 'status=complete' });
|
|
1066
|
+
if (!ok(completed) || field(completed, 'status') !== 'complete') return false;
|
|
1067
|
+
const ev = await h({ m: 'GET', p: '/v1/events?type=checkout.session.completed' });
|
|
1068
|
+
const rows = ((ev.body as Body)?.data ?? []) as Body[];
|
|
1069
|
+
const payload = (rows[0]?.data as Body | undefined)?.object as Body | undefined;
|
|
1070
|
+
return ok(ev) && rows.length === 1 && payload?.object === 'checkout.session'
|
|
1071
|
+
&& payload?.id === id(cs) && payload?.status === 'complete' && payload?.payment_status === 'paid';
|
|
1072
|
+
}),
|
|
1073
|
+
),
|
|
1008
1074
|
done('stripe.billing_portal.session', 'checkout', 'Billing Portal Session create (referential to customer)', 'api', 'common', () =>
|
|
1009
1075
|
withRoot(async (h) => {
|
|
1010
1076
|
const cust = await h({ m: 'POST', p: '/v1/customers', b: 'email=portal@twin.test' });
|
|
@@ -1120,7 +1186,8 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1120
1186
|
if (platData.length !== 1 || platData[0]!.id !== id(plat)) return false;
|
|
1121
1187
|
const badAcct = await h({ m: 'POST', p: '/v1/payouts', b: 'amount=500¤cy=usd', acct: 'acct_nope' });
|
|
1122
1188
|
return badAcct.status === 400;
|
|
1123
|
-
} catch {
|
|
1189
|
+
} catch (err) {
|
|
1190
|
+
if (isInfrastructureError(err)) throw harnessError('stripe.connect.connect_payouts', err);
|
|
1124
1191
|
return false;
|
|
1125
1192
|
} finally {
|
|
1126
1193
|
rmSync(root, { recursive: true, force: true });
|
|
@@ -1195,6 +1262,36 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1195
1262
|
return ok(g) && id(g) === id(e) && field(l, 'object') === 'list';
|
|
1196
1263
|
}),
|
|
1197
1264
|
),
|
|
1265
|
+
done('stripe.events.emit_verb', 'events', 'DELIVER verb (world-stripe emit): synthesizes the event envelope from twin state and signs it with the destination endpoint\'s own whsec; unknown subject + missing endpoint fail loudly', 'api', 'common', async () => {
|
|
1266
|
+
const root = mkdtempSync(join(tmpdir(), 'stp-emit-cap-'));
|
|
1267
|
+
const H = (m: string, p: string, b?: string) => handleStripeTwinRequest({ method: m, path: p, body: b, root, occurredAt: '2026-08-01T00:00:00Z' });
|
|
1268
|
+
try {
|
|
1269
|
+
return await verifyBoundary('stripe.events.emit_verb', async () => {
|
|
1270
|
+
// no endpoint registered → LOUD, not a silent no-op
|
|
1271
|
+
const noEndpoint = await emitTwinEvent(stripeEmitter, { type: 'invoice.paid', subjectId: 'in_twin_1', root }).then(() => false, (e: unknown) => /no stripe webhook endpoint/.test(String(e)));
|
|
1272
|
+
if (!noEndpoint) return false;
|
|
1273
|
+
const cust = await H('POST', '/v1/customers', 'email=e@x.co');
|
|
1274
|
+
await H('POST', '/v1/invoiceitems', `customer=${id(cust)}&amount=700¤cy=usd`);
|
|
1275
|
+
const inv = await H('POST', '/v1/invoices', `customer=${id(cust)}`);
|
|
1276
|
+
const we = await H('POST', '/v1/webhook_endpoints', `url=${encodeURIComponent('http://127.0.0.1:1/hook')}&enabled_events[0]=invoice.*`);
|
|
1277
|
+
const secret = field(we, 'secret') as string;
|
|
1278
|
+
// delivery seam injected — offline + deterministic; the signature is still REAL
|
|
1279
|
+
const posts: Array<{ headers: Record<string, string>; body: string }> = [];
|
|
1280
|
+
const report = await emitTwinEvent(stripeEmitter, {
|
|
1281
|
+
type: 'invoice.paid', subjectId: id(inv), root,
|
|
1282
|
+
fetchFn: async (_url, init) => { posts.push({ headers: init.headers, body: init.body }); return { status: 200 }; },
|
|
1283
|
+
});
|
|
1284
|
+
if (!report.ok || posts.length !== 1) return false;
|
|
1285
|
+
// verified with the same scheme stripe.webhooks.constructEvent implements
|
|
1286
|
+
const evt = constructEvent(posts[0]!.body, posts[0]!.headers['stripe-signature']!, secret);
|
|
1287
|
+
if (evt.type !== 'invoice.paid' || (evt.data.object as Body).id !== id(inv)) return false;
|
|
1288
|
+
// unknown subject → LOUD, listing what exists
|
|
1289
|
+
return await emitTwinEvent(stripeEmitter, { type: 'invoice.paid', subjectId: 'in_twin_999', root }).then(() => false, (e: unknown) => /no invoice "in_twin_999"/.test(String(e)));
|
|
1290
|
+
});
|
|
1291
|
+
} finally {
|
|
1292
|
+
rmSync(root, { recursive: true, force: true });
|
|
1293
|
+
}
|
|
1294
|
+
}),
|
|
1198
1295
|
done('stripe.idempotency', 'core', 'Idempotency-Key: POST replay returns the same resource', 'api', 'core', async () => {
|
|
1199
1296
|
const root = mkdtempSync(join(tmpdir(), 'stp-cap-'));
|
|
1200
1297
|
const H = (idk: string) => handleStripeTwinRequest({ method: 'POST', path: '/v1/customers', body: 'email=idem@twin.test', root, idempotencyKey: idk });
|
|
@@ -1244,7 +1341,8 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1244
1341
|
const c = await H({ m: 'POST', p: `/v1/payment_intents/${id(pi)}/confirm`, b: 'payment_method=pm_card_visa' });
|
|
1245
1342
|
if (!ok(c) || field(c, 'status') !== 'succeeded') return false;
|
|
1246
1343
|
return hits.some((e) => e.type === 'payment_intent.succeeded' && (e.data.object as Body)?.status === 'succeeded');
|
|
1247
|
-
} catch {
|
|
1344
|
+
} catch (err) {
|
|
1345
|
+
if (isInfrastructureError(err)) throw harnessError('stripe.webhooks.emit', err);
|
|
1248
1346
|
return false;
|
|
1249
1347
|
} finally {
|
|
1250
1348
|
setStripeEventDelivery(null);
|
|
@@ -1280,7 +1378,8 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1280
1378
|
// stale timestamp beyond tolerance → throws
|
|
1281
1379
|
let rejStale = false; try { constructEvent(payload, header, secret, { tolerance: 300, now: ts + 1000 }); } catch { rejStale = true; }
|
|
1282
1380
|
return rejWrongSecret && rejTampered && rejMalformed && rejStale;
|
|
1283
|
-
} catch {
|
|
1381
|
+
} catch (err) {
|
|
1382
|
+
if (isInfrastructureError(err)) throw harnessError('stripe.webhooks.signature', err);
|
|
1284
1383
|
return false;
|
|
1285
1384
|
}
|
|
1286
1385
|
}),
|
|
@@ -1417,7 +1516,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1417
1516
|
const ev2 = (((await handleStripeTwinRequest({ method: 'GET', path: '/v1/events', root })).body as Body).data as Body[])
|
|
1418
1517
|
.find((e) => e.type === 'payment_intent.created' && ((e.data as Body)?.object as Body)?.id === id(pi2));
|
|
1419
1518
|
return ev2 ? ev2.api_version === '2024-06-20' : false;
|
|
1420
|
-
} catch { return false; } finally { rmSync(root, { recursive: true, force: true }); }
|
|
1519
|
+
} catch (err) { if (isInfrastructureError(err)) throw harnessError('stripe.api.versioning', err); return false; } finally { rmSync(root, { recursive: true, force: true }); }
|
|
1421
1520
|
}),
|
|
1422
1521
|
|
|
1423
1522
|
// ── Test helpers ──────────────────────────────────────────────────────────────────
|
|
@@ -1456,7 +1555,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1456
1555
|
const ch = await h({ m: 'POST', p: '/v1/issuing/cardholders', b: 'name=Jane&type=individual&billing[address][line1]=1 Main&billing[address][city]=SF&billing[address][country]=US&billing[address][postal_code]=94105&billing[address][state]=CA' });
|
|
1457
1556
|
const card = await h({ m: 'POST', p: `/v1/issuing/cards`, b: `cardholder=${id(ch)}¤cy=usd&type=virtual` });
|
|
1458
1557
|
const auth = await h({ m: 'POST', p: '/v1/test_helpers/issuing/authorizations', b: `card=${id(card)}&amount=2500` });
|
|
1459
|
-
if (!ok(auth) || field(auth, 'status') !== 'pending' || field(auth, 'card') !== id(card) || field(auth, 'cardholder') !== id(ch)) return false;
|
|
1558
|
+
if (!ok(auth) || field(auth, 'status') !== 'pending' || (field(auth, 'card') as Body)?.id !== id(card) || field(auth, 'cardholder') !== id(ch)) return false;
|
|
1460
1559
|
const forced = await h({ m: 'POST', p: '/v1/test_helpers/issuing/transactions/create_force_capture', b: `card=${id(card)}&amount=1000` });
|
|
1461
1560
|
if (!ok(forced) || field(forced, 'type') !== 'capture' || field(forced, 'amount') !== -1000) return false;
|
|
1462
1561
|
const noAmt = await h({ m: 'POST', p: '/v1/test_helpers/issuing/fund_balance', b: 'currency=usd' });
|
|
@@ -1618,6 +1717,225 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1618
1717
|
return twice.status === 400 && noReason.status === 400 && badTxn.status === 400;
|
|
1619
1718
|
}),
|
|
1620
1719
|
),
|
|
1720
|
+
// Real-time authorization — the SYNCHRONOUS issuing_authorization.request leg: when a
|
|
1721
|
+
// registered webhook endpoint subscribes to it, presenting an authorization delivers the
|
|
1722
|
+
// request event synchronously (data.object carries a populated pending_request) and the
|
|
1723
|
+
// endpoint's JSON response ({approved: bool}) decides the authorization; the decision
|
|
1724
|
+
// lands in request_history with reason webhook_approved/webhook_declined. The request
|
|
1725
|
+
// event is also stored in GET /v1/events. An endpoint enrolled for OTHER events is never
|
|
1726
|
+
// consulted (the presentment stays pending awaiting the approve/decline API). Offline via
|
|
1727
|
+
// the injected deliverer.
|
|
1728
|
+
done('stripe.issuing.realtime_auth_webhook', 'issuing', 'Real-time authorization: synchronous issuing_authorization.request webhook decides the presentment', 'api', 'niche', async () => {
|
|
1729
|
+
const root = mkdtempSync(join(tmpdir(), 'stp-cap-'));
|
|
1730
|
+
try {
|
|
1731
|
+
const h = (s: Step) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: '2026-06-14T00:00:00Z' });
|
|
1732
|
+
const delivered: StripeEvent[] = [];
|
|
1733
|
+
setStripeEventDelivery((_url, event) => {
|
|
1734
|
+
delivered.push(event);
|
|
1735
|
+
if (event.type === 'issuing_authorization.request') return { status: 200, body: '{"approved":true}' };
|
|
1736
|
+
});
|
|
1737
|
+
const ch = await h({ m: 'POST', p: '/v1/issuing/cardholders', b: 'name=RT&type=individual&billing[address][country]=US' });
|
|
1738
|
+
const card = await h({ m: 'POST', p: '/v1/issuing/cards', b: `cardholder=${id(ch)}¤cy=usd&type=virtual` });
|
|
1739
|
+
// Family enrollment: the synchronous .request leg matches via the family form, and the
|
|
1740
|
+
// organic .created asserted below reaches this endpoint only because the family covers
|
|
1741
|
+
// it — the fan-out filters by enabled_events, so an exact .request enrollment would
|
|
1742
|
+
// (correctly, like the vendor) receive nothing but .request.
|
|
1743
|
+
await h({ m: 'POST', p: '/v1/webhook_endpoints', b: `url=${encodeURIComponent('https://rt.twin.test/auth')}&enabled_events[]=issuing_authorization.*` });
|
|
1744
|
+
const auth = await h({ m: 'POST', p: '/v1/test_helpers/issuing/authorizations', b: `card=${id(card)}&amount=2500` });
|
|
1745
|
+
if (!ok(auth) || field(auth, 'approved') !== true || field(auth, 'status') !== 'pending' || field(auth, 'pending_request') !== null) return false;
|
|
1746
|
+
const history = field(auth, 'request_history') as Body[];
|
|
1747
|
+
if (!Array.isArray(history) || history.length !== 1 || history[0]!.reason !== 'webhook_approved' || history[0]!.approved !== true) return false;
|
|
1748
|
+
// the endpoint saw exactly one synchronous request event, pending_request populated
|
|
1749
|
+
const req = delivered.filter((e) => e.type === 'issuing_authorization.request');
|
|
1750
|
+
if (req.length !== 1) return false;
|
|
1751
|
+
const reqObj = req[0]!.data.object as Body;
|
|
1752
|
+
if ((reqObj.pending_request as Body)?.amount !== 2500 || reqObj.status !== 'pending') return false;
|
|
1753
|
+
// the organic issuing lifecycle events flow too (created for the card + authorization)
|
|
1754
|
+
if (!delivered.some((e) => e.type === 'issuing_authorization.created')) return false;
|
|
1755
|
+
// stored Events API carries the request event
|
|
1756
|
+
const events = await h({ m: 'GET', p: '/v1/events?limit=100' });
|
|
1757
|
+
if (!((events.body as Body).data as Body[]).some((e) => e.type === 'issuing_authorization.request')) return false;
|
|
1758
|
+
// an endpoint enrolled for OTHER events is never consulted → presentment stays pending
|
|
1759
|
+
const root2 = mkdtempSync(join(tmpdir(), 'stp-cap-'));
|
|
1760
|
+
try {
|
|
1761
|
+
const h2 = (s: Step) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root: root2, occurredAt: '2026-06-14T00:00:00Z' });
|
|
1762
|
+
let consulted = false;
|
|
1763
|
+
setStripeEventDelivery((_url, event) => {
|
|
1764
|
+
if (event.type === 'issuing_authorization.request') consulted = true;
|
|
1765
|
+
return { status: 200, body: '{"approved":false}' };
|
|
1766
|
+
});
|
|
1767
|
+
const ch2 = await h2({ m: 'POST', p: '/v1/issuing/cardholders', b: 'name=RT2&type=individual&billing[address][country]=US' });
|
|
1768
|
+
const card2 = await h2({ m: 'POST', p: '/v1/issuing/cards', b: `cardholder=${id(ch2)}¤cy=usd&type=virtual` });
|
|
1769
|
+
await h2({ m: 'POST', p: '/v1/webhook_endpoints', b: `url=${encodeURIComponent('https://other.twin.test/hook')}&enabled_events[]=invoice.paid` });
|
|
1770
|
+
const auth2 = await h2({ m: 'POST', p: '/v1/test_helpers/issuing/authorizations', b: `card=${id(card2)}&amount=100` });
|
|
1771
|
+
return !consulted && field(auth2, 'status') === 'pending' && field(auth2, 'approved') === false;
|
|
1772
|
+
} finally {
|
|
1773
|
+
rmSync(root2, { recursive: true, force: true });
|
|
1774
|
+
}
|
|
1775
|
+
} catch (err) {
|
|
1776
|
+
if (isInfrastructureError(err)) throw harnessError('stripe.issuing.realtime_auth_webhook', err);
|
|
1777
|
+
return false;
|
|
1778
|
+
} finally {
|
|
1779
|
+
setStripeEventDelivery(null);
|
|
1780
|
+
clearStripeWebhooks();
|
|
1781
|
+
rmSync(root, { recursive: true, force: true });
|
|
1782
|
+
}
|
|
1783
|
+
}),
|
|
1784
|
+
// Real-time authorization fallbacks (the 2s-window posture, fail-closed): a webhook
|
|
1785
|
+
// decline closes the authorization (reason webhook_declined); a TIMEOUT declines with
|
|
1786
|
+
// reason webhook_timeout; an invalid response (non-2xx, non-JSON, missing `approved`)
|
|
1787
|
+
// declines with reason webhook_error + a reason_message; a partial `amount` in the
|
|
1788
|
+
// response is held ONLY when the presentment was is_amount_controllable.
|
|
1789
|
+
done('stripe.issuing.realtime_auth_fallbacks', 'issuing', 'Real-time authorization: decline honored; timeout → webhook_timeout; invalid response → webhook_error; partial amount only when controllable', 'api', 'niche', async () => {
|
|
1790
|
+
const root = mkdtempSync(join(tmpdir(), 'stp-cap-'));
|
|
1791
|
+
try {
|
|
1792
|
+
const h = (s: Step) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: '2026-06-14T00:00:00Z' });
|
|
1793
|
+
const ch = await h({ m: 'POST', p: '/v1/issuing/cardholders', b: 'name=FB&type=individual&billing[address][country]=US' });
|
|
1794
|
+
const card = await h({ m: 'POST', p: '/v1/issuing/cards', b: `cardholder=${id(ch)}¤cy=usd&type=virtual` });
|
|
1795
|
+
await h({ m: 'POST', p: '/v1/webhook_endpoints', b: `url=${encodeURIComponent('https://rt.twin.test/auth')}&enabled_events[]=issuing_authorization.request` });
|
|
1796
|
+
const present = (b: string) => h({ m: 'POST', p: '/v1/test_helpers/issuing/authorizations', b });
|
|
1797
|
+
const reasonOf = (r: StripeResponse) => ((field(r, 'request_history') as Body[])?.[0] ?? {}) as Body;
|
|
1798
|
+
const respond = (res: { status: number; body: string } | (() => never)) =>
|
|
1799
|
+
setStripeEventDelivery((_url, event) => {
|
|
1800
|
+
if (event.type !== 'issuing_authorization.request') return;
|
|
1801
|
+
if (typeof res === 'function') return res();
|
|
1802
|
+
return res;
|
|
1803
|
+
});
|
|
1804
|
+
// decline honored
|
|
1805
|
+
respond({ status: 200, body: '{"approved":false}' });
|
|
1806
|
+
const dec = await present(`card=${id(card)}&amount=900`);
|
|
1807
|
+
if (field(dec, 'approved') !== false || field(dec, 'status') !== 'closed' || reasonOf(dec).reason !== 'webhook_declined') return false;
|
|
1808
|
+
// timeout → webhook_timeout (the exact TimeoutError AbortSignal.timeout raises)
|
|
1809
|
+
respond(() => { const e = new Error('timed out'); e.name = 'TimeoutError'; throw e; });
|
|
1810
|
+
const to = await present(`card=${id(card)}&amount=900`);
|
|
1811
|
+
if (field(to, 'approved') !== false || reasonOf(to).reason !== 'webhook_timeout') return false;
|
|
1812
|
+
// invalid responses → webhook_error with a reason_message
|
|
1813
|
+
for (const bad of [{ status: 500, body: 'oops' }, { status: 200, body: 'not-json' }, { status: 200, body: '{"ok":true}' }]) {
|
|
1814
|
+
respond(bad);
|
|
1815
|
+
const er = await present(`card=${id(card)}&amount=900`);
|
|
1816
|
+
if (field(er, 'approved') !== false || reasonOf(er).reason !== 'webhook_error' || typeof reasonOf(er).reason_message !== 'string') return false;
|
|
1817
|
+
}
|
|
1818
|
+
// partial amount: honored when controllable, ignored when not
|
|
1819
|
+
respond({ status: 200, body: '{"approved":true,"amount":1200}' });
|
|
1820
|
+
const partial = await present(`card=${id(card)}&amount=5000&is_amount_controllable=true`);
|
|
1821
|
+
if (field(partial, 'amount') !== 1200 || field(partial, 'approved') !== true) return false;
|
|
1822
|
+
const fixed = await present(`card=${id(card)}&amount=5000`);
|
|
1823
|
+
return field(fixed, 'amount') === 5000 && field(fixed, 'approved') === true;
|
|
1824
|
+
} catch (err) {
|
|
1825
|
+
if (isInfrastructureError(err)) throw harnessError('stripe.issuing.realtime_auth_fallbacks', err);
|
|
1826
|
+
return false;
|
|
1827
|
+
} finally {
|
|
1828
|
+
setStripeEventDelivery(null);
|
|
1829
|
+
clearStripeWebhooks();
|
|
1830
|
+
rmSync(root, { recursive: true, force: true });
|
|
1831
|
+
}
|
|
1832
|
+
}),
|
|
1833
|
+
// spending_controls enforcement at present time: per_authorization and all_time limits,
|
|
1834
|
+
// blocked categories, and card-state declines all refuse the presentment with the
|
|
1835
|
+
// vendor's request_history reasons ('spending_controls', 'card_inactive'); an invalid
|
|
1836
|
+
// spending_controls dictionary 400s at write time (closed interval enum).
|
|
1837
|
+
done('stripe.issuing.spending_controls', 'issuing', 'spending_controls enforced at authorization time (limits, categories, card state) with vendor decline reasons', 'api', 'niche', async () => {
|
|
1838
|
+
const root = mkdtempSync(join(tmpdir(), 'stp-cap-'));
|
|
1839
|
+
try {
|
|
1840
|
+
const h = (s: Step) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: '2026-06-14T00:00:00Z' });
|
|
1841
|
+
setStripeEventDelivery((_url, event) => {
|
|
1842
|
+
if (event.type === 'issuing_authorization.request') return { status: 200, body: '{"approved":true}' };
|
|
1843
|
+
});
|
|
1844
|
+
const reason = (r: StripeResponse) => ((field(r, 'request_history') as Body[])?.[0] ?? {}) as Body;
|
|
1845
|
+
const ch = await h({ m: 'POST', p: '/v1/issuing/cardholders', b: 'name=SC&type=individual&billing[address][country]=US' });
|
|
1846
|
+
// per_authorization
|
|
1847
|
+
const perAuth = await h({ m: 'POST', p: '/v1/issuing/cards', b: `cardholder=${id(ch)}¤cy=usd&type=virtual&spending_controls[spending_limits][0][amount]=5000&spending_controls[spending_limits][0][interval]=per_authorization` });
|
|
1848
|
+
const over = await h({ m: 'POST', p: '/v1/test_helpers/issuing/authorizations', b: `card=${id(perAuth)}&amount=5001` });
|
|
1849
|
+
if (field(over, 'approved') !== false || field(over, 'status') !== 'closed' || reason(over).reason !== 'spending_controls') return false;
|
|
1850
|
+
const under = await h({ m: 'POST', p: '/v1/test_helpers/issuing/authorizations', b: `card=${id(perAuth)}&amount=5000` });
|
|
1851
|
+
if (field(under, 'status') !== 'pending') return false; // within the limit → not a controls decline
|
|
1852
|
+
// all_time accumulates approved authorizations (webhook approvals via the enrolled endpoint)
|
|
1853
|
+
await h({ m: 'POST', p: '/v1/webhook_endpoints', b: `url=${encodeURIComponent('https://rt.twin.test/auth')}&enabled_events[]=issuing_authorization.request` });
|
|
1854
|
+
const allTime = await h({ m: 'POST', p: '/v1/issuing/cards', b: `cardholder=${id(ch)}¤cy=usd&type=virtual&spending_controls[spending_limits][0][amount]=10000&spending_controls[spending_limits][0][interval]=all_time` });
|
|
1855
|
+
const first = await h({ m: 'POST', p: '/v1/test_helpers/issuing/authorizations', b: `card=${id(allTime)}&amount=3000` });
|
|
1856
|
+
if (field(first, 'approved') !== true) return false;
|
|
1857
|
+
const second = await h({ m: 'POST', p: '/v1/test_helpers/issuing/authorizations', b: `card=${id(allTime)}&amount=8000` });
|
|
1858
|
+
if (field(second, 'approved') !== false || reason(second).reason !== 'spending_controls') return false;
|
|
1859
|
+
// blocked category
|
|
1860
|
+
const blocked = await h({ m: 'POST', p: '/v1/issuing/cards', b: `cardholder=${id(ch)}¤cy=usd&type=virtual&spending_controls[blocked_categories][]=airlines_air_carriers` });
|
|
1861
|
+
const blockedHit = await h({ m: 'POST', p: '/v1/test_helpers/issuing/authorizations', b: `card=${id(blocked)}&amount=100&merchant_data[category]=airlines_air_carriers` });
|
|
1862
|
+
if (field(blockedHit, 'approved') !== false || reason(blockedHit).reason !== 'spending_controls') return false;
|
|
1863
|
+
// card state: a physical (inactive) card declines card_inactive
|
|
1864
|
+
const phys = await h({ m: 'POST', p: '/v1/issuing/cards', b: `cardholder=${id(ch)}¤cy=usd&type=physical` });
|
|
1865
|
+
const inactive = await h({ m: 'POST', p: '/v1/test_helpers/issuing/authorizations', b: `card=${id(phys)}&amount=100` });
|
|
1866
|
+
if (reason(inactive).reason !== 'card_inactive') return false;
|
|
1867
|
+
// vendor 400: invalid interval enum on write
|
|
1868
|
+
const badInterval = await h({ m: 'POST', p: `/v1/issuing/cards/${id(perAuth)}`, b: 'spending_controls[spending_limits][0][amount]=100&spending_controls[spending_limits][0][interval]=fortnightly' });
|
|
1869
|
+
return badInterval.status === 400;
|
|
1870
|
+
} catch (err) {
|
|
1871
|
+
if (isInfrastructureError(err)) throw harnessError('stripe.issuing.spending_controls', err);
|
|
1872
|
+
return false;
|
|
1873
|
+
} finally {
|
|
1874
|
+
setStripeEventDelivery(null);
|
|
1875
|
+
clearStripeWebhooks();
|
|
1876
|
+
rmSync(root, { recursive: true, force: true });
|
|
1877
|
+
}
|
|
1878
|
+
}),
|
|
1879
|
+
// Capture debits the issuing balance: fund_balance accrues the float, a webhook-approved
|
|
1880
|
+
// authorization is captured via POST /v1/test_helpers/issuing/authorizations/:id/capture
|
|
1881
|
+
// (partial capture_amount honored, refused above the held amount / on a non-approved or
|
|
1882
|
+
// closed authorization), the materialized transaction is type 'capture' with a NEGATIVE
|
|
1883
|
+
// amount linked to a balance_transaction {type: 'issuing_transaction', balance_type:
|
|
1884
|
+
// 'issuing'}, and GET /v1/balance serves the reduced issuing.available. force_capture
|
|
1885
|
+
// debits too.
|
|
1886
|
+
done('stripe.issuing.capture_debits_balance', 'issuing', 'Capture (test-helper + force_capture + API approve) debits the issuing balance; GET /v1/balance serves the issuing section', 'api', 'niche', async () => {
|
|
1887
|
+
const root = mkdtempSync(join(tmpdir(), 'stp-cap-'));
|
|
1888
|
+
try {
|
|
1889
|
+
const h = (s: Step) => handleStripeTwinRequest({ method: s.m, path: s.p, body: s.b, root, occurredAt: '2026-06-14T00:00:00Z' });
|
|
1890
|
+
setStripeEventDelivery((_url, event) => {
|
|
1891
|
+
if (event.type === 'issuing_authorization.request') return { status: 200, body: '{"approved":true}' };
|
|
1892
|
+
});
|
|
1893
|
+
const issuingAvailable = async () => {
|
|
1894
|
+
const bal = await h({ m: 'GET', p: '/v1/balance' });
|
|
1895
|
+
return (((field(bal, 'issuing') as Body)?.available as Body[])?.[0] ?? {}).amount;
|
|
1896
|
+
};
|
|
1897
|
+
const ch = await h({ m: 'POST', p: '/v1/issuing/cardholders', b: 'name=Cap&type=individual&billing[address][country]=US' });
|
|
1898
|
+
const card = await h({ m: 'POST', p: '/v1/issuing/cards', b: `cardholder=${id(ch)}¤cy=usd&type=virtual` });
|
|
1899
|
+
await h({ m: 'POST', p: '/v1/webhook_endpoints', b: `url=${encodeURIComponent('https://rt.twin.test/auth')}&enabled_events[]=issuing_authorization.request` });
|
|
1900
|
+
const fund = await h({ m: 'POST', p: '/v1/test_helpers/issuing/fund_balance', b: 'amount=100000¤cy=usd' });
|
|
1901
|
+
if (((field(fund, 'issuing') as Body).available as Body[])[0]!.amount !== 100000) return false;
|
|
1902
|
+
if (await issuingAvailable() !== 100000) return false;
|
|
1903
|
+
const auth = await h({ m: 'POST', p: '/v1/test_helpers/issuing/authorizations', b: `card=${id(card)}&amount=30000` });
|
|
1904
|
+
if (field(auth, 'approved') !== true) return false;
|
|
1905
|
+
// refuse a capture above the held amount
|
|
1906
|
+
const overCap = await h({ m: 'POST', p: `/v1/test_helpers/issuing/authorizations/${id(auth)}/capture`, b: 'capture_amount=30001' });
|
|
1907
|
+
if (overCap.status !== 400) return false;
|
|
1908
|
+
const captured = await h({ m: 'POST', p: `/v1/test_helpers/issuing/authorizations/${id(auth)}/capture` });
|
|
1909
|
+
if (!ok(captured) || field(captured, 'status') !== 'closed') return false;
|
|
1910
|
+
// the materialized transaction: capture, negative amount, linked balance_transaction
|
|
1911
|
+
const txns = await h({ m: 'GET', p: '/v1/issuing/transactions' });
|
|
1912
|
+
const txn = ((txns.body as Body).data as Body[]).find((t) => t.authorization === id(auth));
|
|
1913
|
+
if (!txn || txn.type !== 'capture' || txn.amount !== -30000 || typeof txn.balance_transaction !== 'string') return false;
|
|
1914
|
+
const bt = await h({ m: 'GET', p: `/v1/balance_transactions/${txn.balance_transaction}` });
|
|
1915
|
+
if (field(bt, 'amount') !== -30000 || field(bt, 'type') !== 'issuing_transaction' || field(bt, 'balance_type') !== 'issuing') return false;
|
|
1916
|
+
// the float went down; issuing funds never leak into the payments buckets
|
|
1917
|
+
if (await issuingAvailable() !== 70000) return false;
|
|
1918
|
+
const bal = await h({ m: 'GET', p: '/v1/balance' });
|
|
1919
|
+
if (((field(bal, 'available') as Body[])[0] ?? {}).amount !== 0) return false;
|
|
1920
|
+
// a second capture on the closed authorization 400s
|
|
1921
|
+
const again = await h({ m: 'POST', p: `/v1/test_helpers/issuing/authorizations/${id(auth)}/capture` });
|
|
1922
|
+
if (again.status !== 400) return false;
|
|
1923
|
+
// force_capture debits too
|
|
1924
|
+
const forced = await h({ m: 'POST', p: '/v1/test_helpers/issuing/transactions/create_force_capture', b: `card=${id(card)}&amount=5000` });
|
|
1925
|
+
if (field(forced, 'amount') !== -5000 || typeof field(forced, 'balance_transaction') !== 'string') return false;
|
|
1926
|
+
return await issuingAvailable() === 65000;
|
|
1927
|
+
} catch (err) {
|
|
1928
|
+
if (isInfrastructureError(err)) throw harnessError('stripe.issuing.capture_debits_balance', err);
|
|
1929
|
+
return false;
|
|
1930
|
+
} finally {
|
|
1931
|
+
setStripeEventDelivery(null);
|
|
1932
|
+
clearStripeWebhooks();
|
|
1933
|
+
rmSync(root, { recursive: true, force: true });
|
|
1934
|
+
}
|
|
1935
|
+
}),
|
|
1936
|
+
// Known-unmodeled Issuing surface (kept honest as todos):
|
|
1937
|
+
todo('stripe.issuing.authorization_holds', 'issuing', 'Authorization holds/releases as balance_transactions (type issuing_authorization_hold/_release) while an approved authorization is open', 'api', 'niche'),
|
|
1938
|
+
todo('stripe.issuing.test_helpers.lifecycle', 'issuing', 'Test-helper authorization lifecycle: expire / increment / reverse / finalize_amount / respond', 'api', 'niche'),
|
|
1621
1939
|
|
|
1622
1940
|
// ── Terminal ─────────────────────────────────────────────────────────────────────
|
|
1623
1941
|
// Readers (+process_payment_intent): a reader registers with a registration_code (status
|
|
@@ -1857,7 +2175,7 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
1857
2175
|
// re-sync of identical state appends no deltas (idempotent shadow-diff).
|
|
1858
2176
|
const again = await fullSyncStripe(execute, { root, occurredAt: '2024-01-01T00:00:00.000Z' });
|
|
1859
2177
|
return again.deltasAppended === 0;
|
|
1860
|
-
} catch { return false; } finally { rmSync(root, { recursive: true, force: true }); }
|
|
2178
|
+
} catch (err) { if (isInfrastructureError(err)) throw harnessError('stripe.connector.full_sync', err); return false; } finally { rmSync(root, { recursive: true, force: true }); }
|
|
1861
2179
|
}),
|
|
1862
2180
|
|
|
1863
2181
|
// ── DASHBOARD UI screens (the real Stripe Dashboard) ─────────────────────────────
|
|
@@ -2660,10 +2978,10 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
2660
2978
|
// finalizing an already-finalized invoice 400s.
|
|
2661
2979
|
const finAgain = await h({ m: 'POST', p: `/v1/invoices/${id(inv)}/finalize` });
|
|
2662
2980
|
if (finAgain.status !== 400) return false;
|
|
2663
|
-
const pay = await h({ m: 'POST', p: `/v1/invoices/${id(inv)}/pay
|
|
2981
|
+
const pay = await h({ m: 'POST', p: `/v1/invoices/${id(inv)}/pay`, b: 'payment_method=pm_card_visa' });
|
|
2664
2982
|
if (!ok(pay) || field(pay, 'status') !== 'paid') return false;
|
|
2665
2983
|
// paying an already-paid invoice 400s; voiding a paid invoice 400s.
|
|
2666
|
-
const payAgain = await h({ m: 'POST', p: `/v1/invoices/${id(inv)}/pay
|
|
2984
|
+
const payAgain = await h({ m: 'POST', p: `/v1/invoices/${id(inv)}/pay`, b: 'payment_method=pm_card_visa' });
|
|
2667
2985
|
const voidPaid = await h({ m: 'POST', p: `/v1/invoices/${id(inv)}/void` });
|
|
2668
2986
|
const missing = await h({ m: 'POST', p: '/v1/invoices/in_nope/void' });
|
|
2669
2987
|
return payAgain.status === 400 && voidPaid.status === 400 && missing.status === 404;
|
|
@@ -3291,9 +3609,9 @@ export const STRIPE_CAPABILITIES: CapabilitySpec[] = [
|
|
|
3291
3609
|
todo('stripe.treasury.reversals', 'treasury', 'Treasury: credit reversals / debit reversals', 'api', 'niche'),
|
|
3292
3610
|
todo('stripe.treasury.financial_addresses', 'treasury', 'Treasury: financial-account ABA/address activation', 'api', 'niche'),
|
|
3293
3611
|
todo('stripe.financial_connections.refresh', 'financial_connections', 'Financial Connections: balance/ownership/transaction refresh + inferred balances', 'api', 'niche'),
|
|
3294
|
-
// ── Pull-surface coverage audit gaps (TWIN-46 / G2) — filed as manifest todos
|
|
3295
|
-
//
|
|
3296
|
-
//
|
|
3612
|
+
// ── Pull-surface coverage audit gaps (TWIN-46 / G2) — filed as manifest todos, which is
|
|
3613
|
+
// what the demand-ordered build list is drawn from. See pull-audit.json (repo root) for the
|
|
3614
|
+
// per-pack pull-vs-read-surface evidence.
|
|
3297
3615
|
todo('stripe.connector.pull_payment_methods', 'connector', 'Connector: pull payment methods from the real account (already pushable via COLLECTION, never pulled)', 'connector', 'core'),
|
|
3298
3616
|
todo('stripe.connector.pull_setup_intents', 'connector', 'Connector: pull setup intents from the real account (already pushable via COLLECTION, never pulled)', 'connector', 'common'),
|
|
3299
3617
|
|
|
@@ -30,6 +30,8 @@ const TYPE_TO_OBJECT: Record<string, string> = {
|
|
|
30
30
|
account: 'account', transfer: 'transfer',
|
|
31
31
|
tax_rate: 'tax_rate', tax_calculation: 'tax.calculation', tax_registration: 'tax.registration',
|
|
32
32
|
credit_note: 'credit_note', tax_id: 'tax_id', customer_balance_transaction: 'customer_balance_transaction',
|
|
33
|
+
issuing_authorization: 'issuing.authorization', issuing_card: 'issuing.card',
|
|
34
|
+
issuing_cardholder: 'issuing.cardholder', issuing_transaction: 'issuing.transaction',
|
|
33
35
|
};
|
|
34
36
|
|
|
35
37
|
export type StripeSchemas = Record<string, JsonSchema>;
|
package/src/stripe-connector.ts
CHANGED
|
@@ -16,8 +16,9 @@
|
|
|
16
16
|
// - live runs pass `liveStripeExecute(apiKey)` (the user's own secret key).
|
|
17
17
|
// Same code path either way, so the connector is fully exercisable offline AND
|
|
18
18
|
// runnable against a real account.
|
|
19
|
-
import { confirmAction, pendingActions, syncPull } from '@volter/twin';
|
|
19
|
+
import { assertBudgetGuardIntact, confirmAction, pendingActions, syncPull } from '@volter/twin';
|
|
20
20
|
import type { SyncResource, TwinAction } from '@volter/twin';
|
|
21
|
+
import { StripeBudget, stripeCallWeight, type StripeBudgetOptions } from './stripe-budget.ts';
|
|
21
22
|
|
|
22
23
|
const SERVICE = 'stripe';
|
|
23
24
|
|
|
@@ -40,12 +41,62 @@ export type StripeExecute = (
|
|
|
40
41
|
params?: Record<string, unknown>,
|
|
41
42
|
) => Promise<{ object?: string; data?: any; error?: { message?: string; type?: string }; [k: string]: unknown }>;
|
|
42
43
|
|
|
44
|
+
/** Construction options for the live executor. `budget` cannot be null and cannot be loosened. */
|
|
45
|
+
export type LiveStripeOptions = {
|
|
46
|
+
/** Injected `fetch`, so a test can COUNT the requests the guard did or did not let through. */
|
|
47
|
+
fetchImpl?: typeof fetch;
|
|
48
|
+
/** An existing budget to share across executors. Omit and one is constructed. Cannot be null. */
|
|
49
|
+
budget?: StripeBudget;
|
|
50
|
+
/** Construction options for the default budget (ledger path, clock). Cannot loosen it. */
|
|
51
|
+
budgetOptions?: StripeBudgetOptions;
|
|
52
|
+
};
|
|
53
|
+
|
|
43
54
|
/**
|
|
44
55
|
* A live executor against the real Stripe REST API (secret key = the user's own).
|
|
45
56
|
* Form-encodes POST params; sends GET filters as a query string. Never imported by
|
|
46
57
|
* the pack's own code path — only constructed by a caller that opts into real I/O.
|
|
58
|
+
*
|
|
59
|
+
* THIS IS THE ONE PLACE this pack issues a live `api.stripe.com` request, and therefore the one
|
|
60
|
+
* place the rate budget has to be enforced — and Stripe is the pack where a runaway loop does not
|
|
61
|
+
* merely annoy a vendor, it MOVES MONEY. EVERY call is guarded: the budget is charged BEFORE the
|
|
62
|
+
* request goes out (`checkBudget`, which THROWS `StripeBudgetError` instead of returning when the
|
|
63
|
+
* ceiling or a cooldown says stop) and the response is fed back (`recordCall`) so a `Retry-After` /
|
|
64
|
+
* 429 signal becomes a persisted cooldown that makes every later call fail fast WITHOUT touching
|
|
65
|
+
* Stripe. There is deliberately no OPTION to disable the guard, and no
|
|
66
|
+
* value a caller can pass for `budget` that yields an unguarded client. That is NOT immunity from
|
|
67
|
+
* a caller who WANTS one: a fresh `budgetOptions.path` per construction, or an injected clock,
|
|
68
|
+
* restores the allowance, because the seam tests need cannot be denied to a determined caller in
|
|
69
|
+
* the same process. The kernel header states that limit and this does not upgrade it. See `stripe-budget.ts` for why,
|
|
70
|
+
* and for the limits of the guarantee.
|
|
47
71
|
*/
|
|
48
|
-
export function liveStripeExecute(
|
|
72
|
+
export function liveStripeExecute(
|
|
73
|
+
apiKey: string,
|
|
74
|
+
base = 'https://api.stripe.com',
|
|
75
|
+
opts: LiveStripeOptions = {},
|
|
76
|
+
): StripeExecute {
|
|
77
|
+
// `null`/`undefined` (or omitting it) build the default budget. Anything else must be an
|
|
78
|
+
// UNMODIFIED StripeBudget: a duck-typed stand-in, a SUBCLASS that overrides `checkBudget`, and a
|
|
79
|
+
// Proxy that traps it are all refused, because all three are one-liners that would otherwise
|
|
80
|
+
// hand back a client with no ceiling at all (§9 finding, 2026-07-26 — `instanceof` alone was
|
|
81
|
+
// not a check). What this cannot stop is deliberate sabotage from inside the process (an
|
|
82
|
+
// injected clock, a throwaway ledger path); the kernel's header says so rather than pretending
|
|
83
|
+
// otherwise, and this guards the accident and the one-liner, which are the shapes that happen.
|
|
84
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
85
|
+
// The default ledger is keyed by a hash of THIS key — Stripe's limits attach to the account
|
|
86
|
+
// behind it, so a cwd-scoped ledger would hand it a fresh allowance per checkout/worktree/CI leg.
|
|
87
|
+
// A live key and a test key hash differently, which matches Stripe's separate 100/s and 25/s.
|
|
88
|
+
// ONE expression decides which budget is used, so there is no second, weaker test that could
|
|
89
|
+
// disagree with the first. `null`/`undefined` (or omitting it) build the default; anything else
|
|
90
|
+
// must be an UNMODIFIED StripeBudget — a duck-typed stand-in, a SUBCLASS overriding
|
|
91
|
+
// `checkBudget`, and a Proxy trapping it are ALL refused, because each is a one-liner that
|
|
92
|
+
// would otherwise hand back a client with no ceiling (§9 finding, 2026-07-26: `instanceof`
|
|
93
|
+
// alone was not a check — a subclass satisfied it). What this cannot stop is deliberate
|
|
94
|
+
// sabotage from inside the process (an injected clock, a throwaway ledger path); the kernel
|
|
95
|
+
// header states that limit rather than pretending otherwise. This closes the accident and the
|
|
96
|
+
// one-liner, which are the shapes that actually happen.
|
|
97
|
+
const budget = opts.budget !== undefined && opts.budget !== null
|
|
98
|
+
? assertBudgetGuardIntact(opts.budget, StripeBudget, 'liveStripeExecute')
|
|
99
|
+
: new StripeBudget({ token: apiKey, ...(opts.budgetOptions ?? {}) });
|
|
49
100
|
return async (method, path, params) => {
|
|
50
101
|
const headers: Record<string, string> = { Authorization: `Bearer ${apiKey}` };
|
|
51
102
|
let url = `${base}${path}`;
|
|
@@ -57,8 +108,18 @@ export function liveStripeExecute(apiKey: string, base = 'https://api.stripe.com
|
|
|
57
108
|
headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
|
58
109
|
init.body = form;
|
|
59
110
|
}
|
|
60
|
-
const
|
|
61
|
-
|
|
111
|
+
const weight = stripeCallWeight(method, path);
|
|
112
|
+
// THROWS instead of calling. Nothing below this line runs when the budget refuses.
|
|
113
|
+
const reservation = budget.checkBudget(weight);
|
|
114
|
+
const res = await doFetch(url, init);
|
|
115
|
+
const resHeaders: Record<string, string> = {};
|
|
116
|
+
res.headers.forEach((v: string, k: string) => { resHeaders[k.toLowerCase()] = v; });
|
|
117
|
+
const parsed = (await res.json()) as { object?: string; data?: any; error?: { message?: string } };
|
|
118
|
+
// Settles the reservation and, on a back-off signal, arms the cooldown. May itself throw (a
|
|
119
|
+
// `Retry-After` beyond the cap is not something to sleep off) — the cooldown is persisted
|
|
120
|
+
// first either way, so the refusal survives the throw.
|
|
121
|
+
budget.recordCall(weight, resHeaders, { status: res.status, reservation });
|
|
122
|
+
return parsed;
|
|
62
123
|
};
|
|
63
124
|
}
|
|
64
125
|
|