@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,297 @@
1
+ // The stripe mirror's SHARED vocabulary — formatters, tones, reference resolution — with no
2
+ // import from the server side: the mirror client bundles this into the browser (a server import
3
+ // would drag core, and with it node:async_hooks, into the bundle and the page would never mount).
4
+ // The server module re-exports it, so nothing else moves.
5
+
6
+ export type StripeRow = Record<string, any>;
7
+
8
+ /** The name an account shows its customers (Checkout, the customer portal): its public business name,
9
+ * `business_profile.name` ("The customer-facing business name", docs.stripe.com/api/accounts/object), which the
10
+ * operator sets on the Dashboard ("You can change a Checkout page's name by modifying the Business name field",
11
+ * docs.stripe.com/payments/checkout/customization/appearance). Where the documentation stops and the twin decides:
12
+ * with none set it falls back to the Dashboard's account name, `settings.dashboard.display_name` ("used on the Stripe
13
+ * Dashboard to differentiate between accounts", the same object page), and with neither to "Twin Inc.", the name the
14
+ * Dashboard mirror has always given the World's own account. */
15
+ export const PLATFORM_DEFAULT_NAME = 'Twin Inc.';
16
+ /** The bookkeeping type of a connected account's Connect OAuth connection (screens/connect-oauth.tsx): one row per
17
+ * account, `revoked: true` once deauthorized or its code reused. Read by the account list and the event path too. */
18
+ export const OAUTH_CONNECTIONS = '_oauth_connection';
19
+ export function publicBusinessName(account: StripeRow | undefined): string {
20
+ const named = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() ? v : undefined);
21
+ return named(account?.business_profile?.name) ?? named(account?.settings?.dashboard?.display_name) ?? PLATFORM_DEFAULT_NAME;
22
+ }
23
+
24
+ /** Zero-decimal currencies (Stripe stores these in whole units, not cents). */
25
+ const ZERO_DECIMAL = new Set(['bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga', 'pyg', 'rwf', 'ugx', 'vnd', 'vuv', 'xaf', 'xof', 'xpf']);
26
+
27
+ /**
28
+ * Format a Stripe minor-unit amount (cents) as a localized currency string.
29
+ * Honors zero-decimal currencies (¥4200 not ¥42.00). Non-numbers → an em dash.
30
+ */
31
+ export function formatStripeAmount(value: unknown, currency?: string): string {
32
+ if (typeof value !== 'number' || !Number.isFinite(value)) return '—';
33
+ const ccy = String(currency ?? 'usd').toLowerCase();
34
+ const zero = ZERO_DECIMAL.has(ccy);
35
+ const major = zero ? value : value / 100;
36
+ try {
37
+ return new Intl.NumberFormat('en-US', {
38
+ style: 'currency', currency: ccy.toUpperCase(),
39
+ minimumFractionDigits: zero ? 0 : 2, maximumFractionDigits: zero ? 0 : 2,
40
+ }).format(major);
41
+ } catch {
42
+ // Unknown/invalid currency code → a stable, readable fallback.
43
+ return `${zero ? major : major.toFixed(2)} ${ccy.toUpperCase()}`;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Render a Stripe price's `recurring` block as a short interval label, e.g.
49
+ * "every month", "every 3 months". Returns '' when it isn't a recurring price.
50
+ */
51
+ export function formatRecurring(recurring: unknown): string {
52
+ if (!recurring || typeof recurring !== 'object') return '';
53
+ const r = recurring as Record<string, unknown>;
54
+ const interval = typeof r.interval === 'string' ? r.interval : '';
55
+ if (!interval) return '';
56
+ const count = Number(r.interval_count);
57
+ return count > 1 ? `every ${count} ${interval}s` : `every ${interval}`;
58
+ }
59
+
60
+ /**
61
+ * Render a saved payment method as a short human label, e.g. "Visa •••• 4242"
62
+ * for a card, or the bare `type` ("us_bank_account") for non-card methods.
63
+ */
64
+ export function formatPaymentMethod(pm: unknown): string {
65
+ if (!pm || typeof pm !== 'object') return '—';
66
+ const m = pm as Record<string, any>;
67
+ const card = m.card && typeof m.card === 'object' ? (m.card as Record<string, any>) : undefined;
68
+ if (card && (card.brand || card.last4)) {
69
+ const brand = card.brand ? String(card.brand).replace(/\b\w/g, (c) => c.toUpperCase()) : 'Card';
70
+ return card.last4 ? `${brand} •••• ${card.last4}` : brand;
71
+ }
72
+ return typeof m.type === 'string' && m.type ? m.type : (m.id ?? '—');
73
+ }
74
+
75
+ /**
76
+ * Extract the HTTP(S) image URLs from a product's `images` field (Stripe stores an
77
+ * array of URL strings) so the mirror can render them as <img> thumbnails instead
78
+ * of plain text. Non-arrays / non-URL entries are dropped. Order is preserved.
79
+ */
80
+ export function productImageUrls(images: unknown): string[] {
81
+ if (!Array.isArray(images)) return [];
82
+ return images.filter((u): u is string => typeof u === 'string' && /^https?:\/\//i.test(u));
83
+ }
84
+
85
+ /**
86
+ * Render a payment_intent / charge `last_payment_error` (the test-card decline state)
87
+ * as a single human-readable line, e.g. "card_declined (insufficient_funds): Your card
88
+ * has insufficient funds." Returns '' when there is no error object. This is the
89
+ * vendor-faithful decline reason the twin populates on a declined confirm.
90
+ */
91
+ export function formatPaymentError(error: unknown): string {
92
+ if (!error || typeof error !== 'object') return '';
93
+ const e = error as Record<string, any>;
94
+ const code = typeof e.code === 'string' ? e.code : '';
95
+ const declineCode = typeof e.decline_code === 'string' ? e.decline_code : '';
96
+ const message = typeof e.message === 'string' ? e.message : '';
97
+ const head = code ? (declineCode ? `${code} (${declineCode})` : code) : declineCode;
98
+ if (head && message) return `${head}: ${message}`;
99
+ return head || message;
100
+ }
101
+
102
+ /**
103
+ * Summarize a synthesized Stripe `balance` object (available/pending arrays, one entry
104
+ * per currency) into short per-bucket lines, e.g. ["available: $42.00", "pending: $0.00"].
105
+ * The balance is not a list collection, so the mirror renders this summary directly.
106
+ */
107
+ export function formatBalanceSummary(balance: unknown): Array<{ bucket: string; text: string }> {
108
+ if (!balance || typeof balance !== 'object') return [];
109
+ const b = balance as Record<string, any>;
110
+ const sum = (arr: unknown): string => {
111
+ if (!Array.isArray(arr) || arr.length === 0) return formatStripeAmount(0, 'usd');
112
+ return arr
113
+ .map((e) => formatStripeAmount((e as Record<string, any>)?.amount, (e as Record<string, any>)?.currency))
114
+ .join(', ');
115
+ };
116
+ const out: Array<{ bucket: string; text: string }> = [];
117
+ if (Array.isArray(b.available)) out.push({ bucket: 'available', text: sum(b.available) });
118
+ if (Array.isArray(b.pending)) out.push({ bucket: 'pending', text: sum(b.pending) });
119
+ return out;
120
+ }
121
+
122
+ /**
123
+ * Summarize a Connect connected `account` object's enablement state into the three
124
+ * boolean capability flags a real Stripe Connect dashboard shows up front:
125
+ * charges_enabled, payouts_enabled, details_submitted. Each is rendered as a tone-
126
+ * carrying flag (true → ok, false → warn) so an un-onboarded account reads as such.
127
+ */
128
+ export type AccountFlag = { key: string; label: string; enabled: boolean };
129
+ export function formatAccountFlags(account: unknown): AccountFlag[] {
130
+ if (!account || typeof account !== 'object') return [];
131
+ const a = account as Record<string, any>;
132
+ return [
133
+ { key: 'charges_enabled', label: 'Charges', enabled: a.charges_enabled === true },
134
+ { key: 'payouts_enabled', label: 'Payouts', enabled: a.payouts_enabled === true },
135
+ { key: 'details_submitted', label: 'Details submitted', enabled: a.details_submitted === true },
136
+ ];
137
+ }
138
+
139
+ /**
140
+ * Extract a Connect account's outstanding onboarding requirements (the
141
+ * `requirements.currently_due` list real Stripe shows as "needs attention"). Returns
142
+ * an ordered list of the still-due field paths; empty when nothing is due.
143
+ */
144
+ export function accountCurrentlyDue(account: unknown): string[] {
145
+ if (!account || typeof account !== 'object') return [];
146
+ const req = (account as Record<string, any>).requirements;
147
+ if (!req || typeof req !== 'object') return [];
148
+ const due = (req as Record<string, any>).currently_due;
149
+ return Array.isArray(due) ? due.filter((d): d is string => typeof d === 'string') : [];
150
+ }
151
+
152
+ /** Tone for a status pill: 'ok' (green), 'warn' (amber), 'bad' (red), '' (neutral). */
153
+ export type PillTone = 'ok' | 'warn' | 'bad' | '';
154
+ const PILL_OK = new Set(['active', 'succeeded', 'paid', 'true', 'enabled', 'available', 'won']);
155
+ 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']);
156
+ const PILL_BAD = new Set(['canceled', 'cancelled', 'void', 'uncollectible', 'failed', 'incomplete_expired', 'false', 'disabled', 'lost', 'charge_refunded']);
157
+ export function statusTone(value: unknown): PillTone {
158
+ const v = String(value ?? '').toLowerCase();
159
+ if (PILL_OK.has(v)) return 'ok';
160
+ if (PILL_BAD.has(v)) return 'bad';
161
+ if (PILL_WARN.has(v)) return 'warn';
162
+ return '';
163
+ }
164
+
165
+ /** True when a key names a field whose value is a Stripe object id we can link. */
166
+ export function isReferenceKey(key: string): boolean {
167
+ return REFERENCE_FIELDS.has(key);
168
+ }
169
+ // field name → the COLLECTION it points at (so the UI can jump sections + select).
170
+ const REFERENCE_FIELD_MAP: Record<string, string> = {
171
+ customer: 'customers', product: 'products', price: 'prices',
172
+ subscription: 'subscriptions', latest_invoice: 'invoices', invoice: 'invoices',
173
+ payment_intent: 'payment_intents', latest_charge: 'charges', charge: 'charges',
174
+ payment_method: 'payment_methods', default_payment_method: 'payment_methods',
175
+ dispute: 'disputes', payout: 'payouts', balance_transaction: 'balance_transactions',
176
+ source_transaction: 'charges', setup_intent: 'setup_intents',
177
+ // Connect: a transfer's `destination` points at a connected account.
178
+ account: 'accounts', destination: 'accounts',
179
+ // Billing: a customer_balance_transaction may reference the credit_note that created it.
180
+ credit_note: 'credit_notes',
181
+ };
182
+ const REFERENCE_FIELDS = new Set(Object.keys(REFERENCE_FIELD_MAP));
183
+ /** The collection a reference field points at, or undefined if not a reference. */
184
+ export function referenceCollection(key: string): string | undefined {
185
+ return REFERENCE_FIELD_MAP[key];
186
+ }
187
+
188
+ /** A single line in the flattened, human-readable view of a nested value. */
189
+ export type FlatLine = { depth: number; label: string; value: string; ref?: string };
190
+
191
+ /**
192
+ * Flatten an arbitrary nested Stripe value (object / array / scalar) into ordered,
193
+ * indented label/value lines suitable for a readable detail view — instead of the
194
+ * old "[object Object]". Stripe "list" wrappers ({object:'list',data:[...]}) are
195
+ * unwrapped to their `data`. Amount-ish fields are currency-formatted.
196
+ */
197
+ export function flattenStripeValue(value: unknown, opts: { label?: string; depth?: number; currency?: string } = {}): FlatLine[] {
198
+ const depth = opts.depth ?? 0;
199
+ const label = opts.label ?? '';
200
+ const currency = opts.currency;
201
+ if (value === null || value === undefined) return [{ depth, label, value: '—' }];
202
+
203
+ if (Array.isArray(value)) {
204
+ if (value.length === 0) return [{ depth, label, value: '(none)' }];
205
+ const out: FlatLine[] = label ? [{ depth, label, value: '' }] : [];
206
+ value.forEach((item, i) => {
207
+ const childDepth = label ? depth + 1 : depth;
208
+ out.push(...flattenStripeValue(item, { label: `#${i + 1}`, depth: childDepth, currency }));
209
+ });
210
+ return out;
211
+ }
212
+
213
+ if (typeof value === 'object') {
214
+ const obj = value as Record<string, unknown>;
215
+ // Unwrap Stripe list objects to their data array.
216
+ if (obj.object === 'list' && Array.isArray(obj.data)) {
217
+ return flattenStripeValue(obj.data, { label, depth, currency });
218
+ }
219
+ const childCurrency = typeof obj.currency === 'string' ? obj.currency : currency;
220
+ const keys = Object.keys(obj).filter((k) => obj[k] !== null && obj[k] !== undefined && k !== 'object');
221
+ if (keys.length === 0) return [{ depth, label, value: '(empty)' }];
222
+ const out: FlatLine[] = label ? [{ depth, label, value: '' }] : [];
223
+ const childDepth = label ? depth + 1 : depth;
224
+ for (const k of keys) out.push(...flattenStripeValue(obj[k], { label: k, depth: childDepth, currency: childCurrency }));
225
+ return out;
226
+ }
227
+
228
+ // scalar
229
+ const ref = typeof value === 'string' && isReferenceKey(label) ? value : undefined;
230
+ const isMoney = typeof value === 'number' && (label === 'unit_amount' || /amount|total|subtotal|balance/.test(label));
231
+ const rendered = isMoney ? formatStripeAmount(value, currency) : String(value);
232
+ return [{ depth, label, value: rendered, ...(ref ? { ref } : {}) }];
233
+ }
234
+
235
+ /**
236
+ * Resolve the cross-references for a row: the rows in other collections that this
237
+ * row points AT (outgoing, e.g. invoice→customer) and the rows that point BACK at
238
+ * it (incoming, e.g. customer←subscriptions). Resolved purely from already-fetched
239
+ * collection data, so the UI can render clickable links without extra requests.
240
+ */
241
+ export type RefLink = { collection: string; id: string; label: string };
242
+ export type CrossRefs = { outgoing: RefLink[]; incoming: RefLink[] };
243
+
244
+ const LABELERS: Record<string, (r: StripeRow) => string> = {
245
+ customers: (r) => r.name || r.email || r.id,
246
+ products: (r) => r.name || r.id,
247
+ prices: (r) => `${formatStripeAmount(r.unit_amount, r.currency)}${formatRecurring(r.recurring) ? ` ${formatRecurring(r.recurring)}` : ''}`,
248
+ subscriptions: (r) => `${r.status ?? 'subscription'} · ${r.id}`,
249
+ invoices: (r) => `${r.status ?? 'invoice'} · ${formatStripeAmount(r.total ?? r.amount_due, r.currency)}`,
250
+ payment_intents: (r) => `${formatStripeAmount(r.amount, r.currency)} · ${r.status ?? ''}`,
251
+ charges: (r) => `${formatStripeAmount(r.amount, r.currency)} · ${r.status ?? ''}`,
252
+ refunds: (r) => `${formatStripeAmount(r.amount, r.currency)} · ${r.status ?? 'refund'}`,
253
+ payment_methods: (r) => `${formatPaymentMethod(r)} · ${r.id}`,
254
+ disputes: (r) => `${formatStripeAmount(r.amount, r.currency)} · ${r.status ?? 'dispute'}`,
255
+ payouts: (r) => `${formatStripeAmount(r.amount, r.currency)} · ${r.status ?? 'payout'}`,
256
+ balance_transactions: (r) => `${formatStripeAmount(r.amount, r.currency)} · ${r.type ?? 'txn'}`,
257
+ events: (r) => `${r.type ?? 'event'} · ${r.id}`,
258
+ setup_intents: (r) => `${r.status ?? 'setup_intent'} · ${r.id}`,
259
+ invoiceitems: (r) => `${formatStripeAmount(r.amount, r.currency)} · ${r.id}`,
260
+ accounts: (r) => `${r.email || r.id} · ${r.type ?? 'account'}`,
261
+ transfers: (r) => `${formatStripeAmount(r.amount, r.currency)} → ${r.destination ?? '?'}`,
262
+ credit_notes: (r) => `${formatStripeAmount(r.amount, r.currency)} · ${r.status ?? 'credit_note'}`,
263
+ tax_ids: (r) => `${r.value ?? r.id} · ${r.type ?? ''}`,
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
+ }