@volter/twin-stripe 0.1.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -27
- package/client/dashboard-api.ts +286 -0
- package/client/stripe-mirror.css +272 -159
- package/client/stripe-mirror.tsx +1384 -541
- package/dist/client/dashboard-api.d.ts +107 -0
- package/dist/client/dashboard-api.js +238 -0
- package/dist/client/dashboard-api.ts +286 -0
- package/dist/client/stripe-mirror.bundle.js +236 -0
- package/dist/client/stripe-mirror.css +275 -0
- package/dist/client/stripe-mirror.d.ts +134 -0
- package/dist/client/stripe-mirror.js +823 -0
- package/dist/client/stripe-mirror.tsx +1534 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +39 -0
- package/dist/src/generated/events.gen.json +1 -0
- package/dist/src/generated/surface.gen.json +1 -0
- package/dist/src/generated/ui.gen.json +1 -0
- package/dist/src/index.d.ts +14 -0
- package/dist/src/index.js +73 -0
- package/dist/src/manifest.d.ts +2 -0
- package/dist/src/manifest.js +1065 -0
- package/dist/src/screens/checkout.d.ts +31 -0
- package/dist/src/screens/checkout.js +241 -0
- package/dist/src/screens/consent-skin.d.ts +4 -0
- package/dist/src/screens/consent-skin.js +18 -0
- package/dist/src/screens/financial-connections.d.ts +5 -0
- package/dist/src/screens/financial-connections.js +90 -0
- package/dist/src/screens/identity.d.ts +5 -0
- package/dist/src/screens/identity.js +86 -0
- package/dist/src/screens/industries.d.ts +1 -0
- package/dist/src/screens/industries.js +267 -0
- package/dist/src/screens/onboarding.d.ts +13 -0
- package/dist/src/screens/onboarding.js +225 -0
- package/dist/src/screens/portal.d.ts +5 -0
- package/dist/src/screens/portal.js +214 -0
- package/dist/src/screens/public-details.d.ts +5 -0
- package/dist/src/screens/public-details.js +90 -0
- package/dist/src/semantics/after-payment.d.ts +22 -0
- package/dist/src/semantics/after-payment.js +93 -0
- package/dist/src/semantics/apps-secrets.d.ts +2 -0
- package/dist/src/semantics/apps-secrets.js +54 -0
- package/dist/src/semantics/balance.d.ts +11 -0
- package/dist/src/semantics/balance.js +195 -0
- package/dist/src/semantics/billing.d.ts +2 -0
- package/dist/src/semantics/billing.js +220 -0
- package/dist/src/semantics/charges.d.ts +28 -0
- package/dist/src/semantics/charges.js +201 -0
- package/dist/src/semantics/checkout.d.ts +15 -0
- package/dist/src/semantics/checkout.js +303 -0
- package/dist/src/semantics/connect.d.ts +5 -0
- package/dist/src/semantics/connect.js +476 -0
- package/dist/src/semantics/coupons.d.ts +6 -0
- package/dist/src/semantics/coupons.js +92 -0
- package/dist/src/semantics/credit-notes.d.ts +2 -0
- package/dist/src/semantics/credit-notes.js +172 -0
- package/dist/src/semantics/customers.d.ts +6 -0
- package/dist/src/semantics/customers.js +429 -0
- package/dist/src/semantics/disputes.d.ts +2 -0
- package/dist/src/semantics/disputes.js +51 -0
- package/dist/src/semantics/entitlements.d.ts +2 -0
- package/dist/src/semantics/entitlements.js +95 -0
- package/dist/src/semantics/ephemeral-keys.d.ts +2 -0
- package/dist/src/semantics/ephemeral-keys.js +34 -0
- package/dist/src/semantics/files.d.ts +2 -0
- package/dist/src/semantics/files.js +125 -0
- package/dist/src/semantics/invoices.d.ts +18 -0
- package/dist/src/semantics/invoices.js +541 -0
- package/dist/src/semantics/issuing.d.ts +13 -0
- package/dist/src/semantics/issuing.js +570 -0
- package/dist/src/semantics/ledger.d.ts +54 -0
- package/dist/src/semantics/ledger.js +181 -0
- package/dist/src/semantics/payment-intents.d.ts +18 -0
- package/dist/src/semantics/payment-intents.js +404 -0
- package/dist/src/semantics/payment-links.d.ts +2 -0
- package/dist/src/semantics/payment-links.js +133 -0
- package/dist/src/semantics/payment-methods.d.ts +20 -0
- package/dist/src/semantics/payment-methods.js +138 -0
- package/dist/src/semantics/plans.d.ts +5 -0
- package/dist/src/semantics/plans.js +121 -0
- package/dist/src/semantics/platform.d.ts +9 -0
- package/dist/src/semantics/platform.js +206 -0
- package/dist/src/semantics/products.d.ts +2 -0
- package/dist/src/semantics/products.js +140 -0
- package/dist/src/semantics/radar.d.ts +2 -0
- package/dist/src/semantics/radar.js +83 -0
- package/dist/src/semantics/refunds.d.ts +9 -0
- package/dist/src/semantics/refunds.js +195 -0
- package/dist/src/semantics/renewals.d.ts +47 -0
- package/dist/src/semantics/renewals.js +251 -0
- package/dist/src/semantics/setup-intents.d.ts +2 -0
- package/dist/src/semantics/setup-intents.js +84 -0
- package/dist/src/semantics/shared.d.ts +78 -0
- package/dist/src/semantics/shared.js +192 -0
- package/dist/src/semantics/subscription-schedules.d.ts +2 -0
- package/dist/src/semantics/subscription-schedules.js +119 -0
- package/dist/src/semantics/subscriptions.d.ts +11 -0
- package/dist/src/semantics/subscriptions.js +605 -0
- package/dist/src/semantics/tax.d.ts +2 -0
- package/dist/src/semantics/tax.js +197 -0
- package/dist/src/semantics/terminal.d.ts +5 -0
- package/dist/src/semantics/terminal.js +182 -0
- package/dist/src/semantics/test-clocks.d.ts +6 -0
- package/dist/src/semantics/test-clocks.js +73 -0
- package/dist/src/semantics/tokens.d.ts +4 -0
- package/dist/src/semantics/tokens.js +44 -0
- package/dist/src/semantics/transfers.d.ts +2 -0
- package/dist/src/semantics/transfers.js +154 -0
- package/dist/src/semantics/treasury.d.ts +2 -0
- package/dist/src/semantics/treasury.js +377 -0
- package/dist/src/semantics/webhook-endpoints.d.ts +3 -0
- package/dist/src/semantics/webhook-endpoints.js +85 -0
- package/dist/src/stripe-budget.d.ts +55 -0
- package/dist/src/stripe-budget.js +155 -0
- package/dist/src/stripe-capabilities.d.ts +3 -0
- package/dist/src/stripe-capabilities.js +5052 -0
- package/dist/src/stripe-conformance.d.ts +41 -0
- package/dist/src/stripe-conformance.js +96 -0
- package/dist/src/stripe-connector.d.ts +161 -0
- package/dist/src/stripe-connector.js +414 -0
- package/dist/src/stripe-emit.d.ts +2 -0
- package/dist/src/stripe-emit.js +145 -0
- package/dist/src/stripe-events.d.ts +93 -0
- package/dist/src/stripe-events.js +388 -0
- package/dist/src/stripe-js.d.ts +4 -0
- package/dist/src/stripe-js.js +70 -0
- package/dist/src/stripe-mirror-ui.d.ts +15 -0
- package/dist/src/stripe-mirror-ui.js +87 -0
- package/dist/src/stripe-params.d.ts +3 -0
- package/dist/src/stripe-params.js +43 -0
- package/dist/src/stripe-perform-harness.d.ts +9 -0
- package/dist/src/stripe-perform-harness.js +26 -0
- package/dist/src/stripe-server.d.ts +33 -0
- package/dist/src/stripe-server.js +326 -0
- package/dist/src/stripe-shared.d.ts +106 -0
- package/dist/src/stripe-shared.js +273 -0
- package/dist/src/stripe-twin.d.ts +155 -0
- package/dist/src/stripe-twin.js +1226 -0
- package/dist/src/stripe-ui-conformance.d.ts +5 -0
- package/dist/src/stripe-ui-conformance.js +79 -0
- package/dist/src/stripe-ui-structure.d.ts +3 -0
- package/dist/src/stripe-ui-structure.js +168 -0
- package/dist/src/stripe-version.d.ts +10 -0
- package/dist/src/stripe-version.js +285 -0
- package/dist/test-fixtures/stripe-known-deviations.json +105 -0
- package/dist/test-fixtures/stripe-openapi-operations.SOURCE.md +14 -0
- package/dist/test-fixtures/stripe-openapi-operations.json +4717 -0
- package/dist/test-fixtures/stripe-schemas.SOURCE.md +35 -0
- package/dist/test-fixtures/stripe-schemas.json +3740 -0
- package/package.json +18 -10
- package/src/cli.ts +7 -7
- package/src/generated/events.gen.json +1 -0
- package/src/generated/surface.gen.json +1 -0
- package/src/generated/ui.gen.json +1 -0
- package/src/index.ts +31 -9
- package/src/manifest.ts +1097 -0
- package/src/screens/checkout.tsx +252 -0
- package/src/screens/consent-skin.ts +20 -0
- package/src/screens/financial-connections.tsx +101 -0
- package/src/screens/identity.tsx +96 -0
- package/src/screens/industries.ts +267 -0
- package/src/screens/onboarding.tsx +243 -0
- package/src/screens/portal.tsx +218 -0
- package/src/screens/public-details.tsx +105 -0
- package/src/semantics/after-payment.ts +113 -0
- package/src/semantics/apps-secrets.ts +58 -0
- package/src/semantics/balance.ts +209 -0
- package/src/semantics/billing.ts +216 -0
- package/src/semantics/charges.ts +211 -0
- package/src/semantics/checkout.ts +297 -0
- package/src/semantics/connect.ts +471 -0
- package/src/semantics/coupons.ts +97 -0
- package/src/semantics/credit-notes.ts +168 -0
- package/src/semantics/customers.ts +432 -0
- package/src/semantics/disputes.ts +62 -0
- package/src/semantics/entitlements.ts +94 -0
- package/src/semantics/ephemeral-keys.ts +34 -0
- package/src/semantics/files.ts +143 -0
- package/src/semantics/invoices.ts +541 -0
- package/src/semantics/issuing.ts +585 -0
- package/src/semantics/ledger.ts +216 -0
- package/src/semantics/payment-intents.ts +420 -0
- package/src/semantics/payment-links.ts +148 -0
- package/src/semantics/payment-methods.ts +143 -0
- package/src/semantics/plans.ts +131 -0
- package/src/semantics/platform.ts +220 -0
- package/src/semantics/products.ts +154 -0
- package/src/semantics/radar.ts +85 -0
- package/src/semantics/refunds.ts +218 -0
- package/src/semantics/renewals.ts +274 -0
- package/src/semantics/setup-intents.ts +87 -0
- package/src/semantics/shared.ts +215 -0
- package/src/semantics/subscription-schedules.ts +129 -0
- package/src/semantics/subscriptions.ts +610 -0
- package/src/semantics/tax.ts +220 -0
- package/src/semantics/terminal.ts +195 -0
- package/src/semantics/test-clocks.ts +77 -0
- package/src/semantics/tokens.ts +52 -0
- package/src/semantics/transfers.ts +174 -0
- package/src/semantics/treasury.ts +383 -0
- package/src/semantics/webhook-endpoints.ts +87 -0
- package/src/stripe-budget.ts +4 -4
- package/src/stripe-capabilities.ts +1456 -222
- package/src/stripe-conformance.ts +6 -5
- package/src/stripe-connector.ts +68 -40
- package/src/stripe-emit.ts +14 -7
- package/src/stripe-events.ts +94 -36
- package/src/stripe-js.ts +70 -0
- package/src/stripe-mirror-ui.ts +28 -298
- package/src/stripe-params.ts +44 -0
- package/src/stripe-perform-harness.ts +29 -0
- package/src/stripe-server.ts +263 -38
- package/src/stripe-shared.ts +294 -0
- package/src/stripe-twin.ts +429 -5325
- package/src/stripe-ui-conformance.ts +70 -107
- package/src/stripe-ui-structure.ts +124 -348
- package/src/stripe-version.ts +278 -0
- package/test-fixtures/stripe-known-deviations.json +2 -7
- package/test-fixtures/stripe-openapi-operations.json +1188 -2855
- package/src/stripe-form.ts +0 -35
package/src/stripe-mirror-ui.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
// Stripe
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
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/
|
|
20
|
+
// Keep them free of any `@volter/world-core`/`Bun`/twin-adapter usage.
|
|
19
21
|
// ---------------------------------------------------------------------------
|
|
20
22
|
|
|
21
|
-
export
|
|
22
|
-
|
|
23
|
-
|
|
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>
|
|
302
|
-
<body><div id="root"></div><script type="module" src="
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
63
|
+
return fileResponse(CLIENT_CSS(), { headers: { 'content-type': 'text/css; charset=utf-8' } });
|
|
340
64
|
}
|
|
341
|
-
|
|
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
|
|
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
|
+
}
|