@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,297 @@
1
+ // Checkout Session semantics. The machine in ../manifest.ts says an open session expires or
2
+ // completes and nothing else does. Stripe completes a session when the customer pays on its hosted
3
+ // page (src/screens/checkout.tsx, the session's url), which has no API verb; completing links what
4
+ // the payment made: a succeeded PaymentIntent, an active (or
5
+ // trialing) subscription, or a succeeded SetupIntent. List and retrieve are the derived core's.
6
+ import type { Semantics, SemanticsContext } from '@volter/world-core';
7
+ import { addBillingInterval, applyCouponDiscount, buildDiscount, buildSubscriptionItemsList, declineFor, lineItemEntries, mintClientSecret, paginate, paymentMethodSubObject, resolveLineItem, resolveSubscriptionBillingInterval, resolveTrial } from '../stripe-twin.ts';
8
+ import { afterSuccessOf } from './after-payment.ts';
9
+ import { actingAccount } from './ledger.ts';
10
+ import { mintCharge } from './payment-intents.ts';
11
+ import { collectSubscriptionInvoice, draftSubscriptionInvoice } from './renewals.ts';
12
+ import { storeItems } from './subscriptions.ts';
13
+ import { at, created, fail, finder, path, redeemCoupon, type Row } from './shared.ts';
14
+
15
+ const CS = 'checkout.session';
16
+ const sessionMissing = (ctx: SemanticsContext, id: string): Response => fail(ctx, `No such checkout.session: '${id}'`, 404, 'resource_missing');
17
+
18
+ // Stripe normalizes the create input into the Session: line_items become the list envelope, and
19
+ // subscription_data (create-only) is kept aside for completion to copy onto the subscription
20
+ /** The discount a session is created with (discounts[0][promotion_code] or [coupon]), or Stripe's refusal: an unknown
21
+ * code or coupon, and one that has expired or been deactivated, are refused at create
22
+ * (docs.stripe.com/api/checkout/sessions/create#create_checkout_session-discounts, docs.stripe.com/error-codes#coupon-expired).
23
+ * Where the documentation stops and the twin decides: the refusals' wording. */
24
+ function discountOf(ctx: SemanticsContext, params: Row, now: number): { coupon: Row; promotion?: string } | Response | undefined {
25
+ const first = Array.isArray(params.discounts) ? (params.discounts[0] as Row | undefined) : undefined;
26
+ if (!first || typeof first !== 'object') return undefined;
27
+ const expired = (what: string) => ctx.refuse({ status: 400, code: 'coupon_expired', param: 'discounts', message: `This ${what} has expired.` });
28
+ let promotion: string | undefined;
29
+ let couponId = typeof first.coupon === 'string' ? first.coupon : undefined;
30
+ if (typeof first.promotion_code === 'string') {
31
+ const pc = ctx.get('promotion_code', first.promotion_code);
32
+ if (!pc) return ctx.refuse({ status: 400, code: 'resource_missing', param: 'discounts[0][promotion_code]', message: `No such promotion code: '${first.promotion_code}'` });
33
+ if (typeof pc.expires_at === 'number' && pc.expires_at <= now) return expired('promotion code');
34
+ if (pc.active === false) return ctx.refuse({ status: 400, param: 'discounts[0][promotion_code]', message: 'This promotion code is inactive.' });
35
+ promotion = String(pc.id);
36
+ couponId = typeof pc.coupon === 'string' ? pc.coupon : String((pc.coupon as Row | undefined)?.id ?? '');
37
+ }
38
+ const coupon = couponId ? ctx.get('coupon', couponId) : undefined;
39
+ if (!coupon) return ctx.refuse({ status: 400, code: 'resource_missing', param: 'discounts[0][coupon]', message: `No such coupon: '${couponId ?? ''}'` });
40
+ if (coupon.valid === false || (typeof coupon.redeem_by === 'number' && coupon.redeem_by <= now)) return expired('coupon');
41
+ return { coupon, ...(promotion ? { promotion } : {}) };
42
+ }
43
+
44
+ /** A payment or subscription session with nothing to sell (docs.stripe.com/api/checkout/sessions/create#create_checkout_session-line_items). */
45
+ function noLineItems(ctx: SemanticsContext): Response {
46
+ return fail(ctx, 'You must provide at least one recurring price in `subscription` mode when not using `line_items[].price_data` or a Price with `recurring`, and at least one `line_items` for `payment` mode.', 400, 'parameter_missing');
47
+ }
48
+
49
+ /** A destination charge to an account the platform does not have (docs.stripe.com/connect/destination-charges). */
50
+ function noDestination(ctx: SemanticsContext, destination: unknown): Response {
51
+ return ctx.refuse({ status: 400, code: 'resource_missing', param: 'payment_intent_data[transfer_data][destination]', message: `No such destination: '${String(destination)}'` });
52
+ }
53
+
54
+ /** A session's trial Stripe refuses: `subscription_data.trial_end` "Has to be at least 48 hours in the future", and
55
+ * `subscription_data.trial_period_days` "Has to be at least 1" (docs.stripe.com/api/checkout/sessions/create). Where the
56
+ * documentation stops and the twin decides: the refusals' wording. */
57
+ function sessionTrialRefused(ctx: SemanticsContext, data: unknown, now: number): Response | undefined {
58
+ if (!data || typeof data !== 'object') return undefined;
59
+ const { trial_end: end, trial_period_days: days } = data as Row;
60
+ const endRefused = end !== undefined && !(Number.isInteger(Number(end)) && Number(end) >= now + 48 * 3600);
61
+ const daysRefused = days !== undefined && !(Number.isInteger(Number(days)) && Number(days) >= 1);
62
+ return endRefused || daysRefused ? sessionTrialRefusal(ctx, endRefused ? 'trial_end' : 'trial_period_days') : undefined;
63
+ }
64
+
65
+ function sessionTrialRefusal(ctx: SemanticsContext, param: 'trial_end' | 'trial_period_days'): Response {
66
+ const message = param === 'trial_end' ? 'The `subscription_data[trial_end]` timestamp has to be at least 48 hours in the future.' : 'The `subscription_data[trial_period_days]` has to be at least 1.';
67
+ return ctx.refuse({ status: 400, param: `subscription_data[${param}]`, message });
68
+ }
69
+
70
+ const create: Semantics = async (ctx) => {
71
+ const params = ctx.params;
72
+ const mode = typeof params.mode === 'string' ? params.mode : 'payment';
73
+ if (!['payment', 'subscription', 'setup'].includes(mode)) return fail(ctx, 'Invalid mode: must be one of payment, subscription, or setup.', 400, 'parameter_invalid_string_enum');
74
+ const entries = lineItemEntries(params);
75
+ if (mode !== 'setup' && entries.length === 0) return noLineItems(ctx);
76
+ // hosted Checkout (the only ui_mode the twin models) needs success_url
77
+ if (params.success_url === undefined || params.success_url === '') return fail(ctx, 'Missing required param: success_url.', 400, 'parameter_missing');
78
+ const sessionCurrency = typeof params.currency === 'string' ? params.currency : 'usd';
79
+ const resolved = entries.map((e, i) => resolveLineItem(e, i, sessionCurrency, finder(ctx)));
80
+ const missing = resolved.find((r) => r.priceMissing);
81
+ if (missing) return fail(ctx, `No such price: '${missing.priceMissing}'`, 400, 'resource_missing');
82
+ const items = resolved.map((r) => r.item);
83
+ const now = Number(ctx.now());
84
+ const trialRefused = sessionTrialRefused(ctx, params.subscription_data, now);
85
+ if (trialRefused) return trialRefused;
86
+ const discount = discountOf(ctx, params, now);
87
+ if (discount instanceof Response) return discount;
88
+ const subtotal = items.reduce((n, it) => n + it.amount_subtotal, 0);
89
+ const off = discount ? applyCouponDiscount(subtotal, discount.coupon) : 0;
90
+ // the hosted page is at Stripe's own host (the World routes it to src/screens/checkout.tsx)
91
+ const id = ctx.mint(CS);
92
+ const fields: Row = {
93
+ mode, status: 'open',
94
+ payment_status: mode === 'setup' ? 'no_payment_required' : 'unpaid',
95
+ url: `https://checkout.stripe.com/c/pay/${id}`, success_url: params.success_url ?? null, cancel_url: params.cancel_url ?? null,
96
+ customer: params.customer ?? null, client_reference_id: params.client_reference_id ?? null,
97
+ customer_email: params.customer_email ?? null,
98
+ // whether paying makes a customer: in payment mode only when asked (`always`), `if_required` by default
99
+ customer_creation: mode === 'payment' ? (params.customer_creation === 'always' ? 'always' : 'if_required') : null,
100
+ amount_subtotal: entries.length ? items.reduce((s, it) => s + it.amount_subtotal, 0) : null,
101
+ amount_total: entries.length ? items.reduce((s, it) => s + it.amount_total, 0) - off : null,
102
+ currency: items[0]?.currency ?? (entries.length ? sessionCurrency : null), line_items: { object: 'list', data: items, has_more: false, url: `/v1/checkout/sessions/${id}/line_items` }, payment_intent: null, subscription: null, setup_intent: null, invoice: null, payment_method_types: ['card'], expires_at: now + 24 * 3600, custom_fields: [], shipping_options: [], custom_text: { after_submit: null, shipping_address: null, submit: null, terms_of_service_acceptance: null }, automatic_tax: { enabled: false, liability: null, status: null, provider: null }, total_details: { amount_discount: off, amount_shipping: 0, amount_tax: 0 },
103
+ discounts: discount ? [{ coupon: String(discount.coupon.id), promotion_code: discount.promotion ?? null }] : [],
104
+ metadata: params.metadata && typeof params.metadata === 'object' ? params.metadata : {}, livemode: false,
105
+ // "Details on the state of phone number collection for the session" (docs.stripe.com/api/checkout/sessions/object),
106
+ // off unless the create enables it
107
+ phone_number_collection: { enabled: (params.phone_number_collection as Row | undefined)?.enabled === true || (params.phone_number_collection as Row | undefined)?.enabled === 'true' },
108
+ };
109
+ if (params.subscription_data && typeof params.subscription_data === 'object') fields._subscription_data = params.subscription_data;
110
+ // each line's inline price_data, kept for what the session's line cannot carry: whether it recurs (isOneTime) and the
111
+ // Price a subscription's item is made from at completion (sessionPrices)
112
+ if (mode === 'subscription' && entries.some((e) => e.price_data && typeof e.price_data === 'object')) fields._price_data = entries.map((e) => (e.price_data && typeof e.price_data === 'object' ? e.price_data : null));
113
+ // the hosted page's brand name for this session (screens/checkout.tsx, merchantOf), and the account the session was
114
+ // made on (Stripe-Account: a direct charge), whose name the page shows. Kept apart from `_account`, the books a row is
115
+ // kept on: paying on the hosted page still makes the PaymentIntent and charge on the platform's books (a gap of the
116
+ // twin's direct-charge Checkout), so the session's events stay with them.
117
+ if (params.branding_settings && typeof params.branding_settings === 'object') fields._branding_settings = params.branding_settings;
118
+ if (actingAccount(ctx)) fields._brand_account = actingAccount(ctx);
119
+ // a payment session's payment_intent_data shapes the PaymentIntent paying makes: a destination charge's transfer and
120
+ // the platform's application fee (docs.stripe.com/connect/destination-charges?platform=web&ui=stripe-hosted)
121
+ const pid = params.payment_intent_data && typeof params.payment_intent_data === 'object' ? (params.payment_intent_data as Row) : undefined;
122
+ if (pid && mode === 'payment') {
123
+ const destination = (pid.transfer_data as Row | undefined)?.destination;
124
+ if (destination !== undefined && (typeof destination !== 'string' || !ctx.get('account', destination))) return noDestination(ctx, destination);
125
+ fields._payment_intent_data = pid;
126
+ }
127
+ if (discount) fields._discount = { coupon: String(discount.coupon.id), promotion_code: discount.promotion ?? null };
128
+ return ctx.reply(await created(ctx, CS, { id, ...fields }, {}));
129
+ };
130
+
131
+ // the session's items, a page at a time
132
+ const lineItems: Semantics = async (ctx) => {
133
+ const s = ctx.get(CS, at(ctx, 'session'));
134
+ if (!s) return sessionMissing(ctx, at(ctx, 'session'));
135
+ const li = s.line_items as { data?: Row[] } | undefined;
136
+ const { page, hasMore } = paginate(Array.isArray(li?.data) ? li!.data! : [], ctx.params);
137
+ return ctx.reply({ object: 'list', url: path(ctx), has_more: hasMore, data: page });
138
+ };
139
+
140
+ const expire: Semantics = async (ctx) => {
141
+ const id = at(ctx, 'session');
142
+ const s = ctx.get(CS, id);
143
+ if (!s) return sessionMissing(ctx, id);
144
+ const refused = ctx.legal(CS, 'status', 'PostCheckoutSessionsSessionExpire', s.status, undefined, id);
145
+ if (refused) return ctx.refuse(refused);
146
+ return ctx.reply(await ctx.write(CS, id, { status: 'expired', url: null }, 'checkout.session.expire'));
147
+ };
148
+
149
+ /** The card the customer entered on the page. */
150
+ export type EnteredCard = { number: string; exp_month: number; exp_year: number };
151
+
152
+ /** What completing a session makes, by its mode, as the fields that link it: a payment is a PaymentIntent paid with
153
+ * the entered card, its charge in the ledger as any payment's is. */
154
+ async function complete(ctx: SemanticsContext, id: string, existing: Row, card?: EnteredCard): Promise<Row> {
155
+ const link: Row = { status: 'complete' };
156
+ const customer = typeof existing.customer === 'string' ? existing.customer : undefined;
157
+ const currency = typeof existing.currency === 'string' ? existing.currency : 'usd';
158
+ // the entered card becomes a PaymentMethod, on the customer when there is one, carrying what a later charge to it
159
+ // answers and what Stripe does after it succeeds (docs.stripe.com/testing#declined-payments, #disputes)
160
+ const pm = card && existing.mode !== 'setup' ? await created(ctx, 'payment_method', {}, { type: 'card', customer: customer ?? null, livemode: false, billing_details: { address: null, email: null, name: null, phone: null }, ...paymentMethodSubObject('card', { card }), _declineOutcome: declineFor(() => undefined, { card }) ?? null, ...(afterSuccessOf(ctx, card) ? { _afterSuccess: afterSuccessOf(ctx, card) } : {}) }) : undefined;
161
+ if (existing.mode === 'payment') {
162
+ const amount = Number(existing.amount_total) || 0;
163
+ const piId = ctx.mint('payment_intent');
164
+ const pid = (ctx.row(CS, id)?._payment_intent_data ?? {}) as Row;
165
+ const connect = {
166
+ ...(pid.application_fee_amount !== undefined ? { application_fee_amount: Math.trunc(Number(pid.application_fee_amount) || 0) } : {}),
167
+ ...(pid.transfer_data && typeof pid.transfer_data === 'object' ? { transfer_data: { destination: (pid.transfer_data as Row).destination } } : {}),
168
+ ...(typeof pid.description === 'string' ? { description: pid.description } : {}),
169
+ ...(pid.metadata && typeof pid.metadata === 'object' ? { metadata: pid.metadata } : {}),
170
+ };
171
+ const fields = { amount, currency, id: piId, ...(customer ? { customer } : {}), ...(pm ? { payment_method: pm.id } : {}), ...connect };
172
+ await created(ctx, 'payment_intent', fields, { status: 'succeeded', amount_received: amount, client_secret: mintClientSecret(piId), livemode: false, payment_method_types: ['card'] });
173
+ const charge = await mintCharge(ctx, piId, fields, amount);
174
+ await ctx.write('payment_intent', piId, { latest_charge: charge }, 'payment_intent.succeeded');
175
+ link.payment_intent = piId;
176
+ link.payment_status = 'paid';
177
+ } else if (existing.mode === 'subscription') {
178
+ if (customer) {
179
+ // the subscription bills the session's own (already resolved) line items, and takes the
180
+ // session's subscription_data: metadata, description, and a trial as its first period
181
+ const now = Number(ctx.now());
182
+ // "Line items with one-time Prices will be on the initial invoice only" (docs.stripe.com/api/checkout/sessions/
183
+ // create#create_checkout_session-line_items): the subscription's items are the recurring ones
184
+ const inline = (ctx.row(CS, id)?._price_data as Array<Row | null> | undefined) ?? [];
185
+ const lines = ((existing.line_items as { data?: unknown } | undefined)?.data as Row[] | undefined) ?? [];
186
+ const allLineItems = inline.some(Boolean) ? await sessionPrices(ctx, lines, inline) : lines;
187
+ const sessionLineItems = allLineItems.filter((it) => !isOneTime(it));
188
+ const oneTime = allLineItems.filter((it) => isOneTime(it));
189
+ const { interval, interval_count } = resolveSubscriptionBillingInterval(sessionLineItems, finder(ctx));
190
+ const subId = ctx.mint('subscription');
191
+ // the session's view never carries its create-only subscription_data: read the stored row
192
+ const raw = ctx.row(CS, id)?._subscription_data;
193
+ const subData = (raw && typeof raw === 'object' ? raw : {}) as Row;
194
+ const subMetadata = subData.metadata && typeof subData.metadata === 'object' ? (subData.metadata as Row) : {};
195
+ // a trial of `trial_period_days` from now, or to `trial_end` (docs.stripe.com/payments/checkout/free-trials)
196
+ const trialEnd = resolveTrial(subData, now)?.end ?? null;
197
+ const sub = await created(ctx, 'subscription', { customer, id: subId }, {
198
+ status: trialEnd === null ? 'active' : 'trialing', livemode: false, currency, collection_method: 'charge_automatically',
199
+ // a trial's end is the billing anchor (docs.stripe.com/api/subscriptions/create#create_subscription-trial_end)
200
+ cancel_at_period_end: false, start_date: now, billing_cycle_anchor: trialEnd ?? now, metadata: subMetadata,
201
+ description: typeof subData.description === 'string' ? subData.description : null,
202
+ // the session's discount is the subscription's (docs.stripe.com/payments/checkout/discounts)
203
+ discounts: sessionDiscount(ctx, id, customer, now), billing_schedules: [],
204
+ trial_start: trialEnd === null ? null : now, trial_end: trialEnd,
205
+ current_period_start: now, current_period_end: trialEnd ?? addBillingInterval(now, interval, interval_count),
206
+ // Checkout saves the card as the subscription's default payment method (docs.stripe.com/payments/checkout/how-checkout-works)
207
+ default_payment_method: pm?.id ?? null,
208
+ items: buildSubscriptionItemsList(sessionLineItems, subId, now, finder(ctx)),
209
+ automatic_tax: { enabled: false, liability: null }, billing_mode: { type: 'classic' },
210
+ invoice_settings: { issuer: { type: 'self' } },
211
+ });
212
+ await storeItems(ctx, subId, ((sub.items as Row | undefined)?.data as Row[] | undefined) ?? []);
213
+ // its first invoice, paid on the page by the card just entered (nothing is due on a trial's)
214
+ // (docs.stripe.com/billing/subscriptions/overview#how-payments-work-subscriptions)
215
+ const invoice = await draftSubscriptionInvoice(ctx, sub, { start: now, end: Number(sub.current_period_end), reason: 'subscription_create', trial: trialEnd !== null, once: oneTime });
216
+ await collectSubscriptionInvoice(ctx, invoice, now, 'external');
217
+ await ctx.write('subscription', subId, { latest_invoice: invoice }, 'subscription.update');
218
+ link.subscription = sub.id;
219
+ link.invoice = invoice;
220
+ }
221
+ link.payment_status = 'paid';
222
+ } else link.setup_intent = await completeSetup(ctx, customer);
223
+ return link;
224
+ }
225
+
226
+ /** A line item of a one-time Price: a stored Price, or an inline `price_data`, without `recurring` ("Line items with
227
+ * one-time Prices will be on the initial invoice only", docs.stripe.com/api/checkout/sessions/create). */
228
+ export function isOneTime(item: Row, priceData?: Row | null): boolean {
229
+ if (priceData) return !priceData.recurring;
230
+ return !!item.price && typeof item.price === 'object' && !(item.price as Row).recurring;
231
+ }
232
+
233
+ /** A subscription session's lines at completion, each inline `price_data` made the Price it generates ("Data used to
234
+ * generate a new Price object inline", the same page), under the product it names or one made from its product_data,
235
+ * so the subscription's items bill it. Where the documentation stops and the twin decides: such a Price is archived
236
+ * (active=false), as a subscription item's price_data makes one (docs.stripe.com/products-prices/manage-prices). */
237
+ async function sessionPrices(ctx: SemanticsContext, lines: Row[], inline: Array<Row | null>): Promise<Row[]> {
238
+ const out: Row[] = [];
239
+ for (const [i, line] of lines.entries()) {
240
+ const d = inline[i];
241
+ if (!d) { out.push(line); continue; }
242
+ const pd = d.product_data && typeof d.product_data === 'object' ? (d.product_data as Row) : undefined;
243
+ const product = typeof d.product === 'string' ? d.product : String((await created(ctx, 'product', { name: pd?.name ?? 'Item' }, { active: true, livemode: false, images: [], marketing_features: [], metadata: {}, updated: ctx.now() })).id);
244
+ const r = d.recurring && typeof d.recurring === 'object' ? (d.recurring as Row) : undefined;
245
+ const price = await created(ctx, 'price', {
246
+ product, currency: String(d.currency ?? line.currency ?? 'usd').toLowerCase(),
247
+ unit_amount: d.unit_amount_decimal !== undefined ? null : Math.trunc(Number(d.unit_amount) || 0),
248
+ unit_amount_decimal: d.unit_amount_decimal !== undefined ? String(d.unit_amount_decimal) : String(Math.trunc(Number(d.unit_amount) || 0)),
249
+ ...(r ? { recurring: { interval: r.interval, interval_count: Math.max(1, Math.trunc(Number(r.interval_count ?? 1)) || 1), usage_type: 'licensed', trial_period_days: null, meter: null } } : { recurring: null }),
250
+ }, { active: false, livemode: false, billing_scheme: 'per_unit', type: r ? 'recurring' : 'one_time', metadata: {}, lookup_key: null, nickname: null, tax_behavior: 'unspecified' });
251
+ out.push({ ...line, price });
252
+ }
253
+ return out;
254
+ }
255
+
256
+ /** A setup session saves the card through a succeeded SetupIntent (docs.stripe.com/payments/save-and-reuse?platform=checkout). */
257
+ async function completeSetup(ctx: SemanticsContext, customer: string | undefined): Promise<unknown> {
258
+ const siId = ctx.mint('setup_intent');
259
+ const si = await created(ctx, 'setup_intent', { id: siId, ...(customer ? { customer } : {}) }, { status: 'succeeded', usage: 'off_session', client_secret: mintClientSecret(siId), payment_method_types: ['card'], livemode: false });
260
+ return si.id;
261
+ }
262
+
263
+ /** The discount a completed subscription session gives its subscription. */
264
+ function sessionDiscount(ctx: SemanticsContext, id: string, customer: string, now: number): Row[] {
265
+ const d = ctx.row(CS, id)?._discount as Row | undefined;
266
+ const coupon = d && typeof d.coupon === 'string' ? ctx.get('coupon', d.coupon) : undefined;
267
+ return coupon ? [{ ...buildDiscount(coupon, customer, now), promotion_code: d?.promotion_code ?? null, checkout_session: id }] : [];
268
+ }
269
+
270
+ /** The customer paying on the hosted page (src/screens/checkout.tsx): the external actor's move on the
271
+ * session's machine, then what the payment made, linked, and the session stored complete. */
272
+ export async function completeSession(ctx: SemanticsContext, id: string, existing: Row, details: Row = {}, card?: EnteredCard): Promise<Row | Response> {
273
+ const refused = ctx.legal(CS, 'status', 'PostCheckoutSessionsSession', existing.status, 'complete', id, 'external');
274
+ if (refused) return ctx.refuse(refused);
275
+ const link = await complete(ctx, id, existing, card);
276
+ // the discount the buyer took is redeemed (shared.ts redeemCoupon)
277
+ const d = ctx.row(CS, id)?._discount as Row | undefined;
278
+ if (d) await redeemCoupon(ctx, d.coupon, d.promotion_code);
279
+ // the session's url "is only present when the session is active" (docs.stripe.com/api/checkout/sessions/object)
280
+ return ctx.write(CS, id, { ...details, ...link, url: null }, 'checkout.session.completed');
281
+ }
282
+
283
+ const update: Semantics = async (ctx) => {
284
+ const id = at(ctx, 'session');
285
+ const existing = ctx.get(CS, id);
286
+ if (!existing) return sessionMissing(ctx, id);
287
+ // a session completes only when its customer pays on the hosted page (src/screens/checkout.tsx); the API takes no
288
+ // status, which the parameter check refuses as Stripe does (../stripe-params.ts)
289
+ return ctx.reply(await ctx.write(CS, id, ctx.params, 'checkout.session.update'));
290
+ };
291
+
292
+ export const checkout: Record<string, Semantics> = {
293
+ PostCheckoutSessions: create,
294
+ GetCheckoutSessionsSessionLineItems: lineItems,
295
+ PostCheckoutSessionsSessionExpire: expire,
296
+ PostCheckoutSessionsSession: update,
297
+ };