@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.
Files changed (219) hide show
  1. package/README.md +64 -27
  2. package/client/dashboard-api.ts +286 -0
  3. package/client/stripe-mirror.css +272 -159
  4. package/client/stripe-mirror.tsx +1384 -541
  5. package/dist/client/dashboard-api.d.ts +107 -0
  6. package/dist/client/dashboard-api.js +238 -0
  7. package/dist/client/dashboard-api.ts +286 -0
  8. package/dist/client/stripe-mirror.bundle.js +236 -0
  9. package/dist/client/stripe-mirror.css +275 -0
  10. package/dist/client/stripe-mirror.d.ts +134 -0
  11. package/dist/client/stripe-mirror.js +823 -0
  12. package/dist/client/stripe-mirror.tsx +1534 -0
  13. package/dist/src/cli.d.ts +2 -0
  14. package/dist/src/cli.js +39 -0
  15. package/dist/src/generated/events.gen.json +1 -0
  16. package/dist/src/generated/surface.gen.json +1 -0
  17. package/dist/src/generated/ui.gen.json +1 -0
  18. package/dist/src/index.d.ts +14 -0
  19. package/dist/src/index.js +73 -0
  20. package/dist/src/manifest.d.ts +2 -0
  21. package/dist/src/manifest.js +1065 -0
  22. package/dist/src/screens/checkout.d.ts +31 -0
  23. package/dist/src/screens/checkout.js +241 -0
  24. package/dist/src/screens/consent-skin.d.ts +4 -0
  25. package/dist/src/screens/consent-skin.js +18 -0
  26. package/dist/src/screens/financial-connections.d.ts +5 -0
  27. package/dist/src/screens/financial-connections.js +90 -0
  28. package/dist/src/screens/identity.d.ts +5 -0
  29. package/dist/src/screens/identity.js +86 -0
  30. package/dist/src/screens/industries.d.ts +1 -0
  31. package/dist/src/screens/industries.js +267 -0
  32. package/dist/src/screens/onboarding.d.ts +13 -0
  33. package/dist/src/screens/onboarding.js +225 -0
  34. package/dist/src/screens/portal.d.ts +5 -0
  35. package/dist/src/screens/portal.js +214 -0
  36. package/dist/src/screens/public-details.d.ts +5 -0
  37. package/dist/src/screens/public-details.js +90 -0
  38. package/dist/src/semantics/after-payment.d.ts +22 -0
  39. package/dist/src/semantics/after-payment.js +93 -0
  40. package/dist/src/semantics/apps-secrets.d.ts +2 -0
  41. package/dist/src/semantics/apps-secrets.js +54 -0
  42. package/dist/src/semantics/balance.d.ts +11 -0
  43. package/dist/src/semantics/balance.js +195 -0
  44. package/dist/src/semantics/billing.d.ts +2 -0
  45. package/dist/src/semantics/billing.js +220 -0
  46. package/dist/src/semantics/charges.d.ts +28 -0
  47. package/dist/src/semantics/charges.js +201 -0
  48. package/dist/src/semantics/checkout.d.ts +15 -0
  49. package/dist/src/semantics/checkout.js +303 -0
  50. package/dist/src/semantics/connect.d.ts +5 -0
  51. package/dist/src/semantics/connect.js +476 -0
  52. package/dist/src/semantics/coupons.d.ts +6 -0
  53. package/dist/src/semantics/coupons.js +92 -0
  54. package/dist/src/semantics/credit-notes.d.ts +2 -0
  55. package/dist/src/semantics/credit-notes.js +172 -0
  56. package/dist/src/semantics/customers.d.ts +6 -0
  57. package/dist/src/semantics/customers.js +429 -0
  58. package/dist/src/semantics/disputes.d.ts +2 -0
  59. package/dist/src/semantics/disputes.js +51 -0
  60. package/dist/src/semantics/entitlements.d.ts +2 -0
  61. package/dist/src/semantics/entitlements.js +95 -0
  62. package/dist/src/semantics/ephemeral-keys.d.ts +2 -0
  63. package/dist/src/semantics/ephemeral-keys.js +34 -0
  64. package/dist/src/semantics/files.d.ts +2 -0
  65. package/dist/src/semantics/files.js +125 -0
  66. package/dist/src/semantics/invoices.d.ts +18 -0
  67. package/dist/src/semantics/invoices.js +541 -0
  68. package/dist/src/semantics/issuing.d.ts +13 -0
  69. package/dist/src/semantics/issuing.js +570 -0
  70. package/dist/src/semantics/ledger.d.ts +54 -0
  71. package/dist/src/semantics/ledger.js +181 -0
  72. package/dist/src/semantics/payment-intents.d.ts +18 -0
  73. package/dist/src/semantics/payment-intents.js +404 -0
  74. package/dist/src/semantics/payment-links.d.ts +2 -0
  75. package/dist/src/semantics/payment-links.js +133 -0
  76. package/dist/src/semantics/payment-methods.d.ts +20 -0
  77. package/dist/src/semantics/payment-methods.js +138 -0
  78. package/dist/src/semantics/plans.d.ts +5 -0
  79. package/dist/src/semantics/plans.js +121 -0
  80. package/dist/src/semantics/platform.d.ts +9 -0
  81. package/dist/src/semantics/platform.js +206 -0
  82. package/dist/src/semantics/products.d.ts +2 -0
  83. package/dist/src/semantics/products.js +140 -0
  84. package/dist/src/semantics/radar.d.ts +2 -0
  85. package/dist/src/semantics/radar.js +83 -0
  86. package/dist/src/semantics/refunds.d.ts +9 -0
  87. package/dist/src/semantics/refunds.js +195 -0
  88. package/dist/src/semantics/renewals.d.ts +47 -0
  89. package/dist/src/semantics/renewals.js +251 -0
  90. package/dist/src/semantics/setup-intents.d.ts +2 -0
  91. package/dist/src/semantics/setup-intents.js +84 -0
  92. package/dist/src/semantics/shared.d.ts +78 -0
  93. package/dist/src/semantics/shared.js +192 -0
  94. package/dist/src/semantics/subscription-schedules.d.ts +2 -0
  95. package/dist/src/semantics/subscription-schedules.js +119 -0
  96. package/dist/src/semantics/subscriptions.d.ts +11 -0
  97. package/dist/src/semantics/subscriptions.js +605 -0
  98. package/dist/src/semantics/tax.d.ts +2 -0
  99. package/dist/src/semantics/tax.js +197 -0
  100. package/dist/src/semantics/terminal.d.ts +5 -0
  101. package/dist/src/semantics/terminal.js +182 -0
  102. package/dist/src/semantics/test-clocks.d.ts +6 -0
  103. package/dist/src/semantics/test-clocks.js +73 -0
  104. package/dist/src/semantics/tokens.d.ts +4 -0
  105. package/dist/src/semantics/tokens.js +44 -0
  106. package/dist/src/semantics/transfers.d.ts +2 -0
  107. package/dist/src/semantics/transfers.js +154 -0
  108. package/dist/src/semantics/treasury.d.ts +2 -0
  109. package/dist/src/semantics/treasury.js +377 -0
  110. package/dist/src/semantics/webhook-endpoints.d.ts +3 -0
  111. package/dist/src/semantics/webhook-endpoints.js +85 -0
  112. package/dist/src/stripe-budget.d.ts +55 -0
  113. package/dist/src/stripe-budget.js +155 -0
  114. package/dist/src/stripe-capabilities.d.ts +3 -0
  115. package/dist/src/stripe-capabilities.js +5052 -0
  116. package/dist/src/stripe-conformance.d.ts +41 -0
  117. package/dist/src/stripe-conformance.js +96 -0
  118. package/dist/src/stripe-connector.d.ts +161 -0
  119. package/dist/src/stripe-connector.js +414 -0
  120. package/dist/src/stripe-emit.d.ts +2 -0
  121. package/dist/src/stripe-emit.js +145 -0
  122. package/dist/src/stripe-events.d.ts +93 -0
  123. package/dist/src/stripe-events.js +388 -0
  124. package/dist/src/stripe-js.d.ts +4 -0
  125. package/dist/src/stripe-js.js +70 -0
  126. package/dist/src/stripe-mirror-ui.d.ts +15 -0
  127. package/dist/src/stripe-mirror-ui.js +87 -0
  128. package/dist/src/stripe-params.d.ts +3 -0
  129. package/dist/src/stripe-params.js +43 -0
  130. package/dist/src/stripe-perform-harness.d.ts +9 -0
  131. package/dist/src/stripe-perform-harness.js +26 -0
  132. package/dist/src/stripe-server.d.ts +33 -0
  133. package/dist/src/stripe-server.js +326 -0
  134. package/dist/src/stripe-shared.d.ts +106 -0
  135. package/dist/src/stripe-shared.js +273 -0
  136. package/dist/src/stripe-twin.d.ts +155 -0
  137. package/dist/src/stripe-twin.js +1226 -0
  138. package/dist/src/stripe-ui-conformance.d.ts +5 -0
  139. package/dist/src/stripe-ui-conformance.js +79 -0
  140. package/dist/src/stripe-ui-structure.d.ts +3 -0
  141. package/dist/src/stripe-ui-structure.js +168 -0
  142. package/dist/src/stripe-version.d.ts +10 -0
  143. package/dist/src/stripe-version.js +285 -0
  144. package/dist/test-fixtures/stripe-known-deviations.json +105 -0
  145. package/dist/test-fixtures/stripe-openapi-operations.SOURCE.md +14 -0
  146. package/dist/test-fixtures/stripe-openapi-operations.json +4717 -0
  147. package/dist/test-fixtures/stripe-schemas.SOURCE.md +35 -0
  148. package/dist/test-fixtures/stripe-schemas.json +3740 -0
  149. package/package.json +18 -10
  150. package/src/cli.ts +7 -7
  151. package/src/generated/events.gen.json +1 -0
  152. package/src/generated/surface.gen.json +1 -0
  153. package/src/generated/ui.gen.json +1 -0
  154. package/src/index.ts +31 -9
  155. package/src/manifest.ts +1097 -0
  156. package/src/screens/checkout.tsx +252 -0
  157. package/src/screens/consent-skin.ts +20 -0
  158. package/src/screens/financial-connections.tsx +101 -0
  159. package/src/screens/identity.tsx +96 -0
  160. package/src/screens/industries.ts +267 -0
  161. package/src/screens/onboarding.tsx +243 -0
  162. package/src/screens/portal.tsx +218 -0
  163. package/src/screens/public-details.tsx +105 -0
  164. package/src/semantics/after-payment.ts +113 -0
  165. package/src/semantics/apps-secrets.ts +58 -0
  166. package/src/semantics/balance.ts +209 -0
  167. package/src/semantics/billing.ts +216 -0
  168. package/src/semantics/charges.ts +211 -0
  169. package/src/semantics/checkout.ts +297 -0
  170. package/src/semantics/connect.ts +471 -0
  171. package/src/semantics/coupons.ts +97 -0
  172. package/src/semantics/credit-notes.ts +168 -0
  173. package/src/semantics/customers.ts +432 -0
  174. package/src/semantics/disputes.ts +62 -0
  175. package/src/semantics/entitlements.ts +94 -0
  176. package/src/semantics/ephemeral-keys.ts +34 -0
  177. package/src/semantics/files.ts +143 -0
  178. package/src/semantics/invoices.ts +541 -0
  179. package/src/semantics/issuing.ts +585 -0
  180. package/src/semantics/ledger.ts +216 -0
  181. package/src/semantics/payment-intents.ts +420 -0
  182. package/src/semantics/payment-links.ts +148 -0
  183. package/src/semantics/payment-methods.ts +143 -0
  184. package/src/semantics/plans.ts +131 -0
  185. package/src/semantics/platform.ts +220 -0
  186. package/src/semantics/products.ts +154 -0
  187. package/src/semantics/radar.ts +85 -0
  188. package/src/semantics/refunds.ts +218 -0
  189. package/src/semantics/renewals.ts +274 -0
  190. package/src/semantics/setup-intents.ts +87 -0
  191. package/src/semantics/shared.ts +215 -0
  192. package/src/semantics/subscription-schedules.ts +129 -0
  193. package/src/semantics/subscriptions.ts +610 -0
  194. package/src/semantics/tax.ts +220 -0
  195. package/src/semantics/terminal.ts +195 -0
  196. package/src/semantics/test-clocks.ts +77 -0
  197. package/src/semantics/tokens.ts +52 -0
  198. package/src/semantics/transfers.ts +174 -0
  199. package/src/semantics/treasury.ts +383 -0
  200. package/src/semantics/webhook-endpoints.ts +87 -0
  201. package/src/stripe-budget.ts +4 -4
  202. package/src/stripe-capabilities.ts +1456 -222
  203. package/src/stripe-conformance.ts +6 -5
  204. package/src/stripe-connector.ts +68 -40
  205. package/src/stripe-emit.ts +14 -7
  206. package/src/stripe-events.ts +94 -36
  207. package/src/stripe-js.ts +70 -0
  208. package/src/stripe-mirror-ui.ts +28 -298
  209. package/src/stripe-params.ts +44 -0
  210. package/src/stripe-perform-harness.ts +29 -0
  211. package/src/stripe-server.ts +263 -38
  212. package/src/stripe-shared.ts +294 -0
  213. package/src/stripe-twin.ts +429 -5325
  214. package/src/stripe-ui-conformance.ts +70 -107
  215. package/src/stripe-ui-structure.ts +124 -348
  216. package/src/stripe-version.ts +278 -0
  217. package/test-fixtures/stripe-known-deviations.json +2 -7
  218. package/test-fixtures/stripe-openapi-operations.json +1188 -2855
  219. 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 { twinResources } from '@volter/twin';
14
- import { specConformance } from '@volter/twin-tooling';
15
- import type { TwinResource } from '@volter/twin';
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 Bun.file(fixturePath('stripe-schemas.json')).text()) as StripeSchemas;
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 Bun.file(fixturePath('stripe-known-deviations.json')).text()) as { deviations: KnownDeviation[] };
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
 
@@ -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 event log via syncPull (shadow-diff dedup, so a
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 confirmAction on success — which records the confirmed
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, confirmAction, pendingActions, syncPull } from '@volter/twin';
20
- import type { SyncResource, TwinAction } from '@volter/twin';
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
- budget.recordCall(weight, resHeaders, { status: res.status, reservation });
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). syncPull's shadow-diff makes a re-pull of identical state a no-op.
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 = syncPull({ service: SERVICE, resources, occurredAt: opts.occurredAt, ...(opts.root !== undefined ? { root: opts.root } : {}) });
256
- return { observed: result.observed, deltasAppended: result.deltasAppended };
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
- * Push the twin's PENDING local actions to real Stripe and CONFIRM each (R18): for
361
- * every pending `set` action, call the real API; on success, `confirmAction` records
362
- * the confirmed fields as an observed event and maps action → event (suppressing the
363
- * local projection). Idempotency: a confirmed action is no longer pending, so a
364
- * re-push enacts NOTHING — the executor is never called twice for the same change.
365
- * `execute` is injected (fake offline / live key).
366
- */
367
- export async function pushPendingStripeActions(
368
- execute: StripeExecute,
369
- opts: { root?: string; occurredAt: string },
370
- ): Promise<{ pushed: number; confirmed: string[]; externalIds: Record<string, string> }> {
371
- const confirmed: string[] = [];
372
- const externalIds: Record<string, string> = {};
373
- for (const action of pendingActions(SERVICE, opts.root)) {
374
- // Twin-internal subject types (the recorded Stripe `event` envelopes the write path
375
- // persists for the Events API, and idempotency bookkeeping) are NOT real-Stripe writes —
376
- // they are local-only state and must never be pushed. Skip them (not an error).
377
- if (INTERNAL_SUBJECT_TYPES.has(action.subject.type)) continue;
378
- const { externalId } = await pushStripeAction(execute, action);
379
- confirmAction({ service: SERVICE, actionId: action.id, subject: action.subject, fields: action.fields ?? {}, occurredAt: opts.occurredAt, ...(opts.root !== undefined ? { root: opts.root } : {}) });
380
- confirmed.push(action.id);
381
- externalIds[action.id] = externalId;
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 { pushed: confirmed.length, confirmed, externalIds };
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<{ pushed: number; observed: number; deltasAppended: number; collections: number }> {
425
- // 1. PUSH pending local changes to real Stripe (and confirm each).
426
- const push = await pushPendingStripeActions(execute, { occurredAt: opts.occurredAt, ...(opts.root !== undefined ? { root: opts.root } : {}) });
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 = syncPull({ service: SERVICE, resources, occurredAt: opts.occurredAt, ...(opts.root !== undefined ? { root: opts.root } : {}) });
433
- return { pushed: push.pushed, observed: pull.observed, deltasAppended: pull.deltasAppended, collections: PULL_COLLECTIONS.length + 1 };
460
+ const pull = fold(resources, opts);
461
+ return { observed: pull.observed, deltasAppended: pull.appended, collections: PULL_COLLECTIONS.length + 1 };
434
462
  }
@@ -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/twin emit.ts): the
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/twin';
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._deleted !== true);
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._deleted !== true).map((r) => String(r.id));
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._deleted !== true);
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,
@@ -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 require('node:crypto') as NodeCrypto;
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
- // Endpoint signing secrets by URL. POST /v1/webhook_endpoints mints a `whsec_twin_*`
106
- // secret on the WebhookEndpoint object and registers it here so live HTTP delivery
107
- // signs each POST with THAT endpoint's secret (real Stripe signs per-endpoint). A
108
- // URL registered without a secret (test seam) is signed with the fallback below so
109
- // the header is still a real, verifiable signature — never a placeholder.
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 'charge.refunded';
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 null;
218
+ default: return vendorEventType(operation);
206
219
  }
207
220
  }
208
221
 
209
- let counter = 0;
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
- const payload = JSON.stringify(event);
231
- const secret = secrets.get(url) ?? STRIPE_WEBHOOK_FALLBACK_SECRET;
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
- let defaultDelivery: StripeEventDelivery | null = null;
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
- const payload = JSON.stringify(event);
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
- const name = e instanceof Error ? e.name : '';
310
- if (name === 'TimeoutError' || name === 'AbortError') {
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
- if (!type || registry.length === 0) return [];
355
- const deliver = opts.deliver ?? defaultDelivery ?? httpDelivery;
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: `evt_twin_${++counter}`,
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 url of registry) {
366
- const enabled = subscriptions.get(url);
367
- if (enabled && !stripeEventMatches(enabled, type)) continue;
368
- await deliver(url, event);
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;
@@ -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
+ }