@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
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
// positives. Fields the twin deliberately does not model are DECLARED in
|
|
11
11
|
// stripe-known-deviations.json with reasons; an undeclared missing-required field,
|
|
12
12
|
// a wrong type, an enum violation, or a fabricated field all fail CI.
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import
|
|
13
|
+
import { readFile } from 'node:fs/promises';
|
|
14
|
+
import { twinResources } from '@volter/world-core';
|
|
15
|
+
import { specConformance } from '@volter/world-tooling';
|
|
16
|
+
import type { TwinResource } from '@volter/world-core';
|
|
16
17
|
import { OBJECT_NAME } from './stripe-twin.ts';
|
|
17
18
|
|
|
18
19
|
type JsonSchema = specConformance.JsonSchema;
|
|
@@ -59,12 +60,12 @@ function emitted(r: TwinResource): Record<string, unknown> {
|
|
|
59
60
|
|
|
60
61
|
/** Load the vendored per-object Stripe JSON Schemas (type+required+enum from OpenAPI). */
|
|
61
62
|
export async function loadStripeSchemas(): Promise<StripeSchemas> {
|
|
62
|
-
return JSON.parse(await
|
|
63
|
+
return JSON.parse(await readFile(fixturePath('stripe-schemas.json'), 'utf8')) as StripeSchemas;
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
/** Load the declared scope (Stripe fields the twin does not model, + reasons). */
|
|
66
67
|
export async function loadStripeKnownDeviations(): Promise<KnownDeviation[]> {
|
|
67
|
-
const doc = JSON.parse(await
|
|
68
|
+
const doc = JSON.parse(await readFile(fixturePath('stripe-known-deviations.json'), 'utf8')) as { deviations: KnownDeviation[] };
|
|
68
69
|
return doc.deviations;
|
|
69
70
|
}
|
|
70
71
|
|
package/src/stripe-connector.ts
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
// and Stripe was missing entirely).
|
|
4
4
|
//
|
|
5
5
|
// PULL (real → twin): fetch real Stripe objects, map snake_case → SyncResource[],
|
|
6
|
-
// fold into the
|
|
6
|
+
// fold into the tree through the kernel's observe (its own diff, so a
|
|
7
7
|
// re-pull of identical state appends nothing).
|
|
8
8
|
// PUSH (twin → real): for every PENDING local action, call the real Stripe REST API
|
|
9
|
-
// and
|
|
9
|
+
// and the head confirms on success — which records the confirmed
|
|
10
10
|
// fields as an observed event and suppresses the local
|
|
11
11
|
// projection (the change is counted exactly once).
|
|
12
12
|
//
|
|
@@ -16,15 +16,15 @@
|
|
|
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 { assertBudgetGuardIntact,
|
|
20
|
-
import type { SyncResource, TwinAction } from '@volter/
|
|
21
|
-
import { StripeBudget, stripeCallWeight, type StripeBudgetOptions } from './stripe-budget.ts';
|
|
19
|
+
import { assertBudgetGuardIntact, observeResources } from '@volter/world-core';
|
|
20
|
+
import type { PerformContext, PushOutcome, RemoteExecute, SyncResource, TwinAction } from '@volter/world-core';
|
|
21
|
+
import { StripeBudget, StripeBudgetError, stripeCallWeight, type StripeBudgetOptions } from './stripe-budget.ts';
|
|
22
22
|
|
|
23
23
|
const SERVICE = 'stripe';
|
|
24
24
|
|
|
25
25
|
// Subject types that are twin-internal and are NEVER pushed to real Stripe: the recorded
|
|
26
26
|
// Stripe `event` envelopes (the local Events-API store) and idempotency bookkeeping.
|
|
27
|
-
const INTERNAL_SUBJECT_TYPES = new Set(['event', '_idempotency']);
|
|
27
|
+
export const INTERNAL_SUBJECT_TYPES = new Set(['event', '_idempotency']);
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* The injected real-Stripe boundary. `request` issues ONE Stripe REST call:
|
|
@@ -118,7 +118,14 @@ export function liveStripeExecute(
|
|
|
118
118
|
// Settles the reservation and, on a back-off signal, arms the cooldown. May itself throw (a
|
|
119
119
|
// `Retry-After` beyond the cap is not something to sleep off) — the cooldown is persisted
|
|
120
120
|
// first either way, so the refusal survives the throw.
|
|
121
|
-
|
|
121
|
+
// recordCall may THROW after arming the cooldown (a back-off beyond the cap). On a refused
|
|
122
|
+
// call that louder refusal wins; an answer Stripe ACCEPTED is kept, so a write that landed is
|
|
123
|
+
// never recorded as failed and performed again on retry.
|
|
124
|
+
try {
|
|
125
|
+
budget.recordCall(weight, resHeaders, { status: res.status, reservation });
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (!(error instanceof StripeBudgetError) || !res.ok) throw error;
|
|
128
|
+
}
|
|
122
129
|
return parsed;
|
|
123
130
|
};
|
|
124
131
|
}
|
|
@@ -243,8 +250,15 @@ export async function pullStripeSubscriptions(execute: StripeExecute, opts: { li
|
|
|
243
250
|
|
|
244
251
|
/**
|
|
245
252
|
* Pull from real Stripe (customers + subscriptions) and fold into the twin (mirror
|
|
246
|
-
* seeding).
|
|
253
|
+
* seeding). The fold makes a re-pull of identical state a no-op.
|
|
247
254
|
*/
|
|
255
|
+
/** One fold onto the head: protocol 2's observe, one batch, one instant. */
|
|
256
|
+
function fold(resources: SyncResource[], opts: { root?: string; occurredAt: string }) {
|
|
257
|
+
return observeResources(SERVICE, resources.map((r) => ({ type: r.type, id: r.id, fields: r.fields })), {
|
|
258
|
+
...(opts.root !== undefined ? { root: opts.root } : {}), at: opts.occurredAt, batch: `obs:${SERVICE}:${opts.occurredAt}`,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
248
262
|
export async function syncStripeFromReal(
|
|
249
263
|
execute: StripeExecute,
|
|
250
264
|
opts: { root?: string; occurredAt: string; limit?: number },
|
|
@@ -252,8 +266,8 @@ export async function syncStripeFromReal(
|
|
|
252
266
|
const customers = await pullStripeCustomers(execute, { ...(opts.limit ? { limit: opts.limit } : {}) });
|
|
253
267
|
const subscriptions = await pullStripeSubscriptions(execute, { ...(opts.limit ? { limit: opts.limit } : {}) });
|
|
254
268
|
const resources = [...customers, ...subscriptions];
|
|
255
|
-
const result =
|
|
256
|
-
return { observed: result.observed, deltasAppended: result.
|
|
269
|
+
const result = fold(resources, opts);
|
|
270
|
+
return { observed: result.observed, deltasAppended: result.appended };
|
|
257
271
|
}
|
|
258
272
|
|
|
259
273
|
// ── PUSH ────────────────────────────────────────────────────────────────────
|
|
@@ -332,6 +346,11 @@ export function stripeRequestForAction(action: Pick<TwinAction, 'operation' | 's
|
|
|
332
346
|
const PUSHABLE_VERBS = new Set(['create', 'update', 'cancel', ...SUBACTION_VERBS]);
|
|
333
347
|
|
|
334
348
|
/** Throw if `op` is not a write operation this connector can faithfully push. */
|
|
349
|
+
/** Whether this operation is one the connector can faithfully send to Stripe. */
|
|
350
|
+
export function isPushable(op: string): boolean {
|
|
351
|
+
const verb = op.includes('.') ? op.slice(op.indexOf('.') + 1) : op;
|
|
352
|
+
return PUSHABLE_VERBS.has(verb);
|
|
353
|
+
}
|
|
335
354
|
function assertPushable(op: string): void {
|
|
336
355
|
const verb = op.includes('.') ? op.slice(op.indexOf('.') + 1) : op;
|
|
337
356
|
if (!PUSHABLE_VERBS.has(verb)) {
|
|
@@ -356,31 +375,41 @@ export async function pushStripeAction(
|
|
|
356
375
|
return { externalId: typeof id === 'string' && id ? id : action.subject.id };
|
|
357
376
|
}
|
|
358
377
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
*
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
378
|
+
// ── PROTOCOL 2: the pack's half of the real state system ────────────────────────────────────
|
|
379
|
+
/** The pack's executor over the kernel's: the same Stripe REST call, carried by the head. Stripe's API is
|
|
380
|
+
* form-encoded, which is what `stripeExecute` already speaks — this only carries it. */
|
|
381
|
+
export function stripeExecuteOver(execute: RemoteExecute): StripeExecute {
|
|
382
|
+
return async (method, path, params) => {
|
|
383
|
+
const body = params === undefined ? undefined : encodeForm(params);
|
|
384
|
+
const res = await execute({
|
|
385
|
+
method, path,
|
|
386
|
+
headers: { accept: 'application/json', ...(body === undefined ? {} : { 'content-type': 'application/x-www-form-urlencoded' }) },
|
|
387
|
+
...(body === undefined ? {} : { body }),
|
|
388
|
+
});
|
|
389
|
+
if (res.body === '') return {};
|
|
390
|
+
try { return JSON.parse(res.body) as Record<string, unknown>; } catch { return { error: { type: 'api_error', message: res.body.slice(0, 200) } }; }
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
/** The refresh adapter: pull every modeled collection and the webhook endpoints through the executor. */
|
|
394
|
+
export async function syncStripeFromRemote(execute: RemoteExecute, opts: { root?: string; origin?: string; occurredAt?: string } = {}): Promise<{ observed: number; deltasAppended: number }> {
|
|
395
|
+
const at = opts.occurredAt ?? new Date().toISOString();
|
|
396
|
+
const wire = stripeExecuteOver(execute);
|
|
397
|
+
const resources = [
|
|
398
|
+
...await pullStripeAll(wire),
|
|
399
|
+
...await pullStripeWebhookEndpoints(wire),
|
|
400
|
+
];
|
|
401
|
+
const report = fold(resources, { ...(opts.root !== undefined ? { root: opts.root } : {}), occurredAt: at });
|
|
402
|
+
return { observed: report.observed, deltasAppended: report.appended };
|
|
403
|
+
}
|
|
404
|
+
/** The perform adapter: one entry crosses to Stripe, or settles with the reason it never could. The twin's
|
|
405
|
+
* own subject types — the recorded `event` envelopes the write path persists for the Events API, and the
|
|
406
|
+
* idempotency bookkeeping — are local state and must never be sent. */
|
|
407
|
+
export async function performStripeAction(execute: RemoteExecute, action: TwinAction, _ctx: PerformContext): Promise<PushOutcome> {
|
|
408
|
+
const op = action.operation ?? `${action.subject.type}.update`;
|
|
409
|
+
if (INTERNAL_SUBJECT_TYPES.has(action.subject.type) || !isPushable(op)) {
|
|
410
|
+
return { externalId: action.subject.id, data: { performed: false, reason: `${op} is the twin's own record — nothing at Stripe to write` } };
|
|
382
411
|
}
|
|
383
|
-
return {
|
|
412
|
+
return pushStripeAction(stripeExecuteOver(execute), { operation: op, subject: action.subject, fields: action.fields ?? {} });
|
|
384
413
|
}
|
|
385
414
|
|
|
386
415
|
// ── FULL SYNC (all collections + webhooks, bi-directional) ───────────────────
|
|
@@ -421,14 +450,13 @@ export async function pullStripeWebhookEndpoints(execute: StripeExecute, opts: {
|
|
|
421
450
|
export async function fullSyncStripe(
|
|
422
451
|
execute: StripeExecute,
|
|
423
452
|
opts: { root?: string; occurredAt: string; limit?: number },
|
|
424
|
-
): Promise<{
|
|
425
|
-
//
|
|
426
|
-
|
|
427
|
-
// 2. PULL all collections + webhooks back into the twin.
|
|
453
|
+
): Promise<{ observed: number; deltasAppended: number; collections: number }> {
|
|
454
|
+
// protocol 2: this is the PULL half only. The push half is the head's — it performs each entry through
|
|
455
|
+
// `performStripeAction` and confirms it — so a refresh no longer reconciles both directions at once.
|
|
428
456
|
const resources = [
|
|
429
457
|
...await pullStripeAll(execute, { ...(opts.limit ? { limit: opts.limit } : {}) }),
|
|
430
458
|
...await pullStripeWebhookEndpoints(execute, { ...(opts.limit ? { limit: opts.limit } : {}) }),
|
|
431
459
|
];
|
|
432
|
-
const pull =
|
|
433
|
-
return {
|
|
460
|
+
const pull = fold(resources, opts);
|
|
461
|
+
return { observed: pull.observed, deltasAppended: pull.appended, collections: PULL_COLLECTIONS.length + 1 };
|
|
434
462
|
}
|
package/src/stripe-emit.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The Stripe DELIVER verb — `world-stripe emit` (feature-sweep friction: hand-constructing
|
|
2
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/
|
|
3
|
+
// handler). This is the pack side of the kernel's emit seam (@volter/world-core emit.ts): the
|
|
4
4
|
// catalog of emittable event types, endpoint registrations read FROM TWIN STATE AT REST
|
|
5
5
|
// (webhook_endpoint rows minted by POST /v1/webhook_endpoints — the CLI runs in its own
|
|
6
6
|
// process, so the in-memory delivery registry in stripe-events.ts does not apply), and
|
|
@@ -11,9 +11,10 @@
|
|
|
11
11
|
// DELIVER does not transition state: it snapshots the subject AS IT IS in the twin and fires
|
|
12
12
|
// the named event about it. Completing a checkout session / paying an invoice is the twin
|
|
13
13
|
// API's job; this verb answers "my app's webhook handler needs to SEE the event, now."
|
|
14
|
-
import { projectResources, type EmitEndpoint, type EmittableEvent, type SynthesizedDelivery, type TwinEmitter } from '@volter/
|
|
15
|
-
import { OBJECT_NAME, TWIN_API_VERSION, view } from './stripe-twin.ts';
|
|
14
|
+
import { projectResources, type EmitEndpoint, type EmittableEvent, type SynthesizedDelivery, type TwinEmitter } from '@volter/world-core';
|
|
15
|
+
import { OBJECT_NAME, PLATFORM_ACCOUNT_ID, TWIN_API_VERSION, view } from './stripe-twin.ts';
|
|
16
16
|
import { STRIPE_WEBHOOK_FALLBACK_SECRET, generateTestHeaderString } from './stripe-events.ts';
|
|
17
|
+
import { render } from './stripe-version.ts';
|
|
17
18
|
|
|
18
19
|
const SERVICE = 'stripe';
|
|
19
20
|
|
|
@@ -31,6 +32,7 @@ const EMITTABLE: Record<string, string> = {
|
|
|
31
32
|
'payment_intent.canceled': 'payment_intent',
|
|
32
33
|
// charges + disputes
|
|
33
34
|
'charge.succeeded': 'charge',
|
|
35
|
+
'charge.failed': 'charge',
|
|
34
36
|
'charge.captured': 'charge',
|
|
35
37
|
'charge.refunded': 'charge',
|
|
36
38
|
'charge.dispute.created': 'dispute',
|
|
@@ -86,7 +88,7 @@ function rows(type: string, root?: string): Array<Record<string, unknown>> {
|
|
|
86
88
|
}
|
|
87
89
|
|
|
88
90
|
function liveEndpointRows(root?: string): Array<Record<string, unknown>> {
|
|
89
|
-
return rows('webhook_endpoint', root).filter((w) => w.
|
|
91
|
+
return rows('webhook_endpoint', root).filter((w) => w.deleted !== true);
|
|
90
92
|
}
|
|
91
93
|
|
|
92
94
|
let emitSeq = 0;
|
|
@@ -107,13 +109,13 @@ export const stripeEmitter: TwinEmitter = {
|
|
|
107
109
|
},
|
|
108
110
|
|
|
109
111
|
subjects(subjectType: string, root?: string): string[] {
|
|
110
|
-
return rows(subjectType, root).filter((r) => r.
|
|
112
|
+
return rows(subjectType, root).filter((r) => r.deleted !== true).map((r) => String(r.id));
|
|
111
113
|
},
|
|
112
114
|
|
|
113
115
|
synthesize({ type, subjectId, endpoint, root, occurredAt }): SynthesizedDelivery {
|
|
114
116
|
const subjectType = EMITTABLE[type];
|
|
115
117
|
if (!subjectType) throw new Error(`emit: the stripe pack cannot synthesize "${type}".`);
|
|
116
|
-
const row = rows(subjectType, root).find((r) => String(r.id) === subjectId && r.
|
|
118
|
+
const row = rows(subjectType, root).find((r) => String(r.id) === subjectId && r.deleted !== true);
|
|
117
119
|
if (!row) {
|
|
118
120
|
const have = stripeEmitter.subjects(subjectType, root);
|
|
119
121
|
throw new Error(
|
|
@@ -126,6 +128,10 @@ export const stripeEmitter: TwinEmitter = {
|
|
|
126
128
|
const secret = typeof endpointRow?.secret === 'string' && endpointRow.secret ? endpointRow.secret : STRIPE_WEBHOOK_FALLBACK_SECRET;
|
|
127
129
|
|
|
128
130
|
const created = occurredAt ? Math.floor(Date.parse(occurredAt) / 1000) : Math.floor(Date.now() / 1000);
|
|
131
|
+
// a connected account's object is its event's, as the write path scopes it (stripe-twin.ts afterStripeWrite): the
|
|
132
|
+
// account itself, or a row kept on its books (`_account`). "Each event for a connected account contains a top-level
|
|
133
|
+
// `account` property that identifies the connected account" (docs.stripe.com/connect/webhooks).
|
|
134
|
+
const account = typeof row._account === 'string' ? row._account : subjectType === 'account' && String(row.id) !== PLATFORM_ACCOUNT_ID ? String(row.id) : undefined;
|
|
129
135
|
// Same envelope persistStripeEvent stores for GET /v1/events; `evt_twin_emit_*` marks it
|
|
130
136
|
// operator-fired (never colliding with the write path's organic `evt_twin_<n>` ids).
|
|
131
137
|
const event: Record<string, unknown> = {
|
|
@@ -138,8 +144,9 @@ export const stripeEmitter: TwinEmitter = {
|
|
|
138
144
|
pending_webhooks: 1,
|
|
139
145
|
request: { id: null, idempotency_key: null },
|
|
140
146
|
type,
|
|
147
|
+
...(account ? { account } : {}),
|
|
141
148
|
};
|
|
142
|
-
const payload = JSON.stringify(event);
|
|
149
|
+
const payload = JSON.stringify(render(event, TWIN_API_VERSION));
|
|
143
150
|
const header = generateTestHeaderString({ payload, secret, ...(occurredAt ? { timestamp: created } : {}) });
|
|
144
151
|
return {
|
|
145
152
|
payload,
|
package/src/stripe-events.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { nodeBuiltin } from '@volter/world-core';
|
|
2
|
+
import { worldEgressRefusal } from '@volter/world-core/network-policy';
|
|
3
|
+
import vendorEvents from './generated/events.gen.json' with { type: 'json' };
|
|
4
|
+
import { render } from './stripe-version.ts';
|
|
1
5
|
// Stripe event/webhook emission (scorecard R17). Real Stripe fires `event`
|
|
2
6
|
// objects (and webhooks) on state changes — payment_intent.succeeded,
|
|
3
7
|
// customer.subscription.created, invoice.paid, … — so an app's webhook handler
|
|
@@ -10,6 +14,8 @@ export type StripeEvent = {
|
|
|
10
14
|
type: string;
|
|
11
15
|
created: number;
|
|
12
16
|
livemode: false;
|
|
17
|
+
/** the connected account a Connect event is from */
|
|
18
|
+
account?: string;
|
|
13
19
|
data: { object: Record<string, unknown> };
|
|
14
20
|
};
|
|
15
21
|
// A deliverer MAY return the endpoint's raw HTTP response. Ordinary (asynchronous) event
|
|
@@ -17,7 +23,9 @@ export type StripeEvent = {
|
|
|
17
23
|
// (issuing_authorization.request) reads it to honor the endpoint's approve/decline. A
|
|
18
24
|
// fire-and-forget deliverer returning void stays valid for the asynchronous path.
|
|
19
25
|
export type StripeWebhookEndpointResponse = { status: number; body: string };
|
|
20
|
-
export type StripeEventDelivery = (url: string, event: StripeEvent) => Promise<void | StripeWebhookEndpointResponse> | void | StripeWebhookEndpointResponse;
|
|
26
|
+
export type StripeEventDelivery = (url: string, event: StripeEvent, secret?: string) => Promise<void | StripeWebhookEndpointResponse> | void | StripeWebhookEndpointResponse;
|
|
27
|
+
/** A webhook endpoint a World holds in its tree: where to POST, what to sign with, what it subscribed to. */
|
|
28
|
+
export type StripeWebhookTarget = { url: string; secret?: string; enabledEvents?: string[]; /** a Connect endpoint (`connect: true`): events from connected accounts */ connect?: boolean };
|
|
21
29
|
|
|
22
30
|
// ── Webhook signature verification (Stripe's scheme) ────────────────────────────
|
|
23
31
|
// Real Stripe signs each webhook delivery with an HMAC-SHA256 over `${timestamp}.${payload}`
|
|
@@ -33,7 +41,7 @@ export type StripeEventDelivery = (url: string, event: StripeEvent) => Promise<v
|
|
|
33
41
|
type NodeCrypto = typeof import('node:crypto');
|
|
34
42
|
function nodeCrypto(): NodeCrypto {
|
|
35
43
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
36
|
-
return
|
|
44
|
+
return nodeBuiltin('node:crypto') as NodeCrypto;
|
|
37
45
|
}
|
|
38
46
|
|
|
39
47
|
/** HMAC-SHA256(secret, `${timestamp}.${payload}`) as lowercase hex — Stripe's v1 signature. */
|
|
@@ -102,15 +110,13 @@ export function constructEvent(payload: string, header: string, secret: string,
|
|
|
102
110
|
}
|
|
103
111
|
|
|
104
112
|
const registry: string[] = [];
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
// the header is still a real, verifiable
|
|
113
|
+
// URLs a HOST registered directly (registerStripeWebhook), with the secret to sign with and the
|
|
114
|
+
// enabled_events to filter on. A World's own endpoints are not here: POST /v1/webhook_endpoints
|
|
115
|
+
// writes the WebhookEndpoint to the tree, and every emit reads the tree (the caller passes them as
|
|
116
|
+
// `endpoints`), so delivery survives a restart and belongs to one World. A URL registered here
|
|
117
|
+
// without a secret is signed with the fallback below, so the header is still a real, verifiable
|
|
118
|
+
// signature; one registered without a list receives every event, equivalent to ['*'].
|
|
110
119
|
const secrets = new Map<string, string>();
|
|
111
|
-
// Subscriptions by URL: the endpoint's enabled_events, carried through registration so the
|
|
112
|
-
// organic fan-out filters like the vendor. A URL registered WITHOUT a list (test seam)
|
|
113
|
-
// receives every event — equivalent to ['*'].
|
|
114
120
|
const subscriptions = new Map<string, string[]>();
|
|
115
121
|
export const STRIPE_WEBHOOK_FALLBACK_SECRET = 'whsec_twin_default';
|
|
116
122
|
export function registerStripeWebhook(url: string, secret?: string, enabledEvents?: string[]): void {
|
|
@@ -147,6 +153,8 @@ export function listStripeWebhooks(): string[] {
|
|
|
147
153
|
// twin write operation → Stripe event type (the ones an app's webhooks care about).
|
|
148
154
|
// Returns null when an operation has no event (e.g. a plain create/retrieve we
|
|
149
155
|
// don't model an event for).
|
|
156
|
+
const VENDOR_EVENT_TYPES = new Set((vendorEvents as { types: string[] }).types);
|
|
157
|
+
|
|
150
158
|
export function eventTypeFor(operation: string): string | null {
|
|
151
159
|
// Several twin write operations ARE already the Stripe event type (we record the
|
|
152
160
|
// op as `<resource>.<event>` so it maps 1:1): payment_intent.amount_capturable_updated,
|
|
@@ -156,6 +164,11 @@ export function eventTypeFor(operation: string): string | null {
|
|
|
156
164
|
'payment_intent.payment_failed', 'payment_intent.created', 'payment_intent.processing',
|
|
157
165
|
'payment_intent.requires_action',
|
|
158
166
|
'charge.succeeded', 'charge.captured', 'charge.refunded', 'charge.dispute.created',
|
|
167
|
+
// A refund fires TWO real Stripe events: `refund.created` carrying the Refund, and
|
|
168
|
+
// `charge.refunded` carrying the CHARGE with its new totals. The twin records the charge's
|
|
169
|
+
// own write as `charge.refunded`, so the event a consumer receives is the charge — which is
|
|
170
|
+
// the whole point of that event and the reason a reconciler subscribes to it.
|
|
171
|
+
'refund.created', 'refund.updated',
|
|
159
172
|
'customer.updated', 'customer.deleted', 'invoice.created', 'invoice.finalized',
|
|
160
173
|
'invoice.paid', 'invoice.payment_failed', 'invoice.voided', 'invoice.marked_uncollectible', 'invoice.sent',
|
|
161
174
|
'customer.subscription.updated', 'customer.subscription.paused', 'customer.subscription.resumed',
|
|
@@ -186,7 +199,7 @@ export function eventTypeFor(operation: string): string | null {
|
|
|
186
199
|
case 'invoice.finalize': return 'invoice.finalized';
|
|
187
200
|
case 'invoice.pay': return 'invoice.paid';
|
|
188
201
|
case 'invoice.void': return 'invoice.voided';
|
|
189
|
-
case 'refund.create': return '
|
|
202
|
+
case 'refund.create': return 'refund.created';
|
|
190
203
|
case 'dispute.create': return 'charge.dispute.created';
|
|
191
204
|
case 'payout.create': return 'payout.created';
|
|
192
205
|
case 'product.create': return 'product.created';
|
|
@@ -202,11 +215,27 @@ export function eventTypeFor(operation: string): string | null {
|
|
|
202
215
|
case 'issuing_card.create': return 'issuing_card.created';
|
|
203
216
|
case 'issuing_cardholder.create': return 'issuing_cardholder.created';
|
|
204
217
|
case 'issuing_transaction.create': return 'issuing_transaction.created';
|
|
205
|
-
default: return
|
|
218
|
+
default: return vendorEventType(operation);
|
|
206
219
|
}
|
|
207
220
|
}
|
|
208
221
|
|
|
209
|
-
|
|
222
|
+
/** Any other write records the event Stripe sends for it, when Stripe sends one: the operation itself, or its
|
|
223
|
+
* resource's create/update/delete as `<resource>.created|updated|deleted` (the types Stripe's spec enumerates). */
|
|
224
|
+
function vendorEventType(operation: string): string | null {
|
|
225
|
+
if (VENDOR_EVENT_TYPES.has(operation)) return operation;
|
|
226
|
+
const verb = /^(.+)\.(create|update|delete)$/.exec(operation);
|
|
227
|
+
return verb && VENDOR_EVENT_TYPES.has(`${verb[1]}.${verb[2]}d`) ? `${verb[1]}.${verb[2]}d` : null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** An event's id, DERIVED from the event itself — the operation, the resource it carries and the instant —
|
|
231
|
+
* never a process counter (protocol 2: two identical worlds deliver identically, and a replay of the same
|
|
232
|
+
* write raises the same event id rather than one that drifts with process uptime). */
|
|
233
|
+
function eventId(operation: string, resource: Record<string, unknown>, occurredAt: string, account?: string): string {
|
|
234
|
+
let h = 0x811c9dc5;
|
|
235
|
+
const seed = `${operation}|${String((resource as { id?: unknown }).id ?? '')}|${occurredAt}|${account ?? ''}`;
|
|
236
|
+
for (let i = 0; i < seed.length; i += 1) { h ^= seed.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; }
|
|
237
|
+
return `evt_twin_${h.toString(16).padStart(8, '0')}`;
|
|
238
|
+
}
|
|
210
239
|
// Live HTTP delivery signs exactly like real Stripe: HMAC-SHA256 over
|
|
211
240
|
// `${timestamp}.${payload}` keyed by the ENDPOINT'S OWN signing secret (the
|
|
212
241
|
// `whsec_twin_*` minted by POST /v1/webhook_endpoints and registered alongside the
|
|
@@ -226,9 +255,18 @@ let counter = 0;
|
|
|
226
255
|
// A delivery that still fails after the re-attempts is DROPPED, and says so: silent loss is
|
|
227
256
|
// the one outcome a twin must never have, because it makes every consumer look flaky.
|
|
228
257
|
const DELIVERY_ATTEMPTS = 3;
|
|
229
|
-
const httpDelivery: StripeEventDelivery = async (url, event) => {
|
|
230
|
-
|
|
231
|
-
const
|
|
258
|
+
const httpDelivery: StripeEventDelivery = async (url, event, endpointSecret) => {
|
|
259
|
+
// the event as its API version renders it (stripe-version.ts)
|
|
260
|
+
const payload = JSON.stringify(render(event, (event as { api_version?: string }).api_version));
|
|
261
|
+
const secret = endpointSecret ?? secrets.get(url) ?? STRIPE_WEBHOOK_FALLBACK_SECRET;
|
|
262
|
+
// the World's egress rule: an endpoint it refuses is not delivered to, and says so
|
|
263
|
+
const refusal = worldEgressRefusal(url);
|
|
264
|
+
if (refusal !== null) console.error(`[twin:stripe] webhook delivery DROPPED — ${event.type} ${event.id} -> ${url}: ${refusal}`);
|
|
265
|
+
return refusal !== null ? undefined : postEvent(url, event, payload, secret);
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
/** The HTTP POST of a signed event to an endpoint the World lets it reach, re-attempted on a transport failure. */
|
|
269
|
+
async function postEvent(url: string, event: StripeEvent, payload: string, secret: string): Promise<void> {
|
|
232
270
|
let last = '';
|
|
233
271
|
for (let attempt = 1; attempt <= DELIVERY_ATTEMPTS; attempt += 1) {
|
|
234
272
|
try {
|
|
@@ -241,16 +279,17 @@ const httpDelivery: StripeEventDelivery = async (url, event) => {
|
|
|
241
279
|
}
|
|
242
280
|
}
|
|
243
281
|
console.error(`[twin:stripe] webhook delivery DROPPED after ${DELIVERY_ATTEMPTS} attempts — ${event.type} ${event.id} -> ${url}: ${last}`);
|
|
244
|
-
}
|
|
282
|
+
}
|
|
245
283
|
|
|
246
284
|
// Default deliverer override (a TEST SEAM). The twin's write path emits events without
|
|
247
285
|
// threading a per-call deliverer, so to drive event delivery fully OFFLINE/deterministically
|
|
248
286
|
// — no real sockets — a test installs a fake sink here. When unset, real HTTP POST is used
|
|
249
287
|
// (prod behavior). This keeps D5 honest: verify() exercises the twin's own emission, in-process.
|
|
250
|
-
|
|
288
|
+
// a slot, not module truth (protocol 2): WHERE a delivery goes, set once by the host that mounts the twin
|
|
289
|
+
const defaultDelivery: { current: StripeEventDelivery | null } = { current: null };
|
|
251
290
|
/** Install a default deliverer used by the write path when no per-call `deliver` is passed. */
|
|
252
291
|
export function setStripeEventDelivery(deliver: StripeEventDelivery | null): void {
|
|
253
|
-
defaultDelivery = deliver;
|
|
292
|
+
defaultDelivery.current = deliver;
|
|
254
293
|
}
|
|
255
294
|
|
|
256
295
|
// ── Real-time authorization (issuing_authorization.request) — the SYNCHRONOUS leg ────────
|
|
@@ -283,8 +322,16 @@ export type StripeAuthRequestOutcome =
|
|
|
283
322
|
// the 2s window. Signs with the enrolled endpoint's own whsec (passed by the caller, read
|
|
284
323
|
// from the endpoint's state row) — never a placeholder.
|
|
285
324
|
async function httpAuthRequestDelivery(url: string, event: StripeEvent, secret: string): Promise<StripeWebhookEndpointResponse> {
|
|
286
|
-
|
|
325
|
+
// the event as its API version renders it (stripe-version.ts)
|
|
326
|
+
const payload = JSON.stringify(render(event, (event as { api_version?: string }).api_version));
|
|
287
327
|
const header = generateTestHeaderString({ payload, secret });
|
|
328
|
+
// the World's egress rule: a refused endpoint is an unreachable one (the caller's timeout fallback)
|
|
329
|
+
const refusal = worldEgressRefusal(url);
|
|
330
|
+
return refusal !== null ? Promise.reject(new Error(refusal)) : postAuthRequest(url, payload, header);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** The HTTP POST of a real-time authorization request, cut off at the window, and the endpoint's answer. */
|
|
334
|
+
async function postAuthRequest(url: string, payload: string, header: string): Promise<StripeWebhookEndpointResponse> {
|
|
288
335
|
const res = await fetch(url, {
|
|
289
336
|
method: 'POST',
|
|
290
337
|
headers: { 'content-type': 'application/json', 'stripe-signature': header },
|
|
@@ -304,16 +351,20 @@ async function httpAuthRequestDelivery(url: string, event: StripeEvent, secret:
|
|
|
304
351
|
export async function requestAuthorizationDecision(url: string, event: StripeEvent, secret: string): Promise<StripeAuthRequestOutcome> {
|
|
305
352
|
let res: void | StripeWebhookEndpointResponse;
|
|
306
353
|
try {
|
|
307
|
-
res = defaultDelivery ? await defaultDelivery(url, event) : await httpAuthRequestDelivery(url, event, secret);
|
|
354
|
+
res = defaultDelivery.current ? await defaultDelivery.current(url, event) : await httpAuthRequestDelivery(url, event, secret);
|
|
308
355
|
} catch (e) {
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
return { kind: 'timeout', message: 'Webhook endpoint did not respond within the real-time authorization window.' };
|
|
312
|
-
}
|
|
313
|
-
// No response ever reached us (connection refused, DNS, socket reset) — like an
|
|
314
|
-
// endpoint that never answered inside the window: a timeout, not an invalid response.
|
|
315
|
-
return { kind: 'timeout', message: 'Webhook endpoint was unreachable within the real-time authorization window.' };
|
|
356
|
+
// no answer inside the window: cut off (TimeoutError, AbortError), or never reached (refused, DNS, reset), alike
|
|
357
|
+
return (e instanceof Error && (e.name === 'TimeoutError' || e.name === 'AbortError')) ? TIMED_OUT : UNREACHED;
|
|
316
358
|
}
|
|
359
|
+
return readDecision(res);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const TIMED_OUT: StripeAuthRequestOutcome = { kind: 'timeout', message: 'Webhook endpoint did not respond within the real-time authorization window.' };
|
|
363
|
+
const UNREACHED: StripeAuthRequestOutcome = { kind: 'timeout', message: 'Webhook endpoint was unreachable within the real-time authorization window.' };
|
|
364
|
+
|
|
365
|
+
/** The endpoint's synchronous answer read as the decision: `approved` (and an optional `amount`), or an error for an
|
|
366
|
+
* answer Stripe cannot read. */
|
|
367
|
+
function readDecision(res: void | StripeWebhookEndpointResponse): StripeAuthRequestOutcome {
|
|
317
368
|
if (!res || typeof res !== 'object') {
|
|
318
369
|
return { kind: 'error', message: 'Webhook endpoint returned no response body.' };
|
|
319
370
|
}
|
|
@@ -348,24 +399,31 @@ export async function requestAuthorizationDecision(url: string, event: StripeEve
|
|
|
348
399
|
export async function emitStripeEvent(
|
|
349
400
|
operation: string,
|
|
350
401
|
resource: Record<string, unknown>,
|
|
351
|
-
opts: { occurredAt: string; deliver?: StripeEventDelivery } = { occurredAt: '1970-01-01T00:00:00.000Z' },
|
|
402
|
+
opts: { occurredAt: string; deliver?: StripeEventDelivery; endpoints?: StripeWebhookTarget[]; account?: string; /** the id the stored event takes (afterStripeWrite) */ id?: string } = { occurredAt: '1970-01-01T00:00:00.000Z' },
|
|
352
403
|
): Promise<StripeEvent[]> {
|
|
353
404
|
const type = eventTypeFor(operation);
|
|
354
|
-
|
|
355
|
-
const
|
|
405
|
+
// the World's own endpoints (its tree is the record), then any URL a host registered directly
|
|
406
|
+
const targets: StripeWebhookTarget[] = [...(opts.endpoints ?? [])];
|
|
407
|
+
for (const url of registry) if (!targets.some((t) => t.url === url)) targets.push({ url, ...(secrets.has(url) ? { secret: secrets.get(url)! } : {}), ...(subscriptions.has(url) ? { enabledEvents: subscriptions.get(url)! } : {}) });
|
|
408
|
+
if (!type || targets.length === 0) return [];
|
|
409
|
+
const deliver = opts.deliver ?? defaultDelivery.current ?? httpDelivery;
|
|
356
410
|
const event: StripeEvent = {
|
|
357
|
-
id:
|
|
411
|
+
id: opts.id ?? eventId(operation, resource, opts.occurredAt, opts.account),
|
|
358
412
|
object: 'event',
|
|
359
413
|
type,
|
|
360
414
|
created: Math.floor(Date.parse(opts.occurredAt) / 1000) || 0,
|
|
361
415
|
livemode: false,
|
|
362
416
|
data: { object: resource },
|
|
417
|
+
// "Each event for a connected account contains a top-level `account` property that identifies the connected account"
|
|
418
|
+
...(opts.account ? { account: opts.account } : {}),
|
|
363
419
|
};
|
|
364
420
|
const out: StripeEvent[] = [];
|
|
365
|
-
for (const
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
421
|
+
for (const target of targets) {
|
|
422
|
+
if (target.enabledEvents && !stripeEventMatches(target.enabledEvents, type)) continue;
|
|
423
|
+
// an endpoint's scope (docs.stripe.com/connect/webhooks): `connect: true` receives the Connected accounts scope,
|
|
424
|
+
// `connect: false` Your account; a URL registered without a scope (a host's seam) receives both
|
|
425
|
+
if (target.connect !== undefined && target.connect !== Boolean(opts.account)) continue;
|
|
426
|
+
await deliver(target.url, event, target.secret);
|
|
369
427
|
out.push(event);
|
|
370
428
|
}
|
|
371
429
|
return out;
|
package/src/stripe-js.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// STRIPE.JS — the client library an application's page loads from js.stripe.com (docs/contributing/architecture.md,
|
|
2
|
+
// "Screens": a widget the vendor ships into the application's own page "is not a screen: it is a client library,
|
|
3
|
+
// twinned from its published types as an SDK surface is"). Written from @stripe/stripe-js's published loader and types
|
|
4
|
+
// (7.x: dist/index.mjs, dist/stripe-js/stripe.d.ts, dist/stripe-js/hosted-checkout.d.ts), nothing of Stripe's script
|
|
5
|
+
// copied:
|
|
6
|
+
// - the loader injects https://js.stripe.com/<release train>/stripe.js (or /v3/), and accepts a script already on the
|
|
7
|
+
// page whose src matches `/^https:\/\/js\.stripe\.com\/(v3|[a-z]+)\/stripe\.js(\?.*)?$/` (so /v3/stripe.js too);
|
|
8
|
+
// it resolves `window.Stripe`, reads `Stripe.version` (3 is the v3 URL, otherwise the train's name) and calls the
|
|
9
|
+
// instance's `_registerWrapper` when it has one;
|
|
10
|
+
// - `stripe.redirectToCheckout({ sessionId })` sends the customer to that Checkout Session's page (hosted-checkout.d.ts,
|
|
11
|
+
// RedirectToCheckoutServerOptions), which this twin serves at checkout.stripe.com/c/pay/{id} (screens/checkout.tsx),
|
|
12
|
+
// and its promise settles only on failure (`Promise<never | {error}>`);
|
|
13
|
+
// - `stripe.collectFinancialConnectionsAccounts({ clientSecret })` loads the Financial Connections authentication
|
|
14
|
+
// flow for the session whose client_secret it is (stripe.d.ts; docs.stripe.com/js/financial_connections/
|
|
15
|
+
// collect_financial_connections_accounts: "it will load the Authentication Flow, an on-page modal UI"). Where the
|
|
16
|
+
// twin decides: it navigates to the flow's page, js.stripe.com/v3/financial-connections/{client_secret}
|
|
17
|
+
// (screens/financial-connections.tsx), instead of a modal, so its promise does not settle; the flow returns the
|
|
18
|
+
// holder to the session's return_url, where the application reads the session's accounts.
|
|
19
|
+
// Demand: Dub's upgrade button (apps/web/ui/workspaces/upgrade-plan-button.tsx) creates a session on its server and
|
|
20
|
+
// calls redirectToCheckout. Every other member of the library throws, naming itself, when called: an application
|
|
21
|
+
// reaching one finds the gap instead of a silent no-op.
|
|
22
|
+
|
|
23
|
+
/** The release train a script path names: `/v3`, `/v3/` or `/v3/stripe.js` → 3, `/<train>/stripe.js` → the train. */
|
|
24
|
+
export function stripeJsTrain(pathname: string): 3 | string | undefined {
|
|
25
|
+
if (/^\/v3(\/|\/stripe\.js)?$/.test(pathname)) return 3;
|
|
26
|
+
return /^\/([a-z]+)\/stripe\.js$/.exec(pathname)?.[1];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function script(version: 3 | string): string {
|
|
30
|
+
return `(function () {
|
|
31
|
+
var version = ${JSON.stringify(version)};
|
|
32
|
+
function gap(name) {
|
|
33
|
+
return function () { throw new Error("Stripe.js (Volter twin): stripe." + name + " is not modelled by this twin yet"); };
|
|
34
|
+
}
|
|
35
|
+
function Stripe(publishableKey) {
|
|
36
|
+
if (typeof publishableKey !== "string" || !publishableKey) throw new Error("Stripe.js (Volter twin): Stripe() needs a publishable key");
|
|
37
|
+
var known = {
|
|
38
|
+
_registerWrapper: function () {},
|
|
39
|
+
redirectToCheckout: function (options) {
|
|
40
|
+
if (!options || typeof options.sessionId !== "string" || !options.sessionId) return gap("redirectToCheckout without a sessionId")();
|
|
41
|
+
window.location.assign("https://checkout.stripe.com/c/pay/" + encodeURIComponent(options.sessionId));
|
|
42
|
+
return new Promise(function () {});
|
|
43
|
+
},
|
|
44
|
+
collectFinancialConnectionsAccounts: function (options) {
|
|
45
|
+
if (!options || typeof options.clientSecret !== "string" || !options.clientSecret) return gap("collectFinancialConnectionsAccounts without a clientSecret")();
|
|
46
|
+
window.location.assign("https://js.stripe.com/v3/financial-connections/" + encodeURIComponent(options.clientSecret));
|
|
47
|
+
return new Promise(function () {});
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
return new Proxy(known, {
|
|
51
|
+
get: function (target, name) {
|
|
52
|
+
if (name in target) return target[name];
|
|
53
|
+
if (typeof name === "symbol" || name === "then") return undefined;
|
|
54
|
+
return gap(String(name));
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
Stripe.version = version;
|
|
59
|
+
window.Stripe = Stripe;
|
|
60
|
+
})();
|
|
61
|
+
`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** js.stripe.com's script for a GET of its path, or undefined for any other request. */
|
|
65
|
+
export function stripeJs(request: Request): Response | undefined {
|
|
66
|
+
if (request.method !== 'GET') return undefined;
|
|
67
|
+
const train = stripeJsTrain(new URL(request.url).pathname);
|
|
68
|
+
if (train === undefined) return undefined;
|
|
69
|
+
return new Response(script(train), { headers: { 'content-type': 'application/javascript; charset=utf-8', 'access-control-allow-origin': '*' } });
|
|
70
|
+
}
|