@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,251 @@
1
+ import { addBillingInterval, applyCouponDiscount, declineFor, mintClientSecret, priceAmount, resolveSubscriptionBillingInterval } from "../stripe-twin.js";
2
+ import { finalizedFields } from "./invoices.js";
3
+ import { mintCharge } from "./payment-intents.js";
4
+ import { at_, created, finder } from "./shared.js";
5
+ const SUB = 'subscription';
6
+ const INV = 'invoice';
7
+ const HOUR = 3600;
8
+ /** The statuses a subscription renews in. */
9
+ const RENEWING = ['trialing', 'active', 'past_due'];
10
+ /** A customer's now: its test clock's frozen time when it is on one (docs.stripe.com/billing/testing/test-clocks), the
11
+ * World's otherwise. */
12
+ export function clockNow(ctx, customer) {
13
+ const clock = typeof customer === 'string' ? ctx.get('customer', customer)?.test_clock : undefined;
14
+ const frozen = typeof clock === 'string' ? ctx.get('test_helpers.test_clock', clock)?.frozen_time : undefined;
15
+ return frozen !== undefined ? Number(frozen) : Number(ctx.now());
16
+ }
17
+ /** What a subscription's invoices charge: its own default payment method, else its customer's. */
18
+ export function payerOf(ctx, sub) {
19
+ return typeof sub.default_payment_method === 'string' && sub.default_payment_method ? sub.default_payment_method : customerDefault(ctx, sub);
20
+ }
21
+ /** The customer's default for invoices, charged when the subscription names none of its own. */
22
+ function customerDefault(ctx, sub) {
23
+ const customer = typeof sub.customer === 'string' ? ctx.get('customer', sub.customer) : undefined;
24
+ const settings = customer?.invoice_settings;
25
+ return typeof settings?.default_payment_method === 'string' && settings.default_payment_method ? settings.default_payment_method : undefined;
26
+ }
27
+ const itemsOf = (sub) => (sub.items?.data) ?? [];
28
+ /** An item's quantity as it bills: 1 when none is set, and 0 when 0 is (a quantity of 0 bills nothing). */
29
+ export function itemQuantity(it) {
30
+ return it.quantity === undefined || it.quantity === null ? 1 : Math.max(0, Math.trunc(Number(it.quantity) || 0));
31
+ }
32
+ /** The subscription's discounts with a `once` coupon's spent: "When a subscription uses a coupon with `duration=once`, the
33
+ * coupon is considered used after the invoice finalizes and is removed from the subscription's `discounts` array"
34
+ * (docs.stripe.com/billing/subscriptions/coupons#coupon-duration). */
35
+ export function unspentDiscounts(discounts) {
36
+ return (discounts ?? []).filter((d) => !(d && typeof d === 'object' && d.coupon?.duration === 'once'));
37
+ }
38
+ /** The coupon ids an invoice's discounts name. */
39
+ const couponsOf = (discounts) => new Set((discounts ?? []).map((d) => {
40
+ const c = d && typeof d === 'object' ? d.coupon : undefined;
41
+ return typeof c === 'string' ? c : c && typeof c === 'object' ? String(c.id ?? '') : '';
42
+ }).filter(Boolean));
43
+ /** A subscription whose invoice has just been finalized: the `once` coupon that invoice took is spent, and a once coupon
44
+ * it did not take (added after it was drafted) stays for the next (unspentDiscounts). */
45
+ export async function spendOnceCoupon(ctx, invoice) {
46
+ const subId = typeof invoice.subscription === 'string' ? invoice.subscription : undefined;
47
+ const sub = subId ? ctx.get(SUB, subId) : undefined;
48
+ if (!sub || !subId)
49
+ return;
50
+ const all = sub.discounts ?? [];
51
+ const kept = keptAfter(all, invoice);
52
+ if (kept.length !== all.length)
53
+ await ctx.write(SUB, subId, { discounts: kept }, 'subscription.discount_spent');
54
+ }
55
+ /** A subscription's discounts after `invoice` finalizes: less the `once` coupons it took. */
56
+ export function keptAfter(discounts, invoice) {
57
+ const taken = couponsOf(invoice.discounts);
58
+ return discounts.filter((d) => !(unspentDiscounts([d]).length === 0 && [...couponsOf([d])].some((c) => taken.has(c))));
59
+ }
60
+ /** A draft invoice for one period of a subscription, its lines the subscription's items and any one-time lines `once`
61
+ * gives it (a Checkout session's one-time prices, "on the initial invoice only", docs.stripe.com/api/checkout/sessions/
62
+ * create#create_checkout_session-line_items), under the subscription's discount. Answers the invoice id. */
63
+ export async function draftSubscriptionInvoice(ctx, sub, o) {
64
+ const id = o.id ?? ctx.mint(INV);
65
+ const currency = String(sub.currency ?? 'usd');
66
+ const lines = itemsOf(sub).map((it, i) => {
67
+ const price = it.price && typeof it.price === 'object' ? it.price : ctx.get('price', String(it.price ?? ''));
68
+ const quantity = itemQuantity(it);
69
+ return {
70
+ id: `il_twin_${id}_${i + 1}`, object: 'line_item', type: 'subscription',
71
+ // a trial's first invoice bills nothing for the trial period (docs.stripe.com/billing/subscriptions/trials)
72
+ amount: o.trial ? 0 : priceAmount(price, quantity), currency, quantity, proration: false,
73
+ price: price ?? null, subscription: sub.id, subscription_item: it.id ?? null, invoice_item: null,
74
+ period: { start: o.start, end: o.end }, description: o.trial ? `Trial period for ${String(ctx.get('product', String(price?.product ?? ''))?.name ?? 'the plan')}` : null,
75
+ };
76
+ });
77
+ if (o.once?.length)
78
+ lines.push(...oneTimeLines(id, sub, o.once, lines.length, o.start, currency));
79
+ const subtotal = lines.reduce((s, l) => s + l.amount, 0);
80
+ // a discount the subscription still carries applies; a `once` coupon is removed once an invoice finalizes
81
+ // (spendOnceCoupon), so it discounts one invoice (docs.stripe.com/billing/subscriptions/coupons#coupon-duration)
82
+ const discount = (sub.discounts ?? [])[0];
83
+ const coupon = discount && typeof discount === 'object' ? discount.coupon : undefined;
84
+ const off = coupon ? applyCouponDiscount(subtotal, coupon) : 0;
85
+ const total = Math.max(0, subtotal - off);
86
+ await created(ctx, INV, { id, customer: sub.customer, subscription: sub.id }, {
87
+ created: o.start, status: 'draft', livemode: false, currency, collection_method: 'charge_automatically',
88
+ billing_reason: o.reason, auto_advance: true, attempt_count: 0, attempted: false, next_payment_attempt: null,
89
+ amount_due: total, amount_paid: 0, amount_remaining: total, amount_overpaid: 0, amount_paid_off_stripe: 0, amount_shipping: 0,
90
+ subtotal, total, starting_balance: 0, post_payment_credit_notes_amount: 0, pre_payment_credit_notes_amount: 0,
91
+ // "The invoice that consumed the coupon still shows the applied discount" (docs.stripe.com/billing/subscriptions/coupons)
92
+ period_start: o.start, period_end: o.end, default_tax_rates: [], discounts: coupon && discount ? [discount] : [],
93
+ lines: { object: 'list', data: lines, has_more: false, total_count: lines.length, url: `/v1/invoices/${id}/lines` },
94
+ automatic_tax: { enabled: false, liability: null, status: null },
95
+ status_transitions: { finalized_at: null, marked_uncollectible_at: null, paid_at: null, voided_at: null },
96
+ issuer: { type: 'self' }, payment_settings: { default_mandate: null, payment_method_options: null, payment_method_types: null },
97
+ payment_intent: null, charge: null,
98
+ });
99
+ return id;
100
+ }
101
+ /** A Checkout session's one-time line items as lines of its subscription's first invoice, numbered after its `after` lines. */
102
+ function oneTimeLines(invoiceId, sub, items, after, at, currency) {
103
+ return items.map((it, i) => {
104
+ const price = it.price && typeof it.price === 'object' ? it.price : null;
105
+ const quantity = itemQuantity(it);
106
+ return {
107
+ id: `il_twin_${invoiceId}_${after + i + 1}`, object: 'line_item', type: 'subscription',
108
+ amount: priceAmount(price ?? undefined, quantity), currency, quantity, proration: false,
109
+ price, subscription: sub.id, subscription_item: null, invoice_item: null,
110
+ period: { start: at, end: at }, description: typeof it.description === 'string' ? it.description : null,
111
+ };
112
+ });
113
+ }
114
+ /** Finalize a subscription's draft invoice at `at` and charge what its subscription pays with. The move is time's (a
115
+ * renewal), the customer's (the Checkout page they paid on) or the API's (an update that ends a trial, which names the
116
+ * payer, may ask for no attempt, and moves the subscription itself: semantics/subscriptions.ts applyTrialEnd). Answers
117
+ * whether it was paid. */
118
+ export async function collectSubscriptionInvoice(ctx, invoiceId, at, actor, opts = {}) {
119
+ const inv = ctx.get(INV, invoiceId);
120
+ const sub = inv && typeof inv.subscription === 'string' ? ctx.get(SUB, inv.subscription) : undefined;
121
+ if (!inv || !sub || inv.status !== 'draft')
122
+ return false;
123
+ const total = Number(inv.total) || 0;
124
+ const numbered = { ...finalizedFields(ctx, inv, at), status_transitions: { ...inv.status_transitions, finalized_at: at } };
125
+ const op = ctx.call.operation.id;
126
+ // nothing due (a trial's first invoice) is paid as it is finalized; finalizing spends a `once` coupon
127
+ if (total <= 0) {
128
+ if (ctx.legal(INV, 'status', op, 'draft', 'paid', invoiceId, actor))
129
+ return false;
130
+ await spendOnceCoupon(ctx, inv);
131
+ await ctx.write(INV, invoiceId, { ...numbered, status: 'paid', paid: true, auto_advance: false, status_transitions: { ...numbered.status_transitions, paid_at: at } }, 'invoice.paid');
132
+ return true;
133
+ }
134
+ const payer = actor === 'api' ? opts.payer : payerOf(ctx, sub);
135
+ // default_incomplete finalizes the invoice without attempting payment (docs.stripe.com/api/subscriptions/update#update_subscription-payment_behavior)
136
+ const attempt = opts.attempt !== false;
137
+ const decline = payer && attempt ? declineFor(finder(ctx), { payment_method: payer }) : undefined;
138
+ const pays = attempt && !!payer && !decline;
139
+ if (ctx.legal(INV, 'status', op, 'draft', pays ? 'paid' : 'open', invoiceId, actor))
140
+ return false;
141
+ await spendOnceCoupon(ctx, inv);
142
+ const piId = ctx.mint('payment_intent');
143
+ const currency = String(inv.currency ?? 'usd');
144
+ const chargeId = pays ? await mintCharge(ctx, piId, { currency, customer: inv.customer, payment_method: payer, invoice: invoiceId }, total) : null;
145
+ await created(ctx, 'payment_intent', { id: piId, amount: total, currency, customer: inv.customer, invoice: invoiceId, payment_method: pays ? payer : null }, {
146
+ status: pays ? 'succeeded' : 'requires_payment_method', client_secret: mintClientSecret(piId), livemode: false,
147
+ capture_method: 'automatic', amount_capturable: 0, amount_received: pays ? total : 0, next_action: null, latest_charge: chargeId,
148
+ last_payment_error: decline ? { type: 'card_error', code: decline.code, decline_code: decline.decline_code ?? null, message: decline.message, payment_method: { id: payer } } : null,
149
+ automatic_payment_methods: null, payment_method_types: ['card'], payment_method_options: {},
150
+ });
151
+ await ctx.write(INV, invoiceId, {
152
+ ...numbered, status: pays ? 'paid' : 'open', paid: pays, attempt_count: attempt ? 1 : 0, attempted: attempt, payment_intent: piId, charge: chargeId,
153
+ amount_paid: pays ? total : 0, amount_remaining: pays ? 0 : total, auto_advance: attempt && !pays, next_payment_attempt: null,
154
+ status_transitions: { ...numbered.status_transitions, paid_at: pays ? at : null },
155
+ }, pays ? 'invoice.paid' : attempt ? 'invoice.payment_failed' : 'invoice.finalized');
156
+ if (actor !== 'api' && !pays && sub.status !== 'past_due' && !ctx.legal(SUB, 'status', op, sub.status, 'past_due', String(sub.id), 'time')) {
157
+ await ctx.write(SUB, String(sub.id), { status: 'past_due' }, 'customer.subscription.updated');
158
+ }
159
+ return pays;
160
+ }
161
+ /** Pay a subscription's open invoice with `paymentMethod` (the customer on the portal, `external`): the charge on the
162
+ * invoice's PaymentIntent, the invoice paid, a past_due subscription active. Answers the decline, if the card is refused. */
163
+ export async function payOpenInvoice(ctx, invoiceId, paymentMethod) {
164
+ const inv = ctx.get(INV, invoiceId);
165
+ if (!inv || inv.status !== 'open')
166
+ return { code: 'invoice_not_open', message: 'This invoice is no longer open.' };
167
+ const decline = declineFor(finder(ctx), { payment_method: paymentMethod });
168
+ if (decline)
169
+ return decline;
170
+ const op = ctx.call.operation.id;
171
+ const refused = ctx.legal(INV, 'status', op, 'open', 'paid', invoiceId, 'external');
172
+ if (refused)
173
+ return { code: refused.code ?? 'invoice_not_open', message: refused.message };
174
+ const total = Number(inv.amount_remaining ?? inv.total) || 0;
175
+ const now = Number(ctx.now());
176
+ const piId = typeof inv.payment_intent === 'string' ? inv.payment_intent : undefined;
177
+ let chargeId = null;
178
+ const pi = piId ? ctx.get('payment_intent', piId) : undefined;
179
+ if (piId && pi && !ctx.legal('payment_intent', 'status', op, pi.status, 'succeeded', piId, 'external')) {
180
+ chargeId = await mintCharge(ctx, piId, { ...pi, payment_method: paymentMethod, invoice: invoiceId }, total);
181
+ await ctx.write('payment_intent', piId, { status: 'succeeded', payment_method: paymentMethod, amount_received: total, last_payment_error: null, latest_charge: chargeId }, 'payment_intent.succeeded');
182
+ }
183
+ await ctx.write(INV, invoiceId, {
184
+ status: 'paid', paid: true, amount_paid: total, amount_remaining: 0, charge: chargeId, auto_advance: false,
185
+ attempt_count: (Number(inv.attempt_count) || 0) + 1, attempted: true,
186
+ status_transitions: { ...inv.status_transitions, paid_at: now },
187
+ }, 'invoice.paid');
188
+ const sub = typeof inv.subscription === 'string' ? ctx.get(SUB, inv.subscription) : undefined;
189
+ if (sub && sub.status === 'past_due' && !ctx.legal(SUB, 'status', op, 'past_due', 'active', String(sub.id), 'external')) {
190
+ await ctx.write(SUB, String(sub.id), { status: 'active' }, 'customer.subscription.updated');
191
+ }
192
+ return undefined;
193
+ }
194
+ /** Time's moves on subscriptions, caught up to each customer's clock in the order they fell due, each made at the
195
+ * moment it was due: a period that ended renews (a trial's end among them) or, set to cancel, cancels; a renewal
196
+ * drafted an hour or more ago is finalized and charged. */
197
+ export async function advanceBilling(ctx, only) {
198
+ const at = at_(ctx);
199
+ for (let guard = 0; guard < 10_000 && (await billingStep(ctx, at, only)); guard++)
200
+ ;
201
+ }
202
+ /** The earliest move that has come due, made at its moment; false when none has. */
203
+ async function billingStep(ctx, at, only) {
204
+ // the earliest move that has come due, of any subscription
205
+ let next;
206
+ for (const sub of ctx.rows(SUB)) {
207
+ if (only && !only(sub))
208
+ continue;
209
+ if (!RENEWING.includes(String(sub.status)) || typeof sub.current_period_end !== 'number' || sub.pause_collection)
210
+ continue;
211
+ if (sub.current_period_end <= clockNow(ctx, sub.customer) && (!next || sub.current_period_end < next.t))
212
+ next = { t: sub.current_period_end, kind: 'period', id: String(sub.id) };
213
+ }
214
+ for (const inv of ctx.rows(INV)) {
215
+ if (inv.status !== 'draft' || inv.billing_reason !== 'subscription_cycle' || inv.auto_advance !== true)
216
+ continue;
217
+ const sub = typeof inv.subscription === 'string' ? ctx.get(SUB, inv.subscription) : undefined;
218
+ if (!sub || (only && !only(sub)))
219
+ continue;
220
+ const t = Number(inv.created) + HOUR;
221
+ if (t <= clockNow(ctx, sub.customer) && (!next || t < next.t))
222
+ next = { t, kind: 'collect', id: String(inv.id) };
223
+ }
224
+ if (!next)
225
+ return false;
226
+ const c = await at(next.t);
227
+ if (next.kind === 'collect') {
228
+ await collectSubscriptionInvoice(c, next.id, next.t, 'time');
229
+ return true;
230
+ }
231
+ const id = next.id;
232
+ const sub = c.get(SUB, id);
233
+ const op = c.call.operation.id;
234
+ // one set to cancel at its period's end is canceled then, with no renewal
235
+ // (docs.stripe.com/billing/subscriptions/cancel#cancel-at-end-of-cycle)
236
+ if (sub.cancel_at_period_end === true) {
237
+ if (c.legal(SUB, 'status', op, sub.status, 'canceled', id, 'time'))
238
+ return false;
239
+ await c.write(SUB, id, { status: 'canceled', ended_at: next.t, canceled_at: sub.canceled_at ?? next.t }, 'customer.subscription.deleted');
240
+ return true;
241
+ }
242
+ const start = next.t;
243
+ const { interval, interval_count } = resolveSubscriptionBillingInterval(itemsOf(sub), finder(c));
244
+ const end = addBillingInterval(start, interval, interval_count);
245
+ const trialEnds = sub.status === 'trialing';
246
+ if (trialEnds && c.legal(SUB, 'status', op, 'trialing', 'active', id, 'time'))
247
+ return false;
248
+ const invoiceId = await draftSubscriptionInvoice(c, sub, { start, end, reason: 'subscription_cycle' });
249
+ await c.write(SUB, id, { current_period_start: start, current_period_end: end, latest_invoice: invoiceId, ...(trialEnds ? { status: 'active' } : {}) }, 'customer.subscription.updated');
250
+ return true;
251
+ }
@@ -0,0 +1,2 @@
1
+ import type { Semantics } from '@volter/world-core';
2
+ export declare const setupIntents: Record<string, Semantics>;
@@ -0,0 +1,84 @@
1
+ import { asBool, microdepositsNextAction, mintClientSecret, requiresMicrodeposits, verifyMicrodeposits } from "../stripe-twin.js";
2
+ import { mintMandate } from "./after-payment.js";
3
+ import { attachable, attachPaymentMethod, methodFromData, microdepositsAsked } from "./payment-methods.js";
4
+ import { at, confirmOnly, created, fail, mergeUpdate, send } from "./shared.js";
5
+ const SI = 'setup_intent';
6
+ /** The intent the path names, and the machine's refusal when this operation may not move it. */
7
+ function load(ctx, operationId) {
8
+ const id = at(ctx, 'intent');
9
+ const si = ctx.get(SI, id);
10
+ if (!si)
11
+ return { answer: fail(ctx, `No such setup_intent: '${id}'`, 404, 'resource_missing') };
12
+ const refused = ctx.legal(SI, 'status', operationId, si.status, undefined, id);
13
+ return refused ? { answer: ctx.refuse(refused) } : { si };
14
+ }
15
+ const create = async (ctx) => {
16
+ // mandate_data and return_url each "can only be used with `confirm=true`" (the served spec; shared.ts confirmOnly)
17
+ const unconfirmed = confirmOnly(ctx, ['mandate_data', 'return_url'], asBool(ctx.params.confirm));
18
+ if (unconfirmed)
19
+ return unconfirmed;
20
+ // the id is minted first so the client secret can carry it: Stripe.js parses the id back out
21
+ const id = typeof ctx.params.id === 'string' && ctx.params.id ? ctx.params.id : ctx.mint(SI);
22
+ // it waits for a payment method until it has one, then for its confirmation (manifest.ts)
23
+ const hasMethod = typeof ctx.params.payment_method === 'string' && ctx.params.payment_method !== '';
24
+ if (hasMethod)
25
+ ctx.legal(SI, 'status', 'PostSetupIntents', 'requires_payment_method', 'requires_confirmation', id);
26
+ return ctx.reply(
27
+ // allowed_payment_method_types in the served version names the methods (docs.stripe.com/api/setup_intents/create)
28
+ await created(ctx, SI, { ...ctx.params, ...(Array.isArray(ctx.params.allowed_payment_method_types) ? { payment_method_types: ctx.params.allowed_payment_method_types.map(String) } : {}), id }, {
29
+ status: hasMethod ? 'requires_confirmation' : 'requires_payment_method', usage: 'off_session', client_secret: mintClientSecret(id), payment_method_types: ['card'],
30
+ next_action: null, latest_attempt: null, last_setup_error: null, livemode: false,
31
+ }));
32
+ };
33
+ // a bank account that needs micro-deposit verification waits in requires_action; anything else succeeds. The confirm's
34
+ // payment_method_data and mandate_data are instructions, not fields of the SetupIntent.
35
+ const confirm = async (ctx) => {
36
+ const loaded = load(ctx, 'PostSetupIntentsIntentConfirm');
37
+ if ('answer' in loaded)
38
+ return loaded.answer;
39
+ const si = loaded.si;
40
+ const id = String(si.id);
41
+ const { payment_method_data: data, mandate_data: _mandate, ...fields } = ctx.params;
42
+ const made = await methodFromData(ctx, data);
43
+ const pm = made ?? (typeof ctx.params.payment_method === 'string' ? ctx.params.payment_method : typeof si.payment_method === 'string' ? si.payment_method : undefined);
44
+ if (si.status !== 'requires_action' && (requiresMicrodeposits(ctx.params, si) || microdepositsAsked(ctx, ctx.params, si, pm))) {
45
+ return ctx.reply(await ctx.write(SI, id, mergeUpdate(ctx, SI, id, { ...fields, ...(pm ? { payment_method: pm } : {}), status: 'requires_action', next_action: microdepositsNextAction() }), 'setup_intent.requires_action'));
46
+ }
47
+ // a setup for a customer saves the method to them (docs.stripe.com/payments/save-and-reuse): its payment method is attached
48
+ const customer = typeof si.customer === 'string' ? si.customer : undefined;
49
+ const saved = pm && customer && attachable(ctx, pm) && ctx.get('payment_method', pm)?.customer !== customer ? String((await attachPaymentMethod(ctx, pm, customer)).id) : pm;
50
+ return ctx.reply(await ctx.write(SI, id, mergeUpdate(ctx, SI, id, { ...fields, ...(saved ? { payment_method: saved } : {}), status: 'succeeded' }), 'setup_intent.confirm'));
51
+ };
52
+ const cancel = async (ctx) => {
53
+ const loaded = load(ctx, 'PostSetupIntentsIntentCancel');
54
+ if ('answer' in loaded)
55
+ return loaded.answer;
56
+ const cancellation_reason = typeof ctx.params.cancellation_reason === 'string' ? ctx.params.cancellation_reason : 'abandoned';
57
+ return ctx.reply(await ctx.write(SI, String(loaded.si.id), { status: 'canceled', cancellation_reason }, 'setup_intent.canceled'));
58
+ };
59
+ const verify = async (ctx) => {
60
+ const id = at(ctx, 'intent');
61
+ const si = ctx.get(SI, id);
62
+ if (!si)
63
+ return fail(ctx, `No such setup_intent: '${id}'`, 404, 'resource_missing');
64
+ // the deposit amounts are checked before the intent's state, as Stripe does
65
+ const wrong = verifyMicrodeposits(ctx.params, si);
66
+ if (wrong)
67
+ return send(ctx, wrong);
68
+ const refused = ctx.legal(SI, 'status', 'PostSetupIntentsIntentVerifyMicrodeposits', si.status, undefined, id);
69
+ if (refused)
70
+ return ctx.refuse(refused);
71
+ const mandate = await mintMandate(ctx, si.payment_method, 'multi_use');
72
+ // verified, the bank account is saved to the setup's customer, as a confirmed setup's method is
73
+ const pm = typeof si.payment_method === 'string' ? si.payment_method : undefined;
74
+ const customer = typeof si.customer === 'string' ? si.customer : undefined;
75
+ if (pm && customer && attachable(ctx, pm) && ctx.get('payment_method', pm)?.customer !== customer)
76
+ await attachPaymentMethod(ctx, pm, customer);
77
+ return ctx.reply(await ctx.write(SI, id, { status: 'succeeded', next_action: null, last_setup_error: null, mandate }, 'setup_intent.succeeded'));
78
+ };
79
+ export const setupIntents = {
80
+ PostSetupIntents: create,
81
+ PostSetupIntentsIntentConfirm: confirm,
82
+ PostSetupIntentsIntentCancel: cancel,
83
+ PostSetupIntentsIntentVerifyMicrodeposits: verify,
84
+ };
@@ -0,0 +1,82 @@
1
+ import type { SemanticsContext } from '@volter/world-core';
2
+ import { type StripeResponse } from '../stripe-twin.js';
3
+ export type Row = Record<string, unknown>;
4
+ export declare const send: (ctx: SemanticsContext, r: StripeResponse) => Response;
5
+ /** Stripe's error body: an invalid_request_error with the message, and the code when there is one. */
6
+ export declare const fail: (ctx: SemanticsContext, message: string, status?: number, code?: string) => Response;
7
+ /** A file parameter whose description requires a purpose refuses a file of another one. The served spec requires it
8
+ * on every such parameter ("Must have a `purpose` value of `issuing_logo`", personalization designs' card_logo;
9
+ * "The file's `purpose` must be one of the following: …", file links' file). Where the documentation stops and the
10
+ * twin decides: Stripe documents no error for it (docs.stripe.com/error-codes names none), so the twin answers the
11
+ * 400 invalid_request_error it answers for an unknown file, naming the parameter and the purposes allowed. */
12
+ export declare function filePurposeRefused(ctx: SemanticsContext, param: string, allowed: readonly string[]): Response | undefined;
13
+ /** The request's path, as the list envelope's `url` carries it. */
14
+ export declare const path: (ctx: SemanticsContext) => string;
15
+ /** A path parameter, by the name the spec gives it. */
16
+ export declare const at: (ctx: SemanticsContext, name: string) => string;
17
+ /** An update's fields laid over what is stored, as the derived core does: a nested hash is merged into the stored one,
18
+ * not put in its place (Stripe updates only the keys a request names; docs.stripe.com/api/metadata and each update
19
+ * reference), and an empty string unsets a key. */
20
+ export declare function mergeUpdate(ctx: SemanticsContext, resource: string, id: string, fields: Row): Row;
21
+ /** The tree's type for a resource: what its rows are stored under. */
22
+ export declare const stored: (resource: string) => string;
23
+ /** A resource's rows in Stripe's list order: newest first by `timeField`, ties by mint order (as the derived core breaks them). */
24
+ /** A subject's bookkeeping field (`_…`): kept on the stored row, never in the wire view ctx.get and ctx.rows answer. */
25
+ export declare const kept: (ctx: SemanticsContext, resource: string, row: Row, key: string) => unknown;
26
+ /** A removed discount as Stripe answers it: the discount, marked deleted (docs.stripe.com/api/discounts/delete). */
27
+ export declare function deletedDiscount(d: Row, now: unknown): Row;
28
+ /** A create's parameter that describes a confirmation, sent without confirm=true: the served spec gives each "This
29
+ * parameter can only be used with `confirm=true`" (off_session: "can only be used when confirm=true"); the message's
30
+ * wording is the twin's. Answers the refusal, or undefined. */
31
+ export declare function confirmOnly(ctx: SemanticsContext, names: string[], confirm: boolean): Response | undefined;
32
+ /** A coupon applied (and the promotion code it came by): its times_redeemed, "Number of times this coupon has been
33
+ * applied to a customer" (the served spec's coupon), counts it, as the code's "Number of times this promotion code
34
+ * has been used" (its promotion_code). Where the documentation stops and the twin decides: reaching max_redemptions
35
+ * does not yet lapse the coupon. */
36
+ export declare function redeemCoupon(ctx: SemanticsContext, couponId: unknown, promotionCode?: unknown): Promise<void>;
37
+ export declare function newest(ctx: SemanticsContext, resource: string, timeField?: string): Row[];
38
+ /** Keep the rows whose field equals the request's parameter of the same name, for each name given. */
39
+ /** A list's range filter on a unix time (`created[gte]=…&created[lt]=…`, or one exact time). */
40
+ export declare function inRange(t: unknown, v: unknown): boolean;
41
+ /** A context at the moment Stripe would have made a move time makes (a renewal, a payout on its schedule), so what
42
+ * the move writes carries that moment (its events' occurredAt, `created`, a charge's funds' available_on), not the
43
+ * moment a later request caught it up. */
44
+ export declare function at_(ctx: SemanticsContext): (t: number) => Promise<SemanticsContext>;
45
+ export declare function where(ctx: SemanticsContext, items: Row[], spec: Record<string, (item: Row, value: unknown) => boolean>): Row[];
46
+ /** A balance transaction as the clock reads it: available once its available_on has passed, the clock's move,
47
+ * asked of the machine as a write asks it (semantics/ledger.ts). */
48
+ export declare function asOf(ctx: SemanticsContext, t: Row): Row;
49
+ /** Stripe's expand walk: each dotted path replaces an id with the resource the manifest says it
50
+ * holds, one segment at a time; an id the tree lacks stays an id. A balance transaction reads as the clock has it
51
+ * (asOf), as its own retrieve does. */
52
+ export declare function expandRow(ctx: SemanticsContext, resource: string, body: Row, paths: string[][]): Row;
53
+ /** One resource with the request's `expand[]` applied. */
54
+ export declare const expanded: (ctx: SemanticsContext, resource: string, body: Row) => Row;
55
+ /** Stripe's list envelope over rows already in list order: a page, `has_more`, and `expand[]=data.*`. */
56
+ export declare function list(ctx: SemanticsContext, resource: string, items: Row[]): Response;
57
+ /** Stripe's search over a resource's rows (`query` in its search language). */
58
+ export declare const search: (ctx: SemanticsContext, resource: string, timeField?: string) => Response;
59
+ /** A create as Stripe stamps it: the caller's id when it gave one (seeding uses real ids), else a
60
+ * minted one; `object`, the creation time, the defaults, then the request's own fields. An object
61
+ * Stripe gives no creation time keeps the twin's as `_created`, for ordering. (A request parameter the
62
+ * object has no field for stays on the row, where the rules read it; the wire answer leaves it out,
63
+ * stripe-version.ts.) */
64
+ export declare function created(ctx: SemanticsContext, resource: string, params: Row, defaults: Row, opts?: {
65
+ timeField?: string;
66
+ operation?: string;
67
+ }): Promise<Row>;
68
+ /** A stored subject of any stored type, tombstones included, in the pack's view: what the pack's
69
+ * shared helpers look a price, a payment method or a card up by, read from the context's tree. */
70
+ export type Find = (storedType: string, id: string) => Row | undefined;
71
+ export declare const finder: (ctx: SemanticsContext) => Find;
72
+ /** A refund's destination_details for a card charge: "If this is a `card` refund, this hash contains the transaction
73
+ * specific details" and its `type` "can be `refund`, `reversal`, or `pending`" — a reversal, where "The original charge
74
+ * will drop off the bank statement altogether", for an authorization released uncaptured, else a refund, "a credit entry
75
+ * on the bank statement" (docs.stripe.com/api/refunds/object). The network's reference number the twin never has. A
76
+ * charge not paid by card answers none. */
77
+ export declare function refundDestination(charge: Row, type: 'refund' | 'reversal'): Row;
78
+ /** An account's external_accounts, "External accounts (bank accounts and debit cards) currently attached to this
79
+ * account" (docs.stripe.com/api/accounts/object), as the list its example answers. */
80
+ export declare const externalList: (account: string, data: Row[]) => Row;
81
+ /** The account's external_accounts list, rewritten from its external accounts after one is added or removed. */
82
+ export declare function syncExternals(ctx: SemanticsContext, account: string): Promise<void>;
@@ -0,0 +1,203 @@
1
+ import surface from '../generated/surface.gen.json' with { type: 'json' };
2
+ import { manifest } from "../manifest.js";
3
+ import { OBJECT_NAME, paginate, searchOver, stashVendorType, view } from "../stripe-twin.js";
4
+ export const send = (ctx, r) => ctx.reply(r.body, r.status);
5
+ /** Stripe's error body: an invalid_request_error with the message, and the code when there is one. */
6
+ export const fail = (ctx, message, status = 404, code) => ctx.refuse({ status, message, ...(code ? { code } : {}) });
7
+ /** A file parameter whose description requires a purpose refuses a file of another one. The served spec requires it
8
+ * on every such parameter ("Must have a `purpose` value of `issuing_logo`", personalization designs' card_logo;
9
+ * "The file's `purpose` must be one of the following: …", file links' file). Where the documentation stops and the
10
+ * twin decides: Stripe documents no error for it (docs.stripe.com/error-codes names none), so the twin answers the
11
+ * 400 invalid_request_error it answers for an unknown file, naming the parameter and the purposes allowed. */
12
+ export function filePurposeRefused(ctx, param, allowed) {
13
+ const given = ctx.params[param];
14
+ if (given === undefined || given === '')
15
+ return undefined;
16
+ const file = ctx.get('file', String(given));
17
+ if (!file)
18
+ return fail(ctx, `No such file upload: '${String(given)}'`, 400, 'resource_missing');
19
+ if (allowed.includes(String(file.purpose)))
20
+ return undefined;
21
+ const need = allowed.length === 1 ? `a \`purpose\` value of \`${allowed[0]}\`` : `a \`purpose\` of one of: ${allowed.map((a) => `\`${a}\``).join(', ')}`;
22
+ return fail(ctx, `Invalid ${param}: the file '${String(given)}' has a \`purpose\` of \`${String(file.purpose)}\`, but it must have ${need}.`, 400);
23
+ }
24
+ /** The request's path, as the list envelope's `url` carries it. */
25
+ export const path = (ctx) => new URL(ctx.call.request.url).pathname.replace(/\/+$/, '') || '/';
26
+ /** A path parameter, by the name the spec gives it. */
27
+ export const at = (ctx, name) => ctx.call.params[name] ?? '';
28
+ /** An update's fields laid over what is stored, as the derived core does: a nested hash is merged into the stored one,
29
+ * not put in its place (Stripe updates only the keys a request names; docs.stripe.com/api/metadata and each update
30
+ * reference), and an empty string unsets a key. */
31
+ export function mergeUpdate(ctx, resource, id, fields) {
32
+ const current = ctx.get(resource, id) ?? {};
33
+ const out = {};
34
+ for (const [k, v] of Object.entries(fields)) {
35
+ const prior = current[k];
36
+ out[k] = v && typeof v === 'object' && !Array.isArray(v) && prior && typeof prior === 'object' && !Array.isArray(prior)
37
+ ? Object.fromEntries(Object.entries({ ...prior, ...v }).filter(([, x]) => x !== ''))
38
+ : v;
39
+ }
40
+ return out;
41
+ }
42
+ /** The tree's type for a resource: what its rows are stored under. */
43
+ export const stored = (resource) => manifest.resources[resource]?.storedAs ?? resource;
44
+ /** A resource's rows in Stripe's list order: newest first by `timeField`, ties by mint order (as the derived core breaks them). */
45
+ /** A subject's bookkeeping field (`_…`): kept on the stored row, never in the wire view ctx.get and ctx.rows answer. */
46
+ export const kept = (ctx, resource, row, key) => ctx.row(resource, String(row.id), { withDeleted: true })?.[key];
47
+ /** A removed discount as Stripe answers it: the discount, marked deleted (docs.stripe.com/api/discounts/delete). */
48
+ export function deletedDiscount(d, now) {
49
+ const coupon = d.coupon && typeof d.coupon === 'object' ? String(d.coupon.id) : typeof d.coupon === 'string' ? d.coupon : null;
50
+ return {
51
+ id: d.id ?? `di_${coupon ?? 'twin'}`, object: 'discount', deleted: true, checkout_session: d.checkout_session ?? null, customer: d.customer ?? null,
52
+ invoice: d.invoice ?? null, invoice_item: d.invoice_item ?? null, promotion_code: d.promotion_code ?? null, subscription: d.subscription ?? null,
53
+ subscription_item: d.subscription_item ?? null, source: d.source ?? { type: 'coupon', coupon }, start: d.start ?? now,
54
+ };
55
+ }
56
+ /** A create's parameter that describes a confirmation, sent without confirm=true: the served spec gives each "This
57
+ * parameter can only be used with `confirm=true`" (off_session: "can only be used when confirm=true"); the message's
58
+ * wording is the twin's. Answers the refusal, or undefined. */
59
+ export function confirmOnly(ctx, names, confirm) {
60
+ const sent = confirm ? undefined : names.find((n) => ctx.params[n] !== undefined);
61
+ return sent ? fail(ctx, `\`${sent}\` can only be used when \`confirm\` is set to \`true\`.`, 400, 'parameter_invalid') : undefined;
62
+ }
63
+ /** A coupon applied (and the promotion code it came by): its times_redeemed, "Number of times this coupon has been
64
+ * applied to a customer" (the served spec's coupon), counts it, as the code's "Number of times this promotion code
65
+ * has been used" (its promotion_code). Where the documentation stops and the twin decides: reaching max_redemptions
66
+ * does not yet lapse the coupon. */
67
+ export async function redeemCoupon(ctx, couponId, promotionCode) {
68
+ const coupon = typeof couponId === 'string' ? ctx.get('coupon', couponId) : undefined;
69
+ if (coupon)
70
+ await ctx.write('coupon', couponId, { times_redeemed: (Number(coupon.times_redeemed) || 0) + 1 }, 'coupon.redeemed');
71
+ const code = typeof promotionCode === 'string' ? ctx.get('promotion_code', promotionCode) : undefined;
72
+ if (code)
73
+ await ctx.write('promotion_code', promotionCode, { times_redeemed: (Number(code.times_redeemed) || 0) + 1 }, 'promotion_code.redeemed');
74
+ }
75
+ export function newest(ctx, resource, timeField = 'created') {
76
+ // an object Stripe gives no creation time is ordered by the one the twin keeps for it
77
+ const raw = new Map(ctx.rowsRaw(resource).map((r) => [String(r.id), r]));
78
+ const when = (r) => Number(r[timeField] ?? raw.get(String(r.id))?._created ?? 0);
79
+ return ctx.rows(resource).sort((a, b) => {
80
+ const ta = when(a), tb = when(b);
81
+ if (tb !== ta)
82
+ return tb - ta;
83
+ return String(b.id).localeCompare(String(a.id), undefined, { numeric: true });
84
+ });
85
+ }
86
+ /** Keep the rows whose field equals the request's parameter of the same name, for each name given. */
87
+ /** A list's range filter on a unix time (`created[gte]=…&created[lt]=…`, or one exact time). */
88
+ export function inRange(t, v) {
89
+ const at = Number(t);
90
+ const r = (v && typeof v === 'object' ? v : { gte: v, lte: v });
91
+ return (r.gt === undefined || at > Number(r.gt)) && (r.gte === undefined || at >= Number(r.gte)) && (r.lt === undefined || at < Number(r.lt)) && (r.lte === undefined || at <= Number(r.lte));
92
+ }
93
+ /** A context at the moment Stripe would have made a move time makes (a renewal, a payout on its schedule), so what
94
+ * the move writes carries that moment (its events' occurredAt, `created`, a charge's funds' available_on), not the
95
+ * moment a later request caught it up. */
96
+ export function at_(ctx) {
97
+ return (t) => ctx.at(new Date(t * 1000).toISOString());
98
+ }
99
+ export function where(ctx, items, spec) {
100
+ let out = items;
101
+ for (const [key, pred] of Object.entries(spec)) {
102
+ const value = ctx.params[key];
103
+ if (value !== undefined)
104
+ out = out.filter((item) => pred(item, value));
105
+ }
106
+ return out;
107
+ }
108
+ function expandPaths(ctx) {
109
+ const expand = ctx.params.expand;
110
+ return Array.isArray(expand) ? expand.map((p) => String(p).split('.')) : [];
111
+ }
112
+ /** A balance transaction as the clock reads it: available once its available_on has passed, the clock's move,
113
+ * asked of the machine as a write asks it (semantics/ledger.ts). */
114
+ export function asOf(ctx, t) {
115
+ const due = Number(t.available_on);
116
+ if (t.status !== 'pending' || !Number.isFinite(due) || due > Number(ctx.now()))
117
+ return t;
118
+ ctx.legal('balance_transaction', 'status', ctx.call.operation.id, 'pending', 'available', String(t.id), 'time');
119
+ return { ...t, status: 'available' };
120
+ }
121
+ /** Stripe's expand walk: each dotted path replaces an id with the resource the manifest says it
122
+ * holds, one segment at a time; an id the tree lacks stays an id. A balance transaction reads as the clock has it
123
+ * (asOf), as its own retrieve does. */
124
+ export function expandRow(ctx, resource, body, paths) {
125
+ const embeds = manifest.resources[resource]?.embeds;
126
+ if (!embeds || paths.length === 0)
127
+ return body;
128
+ let out = body;
129
+ const byHead = new Map();
130
+ for (const p of paths) {
131
+ if (p.length === 0)
132
+ continue;
133
+ const list = byHead.get(p[0]) ?? [];
134
+ list.push(p.slice(1));
135
+ byHead.set(p[0], list);
136
+ }
137
+ for (const [head, tails] of byHead) {
138
+ const target = embeds[head];
139
+ if (!target)
140
+ continue;
141
+ const cur = out[head];
142
+ const found = typeof cur === 'string' && cur ? ctx.get(target, cur) : cur && typeof cur === 'object' && !Array.isArray(cur) ? cur : undefined;
143
+ if (!found)
144
+ continue;
145
+ const sub = target === 'balance_transaction' ? asOf(ctx, found) : found;
146
+ const deeper = tails.filter((t) => t.length > 0);
147
+ out = { ...out, [head]: deeper.length ? expandRow(ctx, target, sub, deeper) : sub };
148
+ }
149
+ return out;
150
+ }
151
+ /** One resource with the request's `expand[]` applied. */
152
+ export const expanded = (ctx, resource, body) => expandRow(ctx, resource, body, expandPaths(ctx));
153
+ /** Stripe's list envelope over rows already in list order: a page, `has_more`, and `expand[]=data.*`. */
154
+ export function list(ctx, resource, items) {
155
+ const { page, hasMore } = paginate(items, ctx.params);
156
+ const paths = expandPaths(ctx).filter((p) => p[0] === 'data').map((p) => p.slice(1)).filter((p) => p.length > 0);
157
+ return ctx.reply({ object: 'list', url: path(ctx), has_more: hasMore, data: paths.length ? page.map((r) => expandRow(ctx, resource, r, paths)) : page });
158
+ }
159
+ /** Stripe's search over a resource's rows (`query` in its search language). */
160
+ export const search = (ctx, resource, timeField = 'created') => send(ctx, searchOver(newest(ctx, resource, timeField), ctx.params, path(ctx)));
161
+ const SPEC_FIELDS = new Map(surface.resources.map((r) => [r.name, new Set(r.fields.map((f) => f.name))]));
162
+ /** A create as Stripe stamps it: the caller's id when it gave one (seeding uses real ids), else a
163
+ * minted one; `object`, the creation time, the defaults, then the request's own fields. An object
164
+ * Stripe gives no creation time keeps the twin's as `_created`, for ordering. (A request parameter the
165
+ * object has no field for stays on the row, where the rules read it; the wire answer leaves it out,
166
+ * stripe-version.ts.) */
167
+ export async function created(ctx, resource, params, defaults, opts = {}) {
168
+ const { id: provided, ...rest } = params;
169
+ const type = stored(resource);
170
+ const id = typeof provided === 'string' && provided ? provided : ctx.mint(resource);
171
+ const known = SPEC_FIELDS.get(resource);
172
+ const timeField = opts.timeField ?? 'created';
173
+ const stamped = !known || known.has(timeField) || timeField in defaults || timeField in rest ? timeField : '_created';
174
+ const fields = stashVendorType(type, { object: OBJECT_NAME[type] ?? type, [stamped]: ctx.now(), ...defaults, ...rest });
175
+ return ctx.write(resource, id, fields, opts.operation ?? `${type}.create`);
176
+ }
177
+ export const finder = (ctx) => {
178
+ const tree = ctx.tree();
179
+ return (storedType, id) => {
180
+ const r = tree.find((x) => x.type === storedType && x.id === id);
181
+ return r ? view(storedType, r) : undefined;
182
+ };
183
+ };
184
+ /** A refund's destination_details for a card charge: "If this is a `card` refund, this hash contains the transaction
185
+ * specific details" and its `type` "can be `refund`, `reversal`, or `pending`" — a reversal, where "The original charge
186
+ * will drop off the bank statement altogether", for an authorization released uncaptured, else a refund, "a credit entry
187
+ * on the bank statement" (docs.stripe.com/api/refunds/object). The network's reference number the twin never has. A
188
+ * charge not paid by card answers none. */
189
+ export function refundDestination(charge, type) {
190
+ const pmd = charge.payment_method_details;
191
+ if (pmd && pmd.type !== 'card')
192
+ return {};
193
+ return { destination_details: { type: 'card', card: { reference: null, reference_status: null, reference_type: null, type } } };
194
+ }
195
+ /** An account's external_accounts, "External accounts (bank accounts and debit cards) currently attached to this
196
+ * account" (docs.stripe.com/api/accounts/object), as the list its example answers. */
197
+ export const externalList = (account, data) => ({ object: 'list', data, has_more: false, total_count: data.length, url: `/v1/accounts/${account}/external_accounts` });
198
+ /** The account's external_accounts list, rewritten from its external accounts after one is added or removed. */
199
+ export async function syncExternals(ctx, account) {
200
+ if (!ctx.get('account', account))
201
+ return;
202
+ await ctx.write('account', account, { external_accounts: externalList(account, newest(ctx, 'external_account').filter((e) => e.account === account)) }, 'account.external_accounts');
203
+ }
@@ -0,0 +1,2 @@
1
+ import type { Semantics } from '@volter/world-core';
2
+ export declare const subscriptionSchedules: Record<string, Semantics>;