@volter/twin-stripe 0.1.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -27
- package/client/dashboard-api.ts +286 -0
- package/client/stripe-mirror.css +272 -159
- package/client/stripe-mirror.tsx +1384 -541
- package/dist/client/dashboard-api.d.ts +107 -0
- package/dist/client/dashboard-api.js +238 -0
- package/dist/client/dashboard-api.ts +286 -0
- package/dist/client/stripe-mirror.bundle.js +236 -0
- package/dist/client/stripe-mirror.css +275 -0
- package/dist/client/stripe-mirror.d.ts +134 -0
- package/dist/client/stripe-mirror.js +823 -0
- package/dist/client/stripe-mirror.tsx +1534 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +39 -0
- package/dist/src/generated/events.gen.json +1 -0
- package/dist/src/generated/surface.gen.json +1 -0
- package/dist/src/generated/ui.gen.json +1 -0
- package/dist/src/index.d.ts +14 -0
- package/dist/src/index.js +73 -0
- package/dist/src/manifest.d.ts +2 -0
- package/dist/src/manifest.js +1065 -0
- package/dist/src/screens/checkout.d.ts +31 -0
- package/dist/src/screens/checkout.js +241 -0
- package/dist/src/screens/consent-skin.d.ts +4 -0
- package/dist/src/screens/consent-skin.js +18 -0
- package/dist/src/screens/financial-connections.d.ts +5 -0
- package/dist/src/screens/financial-connections.js +90 -0
- package/dist/src/screens/identity.d.ts +5 -0
- package/dist/src/screens/identity.js +86 -0
- package/dist/src/screens/industries.d.ts +1 -0
- package/dist/src/screens/industries.js +267 -0
- package/dist/src/screens/onboarding.d.ts +13 -0
- package/dist/src/screens/onboarding.js +225 -0
- package/dist/src/screens/portal.d.ts +5 -0
- package/dist/src/screens/portal.js +214 -0
- package/dist/src/screens/public-details.d.ts +5 -0
- package/dist/src/screens/public-details.js +90 -0
- package/dist/src/semantics/after-payment.d.ts +22 -0
- package/dist/src/semantics/after-payment.js +93 -0
- package/dist/src/semantics/apps-secrets.d.ts +2 -0
- package/dist/src/semantics/apps-secrets.js +54 -0
- package/dist/src/semantics/balance.d.ts +11 -0
- package/dist/src/semantics/balance.js +195 -0
- package/dist/src/semantics/billing.d.ts +2 -0
- package/dist/src/semantics/billing.js +220 -0
- package/dist/src/semantics/charges.d.ts +28 -0
- package/dist/src/semantics/charges.js +201 -0
- package/dist/src/semantics/checkout.d.ts +15 -0
- package/dist/src/semantics/checkout.js +303 -0
- package/dist/src/semantics/connect.d.ts +5 -0
- package/dist/src/semantics/connect.js +476 -0
- package/dist/src/semantics/coupons.d.ts +6 -0
- package/dist/src/semantics/coupons.js +92 -0
- package/dist/src/semantics/credit-notes.d.ts +2 -0
- package/dist/src/semantics/credit-notes.js +172 -0
- package/dist/src/semantics/customers.d.ts +6 -0
- package/dist/src/semantics/customers.js +429 -0
- package/dist/src/semantics/disputes.d.ts +2 -0
- package/dist/src/semantics/disputes.js +51 -0
- package/dist/src/semantics/entitlements.d.ts +2 -0
- package/dist/src/semantics/entitlements.js +95 -0
- package/dist/src/semantics/ephemeral-keys.d.ts +2 -0
- package/dist/src/semantics/ephemeral-keys.js +34 -0
- package/dist/src/semantics/files.d.ts +2 -0
- package/dist/src/semantics/files.js +125 -0
- package/dist/src/semantics/invoices.d.ts +18 -0
- package/dist/src/semantics/invoices.js +541 -0
- package/dist/src/semantics/issuing.d.ts +13 -0
- package/dist/src/semantics/issuing.js +570 -0
- package/dist/src/semantics/ledger.d.ts +54 -0
- package/dist/src/semantics/ledger.js +181 -0
- package/dist/src/semantics/payment-intents.d.ts +18 -0
- package/dist/src/semantics/payment-intents.js +404 -0
- package/dist/src/semantics/payment-links.d.ts +2 -0
- package/dist/src/semantics/payment-links.js +133 -0
- package/dist/src/semantics/payment-methods.d.ts +20 -0
- package/dist/src/semantics/payment-methods.js +138 -0
- package/dist/src/semantics/plans.d.ts +5 -0
- package/dist/src/semantics/plans.js +121 -0
- package/dist/src/semantics/platform.d.ts +9 -0
- package/dist/src/semantics/platform.js +206 -0
- package/dist/src/semantics/products.d.ts +2 -0
- package/dist/src/semantics/products.js +140 -0
- package/dist/src/semantics/radar.d.ts +2 -0
- package/dist/src/semantics/radar.js +83 -0
- package/dist/src/semantics/refunds.d.ts +9 -0
- package/dist/src/semantics/refunds.js +195 -0
- package/dist/src/semantics/renewals.d.ts +47 -0
- package/dist/src/semantics/renewals.js +251 -0
- package/dist/src/semantics/setup-intents.d.ts +2 -0
- package/dist/src/semantics/setup-intents.js +84 -0
- package/dist/src/semantics/shared.d.ts +78 -0
- package/dist/src/semantics/shared.js +192 -0
- package/dist/src/semantics/subscription-schedules.d.ts +2 -0
- package/dist/src/semantics/subscription-schedules.js +119 -0
- package/dist/src/semantics/subscriptions.d.ts +11 -0
- package/dist/src/semantics/subscriptions.js +605 -0
- package/dist/src/semantics/tax.d.ts +2 -0
- package/dist/src/semantics/tax.js +197 -0
- package/dist/src/semantics/terminal.d.ts +5 -0
- package/dist/src/semantics/terminal.js +182 -0
- package/dist/src/semantics/test-clocks.d.ts +6 -0
- package/dist/src/semantics/test-clocks.js +73 -0
- package/dist/src/semantics/tokens.d.ts +4 -0
- package/dist/src/semantics/tokens.js +44 -0
- package/dist/src/semantics/transfers.d.ts +2 -0
- package/dist/src/semantics/transfers.js +154 -0
- package/dist/src/semantics/treasury.d.ts +2 -0
- package/dist/src/semantics/treasury.js +377 -0
- package/dist/src/semantics/webhook-endpoints.d.ts +3 -0
- package/dist/src/semantics/webhook-endpoints.js +85 -0
- package/dist/src/stripe-budget.d.ts +55 -0
- package/dist/src/stripe-budget.js +155 -0
- package/dist/src/stripe-capabilities.d.ts +3 -0
- package/dist/src/stripe-capabilities.js +5052 -0
- package/dist/src/stripe-conformance.d.ts +41 -0
- package/dist/src/stripe-conformance.js +96 -0
- package/dist/src/stripe-connector.d.ts +161 -0
- package/dist/src/stripe-connector.js +414 -0
- package/dist/src/stripe-emit.d.ts +2 -0
- package/dist/src/stripe-emit.js +145 -0
- package/dist/src/stripe-events.d.ts +93 -0
- package/dist/src/stripe-events.js +388 -0
- package/dist/src/stripe-js.d.ts +4 -0
- package/dist/src/stripe-js.js +70 -0
- package/dist/src/stripe-mirror-ui.d.ts +15 -0
- package/dist/src/stripe-mirror-ui.js +87 -0
- package/dist/src/stripe-params.d.ts +3 -0
- package/dist/src/stripe-params.js +43 -0
- package/dist/src/stripe-perform-harness.d.ts +9 -0
- package/dist/src/stripe-perform-harness.js +26 -0
- package/dist/src/stripe-server.d.ts +33 -0
- package/dist/src/stripe-server.js +326 -0
- package/dist/src/stripe-shared.d.ts +106 -0
- package/dist/src/stripe-shared.js +273 -0
- package/dist/src/stripe-twin.d.ts +155 -0
- package/dist/src/stripe-twin.js +1226 -0
- package/dist/src/stripe-ui-conformance.d.ts +5 -0
- package/dist/src/stripe-ui-conformance.js +79 -0
- package/dist/src/stripe-ui-structure.d.ts +3 -0
- package/dist/src/stripe-ui-structure.js +168 -0
- package/dist/src/stripe-version.d.ts +10 -0
- package/dist/src/stripe-version.js +285 -0
- package/dist/test-fixtures/stripe-known-deviations.json +105 -0
- package/dist/test-fixtures/stripe-openapi-operations.SOURCE.md +14 -0
- package/dist/test-fixtures/stripe-openapi-operations.json +4717 -0
- package/dist/test-fixtures/stripe-schemas.SOURCE.md +35 -0
- package/dist/test-fixtures/stripe-schemas.json +3740 -0
- package/package.json +18 -10
- package/src/cli.ts +7 -7
- package/src/generated/events.gen.json +1 -0
- package/src/generated/surface.gen.json +1 -0
- package/src/generated/ui.gen.json +1 -0
- package/src/index.ts +31 -9
- package/src/manifest.ts +1097 -0
- package/src/screens/checkout.tsx +252 -0
- package/src/screens/consent-skin.ts +20 -0
- package/src/screens/financial-connections.tsx +101 -0
- package/src/screens/identity.tsx +96 -0
- package/src/screens/industries.ts +267 -0
- package/src/screens/onboarding.tsx +243 -0
- package/src/screens/portal.tsx +218 -0
- package/src/screens/public-details.tsx +105 -0
- package/src/semantics/after-payment.ts +113 -0
- package/src/semantics/apps-secrets.ts +58 -0
- package/src/semantics/balance.ts +209 -0
- package/src/semantics/billing.ts +216 -0
- package/src/semantics/charges.ts +211 -0
- package/src/semantics/checkout.ts +297 -0
- package/src/semantics/connect.ts +471 -0
- package/src/semantics/coupons.ts +97 -0
- package/src/semantics/credit-notes.ts +168 -0
- package/src/semantics/customers.ts +432 -0
- package/src/semantics/disputes.ts +62 -0
- package/src/semantics/entitlements.ts +94 -0
- package/src/semantics/ephemeral-keys.ts +34 -0
- package/src/semantics/files.ts +143 -0
- package/src/semantics/invoices.ts +541 -0
- package/src/semantics/issuing.ts +585 -0
- package/src/semantics/ledger.ts +216 -0
- package/src/semantics/payment-intents.ts +420 -0
- package/src/semantics/payment-links.ts +148 -0
- package/src/semantics/payment-methods.ts +143 -0
- package/src/semantics/plans.ts +131 -0
- package/src/semantics/platform.ts +220 -0
- package/src/semantics/products.ts +154 -0
- package/src/semantics/radar.ts +85 -0
- package/src/semantics/refunds.ts +218 -0
- package/src/semantics/renewals.ts +274 -0
- package/src/semantics/setup-intents.ts +87 -0
- package/src/semantics/shared.ts +215 -0
- package/src/semantics/subscription-schedules.ts +129 -0
- package/src/semantics/subscriptions.ts +610 -0
- package/src/semantics/tax.ts +220 -0
- package/src/semantics/terminal.ts +195 -0
- package/src/semantics/test-clocks.ts +77 -0
- package/src/semantics/tokens.ts +52 -0
- package/src/semantics/transfers.ts +174 -0
- package/src/semantics/treasury.ts +383 -0
- package/src/semantics/webhook-endpoints.ts +87 -0
- package/src/stripe-budget.ts +4 -4
- package/src/stripe-capabilities.ts +1456 -222
- package/src/stripe-conformance.ts +6 -5
- package/src/stripe-connector.ts +68 -40
- package/src/stripe-emit.ts +14 -7
- package/src/stripe-events.ts +94 -36
- package/src/stripe-js.ts +70 -0
- package/src/stripe-mirror-ui.ts +28 -298
- package/src/stripe-params.ts +44 -0
- package/src/stripe-perform-harness.ts +29 -0
- package/src/stripe-server.ts +263 -38
- package/src/stripe-shared.ts +294 -0
- package/src/stripe-twin.ts +429 -5325
- package/src/stripe-ui-conformance.ts +70 -107
- package/src/stripe-ui-structure.ts +124 -348
- package/src/stripe-version.ts +278 -0
- package/test-fixtures/stripe-known-deviations.json +2 -7
- package/test-fixtures/stripe-openapi-operations.json +1188 -2855
- package/src/stripe-form.ts +0 -35
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
// Stripe CONNECTOR — the live-vendor pull/push path that gives the Stripe twin the
|
|
2
|
+
// full "git for SaaS" lifecycle (the piece every other connector pack already had,
|
|
3
|
+
// and Stripe was missing entirely).
|
|
4
|
+
//
|
|
5
|
+
// PULL (real → twin): fetch real Stripe objects, map snake_case → SyncResource[],
|
|
6
|
+
// fold into the tree through the kernel's observe (its own diff, so a
|
|
7
|
+
// re-pull of identical state appends nothing).
|
|
8
|
+
// PUSH (twin → real): for every PENDING local action, call the real Stripe REST API
|
|
9
|
+
// and the head confirms on success — which records the confirmed
|
|
10
|
+
// fields as an observed event and suppresses the local
|
|
11
|
+
// projection (the change is counted exactly once).
|
|
12
|
+
//
|
|
13
|
+
// The vendor I/O is an INJECTED executor (the auth-boundary, hard-problem #6): the
|
|
14
|
+
// kernel and this pack hold NO Stripe key and import NO network client.
|
|
15
|
+
// - offline/tests pass a fake executor (deterministic, no network),
|
|
16
|
+
// - live runs pass `liveStripeExecute(apiKey)` (the user's own secret key).
|
|
17
|
+
// Same code path either way, so the connector is fully exercisable offline AND
|
|
18
|
+
// runnable against a real account.
|
|
19
|
+
import { assertBudgetGuardIntact, observeResources } from '@volter/world-core';
|
|
20
|
+
import { StripeBudget, StripeBudgetError, stripeCallWeight } from "./stripe-budget.js";
|
|
21
|
+
const SERVICE = 'stripe';
|
|
22
|
+
// Subject types that are twin-internal and are NEVER pushed to real Stripe: the recorded
|
|
23
|
+
// Stripe `event` envelopes (the local Events-API store) and idempotency bookkeeping.
|
|
24
|
+
export const INTERNAL_SUBJECT_TYPES = new Set(['event', '_idempotency']);
|
|
25
|
+
/**
|
|
26
|
+
* A live executor against the real Stripe REST API (secret key = the user's own).
|
|
27
|
+
* Form-encodes POST params; sends GET filters as a query string. Never imported by
|
|
28
|
+
* the pack's own code path — only constructed by a caller that opts into real I/O.
|
|
29
|
+
*
|
|
30
|
+
* THIS IS THE ONE PLACE this pack issues a live `api.stripe.com` request, and therefore the one
|
|
31
|
+
* place the rate budget has to be enforced — and Stripe is the pack where a runaway loop does not
|
|
32
|
+
* merely annoy a vendor, it MOVES MONEY. EVERY call is guarded: the budget is charged BEFORE the
|
|
33
|
+
* request goes out (`checkBudget`, which THROWS `StripeBudgetError` instead of returning when the
|
|
34
|
+
* ceiling or a cooldown says stop) and the response is fed back (`recordCall`) so a `Retry-After` /
|
|
35
|
+
* 429 signal becomes a persisted cooldown that makes every later call fail fast WITHOUT touching
|
|
36
|
+
* Stripe. There is deliberately no OPTION to disable the guard, and no
|
|
37
|
+
* value a caller can pass for `budget` that yields an unguarded client. That is NOT immunity from
|
|
38
|
+
* a caller who WANTS one: a fresh `budgetOptions.path` per construction, or an injected clock,
|
|
39
|
+
* restores the allowance, because the seam tests need cannot be denied to a determined caller in
|
|
40
|
+
* the same process. The kernel header states that limit and this does not upgrade it. See `stripe-budget.ts` for why,
|
|
41
|
+
* and for the limits of the guarantee.
|
|
42
|
+
*/
|
|
43
|
+
export function liveStripeExecute(apiKey, base = 'https://api.stripe.com', opts = {}) {
|
|
44
|
+
// `null`/`undefined` (or omitting it) build the default budget. Anything else must be an
|
|
45
|
+
// UNMODIFIED StripeBudget: a duck-typed stand-in, a SUBCLASS that overrides `checkBudget`, and a
|
|
46
|
+
// Proxy that traps it are all refused, because all three are one-liners that would otherwise
|
|
47
|
+
// hand back a client with no ceiling at all (§9 finding, 2026-07-26 — `instanceof` alone was
|
|
48
|
+
// not a check). What this cannot stop is deliberate sabotage from inside the process (an
|
|
49
|
+
// injected clock, a throwaway ledger path); the kernel's header says so rather than pretending
|
|
50
|
+
// otherwise, and this guards the accident and the one-liner, which are the shapes that happen.
|
|
51
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
52
|
+
// The default ledger is keyed by a hash of THIS key — Stripe's limits attach to the account
|
|
53
|
+
// behind it, so a cwd-scoped ledger would hand it a fresh allowance per checkout/worktree/CI leg.
|
|
54
|
+
// A live key and a test key hash differently, which matches Stripe's separate 100/s and 25/s.
|
|
55
|
+
// ONE expression decides which budget is used, so there is no second, weaker test that could
|
|
56
|
+
// disagree with the first. `null`/`undefined` (or omitting it) build the default; anything else
|
|
57
|
+
// must be an UNMODIFIED StripeBudget — a duck-typed stand-in, a SUBCLASS overriding
|
|
58
|
+
// `checkBudget`, and a Proxy trapping it are ALL refused, because each is a one-liner that
|
|
59
|
+
// would otherwise hand back a client with no ceiling (§9 finding, 2026-07-26: `instanceof`
|
|
60
|
+
// alone was not a check — a subclass satisfied it). What this cannot stop is deliberate
|
|
61
|
+
// sabotage from inside the process (an injected clock, a throwaway ledger path); the kernel
|
|
62
|
+
// header states that limit rather than pretending otherwise. This closes the accident and the
|
|
63
|
+
// one-liner, which are the shapes that actually happen.
|
|
64
|
+
const budget = opts.budget !== undefined && opts.budget !== null
|
|
65
|
+
? assertBudgetGuardIntact(opts.budget, StripeBudget, 'liveStripeExecute')
|
|
66
|
+
: new StripeBudget({ token: apiKey, ...(opts.budgetOptions ?? {}) });
|
|
67
|
+
return async (method, path, params) => {
|
|
68
|
+
const headers = { Authorization: `Bearer ${apiKey}` };
|
|
69
|
+
let url = `${base}${path}`;
|
|
70
|
+
const form = encodeForm(params ?? {});
|
|
71
|
+
const init = { method, headers };
|
|
72
|
+
if (method === 'GET') {
|
|
73
|
+
if (form)
|
|
74
|
+
url += `?${form}`;
|
|
75
|
+
}
|
|
76
|
+
else if (form) {
|
|
77
|
+
headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
|
78
|
+
init.body = form;
|
|
79
|
+
}
|
|
80
|
+
const weight = stripeCallWeight(method, path);
|
|
81
|
+
// THROWS instead of calling. Nothing below this line runs when the budget refuses.
|
|
82
|
+
const reservation = budget.checkBudget(weight);
|
|
83
|
+
const res = await doFetch(url, init);
|
|
84
|
+
const resHeaders = {};
|
|
85
|
+
res.headers.forEach((v, k) => { resHeaders[k.toLowerCase()] = v; });
|
|
86
|
+
const parsed = (await res.json());
|
|
87
|
+
// Settles the reservation and, on a back-off signal, arms the cooldown. May itself throw (a
|
|
88
|
+
// `Retry-After` beyond the cap is not something to sleep off) — the cooldown is persisted
|
|
89
|
+
// first either way, so the refusal survives the throw.
|
|
90
|
+
// recordCall may THROW after arming the cooldown (a back-off beyond the cap). On a refused
|
|
91
|
+
// call that louder refusal wins; an answer Stripe ACCEPTED is kept, so a write that landed is
|
|
92
|
+
// never recorded as failed and performed again on retry.
|
|
93
|
+
try {
|
|
94
|
+
budget.recordCall(weight, resHeaders, { status: res.status, reservation });
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
if (!(error instanceof StripeBudgetError) || !res.ok)
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
return parsed;
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
// Stripe's nested form encoding (a[b]=c). Only the shapes the connector pushes
|
|
104
|
+
// (flat scalars + one level of object nesting, e.g. metadata) are handled.
|
|
105
|
+
function encodeForm(params, prefix = '') {
|
|
106
|
+
const parts = [];
|
|
107
|
+
for (const [key, value] of Object.entries(params)) {
|
|
108
|
+
if (value === undefined)
|
|
109
|
+
continue;
|
|
110
|
+
const name = prefix ? `${prefix}[${key}]` : key;
|
|
111
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
112
|
+
const nested = encodeForm(value, name);
|
|
113
|
+
if (nested)
|
|
114
|
+
parts.push(nested);
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
parts.push(`${encodeURIComponent(name)}=${encodeURIComponent(value === null ? '' : String(value))}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return parts.join('&');
|
|
121
|
+
}
|
|
122
|
+
function listOf(res) {
|
|
123
|
+
return Array.isArray(res.data) ? res.data : [];
|
|
124
|
+
}
|
|
125
|
+
function throwIfError(res, ctx) {
|
|
126
|
+
if (res.error)
|
|
127
|
+
throw new Error(`stripe ${ctx} failed: ${res.error.message ?? 'unknown error'}`);
|
|
128
|
+
}
|
|
129
|
+
// ── PULL ────────────────────────────────────────────────────────────────────
|
|
130
|
+
/**
|
|
131
|
+
* Map a real-Stripe customer (snake_case) → a twin sync resource. Only the stable
|
|
132
|
+
* scalar fields the twin tracks are carried; absent fields become null so the diff
|
|
133
|
+
* is faithful (an unset email reads as null, not missing).
|
|
134
|
+
*/
|
|
135
|
+
export function mapCustomer(c) {
|
|
136
|
+
return {
|
|
137
|
+
type: 'customer',
|
|
138
|
+
id: String(c.id),
|
|
139
|
+
fields: {
|
|
140
|
+
email: c.email ?? null,
|
|
141
|
+
name: c.name ?? null,
|
|
142
|
+
description: c.description ?? null,
|
|
143
|
+
phone: c.phone ?? null,
|
|
144
|
+
currency: c.currency ?? null,
|
|
145
|
+
delinquent: typeof c.delinquent === 'boolean' ? c.delinquent : null,
|
|
146
|
+
created: typeof c.created === 'number' ? c.created : null,
|
|
147
|
+
livemode: typeof c.livemode === 'boolean' ? c.livemode : null,
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Map a real-Stripe subscription (snake_case) → a twin sync resource. The single
|
|
153
|
+
* subscribed price id is lifted out of items.data[0].price.id (Stripe's nested list
|
|
154
|
+
* shape) so the twin tracks a flat, diffable `price`.
|
|
155
|
+
*/
|
|
156
|
+
export function mapSubscription(s) {
|
|
157
|
+
const items = s.items;
|
|
158
|
+
const firstPrice = items?.data?.[0]?.price?.id;
|
|
159
|
+
return {
|
|
160
|
+
type: 'subscription',
|
|
161
|
+
id: String(s.id),
|
|
162
|
+
fields: {
|
|
163
|
+
customer: s.customer ?? null,
|
|
164
|
+
status: s.status ?? null,
|
|
165
|
+
currency: s.currency ?? null,
|
|
166
|
+
cancel_at_period_end: typeof s.cancel_at_period_end === 'boolean' ? s.cancel_at_period_end : null,
|
|
167
|
+
price: typeof firstPrice === 'string' ? firstPrice : null,
|
|
168
|
+
created: typeof s.created === 'number' ? s.created : null,
|
|
169
|
+
livemode: typeof s.livemode === 'boolean' ? s.livemode : null,
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* A GENERIC scalar mapper for the collections that don't need special id-lifting like
|
|
175
|
+
* subscriptions do. We carry the stable, diffable scalar fields a twin tracks (anything
|
|
176
|
+
* that is a string/number/boolean), dropping nested objects/arrays (which the twin either
|
|
177
|
+
* synthesizes or doesn't diff on). This keeps every collection's pull faithful without a
|
|
178
|
+
* bespoke mapper per type — vendor-SPECIFIC lifting (subscription.price) stays explicit.
|
|
179
|
+
*/
|
|
180
|
+
export function mapScalarResource(type, r) {
|
|
181
|
+
const fields = {};
|
|
182
|
+
for (const [k, v] of Object.entries(r)) {
|
|
183
|
+
if (k === 'id' || k === 'object')
|
|
184
|
+
continue;
|
|
185
|
+
if (v === null || typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean')
|
|
186
|
+
fields[k] = v ?? null;
|
|
187
|
+
}
|
|
188
|
+
return { type, id: String(r.id), fields };
|
|
189
|
+
}
|
|
190
|
+
// The full set of collections a full sync pulls, mapped to the REST list path + the twin
|
|
191
|
+
// resource type. Customers + subscriptions keep their bespoke mappers (id-lifting); the
|
|
192
|
+
// rest use the generic scalar mapper.
|
|
193
|
+
const PULL_COLLECTIONS = [
|
|
194
|
+
{ path: '/v1/customers', type: 'customer', map: mapCustomer },
|
|
195
|
+
{ path: '/v1/subscriptions', type: 'subscription', map: mapSubscription, params: { status: 'all' } },
|
|
196
|
+
{ path: '/v1/products', type: 'product' },
|
|
197
|
+
{ path: '/v1/prices', type: 'price' },
|
|
198
|
+
{ path: '/v1/charges', type: 'charge' },
|
|
199
|
+
{ path: '/v1/payment_intents', type: 'payment_intent' },
|
|
200
|
+
{ path: '/v1/invoices', type: 'invoice' },
|
|
201
|
+
{ path: '/v1/refunds', type: 'refund' },
|
|
202
|
+
{ path: '/v1/payouts', type: 'payout' },
|
|
203
|
+
{ path: '/v1/disputes', type: 'dispute' },
|
|
204
|
+
{ path: '/v1/coupons', type: 'coupon' },
|
|
205
|
+
];
|
|
206
|
+
/** Pull real Stripe customers via the executor and map them to twin sync resources. */
|
|
207
|
+
export async function pullStripeCustomers(execute, opts = {}) {
|
|
208
|
+
const res = await execute('GET', '/v1/customers', { limit: opts.limit ?? 100 });
|
|
209
|
+
throwIfError(res, 'pull customers');
|
|
210
|
+
return listOf(res).map(mapCustomer);
|
|
211
|
+
}
|
|
212
|
+
/** Pull real Stripe subscriptions via the executor and map them to twin sync resources. */
|
|
213
|
+
export async function pullStripeSubscriptions(execute, opts = {}) {
|
|
214
|
+
const res = await execute('GET', '/v1/subscriptions', { status: 'all', limit: opts.limit ?? 100 });
|
|
215
|
+
throwIfError(res, 'pull subscriptions');
|
|
216
|
+
return listOf(res).map(mapSubscription);
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Pull from real Stripe (customers + subscriptions) and fold into the twin (mirror
|
|
220
|
+
* seeding). The fold makes a re-pull of identical state a no-op.
|
|
221
|
+
*/
|
|
222
|
+
/** One fold onto the head: protocol 2's observe, one batch, one instant. */
|
|
223
|
+
function fold(resources, opts) {
|
|
224
|
+
return observeResources(SERVICE, resources.map((r) => ({ type: r.type, id: r.id, fields: r.fields })), {
|
|
225
|
+
...(opts.root !== undefined ? { root: opts.root } : {}), at: opts.occurredAt, batch: `obs:${SERVICE}:${opts.occurredAt}`,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
export async function syncStripeFromReal(execute, opts) {
|
|
229
|
+
const customers = await pullStripeCustomers(execute, { ...(opts.limit ? { limit: opts.limit } : {}) });
|
|
230
|
+
const subscriptions = await pullStripeSubscriptions(execute, { ...(opts.limit ? { limit: opts.limit } : {}) });
|
|
231
|
+
const resources = [...customers, ...subscriptions];
|
|
232
|
+
const result = fold(resources, opts);
|
|
233
|
+
return { observed: result.observed, deltasAppended: result.appended };
|
|
234
|
+
}
|
|
235
|
+
// ── PUSH ────────────────────────────────────────────────────────────────────
|
|
236
|
+
// Stripe resource type → REST collection segment, for building create paths.
|
|
237
|
+
// (Pluralization isn't uniform — e.g. `dispute`→`disputes` is fine but several types
|
|
238
|
+
// the twin writes need an explicit mapping so the path matches real Stripe exactly.)
|
|
239
|
+
const COLLECTION = {
|
|
240
|
+
customer: 'customers',
|
|
241
|
+
subscription: 'subscriptions',
|
|
242
|
+
charge: 'charges',
|
|
243
|
+
product: 'products',
|
|
244
|
+
price: 'prices',
|
|
245
|
+
invoice: 'invoices',
|
|
246
|
+
payment_intent: 'payment_intents',
|
|
247
|
+
setup_intent: 'setup_intents',
|
|
248
|
+
payment_method: 'payment_methods',
|
|
249
|
+
refund: 'refunds',
|
|
250
|
+
dispute: 'disputes',
|
|
251
|
+
payout: 'payouts',
|
|
252
|
+
};
|
|
253
|
+
// Verb-style twin operations that map to a real-Stripe SUB-ACTION endpoint
|
|
254
|
+
// (POST /v1/<collection>/:id/<verb>) rather than a plain resource update. The twin
|
|
255
|
+
// records these verbs verbatim as the operation's suffix (see stripe-twin.ts:
|
|
256
|
+
// payment_intent.confirm, setup_intent.confirm, payment_method.detach,
|
|
257
|
+
// invoice.finalize|pay|void, dispute.close, payout.cancel). The verb here IS the
|
|
258
|
+
// real Stripe path segment, so this stays faithful as the twin grows.
|
|
259
|
+
const SUBACTION_VERBS = new Set(['confirm', 'detach', 'finalize', 'pay', 'void', 'close']);
|
|
260
|
+
// `cancel` is resource-specific in real Stripe: a subscription is canceled with
|
|
261
|
+
// DELETE /v1/subscriptions/:id, but a payout is canceled with POST
|
|
262
|
+
// /v1/payouts/:id/cancel. Anything else canceled via DELETE on the resource is a safe
|
|
263
|
+
// default (no other type the twin emits a `.cancel` for uses a sub-action path).
|
|
264
|
+
const CANCEL_VIA_SUBACTION = new Set(['payout']);
|
|
265
|
+
/**
|
|
266
|
+
* Resolve the REST (method, path) for ONE pending action — faithful to the real
|
|
267
|
+
* Stripe REST surface for EVERY write operation the twin records. The action carries
|
|
268
|
+
* the twin operation (`<type>.<verb>`) plus its subject:
|
|
269
|
+
* - `<type>.create` → POST /v1/<collection>
|
|
270
|
+
* - `subscription.cancel` → DELETE /v1/subscriptions/:id
|
|
271
|
+
* - `payout.cancel` → POST /v1/payouts/:id/cancel (sub-action)
|
|
272
|
+
* - `payment_intent.confirm` → POST /v1/payment_intents/:id/confirm
|
|
273
|
+
* - `setup_intent.confirm` → POST /v1/setup_intents/:id/confirm
|
|
274
|
+
* - `payment_method.detach` → POST /v1/payment_methods/:id/detach
|
|
275
|
+
* - `invoice.finalize|pay|void` → POST /v1/invoices/:id/<verb>
|
|
276
|
+
* - `dispute.close` → POST /v1/disputes/:id/close
|
|
277
|
+
* - `<type>.update` (or any other verb) → POST /v1/<collection>/:id
|
|
278
|
+
*
|
|
279
|
+
* Unknown/unsupported operations must FAIL LOUDLY at push time (see pushStripeAction),
|
|
280
|
+
* never be silently dropped — so this resolver always returns a concrete request and
|
|
281
|
+
* the caller validates the operation is one the twin is allowed to push.
|
|
282
|
+
*/
|
|
283
|
+
export function stripeRequestForAction(action) {
|
|
284
|
+
const op = action.operation ?? `${action.subject.type}.update`;
|
|
285
|
+
const verb = op.includes('.') ? op.slice(op.indexOf('.') + 1) : op;
|
|
286
|
+
const collection = COLLECTION[action.subject.type] ?? `${action.subject.type}s`;
|
|
287
|
+
const base = `/v1/${collection}`;
|
|
288
|
+
if (verb === 'create')
|
|
289
|
+
return { method: 'POST', path: base };
|
|
290
|
+
if (verb === 'cancel') {
|
|
291
|
+
return CANCEL_VIA_SUBACTION.has(action.subject.type)
|
|
292
|
+
? { method: 'POST', path: `${base}/${action.subject.id}/cancel` }
|
|
293
|
+
: { method: 'DELETE', path: `${base}/${action.subject.id}` };
|
|
294
|
+
}
|
|
295
|
+
// confirm / detach / finalize / pay / void / close → /:id/<verb>
|
|
296
|
+
if (SUBACTION_VERBS.has(verb))
|
|
297
|
+
return { method: 'POST', path: `${base}/${action.subject.id}/${verb}` };
|
|
298
|
+
// update (and any other plain mutation) → POST the resource itself.
|
|
299
|
+
return { method: 'POST', path: `${base}/${action.subject.id}` };
|
|
300
|
+
}
|
|
301
|
+
// The twin operations this connector knows how to push to real Stripe. Anything not
|
|
302
|
+
// here (a new/unmodeled write op) must FAIL LOUDLY rather than be silently dropped —
|
|
303
|
+
// pushing an unrecognized op risks hitting the wrong real endpoint or no-op'ing a
|
|
304
|
+
// real change. New twin write ops must be deliberately added here.
|
|
305
|
+
const PUSHABLE_VERBS = new Set(['create', 'update', 'cancel', ...SUBACTION_VERBS]);
|
|
306
|
+
/** Throw if `op` is not a write operation this connector can faithfully push. */
|
|
307
|
+
/** Whether this operation is one the connector can faithfully send to Stripe. */
|
|
308
|
+
export function isPushable(op) {
|
|
309
|
+
const verb = op.includes('.') ? op.slice(op.indexOf('.') + 1) : op;
|
|
310
|
+
return PUSHABLE_VERBS.has(verb);
|
|
311
|
+
}
|
|
312
|
+
function assertPushable(op) {
|
|
313
|
+
const verb = op.includes('.') ? op.slice(op.indexOf('.') + 1) : op;
|
|
314
|
+
if (!PUSHABLE_VERBS.has(verb)) {
|
|
315
|
+
throw new Error(`stripe push: unsupported operation '${op}' — refusing to silently drop a local write`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Push ONE pending action to REAL Stripe via the injected executor. Returns the real
|
|
320
|
+
* external id (the Stripe object id from the response — for a create that is a freshly
|
|
321
|
+
* minted id; for an update it echoes the subject). WRITES TO THE REAL ACCOUNT.
|
|
322
|
+
*/
|
|
323
|
+
export async function pushStripeAction(execute, action) {
|
|
324
|
+
assertPushable(action.operation ?? `${action.subject.type}.update`);
|
|
325
|
+
const { method, path } = stripeRequestForAction(action);
|
|
326
|
+
const res = await execute(method, path, method === 'DELETE' ? undefined : (action.fields ?? {}));
|
|
327
|
+
throwIfError(res, `push ${action.subject.type}`);
|
|
328
|
+
const id = res.id;
|
|
329
|
+
return { externalId: typeof id === 'string' && id ? id : action.subject.id };
|
|
330
|
+
}
|
|
331
|
+
// ── PROTOCOL 2: the pack's half of the real state system ────────────────────────────────────
|
|
332
|
+
/** The pack's executor over the kernel's: the same Stripe REST call, carried by the head. Stripe's API is
|
|
333
|
+
* form-encoded, which is what `stripeExecute` already speaks — this only carries it. */
|
|
334
|
+
export function stripeExecuteOver(execute) {
|
|
335
|
+
return async (method, path, params) => {
|
|
336
|
+
const body = params === undefined ? undefined : encodeForm(params);
|
|
337
|
+
const res = await execute({
|
|
338
|
+
method, path,
|
|
339
|
+
headers: { accept: 'application/json', ...(body === undefined ? {} : { 'content-type': 'application/x-www-form-urlencoded' }) },
|
|
340
|
+
...(body === undefined ? {} : { body }),
|
|
341
|
+
});
|
|
342
|
+
if (res.body === '')
|
|
343
|
+
return {};
|
|
344
|
+
try {
|
|
345
|
+
return JSON.parse(res.body);
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
return { error: { type: 'api_error', message: res.body.slice(0, 200) } };
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
/** The refresh adapter: pull every modeled collection and the webhook endpoints through the executor. */
|
|
353
|
+
export async function syncStripeFromRemote(execute, opts = {}) {
|
|
354
|
+
const at = opts.occurredAt ?? new Date().toISOString();
|
|
355
|
+
const wire = stripeExecuteOver(execute);
|
|
356
|
+
const resources = [
|
|
357
|
+
...await pullStripeAll(wire),
|
|
358
|
+
...await pullStripeWebhookEndpoints(wire),
|
|
359
|
+
];
|
|
360
|
+
const report = fold(resources, { ...(opts.root !== undefined ? { root: opts.root } : {}), occurredAt: at });
|
|
361
|
+
return { observed: report.observed, deltasAppended: report.appended };
|
|
362
|
+
}
|
|
363
|
+
/** The perform adapter: one entry crosses to Stripe, or settles with the reason it never could. The twin's
|
|
364
|
+
* own subject types — the recorded `event` envelopes the write path persists for the Events API, and the
|
|
365
|
+
* idempotency bookkeeping — are local state and must never be sent. */
|
|
366
|
+
export async function performStripeAction(execute, action, _ctx) {
|
|
367
|
+
const op = action.operation ?? `${action.subject.type}.update`;
|
|
368
|
+
if (INTERNAL_SUBJECT_TYPES.has(action.subject.type) || !isPushable(op)) {
|
|
369
|
+
return { externalId: action.subject.id, data: { performed: false, reason: `${op} is the twin's own record — nothing at Stripe to write` } };
|
|
370
|
+
}
|
|
371
|
+
return pushStripeAction(stripeExecuteOver(execute), { operation: op, subject: action.subject, fields: action.fields ?? {} });
|
|
372
|
+
}
|
|
373
|
+
// ── FULL SYNC (all collections + webhooks, bi-directional) ───────────────────
|
|
374
|
+
/** Pull a single REST list collection and map each row to a twin sync resource. */
|
|
375
|
+
async function pullCollection(execute, spec, limit) {
|
|
376
|
+
const res = await execute('GET', spec.path, { ...(spec.params ?? {}), limit });
|
|
377
|
+
throwIfError(res, `pull ${spec.type}`);
|
|
378
|
+
const map = spec.map ?? ((r) => mapScalarResource(spec.type, r));
|
|
379
|
+
return listOf(res).map(map);
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Pull EVERY tracked collection from real Stripe (not just customers + subscriptions) in
|
|
383
|
+
* one pass and return the combined sync resources. This is the read half of a full sync.
|
|
384
|
+
*/
|
|
385
|
+
export async function pullStripeAll(execute, opts = {}) {
|
|
386
|
+
const limit = opts.limit ?? 100;
|
|
387
|
+
const all = [];
|
|
388
|
+
for (const spec of PULL_COLLECTIONS)
|
|
389
|
+
all.push(...await pullCollection(execute, spec, limit));
|
|
390
|
+
return all;
|
|
391
|
+
}
|
|
392
|
+
/** Pull the account's registered webhook endpoints (so the twin mirrors them too). */
|
|
393
|
+
export async function pullStripeWebhookEndpoints(execute, opts = {}) {
|
|
394
|
+
const res = await execute('GET', '/v1/webhook_endpoints', { limit: opts.limit ?? 100 });
|
|
395
|
+
throwIfError(res, 'pull webhook_endpoints');
|
|
396
|
+
return listOf(res).map((w) => mapScalarResource('webhook_endpoint', w));
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* FULL bi-directional sync over the injected client: (1) PUSH every pending local action to
|
|
400
|
+
* real Stripe and confirm it, then (2) PULL all collections + webhook endpoints back and
|
|
401
|
+
* fold them into the event log. Pushing first means the pull observes the twin's own writes
|
|
402
|
+
* as confirmed external state (no double-count). Returns per-direction counts. Same code
|
|
403
|
+
* path offline (fake executor) and live (real key) — D4-faithful.
|
|
404
|
+
*/
|
|
405
|
+
export async function fullSyncStripe(execute, opts) {
|
|
406
|
+
// protocol 2: this is the PULL half only. The push half is the head's — it performs each entry through
|
|
407
|
+
// `performStripeAction` and confirms it — so a refresh no longer reconciles both directions at once.
|
|
408
|
+
const resources = [
|
|
409
|
+
...await pullStripeAll(execute, { ...(opts.limit ? { limit: opts.limit } : {}) }),
|
|
410
|
+
...await pullStripeWebhookEndpoints(execute, { ...(opts.limit ? { limit: opts.limit } : {}) }),
|
|
411
|
+
];
|
|
412
|
+
const pull = fold(resources, opts);
|
|
413
|
+
return { observed: pull.observed, deltasAppended: pull.appended, collections: PULL_COLLECTIONS.length + 1 };
|
|
414
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// The Stripe DELIVER verb — `world-stripe emit` (feature-sweep friction: hand-constructing
|
|
2
|
+
// checkout.session.completed / invoice.paid envelopes + HMAC signing to poke an app's webhook
|
|
3
|
+
// handler). This is the pack side of the kernel's emit seam (@volter/world-core emit.ts): the
|
|
4
|
+
// catalog of emittable event types, endpoint registrations read FROM TWIN STATE AT REST
|
|
5
|
+
// (webhook_endpoint rows minted by POST /v1/webhook_endpoints — the CLI runs in its own
|
|
6
|
+
// process, so the in-memory delivery registry in stripe-events.ts does not apply), and
|
|
7
|
+
// synthesis of the exact envelope real Stripe delivers, signed with the DESTINATION
|
|
8
|
+
// endpoint's own whsec_twin_* so `stripe.webhooks.constructEvent(rawBody, header, secret)`
|
|
9
|
+
// in the app's unmodified SDK verifies the delivery unchanged.
|
|
10
|
+
//
|
|
11
|
+
// DELIVER does not transition state: it snapshots the subject AS IT IS in the twin and fires
|
|
12
|
+
// the named event about it. Completing a checkout session / paying an invoice is the twin
|
|
13
|
+
// API's job; this verb answers "my app's webhook handler needs to SEE the event, now."
|
|
14
|
+
import { projectResources } from '@volter/world-core';
|
|
15
|
+
import { OBJECT_NAME, PLATFORM_ACCOUNT_ID, TWIN_API_VERSION, view } from "./stripe-twin.js";
|
|
16
|
+
import { STRIPE_WEBHOOK_FALLBACK_SECRET, generateTestHeaderString } from "./stripe-events.js";
|
|
17
|
+
import { render } from "./stripe-version.js";
|
|
18
|
+
const SERVICE = 'stripe';
|
|
19
|
+
// event type → the twin resource type whose current state becomes `data.object`.
|
|
20
|
+
// The catalog is every event type the twin's own write path can produce (eventTypeFor's
|
|
21
|
+
// range) — the emit verb can re-fire anything the twin emits organically.
|
|
22
|
+
const EMITTABLE = {
|
|
23
|
+
// payment intents
|
|
24
|
+
'payment_intent.created': 'payment_intent',
|
|
25
|
+
'payment_intent.succeeded': 'payment_intent',
|
|
26
|
+
'payment_intent.processing': 'payment_intent',
|
|
27
|
+
'payment_intent.requires_action': 'payment_intent',
|
|
28
|
+
'payment_intent.amount_capturable_updated': 'payment_intent',
|
|
29
|
+
'payment_intent.payment_failed': 'payment_intent',
|
|
30
|
+
'payment_intent.canceled': 'payment_intent',
|
|
31
|
+
// charges + disputes
|
|
32
|
+
'charge.succeeded': 'charge',
|
|
33
|
+
'charge.failed': 'charge',
|
|
34
|
+
'charge.captured': 'charge',
|
|
35
|
+
'charge.refunded': 'charge',
|
|
36
|
+
'charge.dispute.created': 'dispute',
|
|
37
|
+
// customers
|
|
38
|
+
'customer.created': 'customer',
|
|
39
|
+
'customer.updated': 'customer',
|
|
40
|
+
'customer.deleted': 'customer',
|
|
41
|
+
// checkout — the sweep's exact pain
|
|
42
|
+
'checkout.session.completed': 'checkout_session',
|
|
43
|
+
// invoices — the sweep's exact pain
|
|
44
|
+
'invoice.created': 'invoice',
|
|
45
|
+
'invoice.finalized': 'invoice',
|
|
46
|
+
'invoice.paid': 'invoice',
|
|
47
|
+
'invoice.payment_failed': 'invoice',
|
|
48
|
+
'invoice.voided': 'invoice',
|
|
49
|
+
'invoice.marked_uncollectible': 'invoice',
|
|
50
|
+
'invoice.sent': 'invoice',
|
|
51
|
+
// subscriptions
|
|
52
|
+
'customer.subscription.created': 'subscription',
|
|
53
|
+
'customer.subscription.updated': 'subscription',
|
|
54
|
+
'customer.subscription.deleted': 'subscription',
|
|
55
|
+
'customer.subscription.paused': 'subscription',
|
|
56
|
+
'customer.subscription.resumed': 'subscription',
|
|
57
|
+
// payment methods
|
|
58
|
+
'payment_method.attached': 'payment_method',
|
|
59
|
+
'payment_method.detached': 'payment_method',
|
|
60
|
+
// setup intents
|
|
61
|
+
'setup_intent.created': 'setup_intent',
|
|
62
|
+
'setup_intent.succeeded': 'setup_intent',
|
|
63
|
+
// payouts, catalog
|
|
64
|
+
'payout.created': 'payout',
|
|
65
|
+
'payout.paid': 'payout',
|
|
66
|
+
'payout.canceled': 'payout',
|
|
67
|
+
'price.created': 'price',
|
|
68
|
+
'product.created': 'product',
|
|
69
|
+
// issuing — the settlement consumer's diet (issuing_transaction.created) plus the
|
|
70
|
+
// authorization/card/cardholder lifecycle. issuing_authorization.request is emittable
|
|
71
|
+
// too: DELIVER re-fires the request event about an authorization AS IT IS in twin state
|
|
72
|
+
// (the organic synchronous leg lives in the present-authorization flow; this verb lets
|
|
73
|
+
// an app's handler see the event again without re-presenting).
|
|
74
|
+
'issuing_authorization.created': 'issuing_authorization',
|
|
75
|
+
'issuing_authorization.request': 'issuing_authorization',
|
|
76
|
+
'issuing_authorization.updated': 'issuing_authorization',
|
|
77
|
+
'issuing_card.created': 'issuing_card',
|
|
78
|
+
'issuing_card.updated': 'issuing_card',
|
|
79
|
+
'issuing_cardholder.created': 'issuing_cardholder',
|
|
80
|
+
'issuing_cardholder.updated': 'issuing_cardholder',
|
|
81
|
+
'issuing_transaction.created': 'issuing_transaction',
|
|
82
|
+
};
|
|
83
|
+
function rows(type, root) {
|
|
84
|
+
return projectResources(SERVICE, root).filter((r) => r.type === type);
|
|
85
|
+
}
|
|
86
|
+
function liveEndpointRows(root) {
|
|
87
|
+
return rows('webhook_endpoint', root).filter((w) => w.deleted !== true);
|
|
88
|
+
}
|
|
89
|
+
let emitSeq = 0;
|
|
90
|
+
export const stripeEmitter = {
|
|
91
|
+
vendor: 'stripe',
|
|
92
|
+
events() {
|
|
93
|
+
return Object.entries(EMITTABLE).map(([type, subjectType]) => ({ type, subjectType }));
|
|
94
|
+
},
|
|
95
|
+
endpoints(root) {
|
|
96
|
+
return liveEndpointRows(root).map((w) => ({
|
|
97
|
+
id: String(w.id),
|
|
98
|
+
url: String(w.url ?? ''),
|
|
99
|
+
...(Array.isArray(w.enabled_events) ? { enabledEvents: w.enabled_events.map(String) } : {}),
|
|
100
|
+
}));
|
|
101
|
+
},
|
|
102
|
+
subjects(subjectType, root) {
|
|
103
|
+
return rows(subjectType, root).filter((r) => r.deleted !== true).map((r) => String(r.id));
|
|
104
|
+
},
|
|
105
|
+
synthesize({ type, subjectId, endpoint, root, occurredAt }) {
|
|
106
|
+
const subjectType = EMITTABLE[type];
|
|
107
|
+
if (!subjectType)
|
|
108
|
+
throw new Error(`emit: the stripe pack cannot synthesize "${type}".`);
|
|
109
|
+
const row = rows(subjectType, root).find((r) => String(r.id) === subjectId && r.deleted !== true);
|
|
110
|
+
if (!row) {
|
|
111
|
+
const have = stripeEmitter.subjects(subjectType, root);
|
|
112
|
+
throw new Error(`emit: no ${subjectType} "${subjectId}" in stripe twin state — cannot synthesize ${type}. ` +
|
|
113
|
+
(have.length ? `${subjectType} ids in state: ${have.join(', ')}.` : `No ${subjectType} exists in this twin yet (object name: ${OBJECT_NAME[subjectType] ?? subjectType}) — create one through the vendor API first.`));
|
|
114
|
+
}
|
|
115
|
+
// The endpoint's own signing secret, from its state row (real Stripe signs per-endpoint).
|
|
116
|
+
const endpointRow = liveEndpointRows(root).find((w) => String(w.id) === endpoint.id || String(w.url) === endpoint.url);
|
|
117
|
+
const secret = typeof endpointRow?.secret === 'string' && endpointRow.secret ? endpointRow.secret : STRIPE_WEBHOOK_FALLBACK_SECRET;
|
|
118
|
+
const created = occurredAt ? Math.floor(Date.parse(occurredAt) / 1000) : Math.floor(Date.now() / 1000);
|
|
119
|
+
// a connected account's object is its event's, as the write path scopes it (stripe-twin.ts afterStripeWrite): the
|
|
120
|
+
// account itself, or a row kept on its books (`_account`). "Each event for a connected account contains a top-level
|
|
121
|
+
// `account` property that identifies the connected account" (docs.stripe.com/connect/webhooks).
|
|
122
|
+
const account = typeof row._account === 'string' ? row._account : subjectType === 'account' && String(row.id) !== PLATFORM_ACCOUNT_ID ? String(row.id) : undefined;
|
|
123
|
+
// Same envelope persistStripeEvent stores for GET /v1/events; `evt_twin_emit_*` marks it
|
|
124
|
+
// operator-fired (never colliding with the write path's organic `evt_twin_<n>` ids).
|
|
125
|
+
const event = {
|
|
126
|
+
id: `evt_twin_emit_${created}_${++emitSeq}`,
|
|
127
|
+
object: 'event',
|
|
128
|
+
api_version: TWIN_API_VERSION,
|
|
129
|
+
created,
|
|
130
|
+
data: { object: view(subjectType, row) },
|
|
131
|
+
livemode: false,
|
|
132
|
+
pending_webhooks: 1,
|
|
133
|
+
request: { id: null, idempotency_key: null },
|
|
134
|
+
type,
|
|
135
|
+
...(account ? { account } : {}),
|
|
136
|
+
};
|
|
137
|
+
const payload = JSON.stringify(render(event, TWIN_API_VERSION));
|
|
138
|
+
const header = generateTestHeaderString({ payload, secret, ...(occurredAt ? { timestamp: created } : {}) });
|
|
139
|
+
return {
|
|
140
|
+
payload,
|
|
141
|
+
headers: { 'content-type': 'application/json', 'stripe-signature': header },
|
|
142
|
+
event,
|
|
143
|
+
};
|
|
144
|
+
},
|
|
145
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
export type StripeEvent = {
|
|
2
|
+
id: string;
|
|
3
|
+
object: 'event';
|
|
4
|
+
type: string;
|
|
5
|
+
created: number;
|
|
6
|
+
livemode: false;
|
|
7
|
+
/** the connected account a Connect event is from */
|
|
8
|
+
account?: string;
|
|
9
|
+
data: {
|
|
10
|
+
object: Record<string, unknown>;
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
export type StripeWebhookEndpointResponse = {
|
|
14
|
+
status: number;
|
|
15
|
+
body: string;
|
|
16
|
+
};
|
|
17
|
+
export type StripeEventDelivery = (url: string, event: StripeEvent, secret?: string) => Promise<void | StripeWebhookEndpointResponse> | void | StripeWebhookEndpointResponse;
|
|
18
|
+
/** A webhook endpoint a World holds in its tree: where to POST, what to sign with, what it subscribed to. */
|
|
19
|
+
export type StripeWebhookTarget = {
|
|
20
|
+
url: string;
|
|
21
|
+
secret?: string;
|
|
22
|
+
enabledEvents?: string[]; /** a Connect endpoint (`connect: true`): events from connected accounts */
|
|
23
|
+
connect?: boolean;
|
|
24
|
+
};
|
|
25
|
+
/** HMAC-SHA256(secret, `${timestamp}.${payload}`) as lowercase hex — Stripe's v1 signature. */
|
|
26
|
+
export declare function computeStripeSignature(payload: string, secret: string, timestamp: number): string;
|
|
27
|
+
/** Build a `Stripe-Signature` header value for `payload` (mirrors generateTestHeaderString). */
|
|
28
|
+
export declare function generateTestHeaderString(opts: {
|
|
29
|
+
payload: string;
|
|
30
|
+
secret: string;
|
|
31
|
+
timestamp?: number;
|
|
32
|
+
scheme?: string;
|
|
33
|
+
}): string;
|
|
34
|
+
export declare class StripeSignatureVerificationError extends Error {
|
|
35
|
+
constructor(message: string);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Verify a webhook payload + signature header against the signing secret and return the
|
|
39
|
+
* parsed event (mirrors `stripe.webhooks.constructEvent`). Throws a
|
|
40
|
+
* StripeSignatureVerificationError on a malformed/missing header, a signature that does
|
|
41
|
+
* not match, or (when `tolerance` is given) a timestamp outside the allowed window.
|
|
42
|
+
*/
|
|
43
|
+
export declare function constructEvent(payload: string, header: string, secret: string, opts?: {
|
|
44
|
+
tolerance?: number;
|
|
45
|
+
now?: number;
|
|
46
|
+
}): StripeEvent;
|
|
47
|
+
export declare const STRIPE_WEBHOOK_FALLBACK_SECRET = "whsec_twin_default";
|
|
48
|
+
export declare function registerStripeWebhook(url: string, secret?: string, enabledEvents?: string[]): void;
|
|
49
|
+
export declare function unregisterStripeWebhook(url: string): void;
|
|
50
|
+
export declare function clearStripeWebhooks(): void;
|
|
51
|
+
export declare function stripeEventMatches(enabledEvents: unknown, type: string): boolean;
|
|
52
|
+
export declare function listStripeWebhooks(): string[];
|
|
53
|
+
export declare function eventTypeFor(operation: string): string | null;
|
|
54
|
+
/** Install a default deliverer used by the write path when no per-call `deliver` is passed. */
|
|
55
|
+
export declare function setStripeEventDelivery(deliver: StripeEventDelivery | null): void;
|
|
56
|
+
export declare const STRIPE_REALTIME_AUTH_TIMEOUT_MS = 2000;
|
|
57
|
+
export type StripeAuthRequestOutcome = {
|
|
58
|
+
kind: 'approved';
|
|
59
|
+
amount?: number;
|
|
60
|
+
} | {
|
|
61
|
+
kind: 'declined';
|
|
62
|
+
} | {
|
|
63
|
+
kind: 'timeout';
|
|
64
|
+
message: string;
|
|
65
|
+
} | {
|
|
66
|
+
kind: 'error';
|
|
67
|
+
message: string;
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* Deliver an issuing_authorization.request event to the enrolled endpoint and interpret
|
|
71
|
+
* its synchronous response as the authorization decision. Honors the installed test
|
|
72
|
+
* deliverer (setStripeEventDelivery) so verifies run fully offline: a fake returning a
|
|
73
|
+
* {status, body} response drives the decision; a fake that throws a TimeoutError (the
|
|
74
|
+
* exact error AbortSignal.timeout produces) exercises the timeout fallback.
|
|
75
|
+
*/
|
|
76
|
+
export declare function requestAuthorizationDecision(url: string, event: StripeEvent, secret: string): Promise<StripeAuthRequestOutcome>;
|
|
77
|
+
/**
|
|
78
|
+
* Emit a Stripe event for a twin write to registered endpoints. `resource` is the
|
|
79
|
+
* Stripe object the event is about. `occurredAt` is caller-supplied (deterministic).
|
|
80
|
+
* Returns the delivered events (for assertions); no-op when nothing is registered
|
|
81
|
+
* or the operation has no mapped event type.
|
|
82
|
+
*
|
|
83
|
+
* Fan-out filters by each endpoint's registered enabled_events (stripeEventMatches — '*',
|
|
84
|
+
* 'family.*', exact), like the vendor: a consumer enrolled for one event type receives
|
|
85
|
+
* only that type. A URL registered without a list (test seam) receives everything.
|
|
86
|
+
*/
|
|
87
|
+
export declare function emitStripeEvent(operation: string, resource: Record<string, unknown>, opts?: {
|
|
88
|
+
occurredAt: string;
|
|
89
|
+
deliver?: StripeEventDelivery;
|
|
90
|
+
endpoints?: StripeWebhookTarget[];
|
|
91
|
+
account?: string; /** the id the stored event takes (afterStripeWrite) */
|
|
92
|
+
id?: string;
|
|
93
|
+
}): Promise<StripeEvent[]>;
|