@volter/twin-stripe 0.1.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.
@@ -0,0 +1,202 @@
1
+ // Stripe event/webhook emission (scorecard R17). Real Stripe fires `event`
2
+ // objects (and webhooks) on state changes — payment_intent.succeeded,
3
+ // customer.subscription.created, invoice.paid, … — so an app's webhook handler
4
+ // runs. A faithful twin must too. On a twin write we build the Stripe `event`
5
+ // envelope and deliver it to registered endpoints (injected delivery: fake in
6
+ // tests, HTTP POST live, failures swallowed like the real vendor).
7
+ export type StripeEvent = {
8
+ id: string;
9
+ object: 'event';
10
+ type: string;
11
+ created: number;
12
+ livemode: false;
13
+ data: { object: Record<string, unknown> };
14
+ };
15
+ export type StripeEventDelivery = (url: string, event: StripeEvent) => Promise<void> | void;
16
+
17
+ // ── Webhook signature verification (Stripe's scheme) ────────────────────────────
18
+ // Real Stripe signs each webhook delivery with an HMAC-SHA256 over `${timestamp}.${payload}`
19
+ // keyed by the endpoint's signing secret, and sends it in the `Stripe-Signature` header as
20
+ // `t=<unix>,v1=<hex-sig>`. The SDK's `stripe.webhooks.constructEvent(payload, header, secret)`
21
+ // recomputes the signature and 400s on a mismatch / stale timestamp; tests build the header
22
+ // with `stripe.webhooks.generateTestHeaderString(...)`. We reproduce BOTH, byte-for-byte, so a
23
+ // real `stripe` SDK consumer verifies twin-delivered webhooks unchanged. Pure Node crypto —
24
+ // no network, fully deterministic. `node:crypto` is required lazily (inside the functions that
25
+ // use it) rather than as a top-level static import, so this module can be pulled into the
26
+ // browser bundle of the UI mirror without Bun's browser target choking on `node:crypto` — the
27
+ // signature helpers are server-only and never reached in the browser path.
28
+ type NodeCrypto = typeof import('node:crypto');
29
+ function nodeCrypto(): NodeCrypto {
30
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
31
+ return require('node:crypto') as NodeCrypto;
32
+ }
33
+
34
+ /** HMAC-SHA256(secret, `${timestamp}.${payload}`) as lowercase hex — Stripe's v1 signature. */
35
+ export function computeStripeSignature(payload: string, secret: string, timestamp: number): string {
36
+ return nodeCrypto().createHmac('sha256', secret).update(`${timestamp}.${payload}`, 'utf8').digest('hex');
37
+ }
38
+
39
+ /** Build a `Stripe-Signature` header value for `payload` (mirrors generateTestHeaderString). */
40
+ export function generateTestHeaderString(opts: { payload: string; secret: string; timestamp?: number; scheme?: string }): string {
41
+ const timestamp = opts.timestamp ?? Math.floor(Date.now() / 1000);
42
+ const scheme = opts.scheme ?? 'v1';
43
+ const signature = computeStripeSignature(opts.payload, opts.secret, timestamp);
44
+ return `t=${timestamp},${scheme}=${signature}`;
45
+ }
46
+
47
+ /** Parse a `Stripe-Signature` header into its timestamp + the list of v1 signatures. */
48
+ function parseSignatureHeader(header: string): { timestamp: number; signatures: string[] } {
49
+ let timestamp = -1;
50
+ const signatures: string[] = [];
51
+ for (const part of header.split(',')) {
52
+ const eq = part.indexOf('=');
53
+ if (eq === -1) continue;
54
+ const key = part.slice(0, eq).trim();
55
+ const value = part.slice(eq + 1).trim();
56
+ if (key === 't') timestamp = Number(value);
57
+ else if (key === 'v1') signatures.push(value);
58
+ }
59
+ return { timestamp, signatures };
60
+ }
61
+
62
+ export class StripeSignatureVerificationError extends Error {
63
+ constructor(message: string) {
64
+ super(message);
65
+ this.name = 'StripeSignatureVerificationError';
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Verify a webhook payload + signature header against the signing secret and return the
71
+ * parsed event (mirrors `stripe.webhooks.constructEvent`). Throws a
72
+ * StripeSignatureVerificationError on a malformed/missing header, a signature that does
73
+ * not match, or (when `tolerance` is given) a timestamp outside the allowed window.
74
+ */
75
+ export function constructEvent(payload: string, header: string, secret: string, opts: { tolerance?: number; now?: number } = {}): StripeEvent {
76
+ if (!header) throw new StripeSignatureVerificationError('No signatures found matching the expected signature for payload.');
77
+ const { timestamp, signatures } = parseSignatureHeader(header);
78
+ if (timestamp < 0 || signatures.length === 0) {
79
+ throw new StripeSignatureVerificationError('Unable to extract timestamp and signatures from header');
80
+ }
81
+ const expected = computeStripeSignature(payload, secret, timestamp);
82
+ const expectedBuf = Buffer.from(expected, 'utf8');
83
+ const matched = signatures.some((s) => {
84
+ const sBuf = Buffer.from(s, 'utf8');
85
+ return sBuf.length === expectedBuf.length && nodeCrypto().timingSafeEqual(sBuf, expectedBuf);
86
+ });
87
+ if (!matched) {
88
+ throw new StripeSignatureVerificationError('No signatures found matching the expected signature for payload.');
89
+ }
90
+ if (opts.tolerance !== undefined) {
91
+ const now = opts.now ?? Math.floor(Date.now() / 1000);
92
+ if (now - timestamp > opts.tolerance) {
93
+ throw new StripeSignatureVerificationError('Timestamp outside the tolerance zone');
94
+ }
95
+ }
96
+ return JSON.parse(payload) as StripeEvent;
97
+ }
98
+
99
+ const registry: string[] = [];
100
+ export function registerStripeWebhook(url: string): void {
101
+ if (!registry.includes(url)) registry.push(url);
102
+ }
103
+ export function unregisterStripeWebhook(url: string): void {
104
+ const i = registry.indexOf(url);
105
+ if (i !== -1) registry.splice(i, 1);
106
+ }
107
+ export function clearStripeWebhooks(): void {
108
+ registry.length = 0;
109
+ }
110
+ export function listStripeWebhooks(): string[] {
111
+ return [...registry];
112
+ }
113
+
114
+ // twin write operation → Stripe event type (the ones an app's webhooks care about).
115
+ // Returns null when an operation has no event (e.g. a plain create/retrieve we
116
+ // don't model an event for).
117
+ export function eventTypeFor(operation: string): string | null {
118
+ // Several twin write operations ARE already the Stripe event type (we record the
119
+ // op as `<resource>.<event>` so it maps 1:1): payment_intent.amount_capturable_updated,
120
+ // payment_intent.canceled, payment_intent.succeeded. Those pass through directly.
121
+ const PASSTHROUGH = new Set([
122
+ 'payment_intent.amount_capturable_updated', 'payment_intent.canceled', 'payment_intent.succeeded',
123
+ 'payment_intent.payment_failed', 'payment_intent.created', 'payment_intent.processing',
124
+ 'payment_intent.requires_action',
125
+ 'charge.succeeded', 'charge.captured', 'charge.refunded', 'charge.dispute.created',
126
+ 'customer.updated', 'customer.deleted', 'invoice.created', 'invoice.finalized',
127
+ 'invoice.paid', 'invoice.payment_failed', 'invoice.voided', 'invoice.marked_uncollectible', 'invoice.sent',
128
+ 'customer.subscription.updated', 'customer.subscription.paused', 'customer.subscription.resumed',
129
+ 'payout.created', 'payout.paid', 'payout.canceled', 'price.created', 'product.created',
130
+ 'setup_intent.succeeded', 'setup_intent.created',
131
+ ]);
132
+ if (PASSTHROUGH.has(operation)) return operation;
133
+ switch (operation) {
134
+ case 'customer.create': return 'customer.created';
135
+ case 'payment_intent.create': return 'payment_intent.created';
136
+ case 'payment_intent.confirm': return 'payment_intent.succeeded';
137
+ case 'payment_method.attach': return 'payment_method.attached';
138
+ case 'payment_method.detach': return 'payment_method.detached';
139
+ case 'setup_intent.confirm': return 'setup_intent.succeeded';
140
+ case 'subscription.create': return 'customer.subscription.created';
141
+ case 'subscription.update': return 'customer.subscription.updated';
142
+ case 'subscription.cancel': return 'customer.subscription.deleted';
143
+ case 'invoice.finalize': return 'invoice.finalized';
144
+ case 'invoice.pay': return 'invoice.paid';
145
+ case 'invoice.void': return 'invoice.voided';
146
+ case 'refund.create': return 'charge.refunded';
147
+ case 'dispute.create': return 'charge.dispute.created';
148
+ case 'payout.create': return 'payout.created';
149
+ case 'product.create': return 'product.created';
150
+ case 'price.create': return 'price.created';
151
+ default: return null;
152
+ }
153
+ }
154
+
155
+ let counter = 0;
156
+ const httpDelivery: StripeEventDelivery = async (url, event) => {
157
+ try {
158
+ await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', 'stripe-signature': 'twin' }, body: JSON.stringify(event) });
159
+ } catch {
160
+ /* fire-and-forget */
161
+ }
162
+ };
163
+
164
+ // Default deliverer override (a TEST SEAM). The twin's write path emits events without
165
+ // threading a per-call deliverer, so to drive event delivery fully OFFLINE/deterministically
166
+ // — no real sockets — a test installs a fake sink here. When unset, real HTTP POST is used
167
+ // (prod behavior). This keeps D5 honest: verify() exercises the twin's own emission, in-process.
168
+ let defaultDelivery: StripeEventDelivery | null = null;
169
+ /** Install a default deliverer used by the write path when no per-call `deliver` is passed. */
170
+ export function setStripeEventDelivery(deliver: StripeEventDelivery | null): void {
171
+ defaultDelivery = deliver;
172
+ }
173
+
174
+ /**
175
+ * Emit a Stripe event for a twin write to registered endpoints. `resource` is the
176
+ * Stripe object the event is about. `occurredAt` is caller-supplied (deterministic).
177
+ * Returns the delivered events (for assertions); no-op when nothing is registered
178
+ * or the operation has no mapped event type.
179
+ */
180
+ export async function emitStripeEvent(
181
+ operation: string,
182
+ resource: Record<string, unknown>,
183
+ opts: { occurredAt: string; deliver?: StripeEventDelivery } = { occurredAt: '1970-01-01T00:00:00.000Z' },
184
+ ): Promise<StripeEvent[]> {
185
+ const type = eventTypeFor(operation);
186
+ if (!type || registry.length === 0) return [];
187
+ const deliver = opts.deliver ?? defaultDelivery ?? httpDelivery;
188
+ const event: StripeEvent = {
189
+ id: `evt_twin_${++counter}`,
190
+ object: 'event',
191
+ type,
192
+ created: Math.floor(Date.parse(opts.occurredAt) / 1000) || 0,
193
+ livemode: false,
194
+ data: { object: resource },
195
+ };
196
+ const out: StripeEvent[] = [];
197
+ for (const url of registry) {
198
+ await deliver(url, event);
199
+ out.push(event);
200
+ }
201
+ return out;
202
+ }
@@ -0,0 +1,35 @@
1
+ // Parse a Stripe x-www-form-urlencoded body, reconstructing Stripe's BRACKET
2
+ // notation into nested objects/arrays (recurring[interval], items[0][price],
3
+ // metadata[key], expand[]) and coercing numeric strings — so the twin stores the
4
+ // same shapes the real Stripe SDK sends, not mangled flat keys. Shared by the
5
+ // request handler and tests.
6
+ export function parseStripeForm(body: string): Record<string, unknown> {
7
+ const out: Record<string, unknown> = {};
8
+ for (const [rawKey, rawValue] of new URLSearchParams(body)) {
9
+ // Coerce the wire types the real Stripe API coerces server-side: integer-looking
10
+ // strings → numbers, and the literal booleans Stripe accepts on form fields
11
+ // (active, captured, auto_advance, cancel_at_period_end, …) → real booleans, so
12
+ // the stored shapes match what live Stripe would return (boolean, not "true").
13
+ const value: unknown = /^-?\d+$/.test(rawValue) ? Number(rawValue)
14
+ : rawValue === 'true' ? true
15
+ : rawValue === 'false' ? false
16
+ : rawValue;
17
+ // split "a[b][0][c]" → ["a","b","0","c"]
18
+ const parts = rawKey.replace(/\]/g, '').split('[');
19
+ let node: any = out;
20
+ for (let i = 0; i < parts.length; i++) {
21
+ const key = parts[i]!;
22
+ const last = i === parts.length - 1;
23
+ if (last) {
24
+ if (key === '') { (Array.isArray(node) ? node : (node = node)).push?.(value); }
25
+ else node[key] = value;
26
+ continue;
27
+ }
28
+ const nextKey = parts[i + 1]!;
29
+ const wantArray = nextKey === '' || /^\d+$/.test(nextKey);
30
+ if (node[key] === undefined) node[key] = wantArray ? [] : {};
31
+ node = node[key];
32
+ }
33
+ }
34
+ return out;
35
+ }
@@ -0,0 +1,345 @@
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
+ import { handleStripeTwinRequest } from './stripe-twin.ts';
6
+
7
+ const CLIENT_ENTRY = new URL('../client/stripe-mirror.tsx', import.meta.url).pathname;
8
+ const CLIENT_CSS = new URL('../client/stripe-mirror.css', import.meta.url).pathname;
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Pure, dependency-free render/format/resolution helpers.
12
+ //
13
+ // These are intentionally framework-agnostic (plain data in → plain data out) so
14
+ // they can be unit-tested in isolation AND imported by the React/TSX client
15
+ // (Bun tree-shakes the server-only exports below out of the browser bundle).
16
+ // Keep them free of any `@volter/twin`/`Bun`/`handleStripeTwinRequest` usage.
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export type StripeRow = Record<string, any>;
20
+
21
+ /** Zero-decimal currencies (Stripe stores these in whole units, not cents). */
22
+ const ZERO_DECIMAL = new Set(['bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga', 'pyg', 'rwf', 'ugx', 'vnd', 'vuv', 'xaf', 'xof', 'xpf']);
23
+
24
+ /**
25
+ * Format a Stripe minor-unit amount (cents) as a localized currency string.
26
+ * Honors zero-decimal currencies (¥4200 not ¥42.00). Non-numbers → an em dash.
27
+ */
28
+ export function formatStripeAmount(value: unknown, currency?: string): string {
29
+ if (typeof value !== 'number' || !Number.isFinite(value)) return '—';
30
+ const ccy = String(currency ?? 'usd').toLowerCase();
31
+ const zero = ZERO_DECIMAL.has(ccy);
32
+ const major = zero ? value : value / 100;
33
+ try {
34
+ return new Intl.NumberFormat('en-US', {
35
+ style: 'currency', currency: ccy.toUpperCase(),
36
+ minimumFractionDigits: zero ? 0 : 2, maximumFractionDigits: zero ? 0 : 2,
37
+ }).format(major);
38
+ } catch {
39
+ // Unknown/invalid currency code → a stable, readable fallback.
40
+ return `${zero ? major : major.toFixed(2)} ${ccy.toUpperCase()}`;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Render a Stripe price's `recurring` block as a short interval label, e.g.
46
+ * "every month", "every 3 months". Returns '' when it isn't a recurring price.
47
+ */
48
+ export function formatRecurring(recurring: unknown): string {
49
+ if (!recurring || typeof recurring !== 'object') return '';
50
+ const r = recurring as Record<string, unknown>;
51
+ const interval = typeof r.interval === 'string' ? r.interval : '';
52
+ if (!interval) return '';
53
+ const count = Number(r.interval_count);
54
+ return count > 1 ? `every ${count} ${interval}s` : `every ${interval}`;
55
+ }
56
+
57
+ /**
58
+ * Render a saved payment method as a short human label, e.g. "Visa •••• 4242"
59
+ * for a card, or the bare `type` ("us_bank_account") for non-card methods.
60
+ */
61
+ export function formatPaymentMethod(pm: unknown): string {
62
+ if (!pm || typeof pm !== 'object') return '—';
63
+ const m = pm as Record<string, any>;
64
+ const card = m.card && typeof m.card === 'object' ? (m.card as Record<string, any>) : undefined;
65
+ if (card && (card.brand || card.last4)) {
66
+ const brand = card.brand ? String(card.brand).replace(/\b\w/g, (c) => c.toUpperCase()) : 'Card';
67
+ return card.last4 ? `${brand} •••• ${card.last4}` : brand;
68
+ }
69
+ return typeof m.type === 'string' && m.type ? m.type : (m.id ?? '—');
70
+ }
71
+
72
+ /**
73
+ * Extract the HTTP(S) image URLs from a product's `images` field (Stripe stores an
74
+ * array of URL strings) so the mirror can render them as <img> thumbnails instead
75
+ * of plain text. Non-arrays / non-URL entries are dropped. Order is preserved.
76
+ */
77
+ export function productImageUrls(images: unknown): string[] {
78
+ if (!Array.isArray(images)) return [];
79
+ return images.filter((u): u is string => typeof u === 'string' && /^https?:\/\//i.test(u));
80
+ }
81
+
82
+ /**
83
+ * Render a payment_intent / charge `last_payment_error` (the test-card decline state)
84
+ * as a single human-readable line, e.g. "card_declined (insufficient_funds): Your card
85
+ * has insufficient funds." Returns '' when there is no error object. This is the
86
+ * vendor-faithful decline reason the twin populates on a declined confirm.
87
+ */
88
+ export function formatPaymentError(error: unknown): string {
89
+ if (!error || typeof error !== 'object') return '';
90
+ const e = error as Record<string, any>;
91
+ const code = typeof e.code === 'string' ? e.code : '';
92
+ const declineCode = typeof e.decline_code === 'string' ? e.decline_code : '';
93
+ const message = typeof e.message === 'string' ? e.message : '';
94
+ const head = code ? (declineCode ? `${code} (${declineCode})` : code) : declineCode;
95
+ if (head && message) return `${head}: ${message}`;
96
+ return head || message;
97
+ }
98
+
99
+ /**
100
+ * Summarize a synthesized Stripe `balance` object (available/pending arrays, one entry
101
+ * per currency) into short per-bucket lines, e.g. ["available: $42.00", "pending: $0.00"].
102
+ * The balance is not a list collection, so the mirror renders this summary directly.
103
+ */
104
+ export function formatBalanceSummary(balance: unknown): Array<{ bucket: string; text: string }> {
105
+ if (!balance || typeof balance !== 'object') return [];
106
+ const b = balance as Record<string, any>;
107
+ const sum = (arr: unknown): string => {
108
+ if (!Array.isArray(arr) || arr.length === 0) return formatStripeAmount(0, 'usd');
109
+ return arr
110
+ .map((e) => formatStripeAmount((e as Record<string, any>)?.amount, (e as Record<string, any>)?.currency))
111
+ .join(', ');
112
+ };
113
+ const out: Array<{ bucket: string; text: string }> = [];
114
+ if (Array.isArray(b.available)) out.push({ bucket: 'available', text: sum(b.available) });
115
+ if (Array.isArray(b.pending)) out.push({ bucket: 'pending', text: sum(b.pending) });
116
+ return out;
117
+ }
118
+
119
+ /**
120
+ * Summarize a Connect connected `account` object's enablement state into the three
121
+ * boolean capability flags a real Stripe Connect dashboard shows up front:
122
+ * charges_enabled, payouts_enabled, details_submitted. Each is rendered as a tone-
123
+ * carrying flag (true → ok, false → warn) so an un-onboarded account reads as such.
124
+ */
125
+ export type AccountFlag = { key: string; label: string; enabled: boolean };
126
+ export function formatAccountFlags(account: unknown): AccountFlag[] {
127
+ if (!account || typeof account !== 'object') return [];
128
+ const a = account as Record<string, any>;
129
+ return [
130
+ { key: 'charges_enabled', label: 'Charges', enabled: a.charges_enabled === true },
131
+ { key: 'payouts_enabled', label: 'Payouts', enabled: a.payouts_enabled === true },
132
+ { key: 'details_submitted', label: 'Details submitted', enabled: a.details_submitted === true },
133
+ ];
134
+ }
135
+
136
+ /**
137
+ * Extract a Connect account's outstanding onboarding requirements (the
138
+ * `requirements.currently_due` list real Stripe shows as "needs attention"). Returns
139
+ * an ordered list of the still-due field paths; empty when nothing is due.
140
+ */
141
+ export function accountCurrentlyDue(account: unknown): string[] {
142
+ if (!account || typeof account !== 'object') return [];
143
+ const req = (account as Record<string, any>).requirements;
144
+ if (!req || typeof req !== 'object') return [];
145
+ const due = (req as Record<string, any>).currently_due;
146
+ return Array.isArray(due) ? due.filter((d): d is string => typeof d === 'string') : [];
147
+ }
148
+
149
+ /** Tone for a status pill: 'ok' (green), 'warn' (amber), 'bad' (red), '' (neutral). */
150
+ export type PillTone = 'ok' | 'warn' | 'bad' | '';
151
+ const PILL_OK = new Set(['active', 'succeeded', 'paid', 'true', 'enabled', 'available', 'won']);
152
+ 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']);
153
+ const PILL_BAD = new Set(['canceled', 'cancelled', 'void', 'uncollectible', 'failed', 'incomplete_expired', 'false', 'disabled', 'lost', 'charge_refunded']);
154
+ export function statusTone(value: unknown): PillTone {
155
+ const v = String(value ?? '').toLowerCase();
156
+ if (PILL_OK.has(v)) return 'ok';
157
+ if (PILL_BAD.has(v)) return 'bad';
158
+ if (PILL_WARN.has(v)) return 'warn';
159
+ return '';
160
+ }
161
+
162
+ /** True when a key names a field whose value is a Stripe object id we can link. */
163
+ export function isReferenceKey(key: string): boolean {
164
+ return REFERENCE_FIELDS.has(key);
165
+ }
166
+ // field name → the COLLECTION it points at (so the UI can jump sections + select).
167
+ const REFERENCE_FIELD_MAP: Record<string, string> = {
168
+ customer: 'customers', product: 'products', price: 'prices',
169
+ subscription: 'subscriptions', latest_invoice: 'invoices', invoice: 'invoices',
170
+ payment_intent: 'payment_intents', latest_charge: 'charges', charge: 'charges',
171
+ payment_method: 'payment_methods', default_payment_method: 'payment_methods',
172
+ dispute: 'disputes', payout: 'payouts', balance_transaction: 'balance_transactions',
173
+ source_transaction: 'charges', setup_intent: 'setup_intents',
174
+ // Connect: a transfer's `destination` points at a connected account.
175
+ account: 'accounts', destination: 'accounts',
176
+ // Billing: a customer_balance_transaction may reference the credit_note that created it.
177
+ credit_note: 'credit_notes',
178
+ };
179
+ const REFERENCE_FIELDS = new Set(Object.keys(REFERENCE_FIELD_MAP));
180
+ /** The collection a reference field points at, or undefined if not a reference. */
181
+ export function referenceCollection(key: string): string | undefined {
182
+ return REFERENCE_FIELD_MAP[key];
183
+ }
184
+
185
+ /** A single line in the flattened, human-readable view of a nested value. */
186
+ export type FlatLine = { depth: number; label: string; value: string; ref?: string };
187
+
188
+ /**
189
+ * Flatten an arbitrary nested Stripe value (object / array / scalar) into ordered,
190
+ * indented label/value lines suitable for a readable detail view — instead of the
191
+ * old "[object Object]". Stripe "list" wrappers ({object:'list',data:[...]}) are
192
+ * unwrapped to their `data`. Amount-ish fields are currency-formatted.
193
+ */
194
+ export function flattenStripeValue(value: unknown, opts: { label?: string; depth?: number; currency?: string } = {}): FlatLine[] {
195
+ const depth = opts.depth ?? 0;
196
+ const label = opts.label ?? '';
197
+ const currency = opts.currency;
198
+ if (value === null || value === undefined) return [{ depth, label, value: '—' }];
199
+
200
+ if (Array.isArray(value)) {
201
+ if (value.length === 0) return [{ depth, label, value: '(none)' }];
202
+ const out: FlatLine[] = label ? [{ depth, label, value: '' }] : [];
203
+ value.forEach((item, i) => {
204
+ const childDepth = label ? depth + 1 : depth;
205
+ out.push(...flattenStripeValue(item, { label: `#${i + 1}`, depth: childDepth, currency }));
206
+ });
207
+ return out;
208
+ }
209
+
210
+ if (typeof value === 'object') {
211
+ const obj = value as Record<string, unknown>;
212
+ // Unwrap Stripe list objects to their data array.
213
+ if (obj.object === 'list' && Array.isArray(obj.data)) {
214
+ return flattenStripeValue(obj.data, { label, depth, currency });
215
+ }
216
+ const childCurrency = typeof obj.currency === 'string' ? obj.currency : currency;
217
+ const keys = Object.keys(obj).filter((k) => obj[k] !== null && obj[k] !== undefined && k !== 'object');
218
+ if (keys.length === 0) return [{ depth, label, value: '(empty)' }];
219
+ const out: FlatLine[] = label ? [{ depth, label, value: '' }] : [];
220
+ const childDepth = label ? depth + 1 : depth;
221
+ for (const k of keys) out.push(...flattenStripeValue(obj[k], { label: k, depth: childDepth, currency: childCurrency }));
222
+ return out;
223
+ }
224
+
225
+ // scalar
226
+ const ref = typeof value === 'string' && isReferenceKey(label) ? value : undefined;
227
+ const isMoney = typeof value === 'number' && (label === 'unit_amount' || /amount|total|subtotal|balance/.test(label));
228
+ const rendered = isMoney ? formatStripeAmount(value, currency) : String(value);
229
+ return [{ depth, label, value: rendered, ...(ref ? { ref } : {}) }];
230
+ }
231
+
232
+ /**
233
+ * Resolve the cross-references for a row: the rows in other collections that this
234
+ * row points AT (outgoing, e.g. invoice→customer) and the rows that point BACK at
235
+ * it (incoming, e.g. customer←subscriptions). Resolved purely from already-fetched
236
+ * collection data, so the UI can render clickable links without extra requests.
237
+ */
238
+ export type RefLink = { collection: string; id: string; label: string };
239
+ export type CrossRefs = { outgoing: RefLink[]; incoming: RefLink[] };
240
+
241
+ const LABELERS: Record<string, (r: StripeRow) => string> = {
242
+ customers: (r) => r.name || r.email || r.id,
243
+ products: (r) => r.name || r.id,
244
+ prices: (r) => `${formatStripeAmount(r.unit_amount, r.currency)}${formatRecurring(r.recurring) ? ` ${formatRecurring(r.recurring)}` : ''}`,
245
+ subscriptions: (r) => `${r.status ?? 'subscription'} · ${r.id}`,
246
+ invoices: (r) => `${r.status ?? 'invoice'} · ${formatStripeAmount(r.total ?? r.amount_due, r.currency)}`,
247
+ payment_intents: (r) => `${formatStripeAmount(r.amount, r.currency)} · ${r.status ?? ''}`,
248
+ charges: (r) => `${formatStripeAmount(r.amount, r.currency)} · ${r.status ?? ''}`,
249
+ refunds: (r) => `${formatStripeAmount(r.amount, r.currency)} · ${r.status ?? 'refund'}`,
250
+ payment_methods: (r) => `${formatPaymentMethod(r)} · ${r.id}`,
251
+ disputes: (r) => `${formatStripeAmount(r.amount, r.currency)} · ${r.status ?? 'dispute'}`,
252
+ payouts: (r) => `${formatStripeAmount(r.amount, r.currency)} · ${r.status ?? 'payout'}`,
253
+ balance_transactions: (r) => `${formatStripeAmount(r.amount, r.currency)} · ${r.type ?? 'txn'}`,
254
+ events: (r) => `${r.type ?? 'event'} · ${r.id}`,
255
+ setup_intents: (r) => `${r.status ?? 'setup_intent'} · ${r.id}`,
256
+ invoiceitems: (r) => `${formatStripeAmount(r.amount, r.currency)} · ${r.id}`,
257
+ accounts: (r) => `${r.email || r.id} · ${r.type ?? 'account'}`,
258
+ transfers: (r) => `${formatStripeAmount(r.amount, r.currency)} → ${r.destination ?? '?'}`,
259
+ credit_notes: (r) => `${formatStripeAmount(r.amount, r.currency)} · ${r.status ?? 'credit_note'}`,
260
+ tax_ids: (r) => `${r.value ?? r.id} · ${r.type ?? ''}`,
261
+ customer_balance_transactions: (r) => `${formatStripeAmount(r.amount, r.currency)} · ${r.id}`,
262
+ };
263
+ function labelFor(collection: string, row: StripeRow): string {
264
+ return (LABELERS[collection] ?? ((r: StripeRow) => r.id))(row);
265
+ }
266
+
267
+ export function resolveCrossRefs(
268
+ collection: string,
269
+ row: StripeRow,
270
+ data: Record<string, StripeRow[]>,
271
+ ): CrossRefs {
272
+ const outgoing: RefLink[] = [];
273
+ const incoming: RefLink[] = [];
274
+ if (!row) return { outgoing, incoming };
275
+
276
+ // Outgoing: every reference field on this row that we can resolve to a row.
277
+ for (const [key, value] of Object.entries(row)) {
278
+ if (typeof value !== 'string' || !isReferenceKey(key)) continue;
279
+ const target = referenceCollection(key)!;
280
+ const found = (data[target] ?? []).find((r) => r.id === value);
281
+ outgoing.push({ collection: target, id: value, label: `${key}: ${found ? labelFor(target, found) : value}` });
282
+ }
283
+
284
+ // Incoming: rows in other collections whose reference field points at this row.
285
+ for (const [other, rows] of Object.entries(data)) {
286
+ if (other === collection) continue;
287
+ for (const r of rows) {
288
+ for (const [key, value] of Object.entries(r)) {
289
+ if (value !== row.id || !isReferenceKey(key)) continue;
290
+ incoming.push({ collection: other, id: r.id, label: `${labelFor(other, r)}` });
291
+ }
292
+ }
293
+ }
294
+ return { outgoing, incoming };
295
+ }
296
+
297
+ const APP_SHELL = `<!doctype html>
298
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
299
+ <title>Stripe UI mirror (twin)</title><link rel="stylesheet" href="/assets/styles.css"></head>
300
+ <body><div id="root"></div><script type="module" src="/assets/app.js"></script></body></html>`;
301
+
302
+ let clientBundle: Promise<string> | null = null;
303
+ /** Build the React/TSX dashboard client to browser JS (Bun bundles TSX); cached. */
304
+ export function buildStripeMirrorClient(): Promise<string> {
305
+ if (!clientBundle) {
306
+ clientBundle = Bun.build({ entrypoints: [CLIENT_ENTRY], target: 'browser', minify: true })
307
+ .then(async (result) => {
308
+ if (!result.success) throw new Error(result.logs.map((l) => l.message).join('\n') || 'stripe mirror client build failed');
309
+ return result.outputs[0]!.text();
310
+ })
311
+ .catch((error) => { clientBundle = null; throw error; });
312
+ }
313
+ return clientBundle;
314
+ }
315
+
316
+ /** Serve the Stripe dashboard mirror UI (React app) + its backing REST API. */
317
+ export function createStripeMirrorServer(options: { root?: string; port?: number }): { port: number; stop: () => void } {
318
+ const server = Bun.serve({
319
+ port: options.port ?? 0,
320
+ idleTimeout: 60,
321
+ async fetch(request) {
322
+ const url = new URL(request.url);
323
+ if (request.method === 'GET' && url.pathname === '/assets/app.js') {
324
+ try { return new Response(await buildStripeMirrorClient(), { headers: { 'content-type': 'text/javascript; charset=utf-8' } }); }
325
+ catch (error) { return new Response(String(error), { status: 500 }); }
326
+ }
327
+ if (request.method === 'GET' && url.pathname === '/assets/styles.css') {
328
+ return new Response(Bun.file(CLIENT_CSS), { headers: { 'content-type': 'text/css; charset=utf-8' } });
329
+ }
330
+ if (request.method === 'GET' && (url.pathname === '/' || url.pathname === '')) {
331
+ return new Response(APP_SHELL, { headers: { 'content-type': 'text/html; charset=utf-8' } });
332
+ }
333
+ // everything else → the twin's REST API (the React client fetches /v1/<collection>)
334
+ const body = request.method === 'GET' ? '' : await request.text();
335
+ const { status, body: out } = await handleStripeTwinRequest({ method: request.method, path: url.pathname + (url.search || ''), body, ...(options.root !== undefined ? { root: options.root } : {}) });
336
+ return new Response(JSON.stringify(out), { status, headers: { 'content-type': 'application/json' } });
337
+ },
338
+ });
339
+ return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
340
+ }
341
+
342
+ /** The app-shell HTML (pure, for tests). The dashboard itself is the React client. */
343
+ export function stripeMirrorHtml(): string {
344
+ return APP_SHELL;
345
+ }
@@ -0,0 +1,46 @@
1
+ // Stripe twin HTTP server — serve the full Stripe twin handler over HTTP so the
2
+ // real `stripe` SDK ({host,port,protocol}) works unmodified (R1), or route the QA
3
+ // backend sandbox's api.stripe.com interception at it. Form-encoded bodies (what
4
+ // the SDK sends) pass through to the handler. Writable by default (the local stack
5
+ // uses the twin as its authoritative Stripe); pass readOnly to reject writes (R4).
6
+ import { handleStripeTwinRequest } from './stripe-twin.ts';
7
+
8
+ export function createStripeTwinServer(options: { root?: string; port?: number; readOnly?: boolean }): { port: number; stop: () => void } {
9
+ const readOnly = options.readOnly ?? false;
10
+ const server = Bun.serve({
11
+ port: options.port ?? 0,
12
+ idleTimeout: 60,
13
+ async fetch(request) {
14
+ const url = new URL(request.url);
15
+ const body = request.method === 'GET' ? '' : await request.text();
16
+ // Stripe sends the idempotency key as the `Idempotency-Key` request header; the
17
+ // real SDK sets it from { idempotencyKey } on a write call. Thread it through so
18
+ // a replay returns the stored response without re-applying the write.
19
+ const idempotencyKey = request.headers.get('idempotency-key') ?? undefined;
20
+ // Stripe pins API behavior via the `Stripe-Version` request header; the real SDK
21
+ // sets it from { apiVersion }. Thread it through so the twin can reflect it (and
22
+ // reject a malformed value with a 400) exactly like real Stripe.
23
+ const apiVersion = request.headers.get('stripe-version') ?? undefined;
24
+ // Connect: the SDK sets `Stripe-Account` from { stripeAccount } to act on behalf of a
25
+ // connected account. Thread it through so account-scoped writes (e.g. a payout created
26
+ // on the connected account's balance) are attributed to that account, like real Stripe.
27
+ const stripeAccount = request.headers.get('stripe-account') ?? undefined;
28
+ const { status, body: out } = await handleStripeTwinRequest({
29
+ method: request.method,
30
+ path: url.pathname + (url.search || ''),
31
+ body,
32
+ readOnly,
33
+ ...(apiVersion ? { apiVersion } : {}),
34
+ ...(stripeAccount ? { stripeAccount } : {}),
35
+ // Stamp real wall-clock time so served objects get a real `created` epoch (not 1970).
36
+ // Embedded/test callers pass their own occurredAt for determinism; the live HTTP path
37
+ // — what the real SDK hits — should reflect actual time, like the real vendor.
38
+ occurredAt: new Date().toISOString(),
39
+ ...(idempotencyKey ? { idempotencyKey } : {}),
40
+ ...(options.root !== undefined ? { root: options.root } : {}),
41
+ });
42
+ return new Response(JSON.stringify(out), { status, headers: { 'content-type': 'application/json', 'request-id': 'req_twin' } });
43
+ },
44
+ });
45
+ return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
46
+ }