@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,281 @@
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
+ /** Every includable field name, as `expand` paths on a top-level object: what a check of an object's FULL answer expands. */
232
+ export const INCLUDABLE_FIELDS: string[] = [...new Set(Object.values(INCLUDABLE).flat())];
233
+
234
+ /** The `expand[]` paths a request names, in its query or its body (JSON or form). */
235
+ export async function expandOf(request: Request): Promise<string[]> {
236
+ const url = new URL(request.url);
237
+ const out = [...url.searchParams.entries()].filter(([k]) => /^expand(\[\d*\])?$/.test(k)).map(([, v]) => v);
238
+ if (request.method === 'GET' || request.method === 'HEAD') return out;
239
+ const text = await request.text().catch(() => '');
240
+ if (!text) return out;
241
+ if ((request.headers.get('content-type') ?? '').includes('json') || text.trim().startsWith('{')) {
242
+ try { const e = (JSON.parse(text) as { expand?: unknown }).expand; if (Array.isArray(e)) out.push(...e.map(String)); } catch { /* not JSON */ }
243
+ return out;
244
+ }
245
+ for (const [k, v] of new URLSearchParams(text)) if (/^expand(\[\d*\])?$/.test(k)) out.push(v);
246
+ return out;
247
+ }
248
+
249
+ /** An answer with every webhook endpoint's `secret` left out: Stripe answers it only when the endpoint is created. */
250
+ export function withoutEndpointSecret(value: unknown): unknown {
251
+ if (Array.isArray(value)) return value.map(withoutEndpointSecret);
252
+ if (!value || typeof value !== 'object') return value;
253
+ const o = value as Row;
254
+ const out = Object.fromEntries(Object.entries(o).filter(([k]) => !(k === 'secret' && o.object === 'webhook_endpoint')).map(([k, v]) => [k, withoutEndpointSecret(v)]));
255
+ return out;
256
+ }
257
+
258
+ /** An answer (any JSON) rendered for the version a caller is served, with only the includable fields `expand` names. */
259
+ export function render(value: unknown, pinned?: string | null, expand: string[] = []): unknown {
260
+ if (!servesCurrent(pinned)) return value;
261
+ const expands = (at: string): boolean => expand.some((e) => e === at || e.startsWith(`${at}.`));
262
+ const walk = (v: unknown, at = ''): unknown => {
263
+ if (Array.isArray(v)) return v.map((x) => walk(x, at));
264
+ if (!v || typeof v !== 'object') return v;
265
+ const kindOf = typeof (v as Row).object === 'string' ? String((v as Row).object) : undefined;
266
+ // `expand` is a request parameter, never a field of any object (the served spec gives none one); a handler that
267
+ // keeps its parameters keeps it too, and the answer leaves it out
268
+ const hidden: string[] = [...((kindOf ? INCLUDABLE[kindOf]?.filter((k) => !expands(at ? `${at}.${k}` : k)) : undefined) ?? []), ...(kindOf ? ['expand'] : [])];
269
+ const o = Object.fromEntries(Object.entries(v as Row).filter(([k]) => !hidden.includes(k)).map(([k, x]) => [k, walk(x, at ? `${at}.${k}` : k)]));
270
+ const kind = typeof o.object === 'string' ? o.object : undefined;
271
+ // a deleted object answers only that it is gone
272
+ if (!kind || o.deleted === true) return o;
273
+ // metadata holds strings ("key-value pairs", each value up to 500 characters, docs.stripe.com/metadata); the form
274
+ // reader coerces a numeric-looking value to a number, which the answer gives back as the string it was sent as
275
+ 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]));
276
+ const kept = KEPT[kind] ? omit(o, KEPT[kind]!) : o;
277
+ // an includable field a version's change adds (an invoice's confirmation_secret) is hidden as its stored ones are
278
+ return omit(withNulls(BASIL_CHANGES[kind] ? BASIL_CHANGES[kind]!(kept) : kept, kind), hidden);
279
+ };
280
+ return walk(value);
281
+ }
@@ -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",
@@ -105,6 +100,11 @@
105
100
  "path": "price",
106
101
  "kind": "extra",
107
102
  "reason": "PEAK-3102 round 2: POST /v1/invoiceitems now stores the caller-supplied `price` id on the invoiceitem (needed to resolve amount = unit_amount * quantity for PeakHealth's catalog-product order path). Real Stripe replaced the top-level InvoiceItem.price field with a `pricing.price_details` object in a later API-version migration — stripe-schemas.json (LATEST published shape) reflects the post-migration object and has no price property, hence 'extra' under the harness's single-snapshot schema, same mechanism as current_period_start/_end and payment_intent above. This twin's default-served TWIN_API_VERSION ('2024-06-20') pre-dates that migration, where real Stripe DOES emit invoiceitem.price top-level, so emitting it here is faithful to what this twin actually serves by default."
103
+ },
104
+ {
105
+ "path": "net_amount",
106
+ "kind": "type",
107
+ "reason": "invoiceitem.net_amount is typed a non-nullable integer in the served spec, but its own description says \"This field is `null` for `discountable=true` items\" (served spec, invoiceitem.net_amount). The twin follows the description: a discountable item answers null. The spec's type and its description disagree; this declares the twin's side."
108
108
  }
109
- ]
109
+ ]
110
110
  }