@volter/twin-stripe 0.1.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/README.md +64 -27
  2. package/client/dashboard-api.ts +286 -0
  3. package/client/stripe-mirror.css +272 -159
  4. package/client/stripe-mirror.tsx +1384 -541
  5. package/dist/client/dashboard-api.d.ts +107 -0
  6. package/dist/client/dashboard-api.js +238 -0
  7. package/dist/client/dashboard-api.ts +286 -0
  8. package/dist/client/stripe-mirror.bundle.js +236 -0
  9. package/dist/client/stripe-mirror.css +275 -0
  10. package/dist/client/stripe-mirror.d.ts +134 -0
  11. package/dist/client/stripe-mirror.js +823 -0
  12. package/dist/client/stripe-mirror.tsx +1534 -0
  13. package/dist/src/cli.d.ts +2 -0
  14. package/dist/src/cli.js +39 -0
  15. package/dist/src/generated/events.gen.json +1 -0
  16. package/dist/src/generated/surface.gen.json +1 -0
  17. package/dist/src/generated/ui.gen.json +1 -0
  18. package/dist/src/index.d.ts +14 -0
  19. package/dist/src/index.js +73 -0
  20. package/dist/src/manifest.d.ts +2 -0
  21. package/dist/src/manifest.js +1065 -0
  22. package/dist/src/screens/checkout.d.ts +31 -0
  23. package/dist/src/screens/checkout.js +241 -0
  24. package/dist/src/screens/consent-skin.d.ts +4 -0
  25. package/dist/src/screens/consent-skin.js +18 -0
  26. package/dist/src/screens/financial-connections.d.ts +5 -0
  27. package/dist/src/screens/financial-connections.js +90 -0
  28. package/dist/src/screens/identity.d.ts +5 -0
  29. package/dist/src/screens/identity.js +86 -0
  30. package/dist/src/screens/industries.d.ts +1 -0
  31. package/dist/src/screens/industries.js +267 -0
  32. package/dist/src/screens/onboarding.d.ts +13 -0
  33. package/dist/src/screens/onboarding.js +225 -0
  34. package/dist/src/screens/portal.d.ts +5 -0
  35. package/dist/src/screens/portal.js +214 -0
  36. package/dist/src/screens/public-details.d.ts +5 -0
  37. package/dist/src/screens/public-details.js +90 -0
  38. package/dist/src/semantics/after-payment.d.ts +22 -0
  39. package/dist/src/semantics/after-payment.js +93 -0
  40. package/dist/src/semantics/apps-secrets.d.ts +2 -0
  41. package/dist/src/semantics/apps-secrets.js +54 -0
  42. package/dist/src/semantics/balance.d.ts +11 -0
  43. package/dist/src/semantics/balance.js +195 -0
  44. package/dist/src/semantics/billing.d.ts +2 -0
  45. package/dist/src/semantics/billing.js +220 -0
  46. package/dist/src/semantics/charges.d.ts +28 -0
  47. package/dist/src/semantics/charges.js +201 -0
  48. package/dist/src/semantics/checkout.d.ts +15 -0
  49. package/dist/src/semantics/checkout.js +303 -0
  50. package/dist/src/semantics/connect.d.ts +5 -0
  51. package/dist/src/semantics/connect.js +476 -0
  52. package/dist/src/semantics/coupons.d.ts +6 -0
  53. package/dist/src/semantics/coupons.js +92 -0
  54. package/dist/src/semantics/credit-notes.d.ts +2 -0
  55. package/dist/src/semantics/credit-notes.js +172 -0
  56. package/dist/src/semantics/customers.d.ts +6 -0
  57. package/dist/src/semantics/customers.js +429 -0
  58. package/dist/src/semantics/disputes.d.ts +2 -0
  59. package/dist/src/semantics/disputes.js +51 -0
  60. package/dist/src/semantics/entitlements.d.ts +2 -0
  61. package/dist/src/semantics/entitlements.js +95 -0
  62. package/dist/src/semantics/ephemeral-keys.d.ts +2 -0
  63. package/dist/src/semantics/ephemeral-keys.js +34 -0
  64. package/dist/src/semantics/files.d.ts +2 -0
  65. package/dist/src/semantics/files.js +125 -0
  66. package/dist/src/semantics/invoices.d.ts +18 -0
  67. package/dist/src/semantics/invoices.js +541 -0
  68. package/dist/src/semantics/issuing.d.ts +13 -0
  69. package/dist/src/semantics/issuing.js +570 -0
  70. package/dist/src/semantics/ledger.d.ts +54 -0
  71. package/dist/src/semantics/ledger.js +181 -0
  72. package/dist/src/semantics/payment-intents.d.ts +18 -0
  73. package/dist/src/semantics/payment-intents.js +404 -0
  74. package/dist/src/semantics/payment-links.d.ts +2 -0
  75. package/dist/src/semantics/payment-links.js +133 -0
  76. package/dist/src/semantics/payment-methods.d.ts +20 -0
  77. package/dist/src/semantics/payment-methods.js +138 -0
  78. package/dist/src/semantics/plans.d.ts +5 -0
  79. package/dist/src/semantics/plans.js +121 -0
  80. package/dist/src/semantics/platform.d.ts +9 -0
  81. package/dist/src/semantics/platform.js +206 -0
  82. package/dist/src/semantics/products.d.ts +2 -0
  83. package/dist/src/semantics/products.js +140 -0
  84. package/dist/src/semantics/radar.d.ts +2 -0
  85. package/dist/src/semantics/radar.js +83 -0
  86. package/dist/src/semantics/refunds.d.ts +9 -0
  87. package/dist/src/semantics/refunds.js +195 -0
  88. package/dist/src/semantics/renewals.d.ts +47 -0
  89. package/dist/src/semantics/renewals.js +251 -0
  90. package/dist/src/semantics/setup-intents.d.ts +2 -0
  91. package/dist/src/semantics/setup-intents.js +84 -0
  92. package/dist/src/semantics/shared.d.ts +78 -0
  93. package/dist/src/semantics/shared.js +192 -0
  94. package/dist/src/semantics/subscription-schedules.d.ts +2 -0
  95. package/dist/src/semantics/subscription-schedules.js +119 -0
  96. package/dist/src/semantics/subscriptions.d.ts +11 -0
  97. package/dist/src/semantics/subscriptions.js +605 -0
  98. package/dist/src/semantics/tax.d.ts +2 -0
  99. package/dist/src/semantics/tax.js +197 -0
  100. package/dist/src/semantics/terminal.d.ts +5 -0
  101. package/dist/src/semantics/terminal.js +182 -0
  102. package/dist/src/semantics/test-clocks.d.ts +6 -0
  103. package/dist/src/semantics/test-clocks.js +73 -0
  104. package/dist/src/semantics/tokens.d.ts +4 -0
  105. package/dist/src/semantics/tokens.js +44 -0
  106. package/dist/src/semantics/transfers.d.ts +2 -0
  107. package/dist/src/semantics/transfers.js +154 -0
  108. package/dist/src/semantics/treasury.d.ts +2 -0
  109. package/dist/src/semantics/treasury.js +377 -0
  110. package/dist/src/semantics/webhook-endpoints.d.ts +3 -0
  111. package/dist/src/semantics/webhook-endpoints.js +85 -0
  112. package/dist/src/stripe-budget.d.ts +55 -0
  113. package/dist/src/stripe-budget.js +155 -0
  114. package/dist/src/stripe-capabilities.d.ts +3 -0
  115. package/dist/src/stripe-capabilities.js +5052 -0
  116. package/dist/src/stripe-conformance.d.ts +41 -0
  117. package/dist/src/stripe-conformance.js +96 -0
  118. package/dist/src/stripe-connector.d.ts +161 -0
  119. package/dist/src/stripe-connector.js +414 -0
  120. package/dist/src/stripe-emit.d.ts +2 -0
  121. package/dist/src/stripe-emit.js +145 -0
  122. package/dist/src/stripe-events.d.ts +93 -0
  123. package/dist/src/stripe-events.js +388 -0
  124. package/dist/src/stripe-js.d.ts +4 -0
  125. package/dist/src/stripe-js.js +70 -0
  126. package/dist/src/stripe-mirror-ui.d.ts +15 -0
  127. package/dist/src/stripe-mirror-ui.js +87 -0
  128. package/dist/src/stripe-params.d.ts +3 -0
  129. package/dist/src/stripe-params.js +43 -0
  130. package/dist/src/stripe-perform-harness.d.ts +9 -0
  131. package/dist/src/stripe-perform-harness.js +26 -0
  132. package/dist/src/stripe-server.d.ts +33 -0
  133. package/dist/src/stripe-server.js +326 -0
  134. package/dist/src/stripe-shared.d.ts +106 -0
  135. package/dist/src/stripe-shared.js +273 -0
  136. package/dist/src/stripe-twin.d.ts +155 -0
  137. package/dist/src/stripe-twin.js +1226 -0
  138. package/dist/src/stripe-ui-conformance.d.ts +5 -0
  139. package/dist/src/stripe-ui-conformance.js +79 -0
  140. package/dist/src/stripe-ui-structure.d.ts +3 -0
  141. package/dist/src/stripe-ui-structure.js +168 -0
  142. package/dist/src/stripe-version.d.ts +10 -0
  143. package/dist/src/stripe-version.js +285 -0
  144. package/dist/test-fixtures/stripe-known-deviations.json +105 -0
  145. package/dist/test-fixtures/stripe-openapi-operations.SOURCE.md +14 -0
  146. package/dist/test-fixtures/stripe-openapi-operations.json +4717 -0
  147. package/dist/test-fixtures/stripe-schemas.SOURCE.md +35 -0
  148. package/dist/test-fixtures/stripe-schemas.json +3740 -0
  149. package/package.json +18 -10
  150. package/src/cli.ts +7 -7
  151. package/src/generated/events.gen.json +1 -0
  152. package/src/generated/surface.gen.json +1 -0
  153. package/src/generated/ui.gen.json +1 -0
  154. package/src/index.ts +31 -9
  155. package/src/manifest.ts +1097 -0
  156. package/src/screens/checkout.tsx +252 -0
  157. package/src/screens/consent-skin.ts +20 -0
  158. package/src/screens/financial-connections.tsx +101 -0
  159. package/src/screens/identity.tsx +96 -0
  160. package/src/screens/industries.ts +267 -0
  161. package/src/screens/onboarding.tsx +243 -0
  162. package/src/screens/portal.tsx +218 -0
  163. package/src/screens/public-details.tsx +105 -0
  164. package/src/semantics/after-payment.ts +113 -0
  165. package/src/semantics/apps-secrets.ts +58 -0
  166. package/src/semantics/balance.ts +209 -0
  167. package/src/semantics/billing.ts +216 -0
  168. package/src/semantics/charges.ts +211 -0
  169. package/src/semantics/checkout.ts +297 -0
  170. package/src/semantics/connect.ts +471 -0
  171. package/src/semantics/coupons.ts +97 -0
  172. package/src/semantics/credit-notes.ts +168 -0
  173. package/src/semantics/customers.ts +432 -0
  174. package/src/semantics/disputes.ts +62 -0
  175. package/src/semantics/entitlements.ts +94 -0
  176. package/src/semantics/ephemeral-keys.ts +34 -0
  177. package/src/semantics/files.ts +143 -0
  178. package/src/semantics/invoices.ts +541 -0
  179. package/src/semantics/issuing.ts +585 -0
  180. package/src/semantics/ledger.ts +216 -0
  181. package/src/semantics/payment-intents.ts +420 -0
  182. package/src/semantics/payment-links.ts +148 -0
  183. package/src/semantics/payment-methods.ts +143 -0
  184. package/src/semantics/plans.ts +131 -0
  185. package/src/semantics/platform.ts +220 -0
  186. package/src/semantics/products.ts +154 -0
  187. package/src/semantics/radar.ts +85 -0
  188. package/src/semantics/refunds.ts +218 -0
  189. package/src/semantics/renewals.ts +274 -0
  190. package/src/semantics/setup-intents.ts +87 -0
  191. package/src/semantics/shared.ts +215 -0
  192. package/src/semantics/subscription-schedules.ts +129 -0
  193. package/src/semantics/subscriptions.ts +610 -0
  194. package/src/semantics/tax.ts +220 -0
  195. package/src/semantics/terminal.ts +195 -0
  196. package/src/semantics/test-clocks.ts +77 -0
  197. package/src/semantics/tokens.ts +52 -0
  198. package/src/semantics/transfers.ts +174 -0
  199. package/src/semantics/treasury.ts +383 -0
  200. package/src/semantics/webhook-endpoints.ts +87 -0
  201. package/src/stripe-budget.ts +4 -4
  202. package/src/stripe-capabilities.ts +1456 -222
  203. package/src/stripe-conformance.ts +6 -5
  204. package/src/stripe-connector.ts +68 -40
  205. package/src/stripe-emit.ts +14 -7
  206. package/src/stripe-events.ts +94 -36
  207. package/src/stripe-js.ts +70 -0
  208. package/src/stripe-mirror-ui.ts +28 -298
  209. package/src/stripe-params.ts +44 -0
  210. package/src/stripe-perform-harness.ts +29 -0
  211. package/src/stripe-server.ts +263 -38
  212. package/src/stripe-shared.ts +294 -0
  213. package/src/stripe-twin.ts +429 -5325
  214. package/src/stripe-ui-conformance.ts +70 -107
  215. package/src/stripe-ui-structure.ts +124 -348
  216. package/src/stripe-version.ts +278 -0
  217. package/test-fixtures/stripe-known-deviations.json +2 -7
  218. package/test-fixtures/stripe-openapi-operations.json +1188 -2855
  219. package/src/stripe-form.ts +0 -35
@@ -0,0 +1,278 @@
1
+ // STRIPE'S API VERSIONS — how an answer is rendered for the version a caller is served. Stripe keeps one
2
+ // account of an object and renders it per API version (docs.stripe.com/upgrades): a request pinning a version
3
+ // with `Stripe-Version` gets that version's shape, and one that pins none gets the account's. The twin keeps
4
+ // its objects in the shape its rules were written against (the 2024-06-20 one, with the request parameters
5
+ // those rules read), and renders them here: in the spec's version (the one this pack serves, surface.version)
6
+ // for a request that pins none or pins basil (2025-03-31) or later, and as kept for an earlier pin.
7
+ //
8
+ // Where the evidence stops: the changes below are the ones between the kept shape and the vendored spec that
9
+ // the pack's journeys reach, each from Stripe's changelog for basil (docs.stripe.com/changelog/basil); a version
10
+ // pinned between basil and the spec's is rendered in the spec's shape, not its own.
11
+ import surface from './generated/surface.gen.json' with { type: 'json' };
12
+
13
+ type Row = Record<string, unknown>;
14
+
15
+ /** The version the pack serves: its vendored spec's. */
16
+ export const SERVED_VERSION = String(surface.version);
17
+ const BASIL = '2025-03-31';
18
+
19
+ /** Whether a request is answered in the served version's shape. */
20
+ export const servesCurrent = (pinned?: string | null): boolean => !pinned || pinned >= BASIL;
21
+
22
+ // What the twin keeps on an object for its rules that Stripe never answers: request parameters (a capture
23
+ // flag, the payment behavior a subscription was created with) and links it follows internally.
24
+ const KEPT: Record<string, string[]> = {
25
+ account: ['livemode'],
26
+ // a meter event is identified by its identifier; the twin's row id is its own
27
+ 'billing.meter_event': ['id'],
28
+ // a card source's creation time is the twin's, for ordering
29
+ card: ['created'],
30
+ charge: ['capture', 'capture_before'],
31
+ dispute: ['submit'],
32
+ ephemeral_key: ['associated_objects'],
33
+ 'financial_connections.account': ['session'],
34
+ 'identity.verification_session': ['return_url'],
35
+ invoice: ['days_until_due', 'pending_invoice_items_behavior'],
36
+ 'issuing.token': ['cardholder'],
37
+ payment_intent: ['mandate', 'off_session'],
38
+ subscription: ['payment_behavior'],
39
+ subscription_item: ['livemode'],
40
+ subscription_schedule: ['from_subscription', 'renewal_interval'],
41
+ 'tax.transaction': ['calculation'],
42
+ 'terminal.reader': ['registration_code'],
43
+ topup: ['destination_balance'],
44
+ 'treasury.outbound_payment': ['destination_payment_method_data'],
45
+ 'treasury.transaction_entry': ['amount'],
46
+ };
47
+
48
+ const omit = (o: Row, keys: string[]): Row => Object.fromEntries(Object.entries(o).filter(([k]) => !keys.includes(k)));
49
+ const idOf = (v: unknown): string | null => (typeof v === 'string' ? v : v && typeof v === 'object' && typeof (v as Row).id === 'string' ? String((v as Row).id) : null);
50
+
51
+ /** A price (an id or an expanded object) as basil's pricing block names it. */
52
+ function pricing(price: unknown, amount: unknown, quantity: unknown): Row | null {
53
+ const id = idOf(price);
54
+ if (!id) return null;
55
+ const product = price && typeof price === 'object' ? idOf((price as Row).product) : null;
56
+ const unit = price && typeof price === 'object' && (price as Row).unit_amount !== undefined ? (price as Row).unit_amount : Number(amount) / (Number(quantity) || 1);
57
+ return { type: 'price_details', price_details: { price: id, product: product ?? '' }, unit_amount_decimal: String(unit ?? 0) };
58
+ }
59
+
60
+ // basil (2025-03-31): what moved, per object
61
+ const BASIL_CHANGES: Record<string, (o: Row, parent?: Row) => Row> = {
62
+ // an invoice's subscription is its parent; its payments are the invoice's payments list (docs.stripe.com/changelog/basil/2025-03-31/add-support-for-multiple-partial-payments-on-invoices)
63
+ invoice: (o) => {
64
+ const subscription = idOf(o.subscription);
65
+ const parent = o.parent !== undefined ? o.parent : subscription ? { type: 'subscription_details', quote_details: null, subscription_details: { metadata: {}, subscription } } : null;
66
+ // "confirmation_secret … Currently, this contains the client_secret of the PaymentIntent that Stripe creates during
67
+ // invoice finalization" (docs.stripe.com/api/invoices/object; includable, answered only when expanded: INCLUDABLE);
68
+ // the intent's client_secret is the one stripe-twin.ts mintClientSecret gives it
69
+ const intent = typeof o.payment_intent === 'string' && o.payment_intent ? o.payment_intent : null;
70
+ const confirmation_secret = intent ? { type: 'payment_intent', client_secret: `${intent}_secret_twin` } : null;
71
+ return { ...omit(o, ['payment_intent', 'charge', 'paid', 'paid_out_of_band', 'subscription']), parent, confirmation_secret };
72
+ },
73
+ charge: (o) => omit(o, ['invoice', 'source']),
74
+ payment_intent: (o) => omit(o, ['invoice']),
75
+ // a subscription's billing period is its items' (docs.stripe.com/changelog/basil/2025-03-31/deprecate-subscription-current-period-start-and-end)
76
+ subscription: (o) => {
77
+ const period = { current_period_start: o.current_period_start, current_period_end: o.current_period_end };
78
+ const items = o.items && typeof o.items === 'object' ? (o.items as Row) : undefined;
79
+ const data = Array.isArray(items?.data) ? (items!.data as Row[]).map((it) => ({ ...it, current_period_start: it.current_period_start ?? period.current_period_start, current_period_end: it.current_period_end ?? period.current_period_end })) : undefined;
80
+ return { ...omit(o, ['current_period_start', 'current_period_end']), ...(items && data ? { items: { ...items, data } } : {}) };
81
+ },
82
+ subscription_item: (o) => ({ ...omit(o, ['plan']), discounts: o.discounts ?? [] }),
83
+ invoiceitem: (o) => ({ ...omit(o, ['price']), pricing: o.pricing ?? pricing(o.price, o.amount, o.quantity) }),
84
+ // a line names what generated it (its parent) and its price and taxes in basil's blocks
85
+ line_item: (o) => {
86
+ const proration = o.proration === true;
87
+ // a line an invoice item made names it; a subscription's line (invoice_item null) is its item's: "Details about the
88
+ // subscription item that generated this line item" (docs.stripe.com/api/invoice-line-item/object, parent)
89
+ const fromItem = o.type === 'invoiceitem' || (o.invoice_item !== undefined && o.invoice_item !== null && o.type !== 'subscription');
90
+ const parent = o.parent !== undefined ? o.parent : fromItem
91
+ ? { type: 'invoice_item_details', invoice_item_details: { invoice_item: idOf(o.invoice_item) ?? String(o.id).replace(/^il_/, ''), proration, proration_details: null, subscription: idOf(o.subscription) }, subscription_item_details: null }
92
+ : { type: 'subscription_item_details', subscription_item_details: { subscription_item: idOf(o.subscription_item) ?? '', proration, proration_details: null, subscription: idOf(o.subscription), invoice_item: null }, invoice_item_details: null };
93
+ const taxes = Array.isArray(o.tax_amounts) ? (o.tax_amounts as Row[]).map((t) => ({ amount: t.amount, tax_behavior: 'exclusive', taxability_reason: t.taxability_reason ?? 'standard_rated', taxable_amount: t.taxable_amount ?? null, type: 'tax_rate_details', tax_rate_details: { tax_rate: idOf(t.tax_rate) } })) : [];
94
+ return {
95
+ ...omit(o, ['price', 'invoice_item', 'proration', 'tax_amounts', 'tax_rates', 'type', 'subscription_item']),
96
+ parent, pricing: o.pricing ?? pricing(o.price, o.amount, o.quantity), taxes,
97
+ discountable: o.discountable ?? !proration, discounts: o.discounts ?? [], livemode: o.livemode ?? false, metadata: o.metadata ?? {},
98
+ period: o.period ?? { start: 0, end: 0 }, subtotal: o.subtotal ?? o.amount,
99
+ };
100
+ },
101
+ // a promotion code promotes a coupon (docs.stripe.com/api/promotion_codes/object)
102
+ promotion_code: (o) => ({ ...omit(o, ['coupon']), promotion: o.promotion ?? { type: 'coupon', coupon: o.coupon ?? null } }),
103
+ };
104
+
105
+ // Stripe answers every field of an object, a nullable one it has no value for as null (the Product object's page,
106
+ // docs.stripe.com/api/products/object, lists `package_dimensions` as "(object, nullable)" and its example answers
107
+ // `"package_dimensions": null`): a nullable field of the served spec the twin never set is rendered null. The spec's
108
+ // top-level fields are read from the surface; a sub-object's nullable fields are listed below, each from its object's
109
+ // page, where a published example met one the twin left out.
110
+ const NULLABLE = new Map((surface.resources as Array<{ schema: string; fields: Array<{ name: string; nullable?: boolean }> }>).map((r) => [r.schema, r.fields.filter((f) => f.nullable).map((f) => f.name)]));
111
+ const NESTED_NULLABLE: Record<string, Record<string, string[]>> = {
112
+ // docs.stripe.com/api/checkout/sessions/object?query=customer_details: business_name, individual_name "(string, nullable)"
113
+ 'checkout.session': { customer_details: ['business_name', 'individual_name'] },
114
+ // docs.stripe.com/api/subscriptions/object: "billing_mode.flexible (object, nullable)", "automatic_tax.disabled_reason
115
+ // (enum, nullable)", invoice_settings.account_tax_ids, .custom_fields, .description, .footer each nullable
116
+ // (and cancellation_details.feedback_option, nullable in the served spec: "Customized feedback options that provide
117
+ // deeper insight into why the subscription was canceled")
118
+ subscription: { automatic_tax: ['disabled_reason'], billing_mode: ['flexible'], invoice_settings: ['account_tax_ids', 'custom_fields', 'description', 'footer'], cancellation_details: ['comment', 'feedback', 'feedback_option', 'reason'] },
119
+ // docs.stripe.com/api/payment_methods/object: billing_details.tax_id, card.fingerprint, card.generated_from,
120
+ // card.regulated_status each "(…, nullable)"
121
+ // docs.stripe.com/api/invoices/object: automatic_tax.disabled_reason "(enum, nullable)", automatic_tax.provider
122
+ // "(string, nullable)"
123
+ invoice: { automatic_tax: ['disabled_reason', 'provider'] },
124
+ // docs.stripe.com/api/customers/object: invoice_settings.custom_fields, .default_payment_method, .footer,
125
+ // .rendering_options each nullable
126
+ customer: { invoice_settings: ['custom_fields', 'default_payment_method', 'footer', 'rendering_options'] },
127
+ // docs.stripe.com/api/accounts/object: a fresh account's business_profile answers each of these null
128
+ account: { business_profile: ['annual_revenue', 'estimated_worker_count', 'mcc', 'minority_owned_business_designation', 'name', 'product_description', 'specified_commercial_transactions_act_url', 'support_address', 'support_email', 'support_phone', 'support_url', 'url'] },
129
+ // docs.stripe.com/api/charges/object: billing_details.tax_id and each of these payment_method_details.card fields
130
+ // "(…, nullable)"; extended_authorization, incremental_authorization, multicapture and overcapture are not nullable
131
+ // in the served spec, so an unmodelled one is left out rather than answered null
132
+ charge: {
133
+ billing_details: ['tax_id'],
134
+ 'payment_method_details.card': ['amount_authorized', 'authorization_code', 'electronic_commerce_indicator', 'network_token', 'network_transaction_id', 'regulated_status', 'transaction_link_id'],
135
+ },
136
+ payment_method: { billing_details: ['tax_id'], card: ['fingerprint', 'generated_from', 'regulated_status'] },
137
+ // the served spec's person_relationship: legal_guardian and authorizer, each "(boolean, nullable)"
138
+ person: { relationship: ['legal_guardian', 'authorizer'] },
139
+ // the served spec's source_owner: each field "(…, nullable)"; the sources create page's example answers them null
140
+ source: { owner: ['address', 'email', 'name', 'phone', 'verified_address', 'verified_email', 'verified_name', 'verified_phone'] },
141
+ // the served spec's address_api_resource_terminal: each line "(string, nullable)"; the location fixture answers line2 null
142
+ 'terminal.location': { address: ['city', 'country', 'line1', 'line2', 'postal_code', 'state'] },
143
+ // the served spec's issuing_cardholder_individual (dob, verification, card_issuing), its address and its authorization
144
+ // controls (allowed_card_presences, blocked_card_presences, spending_limits_currency): each "(…, nullable)"
145
+ 'issuing.cardholder': { 'billing.address': ['city', 'country', 'line1', 'line2', 'postal_code', 'state'], individual: ['dob', 'verification', 'card_issuing'], spending_controls: ['allowed_card_presences', 'blocked_card_presences', 'spending_limits_currency'] },
146
+ // and a card's authorization controls (the served spec's issuing_card_authorization_controls), likewise nullable
147
+ 'issuing.card': { spending_controls: ['allowed_card_presences', 'blocked_card_presences', 'spending_limits_currency'] },
148
+ // the served spec's issuing_dispute_fraudulent_evidence: additional_documentation and explanation, each nullable
149
+ 'issuing.dispute': { 'evidence.fraudulent': ['additional_documentation', 'explanation'] },
150
+ // the served spec's issuing_personalization_design_carrier_text: its four texts, each nullable
151
+ 'issuing.personalization_design': { carrier_text: ['footer_body', 'footer_title', 'header_body', 'header_title'] },
152
+ // the served spec's treasury_shared_resource_billing_details.address: each line "(string, nullable)"
153
+ 'treasury.received_credit': { 'initiating_payment_method_details.billing_details.address': ['city', 'country', 'line1', 'line2', 'postal_code', 'state'], linked_flows: ['credit_reversal', 'issuing_authorization', 'issuing_transaction', 'source_flow', 'source_flow_details', 'source_flow_type'] },
154
+ // (and its linked flows: every one nullable in the served spec's treasury_received_debits_resource_linked_flows, and
155
+ // likewise the credit's)
156
+ 'treasury.received_debit': { 'initiating_payment_method_details.billing_details.address': ['city', 'country', 'line1', 'line2', 'postal_code', 'state'], linked_flows: ['debit_reversal', 'inbound_transfer', 'issuing_authorization', 'issuing_transaction', 'payout', 'topup'] },
157
+ 'treasury.inbound_transfer': { 'origin_payment_method_details.billing_details.address': ['city', 'country', 'line1', 'line2', 'postal_code', 'state'] },
158
+ };
159
+
160
+ // A field the vendor fills with its default when the request set none, by object, each from its object's page.
161
+ const CARD_DISPLAY: Record<string, string> = { amex: 'american_express', diners: 'diners_club', eftpos_au: 'eftpos_australia', unionpay: 'union_pay' };
162
+ const DEFAULTS: Record<string, (o: Row) => Row> = {
163
+ // docs.stripe.com/api/payment_methods/object: allow_redisplay "defaults to “unspecified”"; card.display_brand is "The
164
+ // brand to use when displaying the card … Can be `american_express`, …, `visa`", the brand's display name
165
+ payment_method: (o) => {
166
+ const card = o.card && typeof o.card === 'object' ? (o.card as Row) : undefined;
167
+ const brand = typeof card?.brand === 'string' ? card.brand : undefined;
168
+ return {
169
+ ...(o.allow_redisplay === undefined ? { allow_redisplay: 'unspecified' } : {}),
170
+ ...(card && brand && card.display_brand === undefined ? { card: { ...card, display_brand: CARD_DISPLAY[brand] ?? (brand === 'unknown' ? 'other' : brand) } } : {}),
171
+ };
172
+ },
173
+ // a line names "The ID of the invoice that contains this line item" (docs.stripe.com/api/invoice-line-item/object)
174
+ invoice: (o) => {
175
+ const lines = o.lines && typeof o.lines === 'object' ? (o.lines as Row) : undefined;
176
+ if (!Array.isArray(lines?.data) || typeof o.id !== 'string') return {};
177
+ return { lines: { ...lines, data: (lines!.data as Row[]).map((l) => (l && typeof l === 'object' && (l.invoice === undefined || l.invoice === null) ? { ...l, invoice: o.id } : l)) } };
178
+ },
179
+ // docs.stripe.com/api/invoiceitems/object: net_amount, "The amount after discounts, but before credits and taxes. This
180
+ // field is `null` for `discountable=true` items" (the twin puts no discount on an item itself)
181
+ invoiceitem: (o) => (o.net_amount === undefined ? { net_amount: o.discountable === false ? (typeof o.amount === 'number' ? o.amount : null) : null } : {}),
182
+ // docs.stripe.com/api/payment_intents/object: confirmation_method `automatic` "(Default)"; amount_details as its
183
+ // example answers it for a card payment, `{"tip": {}}`
184
+ payment_intent: (o) => ({
185
+ ...(o.confirmation_method === undefined ? { confirmation_method: 'automatic' } : {}),
186
+ ...(o.amount_details === undefined ? { amount_details: { tip: {} } } : {}),
187
+ }),
188
+ };
189
+ const fill = (o: Row, keys: string[]): Row => (keys.some((k) => !(k in o)) ? { ...o, ...Object.fromEntries(keys.filter((k) => !(k in o)).map((k) => [k, null])) } : o);
190
+ // every object whose served spec gives it a `metadata` it never answers null answers `{}` when none was set: "Set of
191
+ // key-value pairs that you can attach to an object" (docs.stripe.com/api/metadata), `metadata (map)` on each object's
192
+ // page, and its example `"metadata": {}` (the PaymentIntent object's, docs.stripe.com/api/payment_intents/object)
193
+ const METADATA = new Set((surface.resources as Array<{ schema: string; fields: Array<{ name: string; nullable?: boolean }> }>).filter((r) => r.fields.some((f) => f.name === 'metadata' && !f.nullable)).map((r) => r.schema));
194
+ const withNulls = (o: Row, kind: string): Row => {
195
+ let out = fill(DEFAULTS[kind] ? { ...o, ...DEFAULTS[kind]!(o) } : o, NULLABLE.get(kind) ?? []);
196
+ if (METADATA.has(kind) && (out.metadata === undefined || out.metadata === null)) out = { ...out, metadata: {} };
197
+ // a dotted path reaches a sub-object's own sub-object (a charge's payment_method_details.card)
198
+ const at = (o: Row, path: string[], keys: string[]): Row => {
199
+ const [head, ...rest] = path;
200
+ const sub = o[head!];
201
+ if (!sub || typeof sub !== 'object' || Array.isArray(sub)) return o;
202
+ return { ...o, [head!]: rest.length ? at(sub as Row, rest, keys) : fill(sub as Row, keys) };
203
+ };
204
+ for (const [path, keys] of Object.entries(NESTED_NULLABLE[kind] ?? {})) out = at(out, path.split('.'), keys);
205
+ return out;
206
+ };
207
+
208
+ // A field Stripe includes only when the request expands it: the Checkout Session object's page
209
+ // (docs.stripe.com/api/checkout/sessions/object) marks `line_items` "includable (not returned by default; request it
210
+ // with the `expand` request parameter)". The twin keeps it on the object for its rules; the answer carries it only
211
+ // where the request's `expand[]` names it (`line_items`, or `data.line_items` on a list).
212
+ const INCLUDABLE: Record<string, string[]> = {
213
+ 'checkout.session': ['line_items'],
214
+ // docs.stripe.com/api/charges/object: `refunds` "(object, nullable, includable (not returned by default; …))"
215
+ charge: ['refunds'],
216
+ // docs.stripe.com/api/payment-link/object: `line_items` "object Includable", and its example answers none
217
+ payment_link: ['line_items'],
218
+ // docs.stripe.com/api/quotes/object: `line_items` "object Includable", and its example answers none
219
+ quote: ['line_items'],
220
+ // docs.stripe.com/api/secret_management: `payload` "nullable string Includable": a secret's value is answered only
221
+ // when the request expands it
222
+ 'apps.secret': ['payload'],
223
+ // docs.stripe.com/api/tax/calculations/object and /tax/transactions/object: `line_items` "nullable object Includable"
224
+ 'tax.calculation': ['line_items'],
225
+ 'tax.transaction': ['line_items'],
226
+ // docs.stripe.com/api/invoices/object: `confirmation_secret` "(object, nullable, includable (not returned by default;
227
+ // request it with the `expand` request parameter))"
228
+ invoice: ['confirmation_secret'],
229
+ };
230
+
231
+ /** The `expand[]` paths a request names, in its query or its body (JSON or form). */
232
+ export async function expandOf(request: Request): Promise<string[]> {
233
+ const url = new URL(request.url);
234
+ const out = [...url.searchParams.entries()].filter(([k]) => /^expand(\[\d*\])?$/.test(k)).map(([, v]) => v);
235
+ if (request.method === 'GET' || request.method === 'HEAD') return out;
236
+ const text = await request.text().catch(() => '');
237
+ if (!text) return out;
238
+ if ((request.headers.get('content-type') ?? '').includes('json') || text.trim().startsWith('{')) {
239
+ try { const e = (JSON.parse(text) as { expand?: unknown }).expand; if (Array.isArray(e)) out.push(...e.map(String)); } catch { /* not JSON */ }
240
+ return out;
241
+ }
242
+ for (const [k, v] of new URLSearchParams(text)) if (/^expand(\[\d*\])?$/.test(k)) out.push(v);
243
+ return out;
244
+ }
245
+
246
+ /** An answer with every webhook endpoint's `secret` left out: Stripe answers it only when the endpoint is created. */
247
+ export function withoutEndpointSecret(value: unknown): unknown {
248
+ if (Array.isArray(value)) return value.map(withoutEndpointSecret);
249
+ if (!value || typeof value !== 'object') return value;
250
+ const o = value as Row;
251
+ const out = Object.fromEntries(Object.entries(o).filter(([k]) => !(k === 'secret' && o.object === 'webhook_endpoint')).map(([k, v]) => [k, withoutEndpointSecret(v)]));
252
+ return out;
253
+ }
254
+
255
+ /** An answer (any JSON) rendered for the version a caller is served, with only the includable fields `expand` names. */
256
+ export function render(value: unknown, pinned?: string | null, expand: string[] = []): unknown {
257
+ if (!servesCurrent(pinned)) return value;
258
+ const expands = (at: string): boolean => expand.some((e) => e === at || e.startsWith(`${at}.`));
259
+ const walk = (v: unknown, at = ''): unknown => {
260
+ if (Array.isArray(v)) return v.map((x) => walk(x, at));
261
+ if (!v || typeof v !== 'object') return v;
262
+ const kindOf = typeof (v as Row).object === 'string' ? String((v as Row).object) : undefined;
263
+ // `expand` is a request parameter, never a field of any object (the served spec gives none one); a handler that
264
+ // keeps its parameters keeps it too, and the answer leaves it out
265
+ const hidden: string[] = [...((kindOf ? INCLUDABLE[kindOf]?.filter((k) => !expands(at ? `${at}.${k}` : k)) : undefined) ?? []), ...(kindOf ? ['expand'] : [])];
266
+ const o = Object.fromEntries(Object.entries(v as Row).filter(([k]) => !hidden.includes(k)).map(([k, x]) => [k, walk(x, at ? `${at}.${k}` : k)]));
267
+ const kind = typeof o.object === 'string' ? o.object : undefined;
268
+ // a deleted object answers only that it is gone
269
+ if (!kind || o.deleted === true) return o;
270
+ // metadata holds strings ("key-value pairs", each value up to 500 characters, docs.stripe.com/metadata); the form
271
+ // reader coerces a numeric-looking value to a number, which the answer gives back as the string it was sent as
272
+ if (o.metadata && typeof o.metadata === 'object' && !Array.isArray(o.metadata)) o.metadata = Object.fromEntries(Object.entries(o.metadata as Row).map(([k, v]) => [k, typeof v === 'number' || typeof v === 'boolean' ? String(v) : v]));
273
+ const kept = KEPT[kind] ? omit(o, KEPT[kind]!) : o;
274
+ // an includable field a version's change adds (an invoice's confirmation_secret) is hidden as its stored ones are
275
+ return omit(withNulls(BASIL_CHANGES[kind] ? BASIL_CHANGES[kind]!(kept) : kept, kind), hidden);
276
+ };
277
+ return walk(value);
278
+ }
@@ -39,7 +39,7 @@
39
39
  {
40
40
  "path": "_card.declines-test-set-only",
41
41
  "kind": "behavior-modeled-subset",
42
- "reason": "Charge/PaymentIntent-confirm card declines are modeled for Stripe's DOCUMENTED TEST CARDS only (PAN 4242\u20264242 + pm_card_visa/tok_visa succeed; 4000\u20260002 generic_decline, 4000\u20269995 insufficient_funds, 4000\u20260069 expired_card, 4000\u20260127 incorrect_cvc, 4000\u20260119 processing_error, plus the matching pm_card_*/tok_* tokens). A known declining card returns the real Stripe card-error envelope (HTTP 402, error.type=card_error, code, decline_code for card_declined, message, param=card, and the charge/payment_intent id) and leaves a confirmed PaymentIntent at status=requires_payment_method with last_payment_error set; a declined charge is not persisted. ANY card the twin does not recognize (real PANs, unknown test tokens, or no card at all) succeeds, deterministically \u2014 the twin is not a risk/fraud engine and does not simulate issuer behavior beyond Stripe's published test set. Resolution reads card[number]/source[number] (raw PAN) or payment_method/source/card (token id). Leading underscore marks this as a twin-internal note (not a vendor schema path)."
42
+ "reason": "Charge/PaymentIntent-confirm card declines are modeled for Stripe's DOCUMENTED TEST CARDS only (PAN 4242\u20264242 + pm_card_visa/tok_visa succeed; 4000\u20260002 generic_decline, 4000\u20269995 insufficient_funds, 4000\u20260069 expired_card, 4000\u20260127 incorrect_cvc, 4000\u20260119 processing_error, plus the matching pm_card_*/tok_* tokens). A known declining card returns the real Stripe card-error envelope (HTTP 402, error.type=card_error, code, decline_code for card_declined, message, param=card, and the charge/payment_intent id) records the attempt as a failed charge (failure_code, failure_message, outcome) that the error names and a confirmed PaymentIntent's latest_charge points at, leaving the intent at status=requires_payment_method with last_payment_error (carrying the declined card) set and no payment_method. ANY card the twin does not recognize (real PANs, unknown test tokens, or no card at all) succeeds, deterministically \u2014 the twin is not a risk/fraud engine and does not simulate issuer behavior beyond Stripe's published test set. Resolution reads card[number]/source[number] (raw PAN) or payment_method/source/card (token id). Leading underscore marks this as a twin-internal note (not a vendor schema path)."
43
43
  },
44
44
  {
45
45
  "path": "_idempotency.stored-per-root",
@@ -51,15 +51,10 @@
51
51
  "kind": "resource-modeled-statefully",
52
52
  "reason": "The Events API (GET /v1/events, GET /v1/events/:id) is modeled statefully via the action log. The twin separately emits live webhook events on writes (stripe-events.ts); the Events API here serves explicitly-recorded event objects with the faithful vendor shape (type, api_version, created, data.object, livemode, pending_webhooks). pending_webhooks defaults to 0 because the twin delivers webhooks synchronously. data.object defaults to an empty object when the caller does not supply a snapshot. Leading underscore marks this as a twin-internal note (not a vendor schema path)."
53
53
  },
54
- {
55
- "path": "_checkout.hosted-page-pixels",
56
- "kind": "non-goal-render-omitted",
57
- "reason": "Checkout Sessions (POST/GET /v1/checkout/sessions, GET :id/line_items, POST :id/expire) and Customer Portal sessions (POST /v1/billing_portal/sessions, + billing_portal/configurations) are modeled as API OBJECTS with vendor-faithful shapes, ids (cs_/bps_/bpc_), status/payment_status enums, list envelope, and 400/404 errors. Rendering the Stripe-HOSTED Checkout / Customer Portal PAGE PIXELS is declared out of scope: the hosted HTML is Stripe's, not an API object. The twin models the Session object + redirect `url` (https://checkout.twin.local/... and https://billing.twin.local/...), which is the surface the unmodified `stripe` SDK and apps depend on. Leading underscore marks this as a twin-internal note (not a vendor schema path)."
58
- },
59
54
  {
60
55
  "path": "_checkout.completion-modeled",
61
56
  "kind": "behavior-modeled-statefully",
62
- "reason": "Real Stripe completes a Checkout Session when the customer pays on the hosted page (there is no public REST verb to complete a session). The twin models that terminal transition via POST /v1/checkout/sessions/:id with status=complete (only valid from `open`), which faithfully creates+links a real twin object: a succeeded payment_intent (mode=payment, amount = amount_total) with payment_status\u2192paid, an active subscription (mode=subscription, when a customer is present) with payment_status\u2192paid, or a succeeded setup_intent (mode=setup). POST :id/expire transitions open\u2192expired (terminal). amount_subtotal/amount_total are computed by summing resolved line_items (an existing Price's unit_amount, or inline price_data.unit_amount, \u00d7 quantity). Leading underscore marks this as a twin-internal note (not a vendor schema path)."
57
+ "reason": "Real Stripe completes a Checkout Session when the customer pays on the hosted page (there is no public REST verb to complete a session). The twin serves that page at the session's url (/c/pay/:id, src/screens/checkout.tsx): paying there (only while `open`) completes the session and creates+links a real twin object: a succeeded payment_intent (mode=payment, amount = amount_total) with payment_status\u2192paid, an active subscription (mode=subscription, when a customer is present) with payment_status\u2192paid, or a succeeded setup_intent (mode=setup). POST :id/expire transitions open\u2192expired (terminal). amount_subtotal/amount_total are computed by summing resolved line_items (an existing Price's unit_amount, or inline price_data.unit_amount, \u00d7 quantity). Leading underscore marks this as a twin-internal note (not a vendor schema path)."
63
58
  },
64
59
  {
65
60
  "path": "_tax.calculation-list-endpoint",