@volter/twin-stripe 0.1.2 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (229) hide show
  1. package/README.md +96 -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 +75 -0
  20. package/dist/src/manifest.d.ts +2 -0
  21. package/dist/src/manifest.js +1070 -0
  22. package/dist/src/screens/checkout.d.ts +31 -0
  23. package/dist/src/screens/checkout.js +255 -0
  24. package/dist/src/screens/connect-oauth.d.ts +27 -0
  25. package/dist/src/screens/connect-oauth.js +414 -0
  26. package/dist/src/screens/connect-settings.d.ts +22 -0
  27. package/dist/src/screens/connect-settings.js +103 -0
  28. package/dist/src/screens/consent-skin.d.ts +4 -0
  29. package/dist/src/screens/consent-skin.js +18 -0
  30. package/dist/src/screens/financial-connections.d.ts +5 -0
  31. package/dist/src/screens/financial-connections.js +90 -0
  32. package/dist/src/screens/identity.d.ts +5 -0
  33. package/dist/src/screens/identity.js +86 -0
  34. package/dist/src/screens/industries.d.ts +1 -0
  35. package/dist/src/screens/industries.js +267 -0
  36. package/dist/src/screens/onboarding.d.ts +13 -0
  37. package/dist/src/screens/onboarding.js +225 -0
  38. package/dist/src/screens/portal.d.ts +5 -0
  39. package/dist/src/screens/portal.js +216 -0
  40. package/dist/src/screens/public-details.d.ts +5 -0
  41. package/dist/src/screens/public-details.js +90 -0
  42. package/dist/src/semantics/after-payment.d.ts +22 -0
  43. package/dist/src/semantics/after-payment.js +99 -0
  44. package/dist/src/semantics/apps-secrets.d.ts +2 -0
  45. package/dist/src/semantics/apps-secrets.js +54 -0
  46. package/dist/src/semantics/balance.d.ts +11 -0
  47. package/dist/src/semantics/balance.js +195 -0
  48. package/dist/src/semantics/billing.d.ts +2 -0
  49. package/dist/src/semantics/billing.js +220 -0
  50. package/dist/src/semantics/charges.d.ts +28 -0
  51. package/dist/src/semantics/charges.js +209 -0
  52. package/dist/src/semantics/checkout.d.ts +15 -0
  53. package/dist/src/semantics/checkout.js +316 -0
  54. package/dist/src/semantics/connect.d.ts +5 -0
  55. package/dist/src/semantics/connect.js +493 -0
  56. package/dist/src/semantics/coupons.d.ts +6 -0
  57. package/dist/src/semantics/coupons.js +92 -0
  58. package/dist/src/semantics/credit-notes.d.ts +2 -0
  59. package/dist/src/semantics/credit-notes.js +172 -0
  60. package/dist/src/semantics/customers.d.ts +6 -0
  61. package/dist/src/semantics/customers.js +429 -0
  62. package/dist/src/semantics/disputes.d.ts +2 -0
  63. package/dist/src/semantics/disputes.js +51 -0
  64. package/dist/src/semantics/entitlements.d.ts +2 -0
  65. package/dist/src/semantics/entitlements.js +95 -0
  66. package/dist/src/semantics/ephemeral-keys.d.ts +2 -0
  67. package/dist/src/semantics/ephemeral-keys.js +34 -0
  68. package/dist/src/semantics/files.d.ts +2 -0
  69. package/dist/src/semantics/files.js +125 -0
  70. package/dist/src/semantics/invoices.d.ts +18 -0
  71. package/dist/src/semantics/invoices.js +545 -0
  72. package/dist/src/semantics/issuing.d.ts +13 -0
  73. package/dist/src/semantics/issuing.js +575 -0
  74. package/dist/src/semantics/ledger.d.ts +59 -0
  75. package/dist/src/semantics/ledger.js +200 -0
  76. package/dist/src/semantics/payment-intents.d.ts +18 -0
  77. package/dist/src/semantics/payment-intents.js +404 -0
  78. package/dist/src/semantics/payment-links.d.ts +2 -0
  79. package/dist/src/semantics/payment-links.js +133 -0
  80. package/dist/src/semantics/payment-methods.d.ts +20 -0
  81. package/dist/src/semantics/payment-methods.js +140 -0
  82. package/dist/src/semantics/plans.d.ts +5 -0
  83. package/dist/src/semantics/plans.js +121 -0
  84. package/dist/src/semantics/platform.d.ts +9 -0
  85. package/dist/src/semantics/platform.js +206 -0
  86. package/dist/src/semantics/products.d.ts +2 -0
  87. package/dist/src/semantics/products.js +140 -0
  88. package/dist/src/semantics/radar.d.ts +2 -0
  89. package/dist/src/semantics/radar.js +83 -0
  90. package/dist/src/semantics/refunds.d.ts +9 -0
  91. package/dist/src/semantics/refunds.js +195 -0
  92. package/dist/src/semantics/renewals.d.ts +47 -0
  93. package/dist/src/semantics/renewals.js +251 -0
  94. package/dist/src/semantics/setup-intents.d.ts +2 -0
  95. package/dist/src/semantics/setup-intents.js +84 -0
  96. package/dist/src/semantics/shared.d.ts +82 -0
  97. package/dist/src/semantics/shared.js +203 -0
  98. package/dist/src/semantics/subscription-schedules.d.ts +2 -0
  99. package/dist/src/semantics/subscription-schedules.js +119 -0
  100. package/dist/src/semantics/subscriptions.d.ts +11 -0
  101. package/dist/src/semantics/subscriptions.js +605 -0
  102. package/dist/src/semantics/tax.d.ts +2 -0
  103. package/dist/src/semantics/tax.js +197 -0
  104. package/dist/src/semantics/terminal.d.ts +5 -0
  105. package/dist/src/semantics/terminal.js +182 -0
  106. package/dist/src/semantics/test-cards.d.ts +4 -0
  107. package/dist/src/semantics/test-cards.js +7 -0
  108. package/dist/src/semantics/test-clocks.d.ts +6 -0
  109. package/dist/src/semantics/test-clocks.js +73 -0
  110. package/dist/src/semantics/tokens.d.ts +4 -0
  111. package/dist/src/semantics/tokens.js +44 -0
  112. package/dist/src/semantics/transfers.d.ts +2 -0
  113. package/dist/src/semantics/transfers.js +154 -0
  114. package/dist/src/semantics/treasury.d.ts +2 -0
  115. package/dist/src/semantics/treasury.js +377 -0
  116. package/dist/src/semantics/webhook-endpoints.d.ts +3 -0
  117. package/dist/src/semantics/webhook-endpoints.js +85 -0
  118. package/dist/src/stripe-budget.d.ts +55 -0
  119. package/dist/src/stripe-budget.js +155 -0
  120. package/dist/src/stripe-capabilities.d.ts +3 -0
  121. package/dist/src/stripe-capabilities.js +5695 -0
  122. package/dist/src/stripe-conformance.d.ts +43 -0
  123. package/dist/src/stripe-conformance.js +105 -0
  124. package/dist/src/stripe-connector.d.ts +161 -0
  125. package/dist/src/stripe-connector.js +414 -0
  126. package/dist/src/stripe-emit.d.ts +2 -0
  127. package/dist/src/stripe-emit.js +145 -0
  128. package/dist/src/stripe-events.d.ts +93 -0
  129. package/dist/src/stripe-events.js +392 -0
  130. package/dist/src/stripe-js.d.ts +4 -0
  131. package/dist/src/stripe-js.js +70 -0
  132. package/dist/src/stripe-mirror-ui.d.ts +15 -0
  133. package/dist/src/stripe-mirror-ui.js +87 -0
  134. package/dist/src/stripe-params.d.ts +3 -0
  135. package/dist/src/stripe-params.js +43 -0
  136. package/dist/src/stripe-perform-harness.d.ts +9 -0
  137. package/dist/src/stripe-perform-harness.js +26 -0
  138. package/dist/src/stripe-server.d.ts +33 -0
  139. package/dist/src/stripe-server.js +393 -0
  140. package/dist/src/stripe-shared.d.ts +109 -0
  141. package/dist/src/stripe-shared.js +276 -0
  142. package/dist/src/stripe-twin.d.ts +155 -0
  143. package/dist/src/stripe-twin.js +1232 -0
  144. package/dist/src/stripe-ui-conformance.d.ts +5 -0
  145. package/dist/src/stripe-ui-conformance.js +79 -0
  146. package/dist/src/stripe-ui-structure.d.ts +3 -0
  147. package/dist/src/stripe-ui-structure.js +168 -0
  148. package/dist/src/stripe-version.d.ts +12 -0
  149. package/dist/src/stripe-version.js +287 -0
  150. package/dist/test-fixtures/stripe-known-deviations.json +110 -0
  151. package/dist/test-fixtures/stripe-openapi-operations.SOURCE.md +14 -0
  152. package/dist/test-fixtures/stripe-openapi-operations.json +4717 -0
  153. package/dist/test-fixtures/stripe-schemas.SOURCE.md +35 -0
  154. package/dist/test-fixtures/stripe-schemas.json +3813 -0
  155. package/package.json +18 -10
  156. package/src/cli.ts +7 -7
  157. package/src/generated/events.gen.json +1 -0
  158. package/src/generated/surface.gen.json +1 -0
  159. package/src/generated/ui.gen.json +1 -0
  160. package/src/index.ts +34 -10
  161. package/src/manifest.ts +1102 -0
  162. package/src/screens/checkout.tsx +267 -0
  163. package/src/screens/connect-oauth.tsx +400 -0
  164. package/src/screens/connect-settings.tsx +121 -0
  165. package/src/screens/consent-skin.ts +20 -0
  166. package/src/screens/financial-connections.tsx +101 -0
  167. package/src/screens/identity.tsx +96 -0
  168. package/src/screens/industries.ts +267 -0
  169. package/src/screens/onboarding.tsx +243 -0
  170. package/src/screens/portal.tsx +220 -0
  171. package/src/screens/public-details.tsx +105 -0
  172. package/src/semantics/after-payment.ts +118 -0
  173. package/src/semantics/apps-secrets.ts +58 -0
  174. package/src/semantics/balance.ts +209 -0
  175. package/src/semantics/billing.ts +216 -0
  176. package/src/semantics/charges.ts +220 -0
  177. package/src/semantics/checkout.ts +310 -0
  178. package/src/semantics/connect.ts +487 -0
  179. package/src/semantics/coupons.ts +97 -0
  180. package/src/semantics/credit-notes.ts +168 -0
  181. package/src/semantics/customers.ts +432 -0
  182. package/src/semantics/disputes.ts +62 -0
  183. package/src/semantics/entitlements.ts +94 -0
  184. package/src/semantics/ephemeral-keys.ts +34 -0
  185. package/src/semantics/files.ts +143 -0
  186. package/src/semantics/invoices.ts +545 -0
  187. package/src/semantics/issuing.ts +590 -0
  188. package/src/semantics/ledger.ts +253 -0
  189. package/src/semantics/payment-intents.ts +420 -0
  190. package/src/semantics/payment-links.ts +148 -0
  191. package/src/semantics/payment-methods.ts +145 -0
  192. package/src/semantics/plans.ts +131 -0
  193. package/src/semantics/platform.ts +220 -0
  194. package/src/semantics/products.ts +154 -0
  195. package/src/semantics/radar.ts +85 -0
  196. package/src/semantics/refunds.ts +218 -0
  197. package/src/semantics/renewals.ts +274 -0
  198. package/src/semantics/setup-intents.ts +87 -0
  199. package/src/semantics/shared.ts +226 -0
  200. package/src/semantics/subscription-schedules.ts +129 -0
  201. package/src/semantics/subscriptions.ts +610 -0
  202. package/src/semantics/tax.ts +220 -0
  203. package/src/semantics/terminal.ts +195 -0
  204. package/src/semantics/test-cards.ts +7 -0
  205. package/src/semantics/test-clocks.ts +77 -0
  206. package/src/semantics/tokens.ts +52 -0
  207. package/src/semantics/transfers.ts +174 -0
  208. package/src/semantics/treasury.ts +383 -0
  209. package/src/semantics/webhook-endpoints.ts +87 -0
  210. package/src/stripe-budget.ts +4 -4
  211. package/src/stripe-capabilities.ts +2258 -380
  212. package/src/stripe-conformance.ts +19 -7
  213. package/src/stripe-connector.ts +68 -40
  214. package/src/stripe-emit.ts +15 -8
  215. package/src/stripe-events.ts +102 -40
  216. package/src/stripe-js.ts +70 -0
  217. package/src/stripe-mirror-ui.ts +28 -298
  218. package/src/stripe-params.ts +44 -0
  219. package/src/stripe-perform-harness.ts +29 -0
  220. package/src/stripe-server.ts +318 -38
  221. package/src/stripe-shared.ts +297 -0
  222. package/src/stripe-twin.ts +434 -5325
  223. package/src/stripe-ui-conformance.ts +70 -107
  224. package/src/stripe-ui-structure.ts +124 -348
  225. package/src/stripe-version.ts +281 -0
  226. package/test-fixtures/stripe-known-deviations.json +8 -8
  227. package/test-fixtures/stripe-openapi-operations.json +1188 -2855
  228. package/test-fixtures/stripe-schemas.json +85 -12
  229. package/src/stripe-form.ts +0 -35
@@ -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
+ }
@@ -1,9 +1,11 @@
1
- // Stripe MIRROR UI (scorecard R13) — a Stripe-dashboard-like view served as a
2
- // React/TSX app (bundled by Bun, the repo convention; cf. tracker-visualizer).
3
- // It renders by consuming the twin's OWN REST API on the same origin
4
- // (/v1/<collection>) — the same endpoints the app uses. Per-vendor + concrete.
5
- // PURE FRONTEND (R3): the mirror imports no handler and no twin internals — it MOUNTS the pack's
6
- // own fetch adapter as its API backend and reads every byte of state back over the wire.
1
+ // The Stripe Dashboard (manifest screen `dashboard`, dashboard.stripe.com/test/{section}): a React app
2
+ // (Bun-bundled) served beside the twin. A PURE FRONTEND (runtime contract R3): this module is the shell, its
3
+ // assets and one listener that mounts the pack's OWN fetch adapter beside them. It imports no handler and no
4
+ // twin internals; the client reads and writes only through Stripe's API (`/v1/...`, client/dashboard-api.ts),
5
+ // so it renders a twin or a real account's test data unchanged, pointed at any origin by configuration.
6
+ import { readFile } from 'node:fs/promises';
7
+ import { bundleClient, fileResponse } from '@volter/world-core';
8
+ import { serveHttp } from '@volter/world-core';
7
9
  import { createStripeTwinFetch } from './stripe-server.ts';
8
10
 
9
11
  const CLIENT_ENTRY = () => new URL('../client/stripe-mirror.tsx', import.meta.url).pathname; // lazy: workerd rejects top-level relative URL from import.meta.url (bundled via pack index)
@@ -15,310 +17,32 @@ const CLIENT_CSS = () => new URL('../client/stripe-mirror.css', import.meta.url)
15
17
  // These are intentionally framework-agnostic (plain data in → plain data out) so
16
18
  // they can be unit-tested in isolation AND imported by the React/TSX client
17
19
  // (Bun tree-shakes the server-only exports below out of the browser bundle).
18
- // Keep them free of any `@volter/twin`/`Bun`/twin-adapter usage.
20
+ // Keep them free of any `@volter/world-core`/`Bun`/twin-adapter usage.
19
21
  // ---------------------------------------------------------------------------
20
22
 
21
- export type StripeRow = Record<string, any>;
22
-
23
- /** Zero-decimal currencies (Stripe stores these in whole units, not cents). */
24
- const ZERO_DECIMAL = new Set(['bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga', 'pyg', 'rwf', 'ugx', 'vnd', 'vuv', 'xaf', 'xof', 'xpf']);
25
-
26
- /**
27
- * Format a Stripe minor-unit amount (cents) as a localized currency string.
28
- * Honors zero-decimal currencies (¥4200 not ¥42.00). Non-numbers → an em dash.
29
- */
30
- export function formatStripeAmount(value: unknown, currency?: string): string {
31
- if (typeof value !== 'number' || !Number.isFinite(value)) return '—';
32
- const ccy = String(currency ?? 'usd').toLowerCase();
33
- const zero = ZERO_DECIMAL.has(ccy);
34
- const major = zero ? value : value / 100;
35
- try {
36
- return new Intl.NumberFormat('en-US', {
37
- style: 'currency', currency: ccy.toUpperCase(),
38
- minimumFractionDigits: zero ? 0 : 2, maximumFractionDigits: zero ? 0 : 2,
39
- }).format(major);
40
- } catch {
41
- // Unknown/invalid currency code → a stable, readable fallback.
42
- return `${zero ? major : major.toFixed(2)} ${ccy.toUpperCase()}`;
43
- }
44
- }
45
-
46
- /**
47
- * Render a Stripe price's `recurring` block as a short interval label, e.g.
48
- * "every month", "every 3 months". Returns '' when it isn't a recurring price.
49
- */
50
- export function formatRecurring(recurring: unknown): string {
51
- if (!recurring || typeof recurring !== 'object') return '';
52
- const r = recurring as Record<string, unknown>;
53
- const interval = typeof r.interval === 'string' ? r.interval : '';
54
- if (!interval) return '';
55
- const count = Number(r.interval_count);
56
- return count > 1 ? `every ${count} ${interval}s` : `every ${interval}`;
57
- }
58
-
59
- /**
60
- * Render a saved payment method as a short human label, e.g. "Visa •••• 4242"
61
- * for a card, or the bare `type` ("us_bank_account") for non-card methods.
62
- */
63
- export function formatPaymentMethod(pm: unknown): string {
64
- if (!pm || typeof pm !== 'object') return '—';
65
- const m = pm as Record<string, any>;
66
- const card = m.card && typeof m.card === 'object' ? (m.card as Record<string, any>) : undefined;
67
- if (card && (card.brand || card.last4)) {
68
- const brand = card.brand ? String(card.brand).replace(/\b\w/g, (c) => c.toUpperCase()) : 'Card';
69
- return card.last4 ? `${brand} •••• ${card.last4}` : brand;
70
- }
71
- return typeof m.type === 'string' && m.type ? m.type : (m.id ?? '—');
72
- }
73
-
74
- /**
75
- * Extract the HTTP(S) image URLs from a product's `images` field (Stripe stores an
76
- * array of URL strings) so the mirror can render them as <img> thumbnails instead
77
- * of plain text. Non-arrays / non-URL entries are dropped. Order is preserved.
78
- */
79
- export function productImageUrls(images: unknown): string[] {
80
- if (!Array.isArray(images)) return [];
81
- return images.filter((u): u is string => typeof u === 'string' && /^https?:\/\//i.test(u));
82
- }
83
-
84
- /**
85
- * Render a payment_intent / charge `last_payment_error` (the test-card decline state)
86
- * as a single human-readable line, e.g. "card_declined (insufficient_funds): Your card
87
- * has insufficient funds." Returns '' when there is no error object. This is the
88
- * vendor-faithful decline reason the twin populates on a declined confirm.
89
- */
90
- export function formatPaymentError(error: unknown): string {
91
- if (!error || typeof error !== 'object') return '';
92
- const e = error as Record<string, any>;
93
- const code = typeof e.code === 'string' ? e.code : '';
94
- const declineCode = typeof e.decline_code === 'string' ? e.decline_code : '';
95
- const message = typeof e.message === 'string' ? e.message : '';
96
- const head = code ? (declineCode ? `${code} (${declineCode})` : code) : declineCode;
97
- if (head && message) return `${head}: ${message}`;
98
- return head || message;
99
- }
100
-
101
- /**
102
- * Summarize a synthesized Stripe `balance` object (available/pending arrays, one entry
103
- * per currency) into short per-bucket lines, e.g. ["available: $42.00", "pending: $0.00"].
104
- * The balance is not a list collection, so the mirror renders this summary directly.
105
- */
106
- export function formatBalanceSummary(balance: unknown): Array<{ bucket: string; text: string }> {
107
- if (!balance || typeof balance !== 'object') return [];
108
- const b = balance as Record<string, any>;
109
- const sum = (arr: unknown): string => {
110
- if (!Array.isArray(arr) || arr.length === 0) return formatStripeAmount(0, 'usd');
111
- return arr
112
- .map((e) => formatStripeAmount((e as Record<string, any>)?.amount, (e as Record<string, any>)?.currency))
113
- .join(', ');
114
- };
115
- const out: Array<{ bucket: string; text: string }> = [];
116
- if (Array.isArray(b.available)) out.push({ bucket: 'available', text: sum(b.available) });
117
- if (Array.isArray(b.pending)) out.push({ bucket: 'pending', text: sum(b.pending) });
118
- return out;
119
- }
120
-
121
- /**
122
- * Summarize a Connect connected `account` object's enablement state into the three
123
- * boolean capability flags a real Stripe Connect dashboard shows up front:
124
- * charges_enabled, payouts_enabled, details_submitted. Each is rendered as a tone-
125
- * carrying flag (true → ok, false → warn) so an un-onboarded account reads as such.
126
- */
127
- export type AccountFlag = { key: string; label: string; enabled: boolean };
128
- export function formatAccountFlags(account: unknown): AccountFlag[] {
129
- if (!account || typeof account !== 'object') return [];
130
- const a = account as Record<string, any>;
131
- return [
132
- { key: 'charges_enabled', label: 'Charges', enabled: a.charges_enabled === true },
133
- { key: 'payouts_enabled', label: 'Payouts', enabled: a.payouts_enabled === true },
134
- { key: 'details_submitted', label: 'Details submitted', enabled: a.details_submitted === true },
135
- ];
136
- }
137
-
138
- /**
139
- * Extract a Connect account's outstanding onboarding requirements (the
140
- * `requirements.currently_due` list real Stripe shows as "needs attention"). Returns
141
- * an ordered list of the still-due field paths; empty when nothing is due.
142
- */
143
- export function accountCurrentlyDue(account: unknown): string[] {
144
- if (!account || typeof account !== 'object') return [];
145
- const req = (account as Record<string, any>).requirements;
146
- if (!req || typeof req !== 'object') return [];
147
- const due = (req as Record<string, any>).currently_due;
148
- return Array.isArray(due) ? due.filter((d): d is string => typeof d === 'string') : [];
149
- }
150
-
151
- /** Tone for a status pill: 'ok' (green), 'warn' (amber), 'bad' (red), '' (neutral). */
152
- export type PillTone = 'ok' | 'warn' | 'bad' | '';
153
- const PILL_OK = new Set(['active', 'succeeded', 'paid', 'true', 'enabled', 'available', 'won']);
154
- const PILL_WARN = new Set(['open', 'draft', 'pending', 'processing', 'incomplete', 'trialing', 'requires_confirmation', 'requires_action', 'requires_capture', 'requires_payment_method', 'past_due', 'unpaid', 'in_transit', 'warning_needs_response', 'needs_response', 'under_review', 'warning_under_review']);
155
- const PILL_BAD = new Set(['canceled', 'cancelled', 'void', 'uncollectible', 'failed', 'incomplete_expired', 'false', 'disabled', 'lost', 'charge_refunded']);
156
- export function statusTone(value: unknown): PillTone {
157
- const v = String(value ?? '').toLowerCase();
158
- if (PILL_OK.has(v)) return 'ok';
159
- if (PILL_BAD.has(v)) return 'bad';
160
- if (PILL_WARN.has(v)) return 'warn';
161
- return '';
162
- }
163
-
164
- /** True when a key names a field whose value is a Stripe object id we can link. */
165
- export function isReferenceKey(key: string): boolean {
166
- return REFERENCE_FIELDS.has(key);
167
- }
168
- // field name → the COLLECTION it points at (so the UI can jump sections + select).
169
- const REFERENCE_FIELD_MAP: Record<string, string> = {
170
- customer: 'customers', product: 'products', price: 'prices',
171
- subscription: 'subscriptions', latest_invoice: 'invoices', invoice: 'invoices',
172
- payment_intent: 'payment_intents', latest_charge: 'charges', charge: 'charges',
173
- payment_method: 'payment_methods', default_payment_method: 'payment_methods',
174
- dispute: 'disputes', payout: 'payouts', balance_transaction: 'balance_transactions',
175
- source_transaction: 'charges', setup_intent: 'setup_intents',
176
- // Connect: a transfer's `destination` points at a connected account.
177
- account: 'accounts', destination: 'accounts',
178
- // Billing: a customer_balance_transaction may reference the credit_note that created it.
179
- credit_note: 'credit_notes',
180
- };
181
- const REFERENCE_FIELDS = new Set(Object.keys(REFERENCE_FIELD_MAP));
182
- /** The collection a reference field points at, or undefined if not a reference. */
183
- export function referenceCollection(key: string): string | undefined {
184
- return REFERENCE_FIELD_MAP[key];
185
- }
186
-
187
- /** A single line in the flattened, human-readable view of a nested value. */
188
- export type FlatLine = { depth: number; label: string; value: string; ref?: string };
189
-
190
- /**
191
- * Flatten an arbitrary nested Stripe value (object / array / scalar) into ordered,
192
- * indented label/value lines suitable for a readable detail view — instead of the
193
- * old "[object Object]". Stripe "list" wrappers ({object:'list',data:[...]}) are
194
- * unwrapped to their `data`. Amount-ish fields are currency-formatted.
195
- */
196
- export function flattenStripeValue(value: unknown, opts: { label?: string; depth?: number; currency?: string } = {}): FlatLine[] {
197
- const depth = opts.depth ?? 0;
198
- const label = opts.label ?? '';
199
- const currency = opts.currency;
200
- if (value === null || value === undefined) return [{ depth, label, value: '—' }];
201
-
202
- if (Array.isArray(value)) {
203
- if (value.length === 0) return [{ depth, label, value: '(none)' }];
204
- const out: FlatLine[] = label ? [{ depth, label, value: '' }] : [];
205
- value.forEach((item, i) => {
206
- const childDepth = label ? depth + 1 : depth;
207
- out.push(...flattenStripeValue(item, { label: `#${i + 1}`, depth: childDepth, currency }));
208
- });
209
- return out;
210
- }
211
-
212
- if (typeof value === 'object') {
213
- const obj = value as Record<string, unknown>;
214
- // Unwrap Stripe list objects to their data array.
215
- if (obj.object === 'list' && Array.isArray(obj.data)) {
216
- return flattenStripeValue(obj.data, { label, depth, currency });
217
- }
218
- const childCurrency = typeof obj.currency === 'string' ? obj.currency : currency;
219
- const keys = Object.keys(obj).filter((k) => obj[k] !== null && obj[k] !== undefined && k !== 'object');
220
- if (keys.length === 0) return [{ depth, label, value: '(empty)' }];
221
- const out: FlatLine[] = label ? [{ depth, label, value: '' }] : [];
222
- const childDepth = label ? depth + 1 : depth;
223
- for (const k of keys) out.push(...flattenStripeValue(obj[k], { label: k, depth: childDepth, currency: childCurrency }));
224
- return out;
225
- }
226
-
227
- // scalar
228
- const ref = typeof value === 'string' && isReferenceKey(label) ? value : undefined;
229
- const isMoney = typeof value === 'number' && (label === 'unit_amount' || /amount|total|subtotal|balance/.test(label));
230
- const rendered = isMoney ? formatStripeAmount(value, currency) : String(value);
231
- return [{ depth, label, value: rendered, ...(ref ? { ref } : {}) }];
232
- }
233
-
234
- /**
235
- * Resolve the cross-references for a row: the rows in other collections that this
236
- * row points AT (outgoing, e.g. invoice→customer) and the rows that point BACK at
237
- * it (incoming, e.g. customer←subscriptions). Resolved purely from already-fetched
238
- * collection data, so the UI can render clickable links without extra requests.
239
- */
240
- export type RefLink = { collection: string; id: string; label: string };
241
- export type CrossRefs = { outgoing: RefLink[]; incoming: RefLink[] };
242
-
243
- const LABELERS: Record<string, (r: StripeRow) => string> = {
244
- customers: (r) => r.name || r.email || r.id,
245
- products: (r) => r.name || r.id,
246
- prices: (r) => `${formatStripeAmount(r.unit_amount, r.currency)}${formatRecurring(r.recurring) ? ` ${formatRecurring(r.recurring)}` : ''}`,
247
- subscriptions: (r) => `${r.status ?? 'subscription'} · ${r.id}`,
248
- invoices: (r) => `${r.status ?? 'invoice'} · ${formatStripeAmount(r.total ?? r.amount_due, r.currency)}`,
249
- payment_intents: (r) => `${formatStripeAmount(r.amount, r.currency)} · ${r.status ?? ''}`,
250
- charges: (r) => `${formatStripeAmount(r.amount, r.currency)} · ${r.status ?? ''}`,
251
- refunds: (r) => `${formatStripeAmount(r.amount, r.currency)} · ${r.status ?? 'refund'}`,
252
- payment_methods: (r) => `${formatPaymentMethod(r)} · ${r.id}`,
253
- disputes: (r) => `${formatStripeAmount(r.amount, r.currency)} · ${r.status ?? 'dispute'}`,
254
- payouts: (r) => `${formatStripeAmount(r.amount, r.currency)} · ${r.status ?? 'payout'}`,
255
- balance_transactions: (r) => `${formatStripeAmount(r.amount, r.currency)} · ${r.type ?? 'txn'}`,
256
- events: (r) => `${r.type ?? 'event'} · ${r.id}`,
257
- setup_intents: (r) => `${r.status ?? 'setup_intent'} · ${r.id}`,
258
- invoiceitems: (r) => `${formatStripeAmount(r.amount, r.currency)} · ${r.id}`,
259
- accounts: (r) => `${r.email || r.id} · ${r.type ?? 'account'}`,
260
- transfers: (r) => `${formatStripeAmount(r.amount, r.currency)} → ${r.destination ?? '?'}`,
261
- credit_notes: (r) => `${formatStripeAmount(r.amount, r.currency)} · ${r.status ?? 'credit_note'}`,
262
- tax_ids: (r) => `${r.value ?? r.id} · ${r.type ?? ''}`,
263
- customer_balance_transactions: (r) => `${formatStripeAmount(r.amount, r.currency)} · ${r.id}`,
264
- };
265
- function labelFor(collection: string, row: StripeRow): string {
266
- return (LABELERS[collection] ?? ((r: StripeRow) => r.id))(row);
267
- }
268
-
269
- export function resolveCrossRefs(
270
- collection: string,
271
- row: StripeRow,
272
- data: Record<string, StripeRow[]>,
273
- ): CrossRefs {
274
- const outgoing: RefLink[] = [];
275
- const incoming: RefLink[] = [];
276
- if (!row) return { outgoing, incoming };
277
-
278
- // Outgoing: every reference field on this row that we can resolve to a row.
279
- for (const [key, value] of Object.entries(row)) {
280
- if (typeof value !== 'string' || !isReferenceKey(key)) continue;
281
- const target = referenceCollection(key)!;
282
- const found = (data[target] ?? []).find((r) => r.id === value);
283
- outgoing.push({ collection: target, id: value, label: `${key}: ${found ? labelFor(target, found) : value}` });
284
- }
285
-
286
- // Incoming: rows in other collections whose reference field points at this row.
287
- for (const [other, rows] of Object.entries(data)) {
288
- if (other === collection) continue;
289
- for (const r of rows) {
290
- for (const [key, value] of Object.entries(r)) {
291
- if (value !== row.id || !isReferenceKey(key)) continue;
292
- incoming.push({ collection: other, id: r.id, label: `${labelFor(other, r)}` });
293
- }
294
- }
295
- }
296
- return { outgoing, incoming };
297
- }
23
+ export * from './stripe-shared.ts';
24
+ import { flattenStripeValue, resolveCrossRefs, type CrossRefs, type FlatLine, type StripeRow } from './stripe-shared.ts';
25
+ void flattenStripeValue; void resolveCrossRefs;
298
26
 
299
27
  const APP_SHELL = `<!doctype html>
300
28
  <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
301
- <title>Stripe UI mirror (twin)</title><link rel="stylesheet" href="/assets/styles.css"></head>
302
- <body><div id="root"></div><script type="module" src="/assets/app.js"></script></body></html>`;
303
-
29
+ <base href="/"><title>Dashboard – Stripe</title><link rel="stylesheet" href="assets/styles.css"></head>
30
+ <body><div id="root"></div><script type="module" src="assets/app.js"></script></body></html>`;
304
31
  let clientBundle: Promise<string> | null = null;
32
+
305
33
  /** Build the React/TSX dashboard client to browser JS (Bun bundles TSX); cached. */
306
34
  export function buildStripeMirrorClient(): Promise<string> {
307
35
  if (!clientBundle) {
308
- clientBundle = Bun.build({ entrypoints: [CLIENT_ENTRY()], target: 'browser', minify: true })
309
- .then(async (result) => {
310
- if (!result.success) throw new Error(result.logs.map((l) => l.message).join('\n') || 'stripe mirror client build failed');
311
- return result.outputs[0]!.text();
312
- })
36
+ clientBundle = bundleClient(CLIENT_ENTRY())
313
37
  .catch((error) => { clientBundle = null; throw error; });
314
38
  }
315
39
  return clientBundle;
316
40
  }
317
41
 
318
- /** Serve the Stripe dashboard mirror UI (React app) + its backing REST API. */
319
- export function createStripeMirrorServer(options: { root?: string; port?: number }): { port: number; stop: () => void } {
42
+ /** Serve the Stripe Dashboard (React app) with the twin's own fetch adapter mounted beside it. */
43
+ export async function createStripeMirrorServer(options: { root?: string; port?: number }): Promise<{ port: number; stop: () => void }> {
320
44
  const twin = createStripeTwinFetch(options);
321
- const server = Bun.serve({
45
+ const server = await serveHttp({
322
46
  // LOOPBACK-SPECIFIC bind (2026-08-20, the roving ui-verify flake): with the default
323
47
  // wildcard hostname, `port: 0` can be handed a port some long-running app already LISTENS
324
48
  // on at 127.0.0.1 (SO_REUSEADDR allows the overlapping non-identical bind), and the more
@@ -336,9 +60,10 @@ export function createStripeMirrorServer(options: { root?: string; port?: number
336
60
  catch (error) { return new Response(String(error), { status: 500 }); }
337
61
  }
338
62
  if (request.method === 'GET' && url.pathname === '/assets/styles.css') {
339
- return new Response(Bun.file(CLIENT_CSS()), { headers: { 'content-type': 'text/css; charset=utf-8' } });
63
+ return fileResponse(CLIENT_CSS(), { headers: { 'content-type': 'text/css; charset=utf-8' } });
340
64
  }
341
- if (request.method === 'GET' && (url.pathname === '/' || url.pathname === '')) {
65
+ // the Dashboard's own paths (/test/payments, /test/customers/cus_…) are the app; `/` opens Home
66
+ if (request.method === 'GET' && (url.pathname === '/' || url.pathname === '' || url.pathname === '/test' || url.pathname.startsWith('/test/'))) {
342
67
  return new Response(APP_SHELL, { headers: { 'content-type': 'text/html; charset=utf-8' } });
343
68
  }
344
69
  // everything else → the twin's OWN FETCH ADAPTER (composition, R2): the React client fetches
@@ -354,7 +79,12 @@ export function createStripeMirrorServer(options: { root?: string; port?: number
354
79
  return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
355
80
  }
356
81
 
357
- /** The app-shell HTML (pure, for tests). The dashboard itself is the React client. */
82
+ /** The app-shell HTML (pure, for tests). The Dashboard itself is the React client. */
358
83
  export function stripeMirrorHtml(): string {
359
84
  return APP_SHELL;
360
85
  }
86
+
87
+ /** The mirror's stylesheet, for a host serving the shell's `assets/styles.css` itself (the hosted mirror mount). */
88
+ export function stripeMirrorStyles(): Promise<string> {
89
+ return readFile(CLIENT_CSS(), 'utf8');
90
+ }
@@ -0,0 +1,44 @@
1
+ // STRIPE'S PARAMETER CHECK — every API request's top-level parameters against the operation's own in the vendored
2
+ // spec, before any handler reads them. Stripe refuses a parameter the operation does not take with
3
+ // `400 parameter_unknown` ("Received unknown parameter: …", docs.stripe.com/error-codes#parameter-unknown) and a
4
+ // required one left out with `400 parameter_missing` ("Missing required param: …",
5
+ // docs.stripe.com/error-codes#parameter-missing). Without this check a handler read whatever it was sent, so a
6
+ // parameter Stripe had removed (a promotion code's top-level `coupon`, replaced by `promotion[coupon]` in
7
+ // 2025-09-30.clover) was accepted.
8
+ //
9
+ // Where the evidence stops: the check knows only the served version's parameters (the spec's, surface.version). A
10
+ // request pinning an earlier version with Stripe-Version is not checked, since the pack holds no earlier spec;
11
+ // nested parameters are not checked.
12
+ import { readParams, vendorError, type DerivedCall } from '@volter/world-core';
13
+ import surface from './generated/surface.gen.json' with { type: 'json' };
14
+ import { manifest } from './manifest.ts';
15
+ import { SERVED_VERSION } from './stripe-version.ts';
16
+
17
+ type Param = { name: string; required?: boolean };
18
+ type Op = { id: string; method: string; query?: Param[]; body?: Param[] };
19
+ const OPS = new Map((surface.operations as Op[]).map((o) => [o.id, o]));
20
+
21
+ /** Parameters Stripe documents outside its published spec, each with the page that documents it. */
22
+ const DOCUMENTED: Record<string, string[]> = {
23
+ // a top-up into the Issuing balance (docs.stripe.com/issuing/funding/balance: "destination_balance=issuing")
24
+ PostTopups: ['destination_balance'],
25
+ };
26
+
27
+ /** Stripe's refusal of a request whose top-level parameters the operation does not take, or undefined. */
28
+ export async function refuseParameters(call: DerivedCall): Promise<Response | undefined> {
29
+ const op = OPS.get(call.operation.id);
30
+ if (!op) return undefined;
31
+ const pinned = call.request.headers.get('stripe-version');
32
+ if (pinned && pinned < SERVED_VERSION) return undefined;
33
+ const method = call.request.method.toUpperCase();
34
+ const known = new Set([...(op.query ?? []), ...(op.body ?? [])].map((p) => p.name).concat(DOCUMENTED[op.id] ?? []));
35
+ const params = await readParams(manifest, call.request.clone(), call.operation);
36
+ for (const name of Object.keys(params)) {
37
+ if (!known.has(name)) return vendorError(manifest, { status: 400, code: 'parameter_unknown', param: name, message: `Received unknown parameter: ${name}` });
38
+ }
39
+ const required = method === 'GET' || method === 'DELETE' ? (op.query ?? []) : (op.body ?? []);
40
+ for (const p of required) {
41
+ if (p.required && params[p.name] === undefined) return vendorError(manifest, { status: 400, code: 'parameter_missing', param: p.name, message: `Missing required param: ${p.name}.` });
42
+ }
43
+ return undefined;
44
+ }
@@ -0,0 +1,29 @@
1
+ // A MINIATURE OF THE HEAD, for this pack's own claims and suites (protocol 2). The kernel's loop
2
+ // (`performEntries`) needs a bound root and a sealed credential a unit test does not have; this is the same
3
+ // shape without one — the same `deployableEntries`, the pack's own push, the same `confirmAction`. Not on
4
+ // the serve path, not exported from the pack.
5
+ import { confirmAction, deployableEntries, worldNow } from '@volter/world-core';
6
+ import { INTERNAL_SUBJECT_TYPES, isPushable, pushStripeAction, type StripeExecute } from './stripe-connector.ts';
7
+
8
+ export async function performPending(
9
+ execute: StripeExecute,
10
+ opts: { root?: string; occurredAt: string },
11
+ ): Promise<{ pushed: number; confirmed: string[]; externalIds: Record<string, string> }> {
12
+ const confirmed: string[] = [];
13
+ const externalIds: Record<string, string> = {};
14
+ for (const entry of deployableEntries('stripe', opts.root)) {
15
+ const op = entry.operation ?? `${entry.subject.type}.update`;
16
+ // the twin's own records — the recorded event envelopes and the idempotency bookkeeping — are local
17
+ // state and are never sent
18
+ if (INTERNAL_SUBJECT_TYPES.has(entry.subject.type) || !isPushable(op)) continue;
19
+ const { externalId } = await pushStripeAction(execute, { operation: op, subject: entry.subject, fields: entry.fields ?? {} });
20
+ confirmAction({
21
+ service: 'stripe', actionId: entry.id, subject: entry.subject, fields: entry.fields ?? {},
22
+ occurredAt: opts.occurredAt ?? worldNow(), vendorSubjectId: externalId, receipt: { status: 'deployed' },
23
+ ...(opts.root !== undefined ? { root: opts.root } : {}),
24
+ });
25
+ confirmed.push(entry.id);
26
+ externalIds[entry.id] = externalId;
27
+ }
28
+ return { pushed: confirmed.length, confirmed, externalIds };
29
+ }