@volter/twin-stripe 2.0.38 → 3.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 (402) hide show
  1. package/README.md +112 -207
  2. package/dist/generated/pack-facts.json +94 -0
  3. package/dist/journeys/coverage.json +596 -0
  4. package/dist/journeys/customer-life.json +6108 -0
  5. package/dist/journeys/customer-life.outline.md +371 -0
  6. package/dist/journeys/decisions.json +677 -0
  7. package/dist/journeys/demand.json +706 -0
  8. package/dist/journeys/score.json +853 -0
  9. package/dist/journeys/unreachable.json +102 -0
  10. package/dist/journeys/vendor-examples.json +8010 -0
  11. package/dist/spec/SOURCE.md +18 -0
  12. package/dist/spec/doc-examples.json +445 -0
  13. package/dist/spec/fixtures3.json.gz +0 -0
  14. package/dist/spec/openapi.json.gz +0 -0
  15. package/dist/spec/patches.json +34 -0
  16. package/dist/spec/sources.json +306 -0
  17. package/dist/src/cli.d.ts +0 -1
  18. package/dist/src/cli.js +11 -31
  19. package/dist/src/engine/after-payment.d.ts +8 -0
  20. package/dist/src/engine/after-payment.js +27 -0
  21. package/dist/src/engine/apps-secrets.d.ts +1 -0
  22. package/dist/src/engine/apps-secrets.js +3 -0
  23. package/dist/src/engine/balance.d.ts +5 -0
  24. package/dist/src/engine/balance.js +15 -0
  25. package/dist/src/engine/checkout.d.ts +24 -0
  26. package/dist/src/engine/checkout.js +30 -0
  27. package/dist/src/engine/common.d.ts +19 -0
  28. package/dist/src/engine/common.js +34 -0
  29. package/dist/src/engine/connect.d.ts +45 -0
  30. package/dist/src/engine/connect.js +108 -0
  31. package/dist/src/engine/customers.d.ts +4 -0
  32. package/dist/src/engine/customers.js +8 -0
  33. package/dist/src/engine/display.d.ts +11 -0
  34. package/dist/src/engine/display.js +13 -0
  35. package/dist/src/engine/files.d.ts +11 -0
  36. package/dist/src/engine/files.js +23 -0
  37. package/dist/src/engine/invoices.d.ts +21 -0
  38. package/dist/src/engine/invoices.js +84 -0
  39. package/dist/src/engine/issuing.d.ts +33 -0
  40. package/dist/src/engine/issuing.js +54 -0
  41. package/dist/src/engine/ledger.d.ts +8 -0
  42. package/dist/src/engine/ledger.js +8 -0
  43. package/dist/src/engine/oauth.d.ts +4 -0
  44. package/dist/src/engine/oauth.js +24 -0
  45. package/dist/src/engine/onboarding.d.ts +13 -0
  46. package/dist/src/engine/onboarding.js +42 -0
  47. package/dist/src/{stripe-params.d.ts → engine/params.d.ts} +20 -3
  48. package/dist/src/{stripe-params.js → engine/params.js} +7 -60
  49. package/dist/src/engine/payment-intents.d.ts +10 -0
  50. package/dist/src/engine/payment-intents.js +27 -0
  51. package/dist/src/engine/payment-methods.d.ts +16 -0
  52. package/dist/src/engine/payment-methods.js +22 -0
  53. package/dist/src/engine/radar.d.ts +2 -0
  54. package/dist/src/engine/radar.js +5 -0
  55. package/dist/src/engine/refunds.d.ts +1 -0
  56. package/dist/src/engine/refunds.js +3 -0
  57. package/dist/src/engine/renewals.d.ts +36 -0
  58. package/dist/src/engine/renewals.js +65 -0
  59. package/dist/src/{stripe-twin.d.ts → engine/stripe.d.ts} +12 -45
  60. package/dist/src/{stripe-twin.js → engine/stripe.js} +53 -387
  61. package/dist/src/engine/subscriptions.d.ts +11 -0
  62. package/dist/src/engine/subscriptions.js +30 -0
  63. package/dist/src/engine/transfers.d.ts +2 -0
  64. package/dist/src/engine/transfers.js +1 -0
  65. package/dist/src/{stripe-version.d.ts → engine/version.d.ts} +0 -2
  66. package/dist/src/{stripe-version.js → engine/version.js} +21 -9
  67. package/dist/src/engine/webhook-endpoints.d.ts +1 -0
  68. package/dist/src/engine/webhook-endpoints.js +12 -0
  69. package/dist/src/engine/webhooks.d.ts +31 -0
  70. package/dist/src/engine/webhooks.js +68 -0
  71. package/dist/src/fetch.d.ts +2 -0
  72. package/dist/src/fetch.js +26 -0
  73. package/dist/src/generated/surface.gen.json +1 -1
  74. package/dist/src/generated/ui.gen.json +1 -1
  75. package/dist/src/index.d.ts +5 -14
  76. package/dist/src/index.js +10 -75
  77. package/dist/src/manifest.js +478 -876
  78. package/dist/src/screens/billing-portal.d.ts +3 -0
  79. package/dist/src/screens/{portal.js → billing-portal.js} +32 -58
  80. package/dist/src/screens/checkout-js.d.ts +2 -0
  81. package/dist/src/screens/checkout-js.js +84 -0
  82. package/dist/src/screens/checkout.d.ts +2 -31
  83. package/dist/src/screens/checkout.js +32 -121
  84. package/dist/src/screens/connect-onboarding.d.ts +3 -0
  85. package/dist/src/screens/{onboarding.js → connect-onboarding.js} +38 -104
  86. package/dist/src/screens/dashboard.d.ts +3 -0
  87. package/dist/src/screens/dashboard.js +116 -0
  88. package/dist/src/screens/shared.d.ts +27 -0
  89. package/dist/src/screens/shared.js +87 -0
  90. package/dist/src/screens/stripe-js.d.ts +3 -0
  91. package/dist/src/screens/stripe-js.js +95 -0
  92. package/dist/src/semantics/account.d.ts +2 -0
  93. package/dist/src/semantics/account.js +8 -0
  94. package/dist/src/semantics/account_links.d.ts +2 -0
  95. package/dist/src/semantics/account_links.js +22 -0
  96. package/dist/src/semantics/accounts.d.ts +4 -0
  97. package/dist/src/semantics/accounts.js +40 -0
  98. package/dist/src/semantics/apps.d.ts +4 -0
  99. package/dist/src/semantics/{apps-secrets.js → apps.js} +14 -26
  100. package/dist/src/semantics/around.d.ts +2 -0
  101. package/dist/src/semantics/around.js +13 -0
  102. package/dist/src/semantics/balance.d.ts +2 -11
  103. package/dist/src/semantics/balance.js +3 -194
  104. package/dist/src/semantics/balance_transactions.d.ts +2 -0
  105. package/dist/src/semantics/balance_transactions.js +15 -0
  106. package/dist/src/semantics/billing_portal.d.ts +4 -0
  107. package/dist/src/semantics/billing_portal.js +36 -0
  108. package/dist/src/semantics/charges.d.ts +3 -28
  109. package/dist/src/semantics/charges.js +5 -202
  110. package/dist/src/semantics/checkout.d.ts +3 -20
  111. package/dist/src/semantics/checkout.js +21 -271
  112. package/dist/src/semantics/clock.d.ts +2 -0
  113. package/dist/src/semantics/clock.js +15 -0
  114. package/dist/src/semantics/coupons.d.ts +2 -6
  115. package/dist/src/semantics/coupons.js +2 -60
  116. package/dist/src/semantics/customers.d.ts +5 -6
  117. package/dist/src/semantics/customers.js +47 -413
  118. package/dist/src/semantics/doors.d.ts +23 -0
  119. package/dist/src/semantics/doors.js +73 -0
  120. package/dist/src/semantics/entitlements.d.ts +0 -2
  121. package/dist/src/semantics/entitlements.js +4 -95
  122. package/dist/src/semantics/events.d.ts +11 -0
  123. package/dist/src/semantics/events.js +31 -0
  124. package/dist/src/semantics/index.d.ts +2 -0
  125. package/dist/src/semantics/index.js +85 -0
  126. package/dist/src/semantics/invoice_payments.d.ts +2 -0
  127. package/dist/src/semantics/invoice_payments.js +12 -0
  128. package/dist/src/semantics/invoices.d.ts +11 -18
  129. package/dist/src/semantics/invoices.js +121 -440
  130. package/dist/src/semantics/issuing.d.ts +6 -13
  131. package/dist/src/semantics/issuing.js +58 -556
  132. package/dist/src/semantics/payment_intents.d.ts +3 -0
  133. package/dist/src/semantics/payment_intents.js +71 -0
  134. package/dist/src/semantics/payment_methods.d.ts +3 -0
  135. package/dist/src/semantics/payment_methods.js +15 -0
  136. package/dist/src/semantics/payouts.d.ts +3 -0
  137. package/dist/src/semantics/payouts.js +28 -0
  138. package/dist/src/semantics/prices.d.ts +4 -0
  139. package/dist/src/semantics/prices.js +37 -0
  140. package/dist/src/semantics/products.d.ts +4 -2
  141. package/dist/src/semantics/products.js +12 -138
  142. package/dist/src/semantics/promotion_codes.d.ts +3 -0
  143. package/dist/src/semantics/promotion_codes.js +35 -0
  144. package/dist/src/semantics/radar.d.ts +2 -2
  145. package/dist/src/semantics/radar.js +5 -74
  146. package/dist/src/semantics/refunds.d.ts +2 -9
  147. package/dist/src/semantics/refunds.js +3 -193
  148. package/dist/src/semantics/shared.d.ts +558 -55
  149. package/dist/src/semantics/shared.js +2070 -114
  150. package/dist/src/semantics/states.d.ts +462 -0
  151. package/dist/src/semantics/states.js +634 -0
  152. package/dist/src/semantics/subscriptions.d.ts +6 -11
  153. package/dist/src/semantics/subscriptions.js +37 -530
  154. package/dist/src/semantics/test_helpers.d.ts +3 -0
  155. package/dist/src/semantics/test_helpers.js +125 -0
  156. package/dist/src/semantics/transfers.d.ts +2 -2
  157. package/dist/src/semantics/transfers.js +6 -101
  158. package/dist/src/semantics/webhook_endpoints.d.ts +2 -0
  159. package/dist/src/semantics/webhook_endpoints.js +26 -0
  160. package/dist/src/server.d.ts +7 -0
  161. package/dist/src/server.js +7 -0
  162. package/generated/pack-facts.json +94 -0
  163. package/journeys/coverage.json +596 -0
  164. package/journeys/customer-life.json +6108 -0
  165. package/journeys/customer-life.outline.md +371 -0
  166. package/journeys/decisions.json +677 -0
  167. package/journeys/demand.json +706 -0
  168. package/journeys/score.json +853 -0
  169. package/journeys/unreachable.json +102 -0
  170. package/journeys/vendor-examples.json +8010 -0
  171. package/package.json +11 -20
  172. package/spec/SOURCE.md +18 -0
  173. package/spec/doc-examples.json +445 -0
  174. package/spec/fixtures3.json.gz +0 -0
  175. package/spec/openapi.json.gz +0 -0
  176. package/spec/patches.json +34 -0
  177. package/spec/sources.json +306 -0
  178. package/src/cli.ts +11 -28
  179. package/src/engine/after-payment.ts +29 -0
  180. package/src/engine/apps-secrets.ts +4 -0
  181. package/src/engine/balance.ts +32 -0
  182. package/src/engine/checkout.ts +39 -0
  183. package/src/engine/common.ts +49 -0
  184. package/src/engine/connect.ts +106 -0
  185. package/src/engine/customers.ts +10 -0
  186. package/src/engine/display.ts +18 -0
  187. package/src/engine/files.ts +28 -0
  188. package/src/engine/invoices.ts +102 -0
  189. package/src/engine/issuing.ts +66 -0
  190. package/src/engine/ledger.ts +48 -0
  191. package/src/engine/oauth.ts +18 -0
  192. package/src/engine/onboarding.ts +53 -0
  193. package/src/{stripe-params.ts → engine/params.ts} +7 -56
  194. package/src/engine/payment-intents.ts +32 -0
  195. package/src/engine/payment-methods.ts +24 -0
  196. package/src/engine/radar.ts +6 -0
  197. package/src/engine/refunds.ts +3 -0
  198. package/src/engine/renewals.ts +72 -0
  199. package/src/{stripe-twin.ts → engine/stripe.ts} +57 -400
  200. package/src/engine/subscriptions.ts +37 -0
  201. package/src/engine/transfers.ts +22 -0
  202. package/src/{stripe-version.ts → engine/version.ts} +19 -10
  203. package/src/engine/webhook-endpoints.ts +14 -0
  204. package/src/engine/webhooks.ts +70 -0
  205. package/src/fetch.ts +27 -0
  206. package/src/generated/surface.gen.json +1 -1
  207. package/src/generated/ui.gen.json +1 -1
  208. package/src/index.ts +10 -116
  209. package/src/manifest.ts +480 -904
  210. package/src/screens/{portal.tsx → billing-portal.tsx} +35 -42
  211. package/src/screens/checkout-js.ts +80 -0
  212. package/src/screens/checkout.tsx +32 -96
  213. package/src/screens/{onboarding.tsx → connect-onboarding.tsx} +39 -85
  214. package/src/screens/dashboard.tsx +178 -0
  215. package/src/screens/shared.tsx +84 -0
  216. package/src/screens/stripe-js.ts +102 -0
  217. package/src/semantics/account.ts +10 -0
  218. package/src/semantics/account_links.ts +20 -0
  219. package/src/semantics/accounts.ts +44 -0
  220. package/src/semantics/{apps-secrets.ts → apps.ts} +17 -33
  221. package/src/semantics/around.ts +17 -0
  222. package/src/semantics/balance.ts +6 -208
  223. package/src/semantics/balance_transactions.ts +18 -0
  224. package/src/semantics/billing_portal.ts +45 -0
  225. package/src/semantics/charges.ts +8 -212
  226. package/src/semantics/checkout.ts +25 -276
  227. package/src/semantics/clock.ts +19 -0
  228. package/src/semantics/coupons.ts +5 -68
  229. package/src/semantics/customers.ts +48 -414
  230. package/src/semantics/doors.ts +79 -0
  231. package/src/semantics/entitlements.ts +3 -94
  232. package/src/semantics/events.ts +37 -0
  233. package/src/semantics/index.ts +89 -0
  234. package/src/semantics/invoice_payments.ts +16 -0
  235. package/src/semantics/invoices.ts +112 -441
  236. package/src/semantics/issuing.ts +57 -573
  237. package/src/semantics/payment_intents.ts +76 -0
  238. package/src/semantics/payment_methods.ts +20 -0
  239. package/src/semantics/payouts.ts +29 -0
  240. package/src/semantics/prices.ts +41 -0
  241. package/src/semantics/products.ts +12 -146
  242. package/src/semantics/promotion_codes.ts +38 -0
  243. package/src/semantics/radar.ts +8 -79
  244. package/src/semantics/refunds.ts +4 -216
  245. package/src/semantics/shared.ts +2089 -135
  246. package/src/semantics/states.ts +662 -0
  247. package/src/semantics/subscriptions.ts +39 -542
  248. package/src/semantics/test_helpers.ts +119 -0
  249. package/src/semantics/transfers.ts +10 -132
  250. package/src/semantics/webhook_endpoints.ts +27 -0
  251. package/src/server.ts +7 -0
  252. package/client/dashboard-api.ts +0 -286
  253. package/client/stripe-mirror.css +0 -277
  254. package/client/stripe-mirror.tsx +0 -1559
  255. package/dist/client/dashboard-api.d.ts +0 -107
  256. package/dist/client/dashboard-api.js +0 -238
  257. package/dist/client/dashboard-api.ts +0 -286
  258. package/dist/client/stripe-mirror.bundle.js +0 -236
  259. package/dist/client/stripe-mirror.css +0 -277
  260. package/dist/client/stripe-mirror.d.ts +0 -134
  261. package/dist/client/stripe-mirror.js +0 -859
  262. package/dist/client/stripe-mirror.tsx +0 -1559
  263. package/dist/src/screens/connect-oauth.d.ts +0 -27
  264. package/dist/src/screens/connect-oauth.js +0 -414
  265. package/dist/src/screens/connect-settings.d.ts +0 -22
  266. package/dist/src/screens/connect-settings.js +0 -103
  267. package/dist/src/screens/consent-skin.d.ts +0 -4
  268. package/dist/src/screens/consent-skin.js +0 -18
  269. package/dist/src/screens/financial-connections.d.ts +0 -5
  270. package/dist/src/screens/financial-connections.js +0 -90
  271. package/dist/src/screens/identity.d.ts +0 -5
  272. package/dist/src/screens/identity.js +0 -86
  273. package/dist/src/screens/onboarding.d.ts +0 -13
  274. package/dist/src/screens/portal.d.ts +0 -5
  275. package/dist/src/screens/public-details.d.ts +0 -5
  276. package/dist/src/screens/public-details.js +0 -90
  277. package/dist/src/semantics/after-payment.d.ts +0 -22
  278. package/dist/src/semantics/after-payment.js +0 -99
  279. package/dist/src/semantics/apps-secrets.d.ts +0 -2
  280. package/dist/src/semantics/billing.d.ts +0 -2
  281. package/dist/src/semantics/billing.js +0 -220
  282. package/dist/src/semantics/connect.d.ts +0 -5
  283. package/dist/src/semantics/connect.js +0 -493
  284. package/dist/src/semantics/credit-notes.d.ts +0 -2
  285. package/dist/src/semantics/credit-notes.js +0 -172
  286. package/dist/src/semantics/disputes.d.ts +0 -2
  287. package/dist/src/semantics/disputes.js +0 -51
  288. package/dist/src/semantics/ephemeral-keys.d.ts +0 -2
  289. package/dist/src/semantics/ephemeral-keys.js +0 -34
  290. package/dist/src/semantics/files.d.ts +0 -2
  291. package/dist/src/semantics/files.js +0 -125
  292. package/dist/src/semantics/ledger.d.ts +0 -59
  293. package/dist/src/semantics/ledger.js +0 -200
  294. package/dist/src/semantics/payment-intents.d.ts +0 -18
  295. package/dist/src/semantics/payment-intents.js +0 -452
  296. package/dist/src/semantics/payment-links.d.ts +0 -2
  297. package/dist/src/semantics/payment-links.js +0 -133
  298. package/dist/src/semantics/payment-methods.d.ts +0 -20
  299. package/dist/src/semantics/payment-methods.js +0 -140
  300. package/dist/src/semantics/plans.d.ts +0 -5
  301. package/dist/src/semantics/plans.js +0 -121
  302. package/dist/src/semantics/platform.d.ts +0 -9
  303. package/dist/src/semantics/platform.js +0 -206
  304. package/dist/src/semantics/renewals.d.ts +0 -47
  305. package/dist/src/semantics/renewals.js +0 -281
  306. package/dist/src/semantics/setup-intents.d.ts +0 -2
  307. package/dist/src/semantics/setup-intents.js +0 -84
  308. package/dist/src/semantics/subscription-schedules.d.ts +0 -2
  309. package/dist/src/semantics/subscription-schedules.js +0 -119
  310. package/dist/src/semantics/tax.d.ts +0 -2
  311. package/dist/src/semantics/tax.js +0 -197
  312. package/dist/src/semantics/terminal.d.ts +0 -5
  313. package/dist/src/semantics/terminal.js +0 -182
  314. package/dist/src/semantics/test-clocks.d.ts +0 -6
  315. package/dist/src/semantics/test-clocks.js +0 -73
  316. package/dist/src/semantics/tokens.d.ts +0 -4
  317. package/dist/src/semantics/tokens.js +0 -44
  318. package/dist/src/semantics/treasury.d.ts +0 -2
  319. package/dist/src/semantics/treasury.js +0 -377
  320. package/dist/src/semantics/webhook-endpoints.d.ts +0 -3
  321. package/dist/src/semantics/webhook-endpoints.js +0 -85
  322. package/dist/src/stripe-budget.d.ts +0 -55
  323. package/dist/src/stripe-budget.js +0 -155
  324. package/dist/src/stripe-capabilities.d.ts +0 -3
  325. package/dist/src/stripe-capabilities.js +0 -5836
  326. package/dist/src/stripe-conformance.d.ts +0 -43
  327. package/dist/src/stripe-conformance.js +0 -105
  328. package/dist/src/stripe-connector.d.ts +0 -161
  329. package/dist/src/stripe-connector.js +0 -414
  330. package/dist/src/stripe-emit.d.ts +0 -2
  331. package/dist/src/stripe-emit.js +0 -145
  332. package/dist/src/stripe-events.d.ts +0 -93
  333. package/dist/src/stripe-events.js +0 -392
  334. package/dist/src/stripe-js.d.ts +0 -4
  335. package/dist/src/stripe-js.js +0 -70
  336. package/dist/src/stripe-mirror-ui.d.ts +0 -15
  337. package/dist/src/stripe-mirror-ui.js +0 -87
  338. package/dist/src/stripe-perform-harness.d.ts +0 -9
  339. package/dist/src/stripe-perform-harness.js +0 -26
  340. package/dist/src/stripe-server.d.ts +0 -33
  341. package/dist/src/stripe-server.js +0 -429
  342. package/dist/src/stripe-shared.d.ts +0 -109
  343. package/dist/src/stripe-shared.js +0 -276
  344. package/dist/src/stripe-ui-conformance.d.ts +0 -5
  345. package/dist/src/stripe-ui-conformance.js +0 -79
  346. package/dist/src/stripe-ui-structure.d.ts +0 -3
  347. package/dist/src/stripe-ui-structure.js +0 -168
  348. package/dist/test-fixtures/stripe-known-deviations.json +0 -110
  349. package/dist/test-fixtures/stripe-openapi-operations.SOURCE.md +0 -14
  350. package/dist/test-fixtures/stripe-openapi-operations.json +0 -4717
  351. package/dist/test-fixtures/stripe-schemas.SOURCE.md +0 -35
  352. package/dist/test-fixtures/stripe-schemas.json +0 -3813
  353. package/src/screens/connect-oauth.tsx +0 -400
  354. package/src/screens/connect-settings.tsx +0 -121
  355. package/src/screens/consent-skin.ts +0 -20
  356. package/src/screens/financial-connections.tsx +0 -101
  357. package/src/screens/identity.tsx +0 -96
  358. package/src/screens/public-details.tsx +0 -105
  359. package/src/semantics/after-payment.ts +0 -118
  360. package/src/semantics/billing.ts +0 -216
  361. package/src/semantics/connect.ts +0 -487
  362. package/src/semantics/credit-notes.ts +0 -168
  363. package/src/semantics/disputes.ts +0 -62
  364. package/src/semantics/ephemeral-keys.ts +0 -34
  365. package/src/semantics/files.ts +0 -143
  366. package/src/semantics/ledger.ts +0 -253
  367. package/src/semantics/payment-intents.ts +0 -463
  368. package/src/semantics/payment-links.ts +0 -148
  369. package/src/semantics/payment-methods.ts +0 -145
  370. package/src/semantics/plans.ts +0 -131
  371. package/src/semantics/platform.ts +0 -220
  372. package/src/semantics/renewals.ts +0 -279
  373. package/src/semantics/setup-intents.ts +0 -87
  374. package/src/semantics/subscription-schedules.ts +0 -129
  375. package/src/semantics/tax.ts +0 -220
  376. package/src/semantics/terminal.ts +0 -195
  377. package/src/semantics/test-clocks.ts +0 -77
  378. package/src/semantics/tokens.ts +0 -52
  379. package/src/semantics/treasury.ts +0 -383
  380. package/src/semantics/webhook-endpoints.ts +0 -87
  381. package/src/stripe-budget.ts +0 -181
  382. package/src/stripe-capabilities.ts +0 -5651
  383. package/src/stripe-conformance.ts +0 -124
  384. package/src/stripe-connector.ts +0 -462
  385. package/src/stripe-emit.ts +0 -157
  386. package/src/stripe-events.ts +0 -434
  387. package/src/stripe-js.ts +0 -70
  388. package/src/stripe-mirror-ui.ts +0 -90
  389. package/src/stripe-perform-harness.ts +0 -29
  390. package/src/stripe-server.ts +0 -393
  391. package/src/stripe-shared.ts +0 -297
  392. package/src/stripe-ui-conformance.ts +0 -88
  393. package/src/stripe-ui-structure.ts +0 -182
  394. package/test-fixtures/stripe-known-deviations.json +0 -110
  395. package/test-fixtures/stripe-openapi-operations.json +0 -4717
  396. package/test-fixtures/stripe-schemas.json +0 -3813
  397. /package/dist/src/{screens → engine}/industries.d.ts +0 -0
  398. /package/dist/src/{screens → engine}/industries.js +0 -0
  399. /package/dist/src/{semantics → engine}/test-cards.d.ts +0 -0
  400. /package/dist/src/{semantics → engine}/test-cards.js +0 -0
  401. /package/src/{screens → engine}/industries.ts +0 -0
  402. /package/src/{semantics → engine}/test-cards.ts +0 -0
@@ -1,75 +1,790 @@
1
- // What every Stripe family's semantics share: Stripe's list envelope and order, its expand walk,
2
- // its create stamp, and the answers its hand-written routes gave. All of it is over the
3
- // SemanticsContext; the manifest (../manifest.ts) says which fields embed which resource.
4
- import type { SemanticsContext } from '@volter/world-core';
5
- import surface from '../generated/surface.gen.json' with { type: 'json' };
6
- import { manifest } from '../manifest.ts';
7
- import { OBJECT_NAME, paginate, searchOver, stashVendorType, view, type StripeResponse } from '../stripe-twin.ts';
1
+ import type { EventsDecl, Handler, HandlerContext, TwinResource } from '@volter/world-core';
2
+ import { NAMED_EVENTS, readDecision, STRIPE_EVENT_TYPES, STRIPE_REALTIME_AUTH_TIMEOUT_MS, type StripeAuthRequestOutcome, stripeSignature } from '../engine/webhooks.ts';
3
+ import { redactKey, requestKey } from '../engine/oauth.ts';
4
+ import { HOUR, INV, itemQuantity, itemsOf, keptAfter, oneTimeLines, RENEWING, SUB } from '../engine/renewals.ts';
5
+ import { DAY, payoutDay } from '../engine/balance.ts';
6
+ import { AFTER_SUCCESS_CARDS, BY_NUMBER, type Outcome } from '../engine/after-payment.ts';
7
+ import { accountSettings, addBillingInterval, applyCouponDiscount, asBool, buildDiscount, buildSubscriptionItemsList, cardError, chargeDefaults, declineFor, disputeEvidence, microdepositsNextAction, mintClientSecret, nowUnix, OBJECT_NAME, paginate, paymentMethodSubObject, PLATFORM_ACCOUNT_ID, priceAmount, requiresAuthentication, requiresMicrodeposits, resolveSubscriptionBillingInterval, resolveTrial, searchOver, type StripeResponse, subscriptionItemEntries, subscriptionItemPrice, TEST_TOKEN_DECLINES, threeDsNextAction, view } from '../engine/stripe.ts';
8
+ import { SECRET } from '../engine/apps-secrets.ts';
9
+ import { collectsThroughIntent, invoicePrefix, transitions } from '../engine/invoices.ts';
10
+ import { CS, type EnteredCard, isOneTime, nestedMetadata } from '../engine/checkout.ts';
11
+ import { externalList, type Find, type Row, SPEC_FIELDS } from '../engine/common.ts';
12
+ import { noBilling, PM, TEST_PM_CARDS, testCardOf } from '../engine/payment-methods.ts';
13
+ import { intentCarries, PI, TYPES_GIVEN } from '../engine/payment-intents.ts';
14
+ import { BT, BYPASS_NUMBERS, feeOf, onAccount, UNSENT } from '../engine/ledger.ts';
15
+ import { MAX_TRIAL, movedTrial } from '../engine/subscriptions.ts';
16
+ import { VL, VLI } from '../engine/radar.ts';
17
+ import { AUTH, historyEntry, merchantData, REALTIME_WINDOW_SECONDS, TXN } from '../engine/issuing.ts';
18
+ import { BYPASS_PENDING_CARDS } from '../engine/test-cards.ts';
19
+ import { customRequirements } from '../engine/connect.ts';
20
+ import { expandOf, render, SERVED_VERSION, servesCurrent, withoutEndpointSecret } from '../engine/version.ts';
21
+ import { CHECKOUT_METHOD_TYPES, DOCUMENTED, methodTypesRemoved, oneOf, OPS, PAYMENT_METHOD_TYPES, REMOVED_TYPES, TYPED, TYPES_DOC } from '../engine/params.ts';
22
+ import { linesOf, sumLines } from '../engine/invoices.ts';
23
+ import { refundDestination } from '../engine/common.ts';
24
+ import { REFUND_REASONS } from '../engine/refunds.ts';
25
+ /** Stripe's events: `Stripe-Signature: t=…,v1=…` over `${t}.${body}` keyed by the endpoint's secret
26
+ * (docs.stripe.com/webhooks#verify-manually); the Event object (docs.stripe.com/api/events/object) kept as the account's
27
+ * own (`GET /v1/events`), whether or not an endpoint takes it; an endpoint live while `enabled`, taking the types its
28
+ * `enabled_events` names (`*` all, `<family>.*` a family), and a Connect endpoint (`connect: true`) its connected
29
+ * accounts' events ("Connected accounts" scope, docs.stripe.com/connect/webhooks), any other the account's own. */
30
+ export const STRIPE_EVENTS: EventsDecl = {
31
+ scheme: { kind: 'timestamp-v1', header: 'Stripe-Signature' },
32
+ types: NAMED_EVENTS,
33
+ known: STRIPE_EVENT_TYPES,
34
+ store: 'event',
35
+ envelope: {
36
+ id: '$id', object: 'event', account: '$account', api_version: '$version', created: '$time.s', data: '$data', livemode: false,
37
+ pending_webhooks: 0, request: { id: null, idempotency_key: null }, type: '$type',
38
+ },
39
+ endpoints: { storedAs: 'webhook_endpoint', url: 'url', secret: 'secret', filter: 'enabled_events', status: { field: 'status', live: 'enabled' }, scope: { field: 'connect', value: '$account' } },
40
+ };
8
41
 
9
- export type Row = Record<string, unknown>;
42
+ /** A customer's now: its test clock's frozen time when it is on one (docs.stripe.com/billing/testing/test-clocks), the
43
+ * World's otherwise. */
44
+ export function clockNow(ctx: HandlerContext, customer: unknown): number {
45
+ const clock = typeof customer === 'string' ? ctx.get('customer', customer)?.test_clock : undefined;
46
+ const frozen = typeof clock === 'string' ? ctx.get('test_helpers.test_clock', clock)?.frozen_time : undefined;
47
+ return frozen !== undefined ? Number(frozen) : Number(nowUnix(ctx.occurredAt));
48
+ }
10
49
 
11
- export const send = (ctx: SemanticsContext, r: StripeResponse): Response => ctx.reply(r.body, r.status);
50
+ /** What a subscription's invoices charge: its own default payment method, else its customer's. */
51
+ export function payerOf(ctx: HandlerContext, sub: Row): string | undefined {
52
+ return typeof sub.default_payment_method === 'string' && sub.default_payment_method ? sub.default_payment_method : customerDefault(ctx, sub);
53
+ }
12
54
 
13
- /** Stripe's error body: an invalid_request_error with the message, and the code when there is one. */
14
- export const fail = (ctx: SemanticsContext, message: string, status = 404, code?: string): Response => ctx.refuse({ status, message, ...(code ? { code } : {}) });
15
-
16
- /** A file parameter whose description requires a purpose refuses a file of another one. The served spec requires it
17
- * on every such parameter ("Must have a `purpose` value of `issuing_logo`", personalization designs' card_logo;
18
- * "The file's `purpose` must be one of the following: …", file links' file). Where the documentation stops and the
19
- * twin decides: Stripe documents no error for it (docs.stripe.com/error-codes names none), so the twin answers the
20
- * 400 invalid_request_error it answers for an unknown file, naming the parameter and the purposes allowed. */
21
- export function filePurposeRefused(ctx: SemanticsContext, param: string, allowed: readonly string[]): Response | undefined {
22
- const given = ctx.params[param];
23
- if (given === undefined || given === '') return undefined;
24
- const file = ctx.get('file', String(given));
25
- if (!file) return fail(ctx, `No such file upload: '${String(given)}'`, 400, 'resource_missing');
26
- if (allowed.includes(String(file.purpose))) return undefined;
27
- const need = allowed.length === 1 ? `a \`purpose\` value of \`${allowed[0]}\`` : `a \`purpose\` of one of: ${allowed.map((a) => `\`${a}\``).join(', ')}`;
28
- return fail(ctx, `Invalid ${param}: the file '${String(given)}' has a \`purpose\` of \`${String(file.purpose)}\`, but it must have ${need}.`, 400);
55
+ /** The customer's default for invoices, charged when the subscription names none of its own. */
56
+ export function customerDefault(ctx: HandlerContext, sub: Row): string | undefined {
57
+ const customer = typeof sub.customer === 'string' ? ctx.get('customer', sub.customer) : undefined;
58
+ const settings = customer?.invoice_settings as Row | undefined;
59
+ return typeof settings?.default_payment_method === 'string' && settings.default_payment_method ? settings.default_payment_method : undefined;
29
60
  }
30
61
 
31
- /** The request's path, as the list envelope's `url` carries it. */
32
- export const path = (ctx: SemanticsContext): string => new URL(ctx.call.request.url).pathname.replace(/\/+$/, '') || '/';
62
+ /** A subscription whose invoice has just been finalized: the `once` coupon that invoice took is spent, and a once coupon
63
+ * it did not take (added after it was drafted) stays for the next (unspentDiscounts). */
64
+ export async function spendOnceCoupon(ctx: HandlerContext, invoice: Row): Promise<void> {
65
+ const subId = typeof invoice.subscription === 'string' ? invoice.subscription : undefined;
66
+ const sub = subId ? resourceView(ctx, SUB, subId) : undefined;
67
+ if (!sub || !subId) return;
68
+ const all = (sub.discounts as Row[] | undefined) ?? [];
69
+ const kept = keptAfter(all, invoice);
70
+ if (kept.length !== all.length) await ctx.write(SUB, subId, { discounts: kept }, 'subscription.discount_spent');
71
+ }
33
72
 
34
- /** A path parameter, by the name the spec gives it. */
35
- export const at = (ctx: SemanticsContext, name: string): string => ctx.call.params[name] ?? '';
36
-
37
- /** An update's fields laid over what is stored, as the derived core does: a nested hash is merged into the stored one,
38
- * not put in its place (Stripe updates only the keys a request names; docs.stripe.com/api/metadata and each update
39
- * reference), and an empty string unsets a key. */
40
- export function mergeUpdate(ctx: SemanticsContext, resource: string, id: string, fields: Row): Row {
41
- const current = ctx.get(resource, id) ?? {};
42
- const out: Row = {};
43
- for (const [k, v] of Object.entries(fields)) {
44
- const prior = (current as Row)[k];
45
- out[k] = v && typeof v === 'object' && !Array.isArray(v) && prior && typeof prior === 'object' && !Array.isArray(prior)
46
- ? Object.fromEntries(Object.entries({ ...(prior as object), ...(v as object) }).filter(([, x]) => x !== ''))
47
- : v;
73
+ /** A draft invoice for one period of a subscription, its lines the subscription's items and any one-time lines `once`
74
+ * gives it (a Checkout session's one-time prices, "on the initial invoice only", docs.stripe.com/api/checkout/sessions/
75
+ * create#create_checkout_session-line_items), under the subscription's discount. Answers the invoice id. */
76
+ export async function draftSubscriptionInvoice(ctx: HandlerContext, sub: Row, o: { start: number; end: number; reason: 'subscription_create' | 'subscription_cycle' | 'subscription_update'; trial?: boolean; once?: Row[]; id?: string }): Promise<string> {
77
+ const id = o.id ?? ctx.mint(INV);
78
+ const currency = String(sub.currency ?? 'usd');
79
+ const lines = itemsOf(sub).map((it, i) => {
80
+ const price = it.price && typeof it.price === 'object' ? (it.price as Row) : ctx.get('price', String(it.price ?? ''));
81
+ const quantity = itemQuantity(it);
82
+ return {
83
+ id: `il_twin_${id}_${i + 1}`, object: 'line_item', type: 'subscription',
84
+ // a trial's first invoice bills nothing for the trial period (docs.stripe.com/billing/subscriptions/trials)
85
+ amount: o.trial ? 0 : priceAmount(price, quantity), currency, quantity, proration: false,
86
+ price: price ?? null, subscription: sub.id, subscription_item: it.id ?? null, invoice_item: null,
87
+ period: { start: o.start, end: o.end }, description: o.trial ? `Trial period for ${String(ctx.get('product', String(price?.product ?? ''))?.name ?? 'the plan')}` : null,
88
+ };
89
+ });
90
+ if (o.once?.length) lines.push(...oneTimeLines(id, sub, o.once, lines.length, o.start, currency));
91
+ const subtotal = lines.reduce((s, l) => s + l.amount, 0);
92
+ // a discount the subscription still carries applies; a `once` coupon is removed once an invoice finalizes
93
+ // (spendOnceCoupon), so it discounts one invoice (docs.stripe.com/billing/subscriptions/coupons#coupon-duration)
94
+ const discount = ((sub.discounts as Row[] | undefined) ?? [])[0];
95
+ const coupon = discount && typeof discount === 'object' ? (discount.coupon as Row | undefined) : undefined;
96
+ const off = coupon ? applyCouponDiscount(subtotal, coupon) : 0;
97
+ const total = Math.max(0, subtotal - off);
98
+ await created(ctx, INV, { id, customer: sub.customer, subscription: sub.id }, {
99
+ created: o.start, status: 'draft', livemode: false, currency, collection_method: 'charge_automatically',
100
+ billing_reason: o.reason, auto_advance: true, attempt_count: 0, attempted: false, next_payment_attempt: null,
101
+ amount_due: total, amount_paid: 0, amount_remaining: total, amount_overpaid: 0, amount_paid_off_stripe: 0, amount_shipping: 0,
102
+ subtotal, total, starting_balance: 0, post_payment_credit_notes_amount: 0, pre_payment_credit_notes_amount: 0,
103
+ // "The invoice that consumed the coupon still shows the applied discount" (docs.stripe.com/billing/subscriptions/coupons)
104
+ period_start: o.start, period_end: o.end, default_tax_rates: [], discounts: coupon && discount ? [discount] : [],
105
+ lines: { object: 'list', data: lines, has_more: false, total_count: lines.length, url: `/v1/invoices/${id}/lines` },
106
+ automatic_tax: { enabled: false, liability: null, status: null },
107
+ status_transitions: { finalized_at: null, marked_uncollectible_at: null, paid_at: null, voided_at: null },
108
+ issuer: { type: 'self' }, payment_settings: { default_mandate: null, payment_method_options: null, payment_method_types: null },
109
+ payment_intent: null, charge: null,
110
+ });
111
+ return id;
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: HandlerContext, invoiceId: string, at: number, actor: 'time' | 'external' | 'api', opts: { payer?: string; attempt?: boolean } = {}): Promise<boolean> {
119
+ const inv = ctx.get(INV, invoiceId);
120
+ const sub = inv && typeof inv.subscription === 'string' ? resourceView(ctx, SUB, inv.subscription) : undefined;
121
+ if (!inv || !sub || inv.status !== 'draft') return false;
122
+ const total = Number(inv.total) || 0;
123
+ const numbered = { ...finalizedFields(ctx, inv, at), status_transitions: { ...(inv.status_transitions as Row), finalized_at: at } };
124
+ const op = ctx.call.operation.id;
125
+ // nothing due (a trial's first invoice) is paid as it is finalized; finalizing spends a `once` coupon
126
+ if (total <= 0) {
127
+ if (ctx.legal(INV, 'status', op, 'draft', 'paid', invoiceId, actor)) return false;
128
+ await spendOnceCoupon(ctx, inv);
129
+ await ctx.write(INV, invoiceId, { ...numbered, status: 'paid', paid: true, auto_advance: false, status_transitions: { ...numbered.status_transitions, paid_at: at } }, 'invoice.paid');
130
+ return true;
48
131
  }
49
- return out;
132
+ const payer = actor === 'api' ? opts.payer : payerOf(ctx, sub);
133
+ // default_incomplete finalizes the invoice without attempting payment (docs.stripe.com/api/subscriptions/update#update_subscription-payment_behavior)
134
+ const attempt = opts.attempt !== false;
135
+ const decline = payer && attempt ? declineFor(finder(ctx), { payment_method: payer }) : undefined;
136
+ const pays = attempt && !!payer && !decline;
137
+ if (ctx.legal(INV, 'status', op, 'draft', pays ? 'paid' : 'open', invoiceId, actor)) return false;
138
+ await spendOnceCoupon(ctx, inv);
139
+ const piId = ctx.mint('payment_intent');
140
+ const currency = String(inv.currency ?? 'usd');
141
+ const chargeId = pays ? await mintCharge(ctx, piId, { currency, customer: inv.customer, payment_method: payer, invoice: invoiceId }, total) : null;
142
+ const intent = await created(ctx, 'payment_intent', { id: piId, amount: total, currency, customer: inv.customer, invoice: invoiceId, payment_method: pays ? payer : null }, {
143
+ status: pays ? 'succeeded' : 'requires_payment_method', client_secret: mintClientSecret(piId, await ctx.secret(`client-secret:${piId}`)), livemode: false,
144
+ capture_method: 'automatic', amount_capturable: 0, amount_received: pays ? total : 0, next_action: null, latest_charge: chargeId,
145
+ last_payment_error: decline ? { type: 'card_error', code: decline.code, decline_code: decline.decline_code ?? null, message: decline.message, payment_method: { id: payer } } : null,
146
+ automatic_payment_methods: null, payment_method_types: ['card'], payment_method_options: {},
147
+ });
148
+ await ctx.write(INV, invoiceId, {
149
+ ...numbered, status: pays ? 'paid' : 'open', paid: pays, attempt_count: attempt ? 1 : 0, attempted: attempt, payment_intent: piId, charge: chargeId,
150
+ // source: spec:/components/schemas/invoice/properties/confirmation_secret/description "client_secret of the PaymentIntent"
151
+ confirmation_secret: { type: 'payment_intent', client_secret: intent.client_secret },
152
+ amount_paid: pays ? total : 0, amount_remaining: pays ? 0 : total, auto_advance: attempt && !pays, next_payment_attempt: null,
153
+ status_transitions: { ...numbered.status_transitions, paid_at: pays ? at : null },
154
+ }, pays ? 'invoice.paid' : attempt ? 'invoice.payment_failed' : 'invoice.finalized');
155
+ if (actor !== 'api' && !pays && sub.status !== 'past_due' && !ctx.legal(SUB, 'status', op, sub.status, 'past_due', String(sub.id), 'time')) {
156
+ await ctx.write(SUB, String(sub.id), { status: 'past_due' }, 'customer.subscription.updated');
157
+ }
158
+ return pays;
50
159
  }
51
160
 
52
- /** The tree's type for a resource: what its rows are stored under. */
53
- export const stored = (resource: string): string => manifest.resources[resource]?.storedAs ?? resource;
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: HandlerContext, invoiceId: string, paymentMethod: string): Promise<{ code: string; message: string } | undefined> {
164
+ const inv = ctx.get(INV, invoiceId);
165
+ if (!inv || inv.status !== 'open') return { code: 'invoice_not_open', message: 'This invoice is no longer open.' };
166
+ const decline = declineFor(finder(ctx), { payment_method: paymentMethod });
167
+ if (decline) return decline;
168
+ const op = ctx.call.operation.id;
169
+ const refused = ctx.legal(INV, 'status', op, 'open', 'paid', invoiceId, 'external');
170
+ if (refused) return { code: String(refused.code ?? 'invoice_not_open'), message: refused.message };
171
+ const total = Number(inv.amount_remaining ?? inv.total) || 0;
172
+ const now = Number(nowUnix(ctx.occurredAt));
173
+ const piId = typeof inv.payment_intent === 'string' ? inv.payment_intent : undefined;
174
+ let chargeId: string | null = null;
175
+ const pi = piId ? ctx.get('payment_intent', piId) : undefined;
176
+ if (piId && pi && !ctx.legal('payment_intent', 'status', op, pi.status, 'succeeded', piId, 'external')) {
177
+ chargeId = await mintCharge(ctx, piId, { ...pi, payment_method: paymentMethod, invoice: invoiceId }, total);
178
+ await ctx.write('payment_intent', piId, { status: 'succeeded', payment_method: paymentMethod, amount_received: total, last_payment_error: null, latest_charge: chargeId }, 'payment_intent.succeeded');
179
+ }
180
+ await ctx.write(INV, invoiceId, {
181
+ status: 'paid', paid: true, amount_paid: total, amount_remaining: 0, charge: chargeId, auto_advance: false,
182
+ attempt_count: (Number(inv.attempt_count) || 0) + 1, attempted: true,
183
+ status_transitions: { ...(inv.status_transitions as Row), paid_at: now },
184
+ }, 'invoice.paid');
185
+ const sub = typeof inv.subscription === 'string' ? resourceView(ctx, SUB, inv.subscription) : undefined;
186
+ if (sub && sub.status === 'past_due' && !ctx.legal(SUB, 'status', op, 'past_due', 'active', String(sub.id), 'external')) {
187
+ await ctx.write(SUB, String(sub.id), { status: 'active' }, 'customer.subscription.updated');
188
+ }
189
+ return undefined;
190
+ }
54
191
 
55
- /** A resource's rows in Stripe's list order: newest first by `timeField`, ties by mint order (as the derived core breaks them). */
56
- /** A subject's bookkeeping field (`_…`): kept on the stored row, never in the wire view ctx.get and ctx.rows answer. */
57
- export const kept = (ctx: SemanticsContext, resource: string, row: Row, key: string): unknown => ctx.row(resource, String(row.id), { withDeleted: true })?.[key];
192
+ /** Time's moves on subscriptions, caught up to each customer's clock in the order they fell due, each made at the
193
+ * moment it was due: a period that ended renews (a trial's end among them) or, set to cancel, cancels; a renewal
194
+ * drafted an hour or more ago is finalized and charged. */
195
+ export async function advanceBilling(ctx: HandlerContext, only?: (sub: Row) => boolean): Promise<void> {
196
+ // Stripe's own move wherever a handler reaches it: a read that catches a renewal up records it with no caller
197
+ await ctx.asVendor(async () => {
198
+ const at = at_(ctx);
199
+ for (let guard = 0; guard < 10_000 && (await billingStep(ctx, at, only)); guard++);
200
+ });
201
+ }
202
+
203
+ /** The earliest move that has come due, made at its moment; false when none has. */
204
+ export async function billingStep(ctx: HandlerContext, at: (t: number) => Promise<HandlerContext>, only?: (sub: Row) => boolean): Promise<boolean> {
205
+ // the earliest move that has come due, of any subscription
206
+ let next: { t: number; kind: 'period' | 'collect'; id: string } | undefined;
207
+ for (const sub of ctx.rows(SUB).map((s) => ctx.expand(SUB, s))) {
208
+ if (only && !only(sub)) continue;
209
+ if (!RENEWING.includes(String(sub.status)) || typeof sub.current_period_end !== 'number' || sub.pause_collection) continue;
210
+ if (sub.current_period_end <= clockNow(ctx, sub.customer) && (!next || sub.current_period_end < next.t)) next = { t: sub.current_period_end, kind: 'period', id: String(sub.id) };
211
+ }
212
+ for (const inv of ctx.rows(INV)) {
213
+ if (inv.status !== 'draft' || inv.billing_reason !== 'subscription_cycle' || inv.auto_advance !== true) continue;
214
+ const sub = typeof inv.subscription === 'string' ? resourceView(ctx, SUB, inv.subscription) : undefined;
215
+ if (!sub || (only && !only(sub))) continue;
216
+ const t = Number(inv.created) + HOUR;
217
+ if (t <= clockNow(ctx, sub.customer) && (!next || t < next.t)) next = { t, kind: 'collect', id: String(inv.id) };
218
+ }
219
+ if (!next) return false;
220
+ const c = await at(next.t);
221
+ if (next.kind === 'collect') {
222
+ await collectSubscriptionInvoice(c, next.id, next.t, 'time');
223
+ return true;
224
+ }
225
+ const id = next.id;
226
+ const sub = resourceView(c, SUB, id)!;
227
+ const op = c.call.operation.id;
228
+ // one set to cancel at its period's end is canceled then, with no renewal
229
+ // (docs.stripe.com/billing/subscriptions/cancel#cancel-at-end-of-cycle)
230
+ if (sub.cancel_at_period_end === true) {
231
+ if (c.legal(SUB, 'status', op, sub.status, 'canceled', id, 'time')) return false;
232
+ await c.write(SUB, id, { status: 'canceled', ended_at: next.t, canceled_at: sub.canceled_at ?? next.t }, 'customer.subscription.deleted');
233
+ return true;
234
+ }
235
+ const start = next.t;
236
+ const { interval, interval_count } = resolveSubscriptionBillingInterval(itemsOf(sub), finder(c));
237
+ const end = addBillingInterval(start, interval, interval_count);
238
+ const trialEnds = sub.status === 'trialing';
239
+ if (trialEnds && c.legal(SUB, 'status', op, 'trialing', 'active', id, 'time')) return false;
240
+ const invoiceId = await draftSubscriptionInvoice(c, sub, { start, end, reason: 'subscription_cycle' });
241
+ await c.write(SUB, id, { current_period_start: start, current_period_end: end, latest_invoice: invoiceId, ...(trialEnds ? { status: 'active' } : {}) }, 'customer.subscription.updated');
242
+ return true;
243
+ }
244
+
245
+ export const account = (ctx: HandlerContext): string | undefined => actingAccount(ctx);
58
246
 
59
- /** A removed discount as Stripe answers it: the discount, marked deleted (docs.stripe.com/api/discounts/delete). */
60
- export function deletedDiscount(d: Row, now: unknown): Row {
61
- const coupon = d.coupon && typeof d.coupon === 'object' ? String((d.coupon as Row).id) : typeof d.coupon === 'string' ? d.coupon : null;
247
+ /** The acting account's Balance object (docs.stripe.com/api/balance/balance_object). */
248
+ export function balanceBody(ctx: HandlerContext, acct: string | undefined = account(ctx)): Row {
249
+ const { available, pending, issuing } = balanceOf(ctx, acct);
250
+ const toArr = (m: Map<string, number>) => {
251
+ const out = [...m.entries()].map(([currency, amount]) => ({ amount, currency, source_types: { card: amount } }));
252
+ return out.length ? out : [{ amount: 0, currency: 'usd', source_types: { card: 0 } }];
253
+ };
254
+ // a platform's balance holds its connected accounts' reserve, "Funds held due to negative balances on connected accounts
255
+ // where account.controller.requirement_collection is `application`" (docs.stripe.com/api/balance/balance_object, whose
256
+ // example answers `[{"amount": 0, "currency": "usd"}]`); the twin models no such reserve, so it is zero in each currency
257
+ const reserved = acct ? {} : { connect_reserved: toArr(available).map((b) => ({ amount: 0, currency: b.currency })) };
62
258
  return {
63
- id: d.id ?? `di_${coupon ?? 'twin'}`, object: 'discount', deleted: true, checkout_session: d.checkout_session ?? null, customer: d.customer ?? null,
64
- invoice: d.invoice ?? null, invoice_item: d.invoice_item ?? null, promotion_code: d.promotion_code ?? null, subscription: d.subscription ?? null,
65
- subscription_item: d.subscription_item ?? null, source: d.source ?? { type: 'coupon', coupon }, start: d.start ?? now,
259
+ object: 'balance', available: toArr(available), pending: toArr(pending), ...reserved, livemode: false,
260
+ ...(issuing.size ? { issuing: { available: [...issuing.entries()].map(([currency, amount]) => ({ amount, currency, source_types: { card: amount } })) } } : {}),
66
261
  };
67
262
  }
68
263
 
264
+ /** Time's arrivals, written: each of the acting account's payouts whose arrival date has come moves pending → paid
265
+ * (the clock's move, as asOf reads it), written as `payout.paid`, the event Stripe sends for it. Answers their ids. */
266
+ export async function payDuePayouts(ctx: HandlerContext, acct: string | undefined = account(ctx)): Promise<string[]> {
267
+ const now = Number(nowUnix(ctx.occurredAt));
268
+ const paid: string[] = [];
269
+ for (const p of newest(ctx, 'payout')) {
270
+ if ((acct ? kept(ctx, 'payout', p, '_account') !== acct : !!kept(ctx, 'payout', p, '_account')) || p.status !== 'pending' || Number(p.arrival_date) > now) continue;
271
+ if (ctx.legal('payout', 'status', ctx.call.operation.id, 'pending', 'paid', String(p.id), 'time')) continue;
272
+ await ctx.write('payout', String(p.id), { status: 'paid' }, 'payout.paid');
273
+ paid.push(String(p.id));
274
+ }
275
+ return paid;
276
+ }
277
+
278
+ /** Where a connected account's payout goes: "ID of the bank account or card the payout is sent to" (served spec,
279
+ * payout.destination), its default external account for the currency, "When multiple accounts are available for a given
280
+ * currency, Stripe uses the one set as `default_for_currency`" (docs.stripe.com/connect/payouts-bank-accounts), the
281
+ * newest such. Where the documentation stops and the twin decides: the platform's own bank account is not modelled, so
282
+ * its payouts name none. */
283
+ export function payoutBank(ctx: HandlerContext, account: string, currency: string): string | null {
284
+ const banks = newest(ctx, 'external_account').filter((e) => e.account === account && String(e.currency ?? currency) === currency);
285
+ return String((banks.find((e) => e.default_for_currency === true) ?? banks[0])?.id ?? '') || null;
286
+ }
287
+
288
+ export const payoutDefaults = (ctx: HandlerContext): Row => ({
289
+ method: 'standard', type: 'bank_account', source_type: 'card', automatic: false,
290
+ reconciliation_status: 'not_applicable', arrival_date: Number(nowUnix(ctx.occurredAt)) + 2 * DAY, livemode: false, metadata: {},
291
+ });
292
+
293
+ /** A payout as the clock reads it: paid once its arrival date has come, the clock's move, asked of the
294
+ * machine as a write asks it. */
295
+ export function asOf(ctx: HandlerContext, p: Row): Row {
296
+ if (p.status !== 'pending' || Number(p.arrival_date) > Number(nowUnix(ctx.occurredAt))) return p;
297
+ ctx.legal('payout', 'status', ctx.call.operation.id, 'pending', 'paid', String(p.id), 'time');
298
+ return { ...p, status: 'paid' };
299
+ }
300
+
301
+ /** A connected account's payout in a currency none of its bank accounts takes. */
302
+ export function noExternalAccount(ctx: HandlerContext, currency: string): Response {
303
+ return fail(ctx, `Sorry, you don't have any external accounts in that currency (${currency}).`, 400);
304
+ }
305
+
306
+ /** Time's payouts, caught up to the World's clock: each account on an automatic schedule is paid out, on each of its
307
+ * scheduled days that has come, what became available by then. */
308
+ export async function advancePayouts(ctx: HandlerContext): Promise<void> {
309
+ const now = Number(nowUnix(ctx.occurredAt));
310
+ const platform = ctx.get('account', PLATFORM_ACCOUNT_ID);
311
+ const accounts: Array<{ id: string | undefined; settings: unknown }> = [{ id: undefined, settings: platform?.settings }];
312
+ for (const a of ctx.rows('account')) {
313
+ if (a.id === PLATFORM_ACCOUNT_ID || a.payouts_enabled !== true) continue;
314
+ if (!ctx.rows('external_account').some((e) => e.account === a.id)) continue;
315
+ accounts.push({ id: String(a.id), settings: a.settings });
316
+ }
317
+ for (const acct of accounts) {
318
+ const schedule = ((accountSettings(acct.settings, (acct.settings as Row | undefined) ?? undefined).payouts as Row).schedule ?? {}) as Row;
319
+ if (schedule.interval === 'manual') continue;
320
+ const unpaid = unpaidEntries(ctx, acct.id);
321
+ const days = [...new Set(unpaid.map((t) => payoutDay(Number(t.available_on) || 0, schedule)))].filter((d) => d <= now).sort((x, y) => x - y);
322
+ const paid = new Set<unknown>();
323
+ for (const day of days) {
324
+ const due = unpaid.filter((t) => !paid.has(t.id) && (Number(t.available_on) || 0) <= day);
325
+ const byCurrency = new Map<string, Row[]>();
326
+ for (const t of due) byCurrency.set(String(t.currency ?? 'usd'), [...(byCurrency.get(String(t.currency ?? 'usd')) ?? []), t]);
327
+ for (const [currency, entries] of byCurrency) {
328
+ const amount = entries.reduce((n, t) => n + (Number(t.net) || 0), 0);
329
+ // nothing to pay yet: what came due is carried to the next payout
330
+ if (amount <= 0) continue;
331
+ // made on its day, as Stripe makes it, whenever the request that catches it up comes
332
+ const c = await at_(ctx)(day);
333
+ const id = c.mint('payout');
334
+ const bt = await settleAutomaticPayout(c, id, amount, currency, acct.id, day, entries);
335
+ await created(c, 'payout', { id, amount, currency }, {
336
+ status: 'pending', ...payoutDefaults(c), arrival_date: day + 2 * DAY, automatic: true, balance_transaction: bt, destination: acct.id ? payoutBank(c, String(acct.id), currency) : null,
337
+ description: 'STRIPE PAYOUT', ...(acct.id ? { _account: acct.id } : {}),
338
+ });
339
+ for (const t of entries) paid.add(t.id);
340
+ }
341
+ }
342
+ }
343
+ }
344
+
345
+ /** What Stripe does after a card succeeds: from a raw number, a test name (pm_card_* or tok_*), or a stored
346
+ * PaymentMethod that recorded it when it was made. */
347
+ export function afterSuccessOf(ctx: HandlerContext, ref: unknown): Outcome | undefined {
348
+ // a card given as a token (card[token], payment_method_data[card][token]) is the card the token names
349
+ if (ref && typeof ref === 'object' && typeof (ref as Row).token === 'string') return afterSuccessOf(ctx, (ref as Row).token);
350
+ if (ref && typeof ref === 'object') return BY_NUMBER[String((ref as Row).number ?? '').replace(/\D/g, '')];
351
+ if (typeof ref !== 'string' || !ref) return undefined;
352
+ const stored = ctx.row('payment_method', ref);
353
+ if (stored) return (stored._afterSuccess as Outcome | undefined) ?? undefined;
354
+ return AFTER_SUCCESS_CARDS[ref.replace(/^tok_/, 'pm_card_')]?.outcome ?? BY_NUMBER[ref.replace(/\D/g, '')];
355
+ }
356
+
357
+ /** Stripe's acts on a charge that just succeeded: a dispute or a review its card brings, and the platform's fee. */
358
+ export async function afterCharge(ctx: HandlerContext, chargeId: string, charge: { amount: number; currency: string; payment_intent?: string | undefined; application_fee_amount?: unknown; destination?: unknown }, ref: unknown): Promise<void> {
359
+ const outcome = afterSuccessOf(ctx, ref);
360
+ const pi = charge.payment_intent ? { payment_intent: charge.payment_intent } : { payment_intent: null };
361
+ const now = Number(nowUnix(ctx.occurredAt));
362
+ if (outcome === 'dispute' || outcome === 'dispute_not_received' || outcome === 'inquiry') {
363
+ const dispute = await created(ctx, 'dispute', { charge: chargeId, ...pi, amount: charge.amount, currency: charge.currency }, {
364
+ reason: outcome === 'dispute_not_received' ? 'product_not_received' : 'fraudulent', status: outcome === 'inquiry' ? 'warning_needs_response' : 'needs_response', is_charge_refundable: false,
365
+ evidence: disputeEvidence(), evidence_details: { due_by: now + 7 * DAY, has_evidence: false, past_due: false, submission_count: 0, enhanced_eligibility: {} },
366
+ balance_transactions: [], livemode: false, metadata: {}, enhanced_eligibility_types: [],
367
+ });
368
+ // an inquiry moves no money; a dispute takes the amount and its fee until it is decided
369
+ if (outcome !== 'inquiry') {
370
+ const bt = await settleDispute(ctx, String(dispute.id), charge.amount, charge.currency);
371
+ await ctx.write('dispute', String(dispute.id), { balance_transactions: [ctx.get('balance_transaction', bt) ?? bt] }, 'dispute.funds_withdrawn');
372
+ }
373
+ await ctx.write('charge', chargeId, { disputed: true }, 'charge.dispute.created');
374
+ }
375
+ if (outcome === 'review') await openReview(ctx, chargeId, pi);
376
+ const feeAmount = Math.trunc(Number(charge.application_fee_amount) || 0);
377
+ const destination = typeof charge.destination === 'string' && charge.destination ? charge.destination : undefined;
378
+ const direct = actingAccount(ctx);
379
+ const account = destination ?? direct;
380
+ // a destination charge transfers what it collected, less the platform's fee, available when the charge's funds are
381
+ if (destination) {
382
+ const amount = charge.amount - feeAmount;
383
+ const chargeBt = ctx.get('charge', chargeId)?.balance_transaction;
384
+ const availableOn = Number(ctx.get('balance_transaction', String(chargeBt))?.available_on ?? nowUnix(ctx.occurredAt));
385
+ const tr = await created(ctx, 'transfer', { amount, currency: charge.currency, destination }, {
386
+ amount_reversed: 0, balance_transaction: null, livemode: false, metadata: {}, reversed: false, source_type: 'card', source_transaction: chargeId,
387
+ reversals: { object: 'list', data: [], has_more: false, total_count: 0, url: '' },
388
+ });
389
+ const bt = await settleTransfer(ctx, String(tr.id), amount, charge.currency, destination, availableOn);
390
+ await ctx.write('transfer', String(tr.id), { balance_transaction: bt, destination_payment: `py_${String(tr.id).replace(/^tr_/, '')}`, reversals: { object: 'list', data: [], has_more: false, total_count: 0, url: `/v1/transfers/${String(tr.id)}/reversals` } }, 'transfer.created');
391
+ await ctx.write('charge', chargeId, { transfer: tr.id }, 'charge.updated');
392
+ }
393
+ if (feeAmount > 0 && account) {
394
+ const fee = await created(ctx, 'application_fee', { account, amount: feeAmount, charge: chargeId, currency: charge.currency }, {
395
+ amount_refunded: 0, application: 'ca_twin', balance_transaction: null, originating_transaction: null, refunded: false, livemode: false,
396
+ });
397
+ // a direct charge's fee moves from the connected account to the platform (a destination charge kept it back)
398
+ const bt = !destination && direct ? await settleApplicationFee(ctx, String(fee.id), feeAmount, charge.currency, direct) : null;
399
+ await ctx.write('application_fee', String(fee.id), { balance_transaction: bt, refunds: { object: 'list', data: [], has_more: false, total_count: 0, url: `/v1/application_fees/${String(fee.id)}/refunds` } }, 'application_fee.created');
400
+ await ctx.write('charge', chargeId, { application_fee: fee.id, application_fee_amount: feeAmount }, 'charge.updated');
401
+ }
402
+ }
403
+
404
+ /** Radar places an elevated-risk card's charge in review (docs.stripe.com/radar/reviews). */
405
+ export async function openReview(ctx: HandlerContext, chargeId: string, pi: { payment_intent: string | null }): Promise<void> {
406
+ const review = await created(ctx, 'review', { charge: chargeId, ...pi }, {
407
+ open: true, opened_reason: 'rule', reason: 'rule', closed_reason: null, billing_zip: null, ip_address: null, ip_address_location: null, session: null, livemode: false,
408
+ });
409
+ const existing = (ctx.get('charge', chargeId)?.outcome as Row | undefined) ?? {};
410
+ await ctx.write('charge', chargeId, {
411
+ review: review.id,
412
+ outcome: { ...existing, type: 'manual_review', risk_level: 'elevated', risk_score: 67, seller_message: 'Stripe evaluated this payment as having elevated risk, and placed it in review.' },
413
+ }, 'review.opened');
414
+ }
415
+
416
+ export const customerMissing = (ctx: HandlerContext, id: string): Response => fail(ctx, `No such customer: '${id}'`, 404, 'resource_missing');
417
+
418
+ /** The request event asked of the endpoint, signed with its secret and rendered in the event's API version, and its
419
+ * answer read as the decision. */
420
+ export async function askForAuthorization(ctx: HandlerContext, url: string, event: Record<string, unknown>, secret: string): Promise<StripeAuthRequestOutcome> {
421
+ const payload = JSON.stringify(render(event, typeof event.api_version === 'string' ? event.api_version : undefined));
422
+ const signature = stripeSignature(payload, secret, Math.floor(Date.parse(ctx.occurredAt) / 1000));
423
+ const answer = await ctx.ask(url, { method: 'POST', headers: { 'content-type': 'application/json', 'stripe-signature': signature }, body: payload }, STRIPE_REALTIME_AUTH_TIMEOUT_MS);
424
+ if (answer.missed === 'timeout') return { kind: 'timeout', message: 'Webhook endpoint did not respond within the real-time authorization window.' };
425
+ if (answer.missed === 'unreachable') return { kind: 'timeout', message: 'Webhook endpoint was unreachable within the real-time authorization window.' };
426
+ return readDecision(answer);
427
+ }
428
+
429
+ /** Stripe's refusal of a call a publishable key may not make, or undefined when the key may make it. */
430
+ export function publishableKeyRefused(ctx: HandlerContext): Response | undefined {
431
+ // the key however it is sent, Bearer or Basic's user (requestKey): a publishable key is public, and sent as Basic it
432
+ // is still one
433
+ if (!requestKey(ctx.call.request).startsWith('pk_')) return undefined;
434
+ return ctx.refuse({ status: 403, code: 'secret_key_required', message: 'The API key provided is a publishable key, but a secret key is required.' });
435
+ }
436
+
437
+ /** The platform's own keys, as the dashboard's API keys page makes them (the `appCredentials` door): a secret key and a
438
+ * publishable key, held by their SHA-256. A request with no key is refused, as is a platform key Stripe never issued
439
+ * ("Invalid API Key provided"). */
440
+ // source: https://docs.stripe.com/api/authentication "API requests without authentication also fail."
441
+ export function platformKeyRefused(ctx: HandlerContext, request: Request): Response | undefined {
442
+ const key = requestKey(request);
443
+ if (!key) return ctx.refuse({ status: 401, message: "You did not provide an API key. You need to provide your API key in the Authorization header, using Bearer auth (e.g. 'Authorization: Bearer YOUR_SECRET_KEY'). See https://stripe.com/docs/api#authentication for details, or we can help at https://support.stripe.com/." });
444
+ const held = ctx.rowsRaw('_platform_key').some((k) => k._sha256 === ctx.crypto.sha256(key) && k.deleted !== true);
445
+ return held ? undefined : ctx.refuse({ status: 401, message: `Invalid API Key provided: ${redactKey(key)}` });
446
+ }
447
+
448
+ // ── metadata ──
449
+ // "This parameter uses a merge mechanism, which allows you to add new key-value pairs to an object in an update call
450
+ // without affecting any existing metadata"; "Pass in the key with an empty string as the value to remove the key from the
451
+ // metadata"; "Pass an empty string as the value for the metadata attribute to delete all of the keys simultaneously"
452
+ // (docs.stripe.com/metadata), and "Individual keys can be unset by posting an empty value to them" holds on a create
453
+ // too: an object never holds a key set to "" (stripe-node encodes a `null` value as `metadata[key]=`). The derived core
454
+ // merges a nested hash on an update (dropping keys set to ""), so for it only a clear-all is rewritten; where a handler
455
+ // stores the request's metadata whole, the request carries the merged hash, its removed keys as "". The rewritten
456
+ // request is JSON.
457
+ export function withMetadata(ctx: HandlerContext, request: Request): Request {
458
+ if (request.method !== 'POST' || (request.headers.get('content-type') ?? '').includes('multipart')) return request;
459
+ if (!('metadata' in ctx.params)) return request;
460
+ const op = ctx.call.operation;
461
+ const given = ctx.params.metadata;
462
+ let metadata: Row;
463
+ if (op.class === 'create') {
464
+ if (given === '') metadata = {};
465
+ else if (given && typeof given === 'object' && !Array.isArray(given) && Object.values(given).includes('')) metadata = Object.fromEntries(Object.entries(given as Row).filter(([, v]) => v !== ''));
466
+ else return request;
467
+ } else if (op.class === 'update' && op.resource) {
468
+ const resource = op.resource;
469
+ const id = Object.values(ctx.call.params).at(-1) ?? '';
470
+ const held = ctx.get(resource, id)?.metadata;
471
+ const prior = held && typeof held === 'object' ? (held as Row) : {};
472
+ // the merged hash, a key removed carried as "" so a merging update (the derived core's) drops it too; an object
473
+ // never answers a key set to "" (../engine/version.ts render)
474
+ if (given === '') metadata = Object.fromEntries(Object.keys(prior).map((k) => [k, '']));
475
+ else if (given && typeof given === 'object' && !Array.isArray(given)) metadata = { ...prior, ...(given as Row) };
476
+ else return request;
477
+ } else return request;
478
+ const headers = new Headers(request.headers);
479
+ headers.set('content-type', 'application/json');
480
+ return new Request(request.url, { method: request.method, headers, body: JSON.stringify({ ...ctx.params, metadata }) });
481
+ }
482
+
483
+ export function scopeKeyOf(ctx: HandlerContext): string {
484
+ const scope = ctx.params.scope && typeof ctx.params.scope === 'object' ? (ctx.params.scope as Row) : undefined;
485
+ const scopeType = scope && typeof scope.type === 'string' ? scope.type : 'account';
486
+ return scopeType === 'user' ? `user:${String(scope!.user)}` : 'account';
487
+ }
488
+
489
+ export const named = (ctx: HandlerContext, name: string): Row | undefined => ctx.rows(SECRET).find((s) => s.name === name && kept(ctx, SECRET, s, '_scope_key') === scopeKeyOf(ctx));
490
+
491
+ export const invoiceMissing = (ctx: HandlerContext, id: string): Response => fail(ctx, `No such invoice: '${id}'`, 404, 'resource_missing');
492
+
493
+ /** Paying an invoice with nothing to pay it: "If not set, defaults to the subscription's default payment method, if any,
494
+ * or to the default payment method in the customer's invoice settings" (docs.stripe.com/api/invoices/object,
495
+ * default_payment_method), and none of them is set. */
496
+ export function nothingToPayWith(ctx: HandlerContext): Response {
497
+ return fail(ctx, 'This customer has no attached payment source or default payment method. Please consider adding a default payment method.', 400, 'missing');
498
+ }
499
+
500
+ /** A subscription waiting on the invoice paid through the invoice's own pay (incomplete on its first, past_due on a
501
+ * failed renewal) is active again. */
502
+ export async function activatedByPayment(ctx: HandlerContext, subId: string): Promise<void> {
503
+ await ctx.write('subscription', subId, { status: 'active' }, 'subscription.update');
504
+ }
505
+
506
+ /** The invoice the path names, and the machine's refusal when this operation may not move it. */
507
+ export function invoicesLoad(ctx: HandlerContext, operationId: string): { inv: Row } | { answer: Response } {
508
+ const id = at(ctx, 'invoice');
509
+ const inv = ctx.get(INV, id);
510
+ if (!inv) return { answer: invoiceMissing(ctx, id) };
511
+ const refused = ctx.legal(INV, 'status', operationId, inv.status, undefined, id);
512
+ return refused ? { answer: ctx.refuse(refused) } : { inv };
513
+ }
514
+
515
+ /** A draft finalized on its way to being paid: numbered, open, its PaymentIntent opened (PostInvoicesInvoiceFinalize). */
516
+ export async function finalizeForPayment(ctx: HandlerContext, inv: Row, id: string): Promise<Row> {
517
+ const piId = collectsThroughIntent(inv) ? ctx.mint('payment_intent') : null;
518
+ const now = nowUnix(ctx.occurredAt);
519
+ ctx.legal(INV, 'status', 'PostInvoicesInvoicePay', 'draft', 'open', id);
520
+ const body = await ctx.write(INV, id, { ...finalizedFields(ctx, inv, Number(now)), status: 'open', status_transitions: { ...transitions(inv), finalized_at: now }, payment_intent: piId }, 'invoice.finalize');
521
+ if (piId) await openPaymentIntent(ctx, piId, inv, id);
522
+ await spendOnceCoupon(ctx, inv);
523
+ return ctx.get(INV, id) ?? body;
524
+ }
525
+
526
+ /** The PaymentIntent a charge_automatically invoice with a balance gets when it is finalized; it
527
+ * waits for confirmation when the invoice already names a default payment method. */
528
+ export async function openPaymentIntent(ctx: HandlerContext, piId: string, inv: Row, invoiceId: string): Promise<void> {
529
+ const defaultPaymentMethod = typeof inv.default_payment_method === 'string' ? inv.default_payment_method : undefined;
530
+ const intent = await created(ctx, 'payment_intent', {
531
+ id: piId, amount: Number(inv.amount_due) || 0, currency: String(inv.currency ?? 'usd'), customer: inv.customer, invoice: invoiceId,
532
+ ...(defaultPaymentMethod ? { payment_method: defaultPaymentMethod } : {}),
533
+ }, {
534
+ status: defaultPaymentMethod ? 'requires_confirmation' : 'requires_payment_method',
535
+ client_secret: mintClientSecret(piId, await ctx.secret(`client-secret:${piId}`)), livemode: false,
536
+ capture_method: 'automatic', amount_capturable: 0, amount_received: 0, next_action: null,
537
+ automatic_payment_methods: null, payment_method_types: ['card', 'link'], payment_method_options: {},
538
+ });
539
+ // source: spec:/components/schemas/invoice/properties/confirmation_secret/description "client_secret of the PaymentIntent"
540
+ await ctx.write(INV, invoiceId, { confirmation_secret: { type: 'payment_intent', client_secret: intent.client_secret } }, 'invoice.updated');
541
+ }
542
+
543
+ export function finalizedFields(ctx: HandlerContext, inv: Row, now: number): Row {
544
+ const customerId = typeof inv.customer === 'string' ? inv.customer : '';
545
+ const customer = customerId ? ctx.get('customer', customerId) : undefined;
546
+ const prefix = typeof customer?.invoice_prefix === 'string' && customer.invoice_prefix
547
+ ? customer.invoice_prefix
548
+ : invoicePrefix(customerId);
549
+ const numbered = ctx.rows(INV).filter((i) => i.customer === customerId && typeof i.number === 'string' && i.id !== inv.id).length;
550
+ const fields: Row = { number: typeof inv.number === 'string' && inv.number ? inv.number : `${prefix}-${String(numbered + 1).padStart(4, '0')}` };
551
+ const days = Number(inv.days_until_due);
552
+ if (inv.collection_method === 'send_invoice' && inv.due_date == null && Number.isFinite(days) && days > 0) fields.due_date = now + days * 86400;
553
+ return fields;
554
+ }
555
+
556
+ /** A coupon for a fixed amount off, in its currency (docs.stripe.com/api/coupons/create#create_coupon-amount_off). */
557
+ export function amountOffOf(ctx: HandlerContext, params: Record<string, unknown>): number | Response {
558
+ const amountOff = Number(params.amount_off);
559
+ if (!Number.isInteger(amountOff) || amountOff <= 0) return fail(ctx, 'Invalid integer: amount_off must be a positive integer.', 400, 'parameter_invalid_integer');
560
+ if (params.currency === undefined || params.currency === '') return fail(ctx, 'Missing required param: currency.', 400, 'parameter_missing');
561
+ return amountOff;
562
+ }
563
+
564
+ /** Time's lapsing of coupons, caught up to the World's clock: a valid coupon whose redeem_by has passed is no longer
565
+ * valid from that moment (manifest.ts). Where the documentation stops and the twin decides: the lapse sends no event
566
+ * (the events page names none for it). */
567
+ export async function lapseCoupons(ctx: HandlerContext): Promise<void> {
568
+ const now = Number(nowUnix(ctx.occurredAt));
569
+ for (const c of ctx.rowsRaw('coupon')) {
570
+ if (c.valid !== true || typeof c.redeem_by !== 'number' || c.redeem_by > now) continue;
571
+ const t = await at_(ctx)(c.redeem_by);
572
+ t.legal('coupon', 'valid', ctx.call.operation.id, 'true', 'false', String(c.id), 'time');
573
+ await t.write('coupon', String(c.id), { valid: false }, 'coupon.lapsed');
574
+ }
575
+ }
576
+
577
+ export const sessionMissing = (ctx: HandlerContext, id: string): Response => fail(ctx, `No such checkout.session: '${id}'`, 404, 'resource_missing');
578
+
579
+ // Stripe normalizes the create input into the Session: line_items become the list envelope, and
580
+ // subscription_data (create-only) is kept aside for completion to copy onto the subscription
581
+ /** The discount a session is created with (discounts[0][promotion_code] or [coupon]), or Stripe's refusal: an unknown
582
+ * code or coupon, and one that has expired or been deactivated, are refused at create
583
+ * (docs.stripe.com/api/checkout/sessions/create#create_checkout_session-discounts, docs.stripe.com/error-codes#coupon-expired).
584
+ * Where the documentation stops and the twin decides: the refusals' wording. */
585
+ export function discountOf(ctx: HandlerContext, params: Row, now: number): { coupon: Row; promotion?: string } | Response | undefined {
586
+ const first = Array.isArray(params.discounts) ? (params.discounts[0] as Row | undefined) : undefined;
587
+ if (!first || typeof first !== 'object') return undefined;
588
+ const expired = (what: string) => ctx.refuse({ status: 400, code: 'coupon_expired', param: 'discounts', message: `This ${what} has expired.` });
589
+ let promotion: string | undefined;
590
+ let couponId = typeof first.coupon === 'string' ? first.coupon : undefined;
591
+ if (typeof first.promotion_code === 'string') {
592
+ const pc = ctx.get('promotion_code', first.promotion_code);
593
+ if (!pc) return ctx.refuse({ status: 400, code: 'resource_missing', param: 'discounts[0][promotion_code]', message: `No such promotion code: '${first.promotion_code}'` });
594
+ if (typeof pc.expires_at === 'number' && pc.expires_at <= now) return expired('promotion code');
595
+ if (pc.active === false) return ctx.refuse({ status: 400, param: 'discounts[0][promotion_code]', message: 'This promotion code is inactive.' });
596
+ promotion = String(pc.id);
597
+ couponId = typeof pc.coupon === 'string' ? pc.coupon : String((pc.coupon as Row | undefined)?.id ?? '');
598
+ }
599
+ const coupon = couponId ? ctx.get('coupon', couponId) : undefined;
600
+ if (!coupon) return ctx.refuse({ status: 400, code: 'resource_missing', param: 'discounts[0][coupon]', message: `No such coupon: '${couponId ?? ''}'` });
601
+ if (coupon.valid === false || (typeof coupon.redeem_by === 'number' && coupon.redeem_by <= now)) return expired('coupon');
602
+ return { coupon, ...(promotion ? { promotion } : {}) };
603
+ }
604
+
605
+ /** A payment or subscription session with nothing to sell (docs.stripe.com/api/checkout/sessions/create#create_checkout_session-line_items). */
606
+ export function noLineItems(ctx: HandlerContext): Response {
607
+ 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');
608
+ }
609
+
610
+ /** A destination charge to an account the platform does not have (docs.stripe.com/connect/destination-charges). */
611
+ export function checkoutNoDestination(ctx: HandlerContext, destination: unknown): Response {
612
+ return ctx.refuse({ status: 400, code: 'resource_missing', param: 'payment_intent_data[transfer_data][destination]', message: `No such destination: '${String(destination)}'` });
613
+ }
614
+
615
+ /** A session's trial Stripe refuses: `subscription_data.trial_end` "Has to be at least 48 hours in the future", and
616
+ * `subscription_data.trial_period_days` "Has to be at least 1" (docs.stripe.com/api/checkout/sessions/create). Where the
617
+ * documentation stops and the twin decides: the refusals' wording. */
618
+ export function sessionTrialRefused(ctx: HandlerContext, data: unknown, now: number): Response | undefined {
619
+ if (!data || typeof data !== 'object') return undefined;
620
+ const { trial_end: end, trial_period_days: days } = data as Row;
621
+ const endRefused = end !== undefined && !(Number.isInteger(Number(end)) && Number(end) >= now + 48 * 3600);
622
+ const daysRefused = days !== undefined && !(Number.isInteger(Number(days)) && Number(days) >= 1);
623
+ return endRefused || daysRefused ? sessionTrialRefusal(ctx, endRefused ? 'trial_end' : 'trial_period_days') : undefined;
624
+ }
625
+
626
+ export function sessionTrialRefusal(ctx: HandlerContext, param: 'trial_end' | 'trial_period_days'): Response {
627
+ 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.';
628
+ return ctx.refuse({ status: 400, param: `subscription_data[${param}]`, message });
629
+ }
630
+
631
+ /** What completing a session makes, by its mode, as the fields that link it: a payment is a PaymentIntent paid with
632
+ * the entered card, its charge in the ledger as any payment's is. */
633
+ export async function complete(ctx: HandlerContext, id: string, existing: Row, card?: EnteredCard): Promise<Row> {
634
+ const link: Row = { status: 'complete' };
635
+ const customer = typeof existing.customer === 'string' ? existing.customer : undefined;
636
+ const currency = typeof existing.currency === 'string' ? existing.currency : 'usd';
637
+ // the entered card becomes a PaymentMethod carrying what a later charge to it answers and what Stripe does after it
638
+ // succeeds (docs.stripe.com/testing#declined-payments, #disputes)
639
+ const made = card ? await created(ctx, 'payment_method', {}, { type: 'card', 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;
640
+ // and Checkout attaches it to the customer when it saves it: a subscription's card ("If your Checkout Session uses
641
+ // subscription mode, Stripe saves the payment method by default", docs.stripe.com/payments/checkout/how-checkout-works),
642
+ // a setup session's (it exists to save one), and a payment's only under payment_intent_data.setup_future_usage ("to have
643
+ // Checkout automatically attach the payment method to the Customer you pass in", docs.stripe.com/api/checkout/sessions/
644
+ // create#create_checkout_session-customer). Attaching is its own write, so payment_method.attached is delivered ("Occurs
645
+ // whenever a new payment method is attached to a customer", docs.stripe.com/api/events/types) before what the card then
646
+ // pays for. Where the documentation stops and the twin decides: the order of the events, which Stripe does not
647
+ // guarantee (docs.stripe.com/webhooks#event-ordering). A gap: the customer's own opt-in to save a payment's card
648
+ // (saved_payment_method_options.payment_method_save) is not modelled; the page offers no such box.
649
+ const saves = existing.mode !== 'payment' || !!((ctx.row(CS, id)?._payment_intent_data ?? {}) as Row).setup_future_usage;
650
+ const pm = made && customer && saves ? await ctx.write('payment_method', String(made.id), { customer }, 'payment_method.attach') : made;
651
+ if (existing.mode === 'payment') {
652
+ const amount = Number(existing.amount_total) || 0;
653
+ const piId = ctx.mint('payment_intent');
654
+ const pid = (ctx.row(CS, id)?._payment_intent_data ?? {}) as Row;
655
+ const connect = {
656
+ ...(pid.application_fee_amount !== undefined ? { application_fee_amount: Math.trunc(Number(pid.application_fee_amount) || 0) } : {}),
657
+ ...(pid.transfer_data && typeof pid.transfer_data === 'object' ? { transfer_data: { destination: (pid.transfer_data as Row).destination } } : {}),
658
+ ...(typeof pid.description === 'string' ? { description: pid.description } : {}),
659
+ // the session's setup_future_usage is its PaymentIntent's (payment_intent_data: "A subset of parameters to be passed to PaymentIntent creation")
660
+ ...(typeof pid.setup_future_usage === 'string' ? { setup_future_usage: pid.setup_future_usage } : {}),
661
+ ...(nestedMetadata(pid.metadata) ? { metadata: nestedMetadata(pid.metadata) } : {}),
662
+ };
663
+ const fields = { amount, currency, id: piId, ...(customer ? { customer } : {}), ...(pm ? { payment_method: pm.id } : {}), ...connect };
664
+ await created(ctx, 'payment_intent', fields, { status: 'succeeded', amount_received: amount, client_secret: mintClientSecret(piId, await ctx.secret(`client-secret:${piId}`)), livemode: false, payment_method_types: Array.isArray(existing.payment_method_types) ? existing.payment_method_types : ['card'] });
665
+ const charge = await mintCharge(ctx, piId, fields, amount);
666
+ await ctx.write('payment_intent', piId, { latest_charge: charge }, 'payment_intent.succeeded');
667
+ link.payment_intent = piId;
668
+ link.payment_status = 'paid';
669
+ } else if (existing.mode === 'subscription') {
670
+ if (customer) {
671
+ // the subscription bills the session's own (already resolved) line items, and takes the
672
+ // session's subscription_data: metadata, description, and a trial as its first period
673
+ const now = Number(nowUnix(ctx.occurredAt));
674
+ // "Line items with one-time Prices will be on the initial invoice only" (docs.stripe.com/api/checkout/sessions/
675
+ // create#create_checkout_session-line_items): the subscription's items are the recurring ones
676
+ const inline = (ctx.row(CS, id)?._price_data as Array<Row | null> | undefined) ?? [];
677
+ const lines = ((existing.line_items as { data?: unknown } | undefined)?.data as Row[] | undefined) ?? [];
678
+ const allLineItems = inline.some(Boolean) ? await sessionPrices(ctx, lines, inline) : lines;
679
+ const sessionLineItems = allLineItems.filter((it) => !isOneTime(it));
680
+ const oneTime = allLineItems.filter((it) => isOneTime(it));
681
+ const { interval, interval_count } = resolveSubscriptionBillingInterval(sessionLineItems, finder(ctx));
682
+ const subId = ctx.mint('subscription');
683
+ // the session's view never carries its create-only subscription_data: read the stored row
684
+ const raw = ctx.row(CS, id)?._subscription_data;
685
+ const subData = (raw && typeof raw === 'object' ? raw : {}) as Row;
686
+ const subMetadata = nestedMetadata(subData.metadata) ?? {};
687
+ // a trial of `trial_period_days` from now, or to `trial_end` (docs.stripe.com/payments/checkout/free-trials)
688
+ const trialEnd = resolveTrial(subData, now)?.end ?? null;
689
+ const sub = await created(ctx, 'subscription', { customer, id: subId }, {
690
+ status: trialEnd === null ? 'active' : 'trialing', livemode: false, currency, collection_method: 'charge_automatically',
691
+ // a trial's end is the billing anchor (docs.stripe.com/api/subscriptions/create#create_subscription-trial_end)
692
+ cancel_at_period_end: false, start_date: now, billing_cycle_anchor: trialEnd ?? now, metadata: subMetadata,
693
+ description: typeof subData.description === 'string' ? subData.description : null,
694
+ // the session's discount is the subscription's (docs.stripe.com/payments/checkout/discounts)
695
+ discounts: sessionDiscount(ctx, id, customer, now), billing_schedules: [],
696
+ trial_start: trialEnd === null ? null : now, trial_end: trialEnd,
697
+ current_period_start: now, current_period_end: trialEnd ?? addBillingInterval(now, interval, interval_count),
698
+ // Checkout saves the card as the subscription's default payment method (docs.stripe.com/payments/checkout/how-checkout-works)
699
+ default_payment_method: pm?.id ?? null,
700
+ items: buildSubscriptionItemsList(sessionLineItems, subId, now, finder(ctx)),
701
+ automatic_tax: { enabled: false, liability: null }, billing_mode: { type: 'classic' },
702
+ invoice_settings: { issuer: { type: 'self' } },
703
+ });
704
+ await storeItems(ctx, subId, ((sub.items as Row | undefined)?.data as Row[] | undefined) ?? []);
705
+ // its first invoice, paid on the page by the card just entered (nothing is due on a trial's)
706
+ // (docs.stripe.com/billing/subscriptions/overview#how-payments-work-subscriptions)
707
+ const invoice = await draftSubscriptionInvoice(ctx, sub, { start: now, end: Number(sub.current_period_end), reason: 'subscription_create', trial: trialEnd !== null, once: oneTime });
708
+ await collectSubscriptionInvoice(ctx, invoice, now, 'external');
709
+ await ctx.write('subscription', subId, { latest_invoice: invoice }, 'subscription.update');
710
+ link.subscription = sub.id;
711
+ link.invoice = invoice;
712
+ }
713
+ link.payment_status = 'paid';
714
+ } else link.setup_intent = await completeSetup(ctx, customer, pm);
715
+ return link;
716
+ }
717
+
718
+ /** A subscription session's lines at completion, each inline `price_data` made the Price it generates ("Data used to
719
+ * generate a new Price object inline", the same page), under the product it names or one made from its product_data,
720
+ * so the subscription's items bill it. Where the documentation stops and the twin decides: such a Price is archived
721
+ * (active=false), as a subscription item's price_data makes one (docs.stripe.com/products-prices/manage-prices). */
722
+ export async function sessionPrices(ctx: HandlerContext, lines: Row[], inline: Array<Row | null>): Promise<Row[]> {
723
+ const out: Row[] = [];
724
+ for (const [i, line] of lines.entries()) {
725
+ const d = inline[i];
726
+ if (!d) { out.push(line); continue; }
727
+ const pd = d.product_data && typeof d.product_data === 'object' ? (d.product_data as Row) : undefined;
728
+ 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: nowUnix(ctx.occurredAt) })).id);
729
+ const r = d.recurring && typeof d.recurring === 'object' ? (d.recurring as Row) : undefined;
730
+ const price = await created(ctx, 'price', {
731
+ product, currency: String(d.currency ?? line.currency ?? 'usd').toLowerCase(),
732
+ unit_amount: d.unit_amount_decimal !== undefined ? null : Math.trunc(Number(d.unit_amount) || 0),
733
+ unit_amount_decimal: d.unit_amount_decimal !== undefined ? String(d.unit_amount_decimal) : String(Math.trunc(Number(d.unit_amount) || 0)),
734
+ ...(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 }),
735
+ }, { active: false, livemode: false, billing_scheme: 'per_unit', type: r ? 'recurring' : 'one_time', metadata: {}, lookup_key: null, nickname: null, tax_behavior: 'unspecified' });
736
+ out.push({ ...line, price });
737
+ }
738
+ return out;
739
+ }
740
+
741
+ /** A setup session saves the card through a succeeded SetupIntent (docs.stripe.com/payments/save-and-reuse?platform=checkout). */
742
+ export async function completeSetup(ctx: HandlerContext, customer: string | undefined, pm: Row | undefined): Promise<unknown> {
743
+ const siId = ctx.mint('setup_intent');
744
+ const si = await created(ctx, 'setup_intent', { id: siId, ...(customer ? { customer } : {}), ...(pm ? { payment_method: pm.id } : {}) }, { status: 'succeeded', usage: 'off_session', client_secret: mintClientSecret(siId, await ctx.secret(`client-secret:${siId}`)), payment_method_types: ['card'], livemode: false });
745
+ return si.id;
746
+ }
747
+
748
+ /** The discount a completed subscription session gives its subscription. */
749
+ export function sessionDiscount(ctx: HandlerContext, id: string, customer: string, now: number): Row[] {
750
+ const d = ctx.row(CS, id)?._discount as Row | undefined;
751
+ const coupon = d && typeof d.coupon === 'string' ? ctx.get('coupon', d.coupon) : undefined;
752
+ return coupon ? [{ ...buildDiscount(coupon, customer, now), promotion_code: d?.promotion_code ?? null, checkout_session: id }] : [];
753
+ }
754
+
755
+ /** The customer paying on the hosted page (src/screens/checkout.tsx): the external actor's move on the
756
+ * session's machine, then what the payment made, linked, and the session stored complete. */
757
+ export async function completeSession(ctx: HandlerContext, id: string, existing: Row, details: Row = {}, card?: EnteredCard): Promise<Row | Response> {
758
+ const refused = ctx.legal(CS, 'status', 'PostCheckoutSessionsSession', existing.status, 'complete', id, 'external');
759
+ if (refused) return ctx.refuse(refused);
760
+ const link = await complete(ctx, id, existing, card);
761
+ // the discount the buyer took is redeemed (shared.ts redeemCoupon)
762
+ const d = ctx.row(CS, id)?._discount as Row | undefined;
763
+ if (d) await redeemCoupon(ctx, d.coupon, d.promotion_code);
764
+ // the session's url "is only present when the session is active" (docs.stripe.com/api/checkout/sessions/object)
765
+ if (link.payment_status !== undefined) ctx.legal(CS, 'payment_status', 'PostCheckoutSessionsSession', existing.payment_status, String(link.payment_status), id, 'external');
766
+ return ctx.write(CS, id, { ...details, ...link, url: null }, 'checkout.session.completed');
767
+ }
768
+
769
+ export const send = (ctx: HandlerContext, r: StripeResponse): Response => ctx.reply(r.body, r.status);
770
+
771
+ /** Stripe's error body: an invalid_request_error with the message, and the code when there is one. */
772
+ export const fail = (ctx: HandlerContext, message: string, status = 404, code?: string): Response => ctx.refuse({ status, message, ...(code ? { code } : {}) });
773
+
774
+ /** The request's path, as the list envelope's `url` carries it. */
775
+ export const path = (ctx: HandlerContext): string => new URL(ctx.call.request.url).pathname.replace(/\/+$/, '') || '/';
776
+
777
+ /** A path parameter, by the name the spec gives it. */
778
+ export const at = (ctx: HandlerContext, name: string): string => ctx.call.params[name] ?? '';
779
+
780
+ /** A resource's rows in Stripe's list order: newest first by `timeField`, ties by mint order (as the derived core breaks them). */
781
+ /** A subject's bookkeeping field (`_…`): kept on the stored row, never in the wire view ctx.get and ctx.rows answer. */
782
+ export const kept = (ctx: HandlerContext, resource: string, row: Row, key: string): unknown => ctx.row(resource, String(row.id), { withDeleted: true })?.[key];
783
+
69
784
  /** A create's parameter that describes a confirmation, sent without confirm=true: the served spec gives each "This
70
785
  * parameter can only be used with `confirm=true`" (off_session: "can only be used when confirm=true"); the message's
71
786
  * wording is the twin's. Answers the refusal, or undefined. */
72
- export function confirmOnly(ctx: SemanticsContext, names: string[], confirm: boolean): Response | undefined {
787
+ export function confirmOnly(ctx: HandlerContext, names: string[], confirm: boolean): Response | undefined {
73
788
  const sent = confirm ? undefined : names.find((n) => ctx.params[n] !== undefined);
74
789
  return sent ? fail(ctx, `\`${sent}\` can only be used when \`confirm\` is set to \`true\`.`, 400, 'parameter_invalid') : undefined;
75
790
  }
@@ -78,40 +793,35 @@ export function confirmOnly(ctx: SemanticsContext, names: string[], confirm: boo
78
793
  * applied to a customer" (the served spec's coupon), counts it, as the code's "Number of times this promotion code
79
794
  * has been used" (its promotion_code). Where the documentation stops and the twin decides: reaching max_redemptions
80
795
  * does not yet lapse the coupon. */
81
- export async function redeemCoupon(ctx: SemanticsContext, couponId: unknown, promotionCode?: unknown): Promise<void> {
796
+ export async function redeemCoupon(ctx: HandlerContext, couponId: unknown, promotionCode?: unknown): Promise<void> {
82
797
  const coupon = typeof couponId === 'string' ? ctx.get('coupon', couponId) : undefined;
83
798
  if (coupon) await ctx.write('coupon', couponId as string, { times_redeemed: (Number(coupon.times_redeemed) || 0) + 1 }, 'coupon.redeemed');
84
799
  const code = typeof promotionCode === 'string' ? ctx.get('promotion_code', promotionCode) : undefined;
85
800
  if (code) await ctx.write('promotion_code', promotionCode as string, { times_redeemed: (Number(code.times_redeemed) || 0) + 1 }, 'promotion_code.redeemed');
86
801
  }
87
802
 
88
- export function newest(ctx: SemanticsContext, resource: string, timeField = 'created'): Row[] {
803
+ export function newest(ctx: HandlerContext, resource: string, timeField = 'created'): Row[] {
89
804
  // an object Stripe gives no creation time is ordered by the one the twin keeps for it
90
805
  const raw = new Map(ctx.rowsRaw(resource).map((r) => [String(r.id), r]));
91
806
  const when = (r: Row): number => Number(r[timeField] ?? raw.get(String(r.id))?._created ?? 0);
92
- return ctx.rows(resource).sort((a, b) => {
807
+ // ties by the order the World made them (an event's id is a digest, not a count)
808
+ const rows = ctx.rows(resource);
809
+ const made = new Map(rows.map((r, i) => [r, i]));
810
+ return rows.sort((a, b) => {
93
811
  const ta = when(a), tb = when(b);
94
812
  if (tb !== ta) return tb - ta;
95
- return String(b.id).localeCompare(String(a.id), undefined, { numeric: true });
96
- });
97
- }
98
-
99
- /** Keep the rows whose field equals the request's parameter of the same name, for each name given. */
100
- /** A list's range filter on a unix time (`created[gte]=…&created[lt]=…`, or one exact time). */
101
- export function inRange(t: unknown, v: unknown): boolean {
102
- const at = Number(t);
103
- const r = (v && typeof v === 'object' ? v : { gte: v, lte: v }) as Row;
104
- 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));
813
+ return made.get(b)! - made.get(a)!;
814
+ }).map((r) => ctx.expand(resource, r));
105
815
  }
106
816
 
107
817
  /** A context at the moment Stripe would have made a move time makes (a renewal, a payout on its schedule), so what
108
818
  * the move writes carries that moment (its events' occurredAt, `created`, a charge's funds' available_on), not the
109
819
  * moment a later request caught it up. */
110
- export function at_(ctx: SemanticsContext): (t: number) => Promise<SemanticsContext> {
820
+ export function at_(ctx: HandlerContext): (t: number) => Promise<HandlerContext> {
111
821
  return (t) => ctx.at(new Date(t * 1000).toISOString());
112
822
  }
113
823
 
114
- export function where(ctx: SemanticsContext, items: Row[], spec: Record<string, (item: Row, value: unknown) => boolean>): Row[] {
824
+ export function where(ctx: HandlerContext, items: Row[], spec: Record<string, (item: Row, value: unknown) => boolean>): Row[] {
115
825
  let out = items;
116
826
  for (const [key, pred] of Object.entries(spec)) {
117
827
  const value = ctx.params[key];
@@ -120,107 +830,1351 @@ export function where(ctx: SemanticsContext, items: Row[], spec: Record<string,
120
830
  return out;
121
831
  }
122
832
 
123
- function expandPaths(ctx: SemanticsContext): string[][] {
833
+ export function expandPaths(ctx: HandlerContext): string[][] {
124
834
  const expand = ctx.params.expand;
125
835
  return Array.isArray(expand) ? expand.map((p) => String(p).split('.')) : [];
126
836
  }
127
837
 
128
838
  /** A balance transaction as the clock reads it: available once its available_on has passed, the clock's move,
129
- * asked of the machine as a write asks it (semantics/ledger.ts). */
130
- export function asOf(ctx: SemanticsContext, t: Row): Row {
839
+ * asked of the machine as a write asks it (./ledger.ts). */
840
+ export function commonAsOf(ctx: HandlerContext, t: Row): Row {
131
841
  const due = Number(t.available_on);
132
- if (t.status !== 'pending' || !Number.isFinite(due) || due > Number(ctx.now())) return t;
842
+ if (t.status !== 'pending' || !Number.isFinite(due) || due > Number(nowUnix(ctx.occurredAt))) return t;
133
843
  ctx.legal('balance_transaction', 'status', ctx.call.operation.id, 'pending', 'available', String(t.id), 'time');
134
844
  return { ...t, status: 'available' };
135
845
  }
136
846
 
137
- /** Stripe's expand walk: each dotted path replaces an id with the resource the manifest says it
138
- * holds, one segment at a time; an id the tree lacks stays an id. A balance transaction reads as the clock has it
139
- * (asOf), as its own retrieve does. */
140
- export function expandRow(ctx: SemanticsContext, resource: string, body: Row, paths: string[][]): Row {
141
- const embeds = manifest.resources[resource]?.embeds;
142
- if (!embeds || paths.length === 0) return body;
143
- let out = body;
144
- const byHead = new Map<string, string[][]>();
145
- for (const p of paths) {
146
- if (p.length === 0) continue;
147
- const list = byHead.get(p[0]!) ?? [];
148
- list.push(p.slice(1));
149
- byHead.set(p[0]!, list);
150
- }
151
- for (const [head, tails] of byHead) {
152
- const target = embeds[head];
153
- if (!target) continue;
154
- const cur = out[head];
155
- const found = typeof cur === 'string' && cur ? ctx.get(target, cur) : cur && typeof cur === 'object' && !Array.isArray(cur) ? (cur as Row) : undefined;
156
- if (!found) continue;
157
- const sub = target === 'balance_transaction' ? asOf(ctx, found) : found;
158
- const deeper = tails.filter((t) => t.length > 0);
159
- out = { ...out, [head]: deeper.length ? expandRow(ctx, target, sub, deeper) : sub };
160
- }
161
- return out;
847
+ /** Stripe's expand walk (the kernel's, over the manifest's `embeds`): each dotted path replaces an id with the resource
848
+ * it holds, one segment at a time; an id the tree lacks stays an id. An embedded balance transaction reads as the clock
849
+ * has it (asOf), as its own retrieve does. */
850
+ export function expandRow(ctx: HandlerContext, resource: string, body: Row, paths: string[][]): Row {
851
+ const asOf = (v: unknown): unknown => {
852
+ if (Array.isArray(v)) return v.map(asOf);
853
+ if (!v || typeof v !== 'object') return v;
854
+ const o = Object.fromEntries(Object.entries(v as Row).map(([k, x]) => [k, asOf(x)]));
855
+ return o.object === 'balance_transaction' ? commonAsOf(ctx, o) : o;
856
+ };
857
+ const out = ctx.expand(resource, body, paths.map((p) => p.join('.')));
858
+ return Object.fromEntries(Object.entries(out).map(([k, v]) => [k, asOf(v)]));
162
859
  }
163
860
 
164
861
  /** One resource with the request's `expand[]` applied. */
165
- export const expanded = (ctx: SemanticsContext, resource: string, body: Row): Row => expandRow(ctx, resource, body, expandPaths(ctx));
862
+ export const expanded = (ctx: HandlerContext, resource: string, body: Row): Row => expandRow(ctx, resource, body, expandPaths(ctx));
863
+
864
+ export const resourceView = (ctx: HandlerContext, resource: string, id: string): Row | undefined => {
865
+ const row = ctx.get(resource, id);
866
+ return row ? ctx.expand(resource, row) : undefined;
867
+ };
166
868
 
167
869
  /** Stripe's list envelope over rows already in list order: a page, `has_more`, and `expand[]=data.*`. */
168
- export function list(ctx: SemanticsContext, resource: string, items: Row[]): Response {
870
+ export function list(ctx: HandlerContext, resource: string, items: Row[]): Response {
169
871
  const { page, hasMore } = paginate(items, ctx.params);
170
872
  const paths = expandPaths(ctx).filter((p) => p[0] === 'data').map((p) => p.slice(1)).filter((p) => p.length > 0);
171
- return ctx.reply({ object: 'list', url: path(ctx), has_more: hasMore, data: paths.length ? page.map((r) => expandRow(ctx, resource, r, paths)) : page });
873
+ return ctx.reply({ object: 'list', url: path(ctx), has_more: hasMore, data: page.map((r) => expandRow(ctx, resource, r, paths)) });
172
874
  }
173
875
 
174
876
  /** Stripe's search over a resource's rows (`query` in its search language). */
175
- export const search = (ctx: SemanticsContext, resource: string, timeField = 'created'): Response => send(ctx, searchOver(newest(ctx, resource, timeField), ctx.params, path(ctx)));
176
-
177
- const SPEC_FIELDS = new Map((surface.resources as Array<{ name: string; fields: Array<{ name: string }> }>).map((r) => [r.name, new Set(r.fields.map((f) => f.name))]));
877
+ export const search = (ctx: HandlerContext, resource: string, timeField = 'created'): Response => send(ctx, searchOver(newest(ctx, resource, timeField), ctx.params, path(ctx)));
178
878
 
179
879
  /** A create as Stripe stamps it: the caller's id when it gave one (seeding uses real ids), else a
180
880
  * minted one; `object`, the creation time, the defaults, then the request's own fields. An object
181
881
  * Stripe gives no creation time keeps the twin's as `_created`, for ordering. (A request parameter the
182
882
  * object has no field for stays on the row, where the rules read it; the wire answer leaves it out,
183
- * stripe-version.ts.) */
184
- export async function created(ctx: SemanticsContext, resource: string, params: Row, defaults: Row, opts: { timeField?: string; operation?: string } = {}): Promise<Row> {
883
+ * ./version.ts.) */
884
+ export async function created(ctx: HandlerContext, resource: string, params: Row, defaults: Row, opts: { timeField?: string; operation?: string } = {}): Promise<Row> {
185
885
  const { id: provided, ...rest } = params;
186
- const type = stored(resource);
886
+ const type = ctx.storedType(resource);
187
887
  const id = typeof provided === 'string' && provided ? provided : ctx.mint(resource);
188
888
  const known = SPEC_FIELDS.get(resource);
189
889
  const timeField = opts.timeField ?? 'created';
190
890
  const stamped = !known || known.has(timeField) || timeField in defaults || timeField in rest ? timeField : '_created';
191
- const fields = stashVendorType(type, { object: OBJECT_NAME[type] ?? type, [stamped]: ctx.now(), ...defaults, ...rest });
192
- return ctx.write(resource, id, fields, opts.operation ?? `${type}.create`);
193
- }
194
-
195
- /** A stored subject of any stored type, tombstones included, in the pack's view: what the pack's
196
- * shared helpers look a price, a payment method or a card up by, read from the context's tree. */
197
- export type Find = (storedType: string, id: string) => Row | undefined;
198
- export const finder = (ctx: SemanticsContext): Find => {
199
- const tree = ctx.tree();
200
- return (storedType, id) => {
201
- const r = tree.find((x) => x.type === storedType && x.id === id);
202
- return r ? view(storedType, r) : undefined;
203
- };
204
- };
205
-
206
- /** A refund's destination_details for a card charge: "If this is a `card` refund, this hash contains the transaction
207
- * specific details" and its `type` "can be `refund`, `reversal`, or `pending`" — a reversal, where "The original charge
208
- * will drop off the bank statement altogether", for an authorization released uncaptured, else a refund, "a credit entry
209
- * on the bank statement" (docs.stripe.com/api/refunds/object). The network's reference number the twin never has. A
210
- * charge not paid by card answers none. */
211
- export function refundDestination(charge: Row, type: 'refund' | 'reversal'): Row {
212
- const pmd = charge.payment_method_details as Row | null | undefined;
213
- if (pmd && pmd.type !== 'card') return {};
214
- return { destination_details: { type: 'card', card: { reference: null, reference_status: null, reference_type: null, type } } };
891
+ const fields = { object: OBJECT_NAME[type] ?? type, [stamped]: nowUnix(ctx.occurredAt), ...defaults, ...rest };
892
+ // source: spec:/components/schemas/subscription/properties/items/description "List of subscription items, each with an attached price."
893
+ const children = resource === SUB ? ((fields.items as { data?: Row[] } | undefined)?.data ?? []) : [];
894
+ if (resource === SUB) delete (fields as Row).items;
895
+ // source: spec:/components/schemas/subscription_item/properties/price "price"
896
+ if (resource === 'subscription_item') {
897
+ const item = fields as Row;
898
+ if (item.price && typeof item.price === 'object') item.price = (item.price as Row).id;
899
+ delete item.plan; // the served subscription_item schema has no plan field
900
+ }
901
+ // source: spec:/components/schemas/account/properties/individual "person"
902
+ if (resource === 'account' && (fields as Row).individual && typeof (fields as Row).individual === 'object') {
903
+ const person = await created(ctx, 'person', { ...((fields as Row).individual as Row), account: id }, { metadata: {}, relationship: { representative: true } });
904
+ (fields as Row).individual = person.id;
905
+ }
906
+ const row = await ctx.write(resource, id, fields, opts.operation ?? `${type}.create`);
907
+ if (children.length) await storeItems(ctx, id, children);
908
+ return ctx.expand(resource, row);
215
909
  }
216
910
 
217
- /** An account's external_accounts, "External accounts (bank accounts and debit cards) currently attached to this
218
- * account" (docs.stripe.com/api/accounts/object), as the list its example answers. */
219
- export const externalList = (account: string, data: Row[]): Row => ({ object: 'list', data, has_more: false, total_count: data.length, url: `/v1/accounts/${account}/external_accounts` });
911
+ export const finder = (ctx: Pick<HandlerContext, 'rowsRaw'>): Find => (storedType, id) => {
912
+ const r = ctx.rowsRaw(storedType, { withDeleted: true }).find((x) => x.id === id);
913
+ // source: https://docs.stripe.com/testing "Generic decline 4000 0000 0000 0002"
914
+ // Private test-card behavior is used for subsequent charges, never sent as a vendor field.
915
+ return r ? { ...view(storedType, r as TwinResource), ...(r._declineOutcome !== undefined ? { _declineOutcome: r._declineOutcome } : {}) } : undefined;
916
+ };
220
917
 
221
918
  /** The account's external_accounts list, rewritten from its external accounts after one is added or removed. */
222
- export async function syncExternals(ctx: SemanticsContext, account: string): Promise<void> {
919
+ export async function syncExternals(ctx: HandlerContext, account: string): Promise<void> {
223
920
  if (!ctx.get('account', account)) return;
224
921
  await ctx.write('account', account, { external_accounts: externalList(account, newest(ctx, 'external_account').filter((e) => e.account === account)) }, 'account.external_accounts');
225
922
  }
226
923
 
924
+ export const pmMissing = (ctx: HandlerContext, id: string): Response => fail(ctx, `No such payment_method: '${id}'`, 404, 'resource_missing');
925
+
926
+ /** The card a charge was paid with, as its payment_method_details reports it
927
+ * (docs.stripe.com/api/charges/object#charge_object-payment_method_details): a stored PaymentMethod's card, a
928
+ * documented test name (pm_card_* or tok_*, the same cards), or a raw card; undefined for anything else. */
929
+ export function paymentMethodDetails(ctx: HandlerContext, ref: unknown): Row | undefined {
930
+ let card: Row | undefined;
931
+ // a payment method of another kind (a US bank account) is described by its own block, as the charge reports it
932
+ const stored = typeof ref === 'string' && ref ? ctx.get(PM, ref) : undefined;
933
+ // Stripe's US bank account test methods (pm_us_bank_account, pm_usBankAccount_*) are its test bank's account
934
+ const pm = stored ?? (typeof ref === 'string' && /^pm_us_?bank_?account/i.test(ref) ? { type: 'us_bank_account', ...paymentMethodSubObject('us_bank_account', {}) } : undefined);
935
+ if (pm && typeof pm.type === 'string' && pm.type !== 'card') {
936
+ const own = (pm[pm.type] as Row | undefined) ?? {};
937
+ const pick = ['account_holder_type', 'account_type', 'bank_name', 'fingerprint', 'last4', 'routing_number'];
938
+ return { type: pm.type, [pm.type]: Object.fromEntries(pick.filter((k) => own[k] !== undefined).map((k) => [k, own[k]])) };
939
+ }
940
+ if (typeof ref === 'string' && ref) {
941
+ const stored = ctx.get(PM, ref)?.card as Row | undefined;
942
+ if (stored) card = stored;
943
+ else card = testCardOf(ref);
944
+ } else if (ref && typeof ref === 'object') card = paymentMethodSubObject('card', { card: ref }).card as Row;
945
+ if (!card) return undefined;
946
+ return {
947
+ type: 'card',
948
+ card: {
949
+ brand: card.brand, last4: card.last4, exp_month: card.exp_month, exp_year: card.exp_year, funding: card.funding ?? 'credit',
950
+ country: card.country ?? 'US', network: card.brand, fingerprint: card.fingerprint ?? null,
951
+ checks: card.checks ?? { address_line1_check: null, address_postal_code_check: null, cvc_check: 'pass' },
952
+ three_d_secure: null, wallet: null, installments: null, mandate: null,
953
+ },
954
+ };
955
+ }
956
+
957
+ // attaching needs an existing customer; a well-known test token (pm_card_visa, …) is a template:
958
+ // attaching it mints a new PaymentMethod on the customer carrying that card's outcome
959
+ /** Whether a payment method can be attached: a stored one, or a documented test name that attaching mints. */
960
+ export const attachable = (ctx: HandlerContext, id: string): boolean => !!ctx.get(PM, id) || (id.startsWith('pm_card_') && (id in TEST_TOKEN_DECLINES || id in AFTER_SUCCESS_CARDS));
961
+
962
+ export async function attachPaymentMethod(ctx: HandlerContext, id: string, customer: string): Promise<Row> {
963
+ if (!ctx.get(PM, id)) return materializeTestMethod(ctx, id, customer);
964
+ return ctx.write(PM, id, { customer }, 'payment_method.attach');
965
+ }
966
+
967
+ /** A documented test name (pm_card_visa, pm_card_visa_chargeDeclined, …) made a real PaymentMethod, as Stripe
968
+ * does when one is used: the brand and number it stands for (docs.stripe.com/testing#cards) and its outcome. */
969
+ export async function materializeTestMethod(ctx: HandlerContext, id: string, customer: string | null): Promise<Row> {
970
+ const card = TEST_PM_CARDS[id] ?? { brand: 'visa', number: '4242424242424242' };
971
+ const sub = paymentMethodSubObject('card', { card: { number: card.number } });
972
+ (sub.card as Record<string, unknown>).brand = card.brand;
973
+ const afterSuccess = AFTER_SUCCESS_CARDS[id]?.outcome;
974
+ return created(ctx, PM, { type: 'card', customer, livemode: false, billing_details: noBilling, ...sub, _declineOutcome: TEST_TOKEN_DECLINES[id] ?? null, ...(afterSuccess ? { _afterSuccess: afterSuccess } : {}) }, {});
975
+ }
976
+
977
+ /** A PaymentMethod made from a confirm's `payment_method_data` ("If provided, this hash will be used to create a
978
+ * PaymentMethod", docs.stripe.com/api/setup_intents/confirm, docs.stripe.com/api/payment_intents/confirm), or none. */
979
+ export async function methodFromData(ctx: HandlerContext, data: unknown): Promise<string | undefined> {
980
+ if (!data || typeof data !== 'object') return undefined;
981
+ const d = data as Row;
982
+ const type = typeof d.type === 'string' ? d.type : 'card';
983
+ const made = await created(ctx, PM, {}, {
984
+ type, customer: null, livemode: false,
985
+ // source: spec:/paths/~1v1~1setup_intents~1{intent}~1confirm/post/requestBody/content/application~1x-www-form-urlencoded/schema/properties/payment_method_data/description "this hash creates a PaymentMethod"
986
+ metadata: d.metadata && typeof d.metadata === 'object' ? d.metadata : {},
987
+ billing_details: { ...noBilling, ...((d.billing_details as Row | undefined) ?? {}) },
988
+ ...paymentMethodSubObject(type, d),
989
+ // what follows its card's success (a dispute, funds straight to available), as a PaymentMethod created directly carries
990
+ ...(type === 'card' && afterSuccessOf(ctx, d.card) ? { _afterSuccess: afterSuccessOf(ctx, d.card) } : {}),
991
+ // and what a payment with its card answers: a test card the issuer declines, or one that asks the cardholder to
992
+ // authenticate, entered as its number (Stripe.js sends it so), as the same card made a PaymentMethod directly does
993
+ // source: https://docs.stripe.com/testing "Generic decline 4000 0000 0000 0002"
994
+ // source: https://docs.stripe.com/testing "This card requires authentication on all transactions, regardless of how the card is set up."
995
+ ...(type === 'card' ? { _declineOutcome: declineFor(() => undefined, { card: d.card }) ?? null, ...(requiresAuthentication({ card: d.card }) ? { _requiresAuthentication: true } : {}) } : {}),
996
+ });
997
+ return String(made.id);
998
+ }
999
+
1000
+ /** Whether an intent sets up or pays with a bank account by micro-deposits because it asks for them:
1001
+ * payment_method_options[us_bank_account][verification_method]=microdeposits with a us_bank_account method
1002
+ * ("Microdeposit only verification", docs.stripe.com/payments/ach-direct-debit/set-up-payment). */
1003
+ export function microdepositsAsked(ctx: HandlerContext, params: Row, intent: Row, pm: string | undefined): boolean {
1004
+ const options = ((params.payment_method_options ?? intent.payment_method_options) as Row | undefined)?.us_bank_account as Row | undefined;
1005
+ return options?.verification_method === 'microdeposits' && !!pm && ctx.get(PM, pm)?.type === 'us_bank_account';
1006
+ }
1007
+
1008
+ // ── features: an entitlements feature granted to a product, at most once ──
1009
+
1010
+ /** A tiered price: tiers_mode and tiers, each tier with up_to and a unit or flat amount; no single unit_amount, and
1011
+ * the last tier's up_to null (∞) (docs.stripe.com/products-prices/pricing-models#tiered-pricing). */
1012
+ export async function tieredPrice(ctx: HandlerContext, params: Row): Promise<Response> {
1013
+ const mode = typeof params.tiers_mode === 'string' ? params.tiers_mode : '';
1014
+ if (mode !== 'graduated' && mode !== 'volume') return fail(ctx, 'Missing required param: tiers_mode (graduated or volume).', 400, 'parameter_missing');
1015
+ const rawTiers = Array.isArray(params.tiers) ? (params.tiers as Row[]) : [];
1016
+ if (rawTiers.length === 0) return fail(ctx, 'Missing required param: tiers.', 400, 'parameter_missing');
1017
+ const tiers = rawTiers.map((t) => {
1018
+ const upToRaw = t.up_to;
1019
+ return {
1020
+ up_to: upToRaw === 'inf' || upToRaw === undefined || upToRaw === null ? null : Math.trunc(Number(upToRaw)),
1021
+ unit_amount: t.unit_amount !== undefined ? Math.trunc(Number(t.unit_amount)) : null,
1022
+ unit_amount_decimal: t.unit_amount !== undefined ? String(Math.trunc(Number(t.unit_amount))) : null,
1023
+ flat_amount: t.flat_amount !== undefined ? Math.trunc(Number(t.flat_amount)) : null,
1024
+ flat_amount_decimal: t.flat_amount !== undefined ? String(Math.trunc(Number(t.flat_amount))) : null,
1025
+ };
1026
+ });
1027
+ if (tiers.some((t) => t.unit_amount === null && t.flat_amount === null)) return fail(ctx, 'Each tier must specify a unit_amount or flat_amount.', 400, 'parameter_invalid_empty');
1028
+ const { tiers: _t, ...rest } = params;
1029
+ return ctx.reply(await created(ctx, 'price', { ...rest, billing_scheme: 'tiered', tiers_mode: mode, tiers, unit_amount: null }, { active: true, livemode: false, type: rest.recurring ? 'recurring' : 'one_time', metadata: {} }));
1030
+ }
1031
+
1032
+ /** Whether the intent's caller named its methods: on create (TYPES_GIVEN), or by an update that set its
1033
+ * payment_method_types ("Alternatively, update the allowed payment_method_types for this PaymentIntent",
1034
+ * methodNotAllowed below), which the generic update writes as given, so the intent's history holds it. */
1035
+ export const typesGiven = (ctx: HandlerContext, id: string): boolean =>
1036
+ ctx.rowsRaw(TYPES_GIVEN).some((r) => r.id === id)
1037
+ || ctx.history(PI, id).some((e) => e.operation !== 'payment_intent.create' && Array.isArray(e.fields?.payment_method_types));
1038
+
1039
+ export const paymentIntentsSend = (ctx: HandlerContext, r: StripeResponse): Response => ctx.reply(r.body, r.status);
1040
+
1041
+ export async function mintCharge(ctx: HandlerContext, piId: string, pi: Row, amount: number): Promise<string> {
1042
+ const already = ctx.get(PI, piId)?.latest_charge;
1043
+ if (typeof already === 'string' && ctx.get('charge', already)?.status === 'succeeded') return already;
1044
+ const chargeId = ctx.mint('charge');
1045
+ const bt = await settleCharge(ctx, chargeId, amount, String(pi.currency ?? 'usd'), typeof pi.payment_method === 'string' ? pi.payment_method : undefined);
1046
+ await created(ctx, 'charge', {
1047
+ id: chargeId, amount, currency: pi.currency ?? 'usd', payment_intent: piId,
1048
+ ...(typeof pi.customer === 'string' ? { customer: pi.customer } : {}),
1049
+ ...(typeof pi.payment_method === 'string' ? { payment_method: pi.payment_method } : {}),
1050
+ ...(typeof pi.invoice === 'string' ? { invoice: pi.invoice } : {}),
1051
+ ...intentCarries(pi),
1052
+ }, { ...chargeDefaults(chargeId, amount, true, ctx.occurredAt), balance_transaction: bt, payment_method_details: paymentMethodDetails(ctx, pi.payment_method) ?? null }, { operation: 'charge.succeeded' });
1053
+ await afterCharge(ctx, chargeId, { amount, currency: String(pi.currency ?? 'usd'), payment_intent: piId, application_fee_amount: pi.application_fee_amount, destination: (pi.transfer_data as Row | undefined)?.destination }, pi.payment_method);
1054
+ return chargeId;
1055
+ }
1056
+
1057
+ /** A manual-capture intent's authorization: "Separate payment authorization and capture to create a charge now, but
1058
+ * capture funds later", and on capture "A partial capture automatically releases the remaining amount"
1059
+ * (docs.stripe.com/payments/place-a-hold-on-a-payment-method). The charge succeeded and is not captured, holding the
1060
+ * amount until capture_before, with nothing on the balance yet. Written as
1061
+ * charge.succeeded, as a charge made with capture=false is (charges.ts); its capture writes charge.captured
1062
+ * (captureAuthorization). Where the documentation stops and the twin decides: the events page names no event for an
1063
+ * authorization of its own, so the authorized charge sends charge.succeeded, its status being succeeded. */
1064
+ export async function authorizeCharge(ctx: HandlerContext, piId: string, pi: Row, amount: number): Promise<string> {
1065
+ const chargeId = ctx.mint('charge');
1066
+ await created(ctx, 'charge', {
1067
+ id: chargeId, amount, currency: pi.currency ?? 'usd', payment_intent: piId,
1068
+ ...(typeof pi.customer === 'string' ? { customer: pi.customer } : {}),
1069
+ ...(typeof pi.payment_method === 'string' ? { payment_method: pi.payment_method } : {}),
1070
+ ...(typeof pi.invoice === 'string' ? { invoice: pi.invoice } : {}),
1071
+ ...intentCarries(pi),
1072
+ }, { ...chargeDefaults(chargeId, amount, false, ctx.occurredAt), balance_transaction: null, payment_method_details: paymentMethodDetails(ctx, pi.payment_method) ?? null }, { operation: 'charge.succeeded' });
1073
+ return chargeId;
1074
+ }
1075
+
1076
+ /**
1077
+ * An intent an invoice collects through succeeded (the default_incomplete subscription flow: create
1078
+ * now, confirm client-side): its invoice is paid and, when it is a subscription's first, the
1079
+ * subscription leaves incomplete for active. Both are moves the invoice and subscription machines
1080
+ * declare under the confirming operation. No-op when the intent names no invoice, or the invoice is
1081
+ * already paid (a duplicate confirm) or void (voiding ends what it collects).
1082
+ */
1083
+ export async function payInvoiceOf(ctx: HandlerContext, pi: Row, operationId: string, actor?: 'vendor'): Promise<void> {
1084
+ const invoiceId = typeof pi.invoice === 'string' ? pi.invoice : undefined;
1085
+ const invoice = invoiceId ? ctx.get('invoice', invoiceId) : undefined;
1086
+ if (!invoiceId || !invoice || invoice.status === 'paid' || invoice.status === 'void') return;
1087
+ await finishInvoiceIntent(ctx, invoice, invoiceId, operationId, actor);
1088
+ }
1089
+
1090
+ /** An invoice-linked intent read back from a live account; the served create API cannot set invoice. */
1091
+ export async function finishInvoiceIntent(ctx: HandlerContext, invoice: Row, invoiceId: string, operationId: string, actor?: 'vendor'): Promise<void> {
1092
+ if (ctx.legal('invoice', 'status', operationId, invoice.status, 'paid', invoiceId, actor)) return;
1093
+ const amount = Number(invoice.total ?? invoice.amount_due) || 0;
1094
+ // `invoice.pay` is the event the standalone pay action sends: invoice.paid
1095
+ await ctx.write('invoice', invoiceId, {
1096
+ status: 'paid', paid: true, amount_paid: amount, amount_remaining: 0,
1097
+ status_transitions: { ...((invoice.status_transitions as object) ?? {}), paid_at: nowUnix(ctx.occurredAt) },
1098
+ }, 'invoice.pay');
1099
+ const subId = typeof invoice.subscription === 'string' ? invoice.subscription : undefined;
1100
+ const sub = subId ? resourceView(ctx, 'subscription', subId) : undefined;
1101
+ if (subId && sub && sub.status === 'incomplete' && !ctx.legal('subscription', 'status', operationId, sub.status, 'active', subId, actor)) {
1102
+ await ctx.write('subscription', subId, { status: 'active' }, 'subscription.update');
1103
+ }
1104
+ }
1105
+
1106
+ /** The type of the payment method an intent is paid with: its PaymentMethod's, or a documented test name's (pm_card_*,
1107
+ * tok_* a card; pm_usBankAccount_* a US bank account), else unknown. */
1108
+ export function methodType(ctx: HandlerContext, method: unknown): string | undefined {
1109
+ if (typeof method !== 'string' || !method) return undefined;
1110
+ const row = ctx.get('payment_method', method);
1111
+ if (row) return typeof row.type === 'string' ? row.type : undefined;
1112
+ if (/^pm_us_?bank_?account/i.test(method)) return 'us_bank_account';
1113
+ if (/^(pm_card_|tok_)/.test(method)) return 'card';
1114
+ return undefined;
1115
+ }
1116
+
1117
+ /** A confirm with a payment method of a type the intent's caller did not allow, refused as Stripe refuses it: "The
1118
+ * PaymentMethod provided (card_present) is not allowed for this PaymentIntent. Please attach a PaymentMethod of one of
1119
+ * the following types: card. Alternatively, update the allowed payment_method_types for this PaymentIntent to include
1120
+ * "card_present"" (a real answer quoted in github.com/stripe/stripe-terminal-react-native/issues/745, an intent made
1121
+ * with payment_method_types ['card']). Only a list the caller gave restricts: without one Stripe offers the methods
1122
+ * the account enables, which the twin does not model. Where the evidence stops: that answer's code is not quoted; the
1123
+ * twin answers payment_intent_invalid_parameter ("One or more provided parameters wasn't allowed for the given
1124
+ * operation on the PaymentIntent", docs.stripe.com/error-codes), param payment_method. */
1125
+ export function methodNotAllowed(ctx: HandlerContext, existing: Row, params: Row): Response | undefined {
1126
+ const types = Array.isArray(params.payment_method_types) ? params.payment_method_types.map(String)
1127
+ : Array.isArray(params.allowed_payment_method_types) ? params.allowed_payment_method_types.map(String)
1128
+ : typesGiven(ctx, String(existing.id)) && Array.isArray(existing.payment_method_types) ? (existing.payment_method_types as unknown[]).map(String) : undefined;
1129
+ if (!types) return undefined;
1130
+ const type = methodType(ctx, params.payment_method ?? existing.payment_method);
1131
+ if (!type || types.includes(type)) return undefined;
1132
+ return ctx.refuse({ status: 400, code: 'payment_intent_invalid_parameter', param: 'payment_method', message: `The PaymentMethod provided (${type}) is not allowed for this PaymentIntent. Please attach a PaymentMethod of one of the following types: ${types.join(', ')}. Alternatively, update the allowed payment_method_types for this PaymentIntent to include "${type}"` });
1133
+ }
1134
+
1135
+ // Confirmation controls are request instructions, absent from the pinned PaymentIntent object.
1136
+ // source: spec:/paths/~1v1~1payment_intents~1{intent}~1confirm/post/requestBody/content/application~1x-www-form-urlencoded/schema/properties/return_url/description "The URL to redirect your customer back to after they authenticate or cancel their payment"
1137
+ export function intentFields(params: Record<string, unknown>): Record<string, unknown> {
1138
+ const { confirm: _confirm, amount_to_confirm: _amount, confirmation_token: _token, error_on_requires_action: _error,
1139
+ expand: _expand, mandate: _mandate, mandate_data: _mandateData, off_session: _offSession,
1140
+ payment_method_data: _methodData, radar_options: _radar, return_url: _returnUrl, use_stripe_sdk: _sdk, ...fields } = params;
1141
+ return fields;
1142
+ }
1143
+
1144
+ /** Confirm an intent the confirm machine allows to move: the confirm action, and a create with `confirm=true`. */
1145
+ export async function confirmIntent(ctx: HandlerContext, existing: Record<string, unknown>, params: Record<string, unknown>): Promise<Response> {
1146
+ const id = String(existing.id);
1147
+ // a payment names a mandate only an active one authorizes: an inactive mandate "was rejected, revoked, or previously
1148
+ // used, and may not be used to initiate future payments" (docs.stripe.com/api/mandates/object), refused as
1149
+ // payment_intent_mandate_invalid, "The provided mandate is invalid and can't be used for the payment intent"
1150
+ // (docs.stripe.com/error-codes)
1151
+ const named = typeof params.mandate === 'string' ? params.mandate : typeof existing.mandate === 'string' ? existing.mandate : undefined;
1152
+ if (named && ctx.get('mandate', named)?.status !== 'active') return fail(ctx, 'The provided mandate is invalid and can\'t be used for the payment intent.', 400, 'payment_intent_mandate_invalid');
1153
+ const notAllowed = methodNotAllowed(ctx, existing, params);
1154
+ if (notAllowed) return notAllowed;
1155
+ // a declining test card leaves the intent needing a payment method, with the error on it and a 402
1156
+ const decline = declineFor(finder(ctx), params, existing);
1157
+ if (decline) {
1158
+ // Stripe records the attempt: a failed charge the intent's latest_charge names, the declined card on
1159
+ // last_payment_error (a test name made a real PaymentMethod), and no payment_method left on the intent
1160
+ // (docs.stripe.com/payments/paymentintents/lifecycle, docs.stripe.com/declines)
1161
+ const ref = params.payment_method ?? existing.payment_method;
1162
+ let pmId = typeof ref === 'string' ? ref : undefined;
1163
+ if (pmId && !ctx.get('payment_method', pmId) && pmId.startsWith('pm_card_')) pmId = String((await materializeTestMethod(ctx, pmId, typeof existing.customer === 'string' ? existing.customer : null)).id);
1164
+ const pm = pmId ? ctx.get('payment_method', pmId) : undefined;
1165
+ const amount = Number(params.amount ?? existing.amount) || 0;
1166
+ const chargeId = ctx.mint('charge');
1167
+ await created(ctx, 'charge', {
1168
+ id: chargeId, amount, currency: existing.currency ?? 'usd', payment_intent: id,
1169
+ ...(typeof existing.customer === 'string' ? { customer: existing.customer } : {}),
1170
+ ...(pmId ? { payment_method: pmId } : {}),
1171
+ ...intentCarries({ ...existing, ...intentFields(params) }),
1172
+ }, {
1173
+ ...chargeDefaults(chargeId, amount, false, ctx.occurredAt), status: 'failed', paid: false, captured: false, capture_before: null,
1174
+ failure_code: decline.code, failure_message: decline.message, balance_transaction: null,
1175
+ outcome: { type: 'issuer_declined', network_status: 'declined_by_network', reason: decline.decline_code ?? decline.code, risk_level: 'normal', seller_message: 'The bank did not return any further details with this decline.' },
1176
+ payment_method_details: pmId ? paymentMethodDetails(ctx, pmId) ?? null : null,
1177
+ }, { operation: 'charge.failed' }); // "Occurs whenever a failed charge attempt occurs." (docs.stripe.com/api/events/types)
1178
+ const { payment_method: _pm, ...rest } = params;
1179
+ const last_payment_error = { type: 'card_error', code: decline.code, ...(decline.decline_code ? { decline_code: decline.decline_code } : {}), message: decline.message, param: 'card', charge: chargeId, payment_method: pm ?? null };
1180
+ const failed = await ctx.write(PI, id, { ...intentFields(rest), payment_method: null, latest_charge: chargeId, status: 'requires_payment_method', last_payment_error }, 'payment_intent.payment_failed');
1181
+ return paymentIntentsSend(ctx, cardError(decline, { payment_intent: failed, charge: chargeId }));
1182
+ }
1183
+ // a bank debit that needs micro-deposit verification waits in requires_action
1184
+ const method = typeof params.payment_method === 'string' ? params.payment_method : typeof existing.payment_method === 'string' ? existing.payment_method : undefined;
1185
+ // a bank transfer is paid from the customer's cash balance, or waits for the transfer (fundFromCashBalance)
1186
+ if (method && ctx.get('payment_method', method)?.type === 'customer_balance') return ctx.reply(ctx.expand(PI, await fundFromCashBalance(ctx, { ...existing, ...intentFields(params) }, intentFields(params))));
1187
+ if (requiresMicrodeposits(params, existing) || microdepositsAsked(ctx, params, existing, method)) {
1188
+ return ctx.reply(ctx.expand(PI, await ctx.write(PI, id, { ...intentFields(params), status: 'requires_action', next_action: microdepositsNextAction(), last_payment_error: null }, 'payment_intent.requires_action')));
1189
+ }
1190
+ // 3DS: the first confirm asks for authentication; a second one completes it
1191
+ if (existing.status !== 'requires_action' && (requiresAuthentication(params, existing) || (!!method && ctx.row(PM, method)?._requiresAuthentication === true))) {
1192
+ return ctx.reply(ctx.expand(PI, await ctx.write(PI, id, { ...intentFields(params), status: 'requires_action', next_action: threeDsNextAction(), last_payment_error: null }, 'payment_intent.requires_action')));
1193
+ }
1194
+ // manual capture authorizes and waits for /capture: the authorization is a charge, succeeded and not captured, the
1195
+ // intent's latest_charge
1196
+ if ((params.capture_method ?? existing.capture_method) === 'manual') {
1197
+ const amount = Number(existing.amount ?? params.amount ?? 0);
1198
+ const charge = await authorizeCharge(ctx, id, { ...existing, ...intentFields(params) }, amount);
1199
+ return ctx.reply(ctx.expand(PI, await ctx.write(PI, id, { ...intentFields(params), status: 'requires_capture', amount_capturable: amount, amount_received: 0, next_action: null, last_payment_error: null, latest_charge: charge }, 'payment_intent.amount_capturable_updated')));
1200
+ }
1201
+ // a bank debit is submitted and settles later (beginBankDebit)
1202
+ if (isBankDebit(ctx, method)) return ctx.reply(ctx.expand(PI, await beginBankDebit(ctx, id, intentFields(params))));
1203
+ const amount = Number(existing.amount ?? params.amount ?? 0);
1204
+ // a succeeded intent has a Charge, and latest_charge names it: what a refund of it lands on
1205
+ const charge = await mintCharge(ctx, id, { ...existing, ...intentFields(params) }, amount);
1206
+ const body = await ctx.write(PI, id, { ...intentFields(params), status: 'succeeded', amount_received: amount, next_action: null, last_payment_error: null, latest_charge: charge }, 'payment_intent.confirm');
1207
+ await payInvoiceOf(ctx, body, 'PostPaymentIntentsIntentConfirm');
1208
+ return ctx.reply(ctx.expand(PI, body));
1209
+ }
1210
+
1211
+ /** Whether the method an intent is confirmed with is a bank debit: a us_bank_account PaymentMethod, or one of Stripe's
1212
+ * test bank accounts named as one (pm_usBankAccount_*, pm_us_bank_account). */
1213
+ export function isBankDebit(ctx: HandlerContext, method: unknown): boolean {
1214
+ if (typeof method !== 'string') return false;
1215
+ return ctx.get('payment_method', method)?.type === 'us_bank_account' || /^pm_us_?bank_?account/i.test(method);
1216
+ }
1217
+
1218
+ /** Whether a bank debit stays processing: the page's pm_usBankAccount_processing, or its account 000000000009. */
1219
+ export function staysProcessing(ctx: HandlerContext, method: unknown): boolean {
1220
+ if (method === 'pm_usBankAccount_processing') return true;
1221
+ const bank = typeof method === 'string' ? (ctx.get('payment_method', method)?.us_bank_account as Row | undefined) : undefined;
1222
+ return bank?.last4 === '0009';
1223
+ }
1224
+
1225
+ /** A bank debit submitted: the intent processes, nothing received yet, sent as payment_intent.processing; it is due to
1226
+ * settle at this instant, unless its test account stays processing. */
1227
+ export async function beginBankDebit(ctx: HandlerContext, id: string, fields: Row): Promise<Row> {
1228
+ const method = fields.payment_method ?? ctx.get(PI, id)?.payment_method;
1229
+ return ctx.write(PI, id, { ...fields, status: 'processing', amount_received: 0, next_action: null, last_payment_error: null, _settles_at: staysProcessing(ctx, method) ? null : Number(nowUnix(ctx.occurredAt)) }, 'payment_intent.processing');
1230
+ }
1231
+
1232
+ /** Time's settling of bank debits, caught up to the World's clock: each processing debit whose moment has come succeeds
1233
+ * then, its Charge made and the invoice it collects for paid. */
1234
+ export async function settleBankDebits(ctx: HandlerContext): Promise<void> {
1235
+ const now = Number(nowUnix(ctx.occurredAt));
1236
+ const due = ctx.rowsRaw(PI).filter((p) => p.status === 'processing' && typeof p._settles_at === 'number' && p._settles_at <= now).sort((a, b) => Number(a._settles_at) - Number(b._settles_at));
1237
+ for (const pi of due) {
1238
+ const id = String(pi.id);
1239
+ const c = await at_(ctx)(Number(pi._settles_at));
1240
+ c.legal(PI, 'status', ctx.call.operation.id, 'processing', 'succeeded', id, 'vendor');
1241
+ const amount = Number(pi.amount) || 0;
1242
+ const charge = await mintCharge(c, id, pi, amount);
1243
+ const body = await c.write(PI, id, { status: 'succeeded', amount_received: amount, latest_charge: charge, _settles_at: null }, 'payment_intent.succeeded');
1244
+ // (a debit run under a mandate names it on its charge, and a single-use mandate is spent by it, at Stripe; the twin
1245
+ // makes no mandate: micro-deposit verification, which accepts one, is the gap here, ../manifest.ts unmodeled)
1246
+ await payInvoiceOf(c, body, ctx.call.operation.id, 'vendor');
1247
+ }
1248
+ }
1249
+
1250
+ /** A customer_balance intent funded from the customer's cash balance: paid when the balance covers it ("If the customer
1251
+ * already has a balance high enough to cover the payment amount, the PaymentIntent immediately succeeds"), else
1252
+ * waiting for a transfer: "If the customer balance isn’t high enough to cover the request amount, the PaymentIntent
1253
+ * shows a requires_action status ... next_action ... display_bank_transfer_instructions" with the amount_remaining
1254
+ * (docs.stripe.com/payments/bank-transfers/accept-a-payment). Answers the intent as written. */
1255
+ export async function fundFromCashBalance(ctx: HandlerContext, pi: Row, fields: Row = {}): Promise<Row> {
1256
+ const customer = typeof pi.customer === 'string' ? pi.customer : '';
1257
+ const currency = String(pi.currency ?? 'usd');
1258
+ // what the customer's funded cash balance holds in this currency, from its ledger
1259
+ const available = customer ? ctx.rows('customer_cash_balance_transaction').filter((t) => t.customer === customer && String(t.currency) === currency).reduce((n, t) => n + (Number(t.net_amount) || 0), 0) : 0;
1260
+ const amount = Number(pi.amount) || 0;
1261
+ if (available >= amount) return payFromCashBalance(ctx, pi, fields, { customer, currency, available, amount });
1262
+ // not enough yet: the intent waits for the rest, with the instructions to send it
1263
+ const next_action = { type: 'display_bank_transfer_instructions', display_bank_transfer_instructions: { amount_remaining: amount - available, currency, type: 'us_bank_transfer' } };
1264
+ return ctx.write(PI, String(pi.id), { ...fields, status: 'requires_action', next_action }, 'payment_intent.requires_action');
1265
+ }
1266
+
1267
+ /** A customer_balance intent the customer's funded cash balance pays: its ledger records what was applied, and the intent
1268
+ * succeeds with its charge. */
1269
+ export async function payFromCashBalance(ctx: HandlerContext, pi: Row, fields: Row, o: { customer: string; currency: string; available: number; amount: number }): Promise<Row> {
1270
+ await created(ctx, 'customer_cash_balance_transaction', { customer: o.customer }, { currency: o.currency, type: 'applied_to_payment', net_amount: -o.amount, ending_balance: o.available - o.amount, applied_to_payment: { payment_intent: String(pi.id) }, livemode: false });
1271
+ const charge = await mintCharge(ctx, String(pi.id), { ...pi, ...fields }, o.amount);
1272
+ return ctx.write(PI, String(pi.id), { ...fields, status: 'succeeded', next_action: null, amount_received: o.amount, last_payment_error: null, latest_charge: charge }, 'payment_intent.succeeded');
1273
+ }
1274
+
1275
+ /** Whether a charge's funds go straight to the available balance: a bypass card as a raw number, a test name (pm_card_*
1276
+ * or tok_*), or a stored PaymentMethod made from one (which records it as what follows its success, after-payment.ts);
1277
+ * or a US bank account, stored or one of Stripe's test bank accounts named as one (pm_usBankAccount_*). */
1278
+ export function bypassesPending(ctx: HandlerContext, card: string | undefined): boolean {
1279
+ if (!card) return false;
1280
+ const stored = ctx.row('payment_method', card);
1281
+ // the row carries what the method recorded; its type is the vendor object's (ctx.get), not the row's envelope
1282
+ if (stored) return stored._afterSuccess === 'available' || ctx.get('payment_method', card)?.type === 'us_bank_account';
1283
+ if (/^pm_us_?bank_?account/i.test(card)) return true;
1284
+ return BYPASS_NUMBERS.has(card.replace(/\D/g, '')) || card.replace(/^tok_/, 'pm_card_') in BYPASS_PENDING_CARDS;
1285
+ }
1286
+
1287
+ /** The connected account a request acts for, or undefined for the platform. */
1288
+ // The platform's own account ID denotes the same balance as an omitted Stripe-Account header.
1289
+ export const actingAccount = (ctx: HandlerContext): string | undefined => { const id = ctx.call.request.headers.get('stripe-account'); return id && id !== PLATFORM_ACCOUNT_ID ? id : undefined; };
1290
+
1291
+ /** A ledger entry on an account's balance: the acting account's unless one is named (null names the platform). */
1292
+ export async function write(ctx: HandlerContext, fields: Row, account: string | null | undefined = actingAccount(ctx)): Promise<string> {
1293
+ // a credit available at once waits for the drain's balance.available (settleDueEntries)
1294
+ const unsent = (fields.status ?? 'available') === 'available' && Number(fields.net) > 0 ? { [UNSENT]: true } : {};
1295
+ const bt = await created(ctx, BT, {}, { status: 'available', fee_details: [], description: null, exchange_rate: null, balance_type: 'payments', ...fields, ...unsent, ...(account ? { _account: account } : {}) });
1296
+ return String(bt.id);
1297
+ }
1298
+
1299
+ /** A captured charge's credit: its amount less the fee, pending two days unless the card bypasses the pending
1300
+ * balance (bypassesPending), when it is due at once. The
1301
+ * caller mints the charge's id first and stores the returned id as the charge's balance_transaction. */
1302
+ export async function settleCharge(ctx: HandlerContext, chargeId: string, amount: number, currency: string, card?: string): Promise<string> {
1303
+ const now = Number(nowUnix(ctx.occurredAt));
1304
+ const fee = feeOf(amount);
1305
+ const atOnce = bypassesPending(ctx, card);
1306
+ const id = await write(ctx, {
1307
+ amount, currency, fee, net: amount - fee, type: 'charge', reporting_category: 'charge', source: chargeId,
1308
+ ...(atOnce ? { status: 'available', available_on: now } : { status: 'pending', available_on: now + 2 * DAY }),
1309
+ fee_details: fee ? [{ amount: fee, application: null, currency, description: 'Stripe processing fees', type: 'stripe_fee' }] : [],
1310
+ });
1311
+ return id;
1312
+ }
1313
+
1314
+ /** A refund's debit, at once, on the acting account's balance unless one is named (null names the platform). */
1315
+ export async function settleRefund(ctx: HandlerContext, refundId: string, amount: number, currency: string, account: string | null | undefined = actingAccount(ctx)): Promise<string> {
1316
+ return write(ctx, { amount: -amount, currency, fee: 0, net: -amount, type: 'refund', reporting_category: 'refund', source: refundId, available_on: Number(nowUnix(ctx.occurredAt)) }, account);
1317
+ }
1318
+
1319
+ /** What an account had available in a currency at a moment: every entry of its payments balance whose funds had come
1320
+ * due by then. */
1321
+ export function availableAt(ctx: HandlerContext, account: string | undefined, currency: string, at: number): number {
1322
+ let sum = 0;
1323
+ for (const t of ctx.rowsRaw(BT)) {
1324
+ if (!onAccount(t, account) || t.balance_type === 'issuing' || String(t.currency ?? 'usd') !== currency) continue;
1325
+ const due = Number(t.available_on ?? t.created);
1326
+ if (Number.isFinite(due) && due <= at) sum += Number(t.net ?? 0);
1327
+ }
1328
+ return sum;
1329
+ }
1330
+
1331
+ /** The moments after `from` and up to `until` when funds of an account came due, in order: when a held refund can
1332
+ * next be covered. */
1333
+ export function fundsDueBetween(ctx: HandlerContext, account: string | undefined, currency: string, from: number, until: number): number[] {
1334
+ const times = ctx.rowsRaw(BT).filter((t) => onAccount(t, account) && String(t.currency ?? 'usd') === currency).map((t) => Number(t.available_on ?? t.created)).filter((d) => Number.isFinite(d) && d > from && d <= until);
1335
+ return [...new Set(times)].sort((a, b) => a - b);
1336
+ }
1337
+
1338
+ /** A dispute's debit, at once: the disputed amount and the dispute fee (the twin's 1500 cents, Stripe's US $15). */
1339
+ export async function settleDispute(ctx: HandlerContext, disputeId: string, amount: number, currency: string): Promise<string> {
1340
+ const fee = 1500;
1341
+ return write(ctx, {
1342
+ amount: -amount, currency, fee, net: -amount - fee, type: 'adjustment', reporting_category: 'dispute', source: disputeId, available_on: Number(nowUnix(ctx.occurredAt)),
1343
+ fee_details: [{ amount: fee, application: null, currency, description: 'Dispute fee', type: 'stripe_fee' }],
1344
+ });
1345
+ }
1346
+
1347
+ /** An account's ledger entries an automatic payout has not yet paid out: every entry on its payments balance that no
1348
+ * automatic payout took (the automatic payouts' own debits excluded), as stored. */
1349
+ export function unpaidEntries(ctx: HandlerContext, account: string | undefined): Row[] {
1350
+ const automatic = new Set(ctx.rowsRaw('payout').filter((p) => p.automatic === true).map((p) => p.id));
1351
+ return ctx.rowsRaw(BT).filter((t) => onAccount(t, account) && t.balance_type !== 'issuing' && !t._payout && !automatic.has(t.source));
1352
+ }
1353
+
1354
+ /** An automatic payout's debit at `at`, and the entries it pays out marked with it, so the ledger lists them under
1355
+ * the payout (docs.stripe.com/api/balance_transactions/list#balance_transaction_list-payout). */
1356
+ export async function settleAutomaticPayout(ctx: HandlerContext, payoutId: string, amount: number, currency: string, account: string | undefined, at: number, entries: Row[]): Promise<string> {
1357
+ for (const t of entries) await ctx.write(BT, String(t.id), { _payout: payoutId }, 'balance_transaction.paid_out');
1358
+ return write(ctx, { amount: -amount, currency, fee: 0, net: -amount, type: 'payout', reporting_category: 'payout', source: payoutId, available_on: at, _payout: payoutId }, account ?? null);
1359
+ }
1360
+
1361
+ /** A payout's debit (negative) or a cancellation's or reversal's credit (positive), at once, on the payout's account. */
1362
+ export async function settlePayout(ctx: HandlerContext, payoutId: string, amount: number, currency: string, account: string | null | undefined = actingAccount(ctx)): Promise<string> {
1363
+ const type = amount < 0 ? 'payout' : 'payout_cancel';
1364
+ return write(ctx, { amount, currency, fee: 0, net: amount, type, reporting_category: type === 'payout' ? 'payout' : 'payout_reversal', source: payoutId, available_on: Number(nowUnix(ctx.occurredAt)) }, account);
1365
+ }
1366
+
1367
+ /** A transfer's two entries: out of the platform's balance at once, into the destination's when `fromCharge` (its
1368
+ * charge's funds' availability) comes (a plain transfer, with no `fromCharge`, moves funds available already).
1369
+ * Answers the platform's entry. */
1370
+ export async function settleTransfer(ctx: HandlerContext, transferId: string, amount: number, currency: string, destination: string, fromCharge?: number, fromPending = false): Promise<string> {
1371
+ const availableOn = fromCharge ?? Number(nowUnix(ctx.occurredAt));
1372
+ const settled = availableOn <= Number(nowUnix(ctx.occurredAt));
1373
+ // a transfer from a charge's pending funds (source_transaction) leaves the platform when they arrive, not before
1374
+ const platform = await write(ctx, { amount: -amount, currency, fee: 0, net: -amount, type: 'transfer', reporting_category: 'transfer', source: transferId, ...(fromPending ? { status: settled ? 'available' : 'pending', available_on: availableOn } : { available_on: Number(nowUnix(ctx.occurredAt)) }) }, null);
1375
+ await write(ctx, { amount, currency, fee: 0, net: amount, type: 'payment', reporting_category: 'charge', source: transferId, status: settled ? 'available' : 'pending', available_on: availableOn }, destination);
1376
+ return platform;
1377
+ }
1378
+
1379
+ /** A direct charge's application fee: out of the connected account's balance, into the platform's. */
1380
+ export async function settleApplicationFee(ctx: HandlerContext, feeId: string, amount: number, currency: string, account: string): Promise<string> {
1381
+ await write(ctx, { amount: -amount, currency, fee: 0, net: -amount, type: 'application_fee', reporting_category: 'platform_earning', source: feeId, available_on: Number(nowUnix(ctx.occurredAt)) }, account);
1382
+ return write(ctx, { amount, currency, fee: 0, net: amount, type: 'application_fee', reporting_category: 'platform_earning', source: feeId, available_on: Number(nowUnix(ctx.occurredAt)) }, null);
1383
+ }
1384
+
1385
+ /** Time's settlements, written: each entry on the acting account's balance whose funds came due by the World's clock
1386
+ * moves pending → available (the clock's move the machine allows), so a settlement is recorded once. A read already
1387
+ * sees it (asOf); the record is what lets Stripe's balance.available be sent once (./front.ts, the drain).
1388
+ * Funds that were available at once and not yet sent are taken too, and marked sent. Answers the entries taken. */
1389
+ export async function settleDueEntries(ctx: HandlerContext, account: string | undefined = actingAccount(ctx)): Promise<Row[]> {
1390
+ const now = Number(nowUnix(ctx.occurredAt));
1391
+ const moved: Row[] = [];
1392
+ for (const t of ctx.rowsRaw(BT)) {
1393
+ if (!onAccount(t, account)) continue;
1394
+ if (t.status === 'available' && t[UNSENT] === true) {
1395
+ await ctx.write(BT, String(t.id), { [UNSENT]: false }, 'balance_transaction.available_sent');
1396
+ moved.push(t);
1397
+ continue;
1398
+ }
1399
+ if (t.status !== 'pending' || !(Number(t.available_on) <= now)) continue;
1400
+ if (ctx.legal(BT, 'status', ctx.call.operation.id, 'pending', 'available', String(t.id), 'time')) continue;
1401
+ await ctx.write(BT, String(t.id), { status: 'available' }, 'balance_transaction.available');
1402
+ moved.push(t);
1403
+ }
1404
+ return moved;
1405
+ }
1406
+
1407
+ /** An account's balance, by currency: what the clock has made available, what is still pending, and Issuing's own. */
1408
+ export function balanceOf(ctx: HandlerContext, account: string | undefined = actingAccount(ctx)): { available: Map<string, number>; pending: Map<string, number>; issuing: Map<string, number> } {
1409
+ const available = new Map<string, number>();
1410
+ const pending = new Map<string, number>();
1411
+ const issuing = new Map<string, number>();
1412
+ for (const raw of ctx.rowsRaw(BT)) {
1413
+ if (!onAccount(raw, account)) continue;
1414
+ const t = commonAsOf(ctx, raw);
1415
+ const cur = String(t.currency ?? 'usd');
1416
+ const net = Number(t.net ?? 0);
1417
+ const bucket = t.balance_type === 'issuing' ? issuing : t.status === 'available' ? available : pending;
1418
+ bucket.set(cur, (bucket.get(cur) ?? 0) + net);
1419
+ }
1420
+ return { available, pending, issuing };
1421
+ }
1422
+
1423
+ /** balance_insufficient when a payout or transfer would take more than the account has available in its currency. */
1424
+ export function refusePayout(ctx: HandlerContext, amount: number, currency: string, account: string | undefined = actingAccount(ctx)): Response | undefined {
1425
+ const have = balanceOf(ctx, account).available.get(currency) ?? 0;
1426
+ if (amount <= have) return undefined;
1427
+ return fail(ctx, "The transfer or payout couldn't be completed because the associated account doesn't have a sufficient balance available.", 400, 'balance_insufficient');
1428
+ }
1429
+
1430
+ /** The ids of the ledger entries on the acting account's balance. */
1431
+ export const mine = (ctx: HandlerContext): Set<unknown> => new Set(ctx.rowsRaw(BT).filter((t) => onAccount(t, actingAccount(ctx))).map((t) => t.id));
1432
+
1433
+ export const chargeMissing = (ctx: HandlerContext, id: string): Response => fail(ctx, `No such charge: '${id}'`, 404, 'resource_missing');
1434
+
1435
+ /** Every egress of a charge carries its refunds, derived from the refund rows in list order. */
1436
+ export function chargeBody(ctx: HandlerContext, c: Row): Row {
1437
+ const data = newest(ctx, 'refund').filter((r) => r.charge === c.id);
1438
+ return { ...c, refunds: { object: 'list', data, has_more: false, total_count: data.length, url: `/v1/charges/${String(c.id)}/refunds` } };
1439
+ }
1440
+
1441
+ export const subMissing = (ctx: HandlerContext, id: string): Response => fail(ctx, `No such subscription: '${id}'`, 404, 'resource_missing');
1442
+
1443
+ /** A coupon that replaces the subscription's discount, or an empty one that clears it (docs.stripe.com/api/subscriptions/update#update_subscription-discounts). */
1444
+ export function replacedDiscounts(ctx: HandlerContext, couponId: string, sub: Row): Row[] | Response {
1445
+ if (couponId === '') return [];
1446
+ const coupon = ctx.get('coupon', couponId);
1447
+ return coupon ? [buildDiscount(coupon, String(sub.customer ?? ''), Number(nowUnix(ctx.occurredAt)), { subscription: String(sub.id) })] : fail(ctx, `No such coupon: '${couponId}'`, 400, 'resource_missing');
1448
+ }
1449
+
1450
+ /** A `trial_end` Stripe refuses: the parameter is `"now" | timestamp` (docs.stripe.com/api/subscriptions/update), and a
1451
+ * timestamp that is not in the future answers "Invalid timestamp: must be an integer Unix timestamp in the future."
1452
+ * (Stripe's own message, as its SDKs' users report it: github.com/stripe/stripe-dotnet/issues/805,
1453
+ * github.com/stripe/stripe-ruby/issues/682). Where the documentation stops and the twin decides: a value that is no
1454
+ * number at all, and one more than two years on, answer that same message. */
1455
+ export function trialEndRefused(ctx: HandlerContext, value: unknown, now: number): Response | undefined {
1456
+ const t = typeof value === 'number' ? value : typeof value === 'string' && /^\d+$/.test(value.trim()) ? Number(value) : NaN;
1457
+ return value === undefined || value === 'now' || (Number.isInteger(t) && t > now && t <= now + MAX_TRIAL) ? undefined : invalidTrialEnd(ctx);
1458
+ }
1459
+
1460
+ export function invalidTrialEnd(ctx: HandlerContext): Response {
1461
+ return ctx.refuse({ status: 400, param: 'trial_end', message: 'Invalid timestamp: must be an integer Unix timestamp in the future.' });
1462
+ }
1463
+
1464
+ /** An update's `trial_end` on the subscription, into `fields`.
1465
+ *
1466
+ * `now` on a trialing subscription ends its trial at once: "The special value `now` can be provided to end the customer's
1467
+ * trial immediately" (docs.stripe.com/api/subscriptions/update#update_subscription-trial_end; the trials guide's "To end a
1468
+ * trial early … setting the `trial_end` value to … **now** to end immediately", docs.stripe.com/billing/subscriptions/
1469
+ * trials/free-trials). The update resets the billing date and charges at once: "Switching prices does not normally change
1470
+ * the billing date or generate an immediate charge unless … A trial starts or ends. In these cases, we … immediately
1471
+ * charge the customer using the new price, and reset the billing date" (docs.stripe.com/api/subscriptions/update), and
1472
+ * "Stripe immediately attempts payment when a subscription's billing cycle anchor is reset … When billing is performed
1473
+ * immediately, but the required payment fails, the subscription change request succeeds and the subscription transitions
1474
+ * to `past_due`" (docs.stripe.com/billing/subscriptions/upgrade-downgrade#immediate-payment). So a new period starts now,
1475
+ * its invoice is made (`invoice.created`) and charged to the subscription's default payment method, else the
1476
+ * customer's (paid: `invoice.paid`, the subscription `active`; declined or none: the invoice open, `past_due`), and the
1477
+ * subscription's own write sends `customer.subscription.updated`. `payment_behavior` (the same page): `error_if_incomplete`
1478
+ * answers 402 and leaves the subscription as it was, `default_incomplete` leaves the invoice open without an attempt.
1479
+ *
1480
+ * A future timestamp moves the trial's end, and the period and billing anchor with it: "The `billing_cycle_anchor` will be
1481
+ * updated to the `trial_end` value" (the same parameter); on a subscription out of its trial it starts a new one ("You can
1482
+ * add a new trial on a non-trialing subscription by updating the subscription while specifying trial_end", and for
1483
+ * classic billing mode "the `trial_start` field remains set to the start of the first trial", docs.stripe.com/billing/
1484
+ * subscriptions/trials/free-trials).
1485
+ *
1486
+ * Where the documentation stops and the twin decides: the invoice's billing_reason is `subscription_update` ("A
1487
+ * subscription was updated", docs.stripe.com/api/invoices/object#invoice_object-billing_reason); `now` on a subscription
1488
+ * that is not trialing has no trial to end and changes nothing; the credit Stripe prorates for the unused time when a new
1489
+ * trial starts mid-period is not made (as `proration_behavior=none` would have it); `pending_if_incomplete` answers as
1490
+ * `allow_incomplete` (no pending update is modelled). Answers a refusal, or the invoice to collect once the subscription is written. */
1491
+ export async function applyTrialEnd(ctx: HandlerContext, sub: Row, fields: Row, now: number): Promise<{ invoice: string; payer?: string; attempt: boolean } | undefined> {
1492
+ const value = ctx.params.trial_end;
1493
+ delete fields.trial_end;
1494
+ const id = String(sub.id);
1495
+ if (value !== 'now' || sub.status !== 'trialing') return value === 'now' ? undefined : movedTrial(sub, fields, Number(value), now);
1496
+ // the period's invoice bills the items as this update leaves them, under its discount
1497
+ const next: Row = { ...sub, ...fields, items: resourceView(ctx, SUB, id)?.items ?? sub.items };
1498
+ const { interval, interval_count } = resolveSubscriptionBillingInterval(((next.items as { data?: Row[] } | undefined)?.data) ?? [], finder(ctx));
1499
+ const period = { start: now, end: addBillingInterval(now, interval, interval_count) };
1500
+ const invoice = await draftSubscriptionInvoice(ctx, next, { ...period, reason: 'subscription_update' });
1501
+ Object.assign(fields, { trial_end: now, billing_cycle_anchor: now, current_period_start: period.start, current_period_end: period.end, latest_invoice: invoice });
1502
+ const payer = trialPayer(ctx, sub);
1503
+ return { invoice, ...(payer ? { payer } : {}), attempt: ctx.params.payment_behavior !== 'default_incomplete' };
1504
+ }
1505
+
1506
+ /** What ending the trial charges: the update's own default payment method, else the subscription's, else its customer's. */
1507
+ export function trialPayer(ctx: HandlerContext, sub: Row): string | undefined {
1508
+ const given = ctx.params.default_payment_method;
1509
+ return typeof given === 'string' && given ? given : payerOf(ctx, sub);
1510
+ }
1511
+
1512
+ /** What an update's `trial_end` refuses before anything is written: a new trial the machine does not allow from the
1513
+ * subscription's status, or, under `error_if_incomplete`, ending a trial that cannot be paid at once ("If payment fails,
1514
+ * return an HTTP `402` status code and don't update the subscription", docs.stripe.com/api/subscriptions/update). */
1515
+ export function trialEndBlocked(ctx: HandlerContext, sub: Row, discounts: Row[] | undefined): Response | undefined {
1516
+ if (ctx.params.trial_end !== 'now') return newTrialRefused(ctx, sub);
1517
+ if (sub.status !== 'trialing') return undefined;
1518
+ const charge = trialEndCharge(ctx, sub, discounts);
1519
+ if (ctx.params.payment_behavior === 'error_if_incomplete' && charge.due > 0 && !charge.pays) return firstPaymentRefused(ctx, charge.declined);
1520
+ // the move ending the trial makes (paid or nothing due: active; else past_due) is the machine's, asked before
1521
+ // anything is written
1522
+ const refused = ctx.legal(SUB, 'status', ctx.call.operation.id, 'trialing', charge.due <= 0 || charge.pays ? 'active' : 'past_due', String(sub.id));
1523
+ return refused ? ctx.refuse(refused) : undefined;
1524
+ }
1525
+
1526
+ /** A price named by id, or given as an object. */
1527
+ export function priceRow(ctx: HandlerContext, price: unknown): Row | undefined {
1528
+ return typeof price === 'string' ? ctx.get('price', price) : price && typeof price === 'object' ? (price as Row) : undefined;
1529
+ }
1530
+
1531
+ /** One period of the subscription's items as an update's `items` would leave them, before any discount: an entry naming
1532
+ * an item changes its price and quantity (a new price resets the quantity to 1 unless one is given) or deletes it, and
1533
+ * one naming none adds an item (applyItemChanges). */
1534
+ export function dueAfter(ctx: HandlerContext, sub: Row, entries: Row[]): number {
1535
+ const lines = new Map<string, { price: Row | undefined; quantity: number }>();
1536
+ for (const it of ((sub.items as { data?: Row[] } | undefined)?.data) ?? []) lines.set(String(it.id), { price: priceRow(ctx, it.price), quantity: itemQuantity(it) });
1537
+ entries.forEach((e, i) => entryAfter(ctx, lines, e, i));
1538
+ return [...lines.values()].reduce((sum, l) => sum + priceAmount(l.price, l.quantity), 0);
1539
+ }
1540
+
1541
+ /** One entry of an update's `items` on the lines it would leave (dueAfter). */
1542
+ export function entryAfter(ctx: HandlerContext, lines: Map<string, { price: Row | undefined; quantity: number }>, e: Row, i: number): void {
1543
+ // an inline price bills as the Price inlinePrice makes of it
1544
+ const d = e.price_data && typeof e.price_data === 'object' ? (e.price_data as Row) : undefined;
1545
+ const inline: Row | undefined = d ? (d.unit_amount_decimal !== undefined ? { unit_amount_decimal: String(d.unit_amount_decimal) } : { unit_amount: Math.trunc(Number(d.unit_amount) || 0) }) : undefined;
1546
+ const price = typeof e.price === 'string' && e.price ? priceRow(ctx, e.price) : inline;
1547
+ const named = typeof e.id === 'string' && e.id;
1548
+ const key = named ? String(e.id) : `new_${i}`;
1549
+ // only a named item is deleted; an entry naming none adds one, as applyItemChanges does
1550
+ if (named && asBool(e.deleted)) { lines.delete(key); return; }
1551
+ const was = lines.get(key);
1552
+ const quantity = e.quantity !== undefined ? Math.max(0, Math.trunc(Number(e.quantity) || 0)) : price && was && price.id !== was.price?.id ? 1 : was?.quantity ?? 1;
1553
+ lines.set(key, { price: price ?? was?.price, quantity });
1554
+ }
1555
+
1556
+ /** A new trial on a subscription whose status the machine does not let start one. */
1557
+ export function newTrialRefused(ctx: HandlerContext, sub: Row): Response | undefined {
1558
+ const refused = sub.status === 'trialing' ? undefined : ctx.legal(SUB, 'status', ctx.call.operation.id, sub.status, 'trialing', String(sub.id));
1559
+ return refused ? ctx.refuse(refused) : undefined;
1560
+ }
1561
+
1562
+ /** What ending the trial charges, judged before anything is written: the period of the items as this update leaves them
1563
+ * (dueAfter) under the discount it leaves, and whether the payer pays it (an attempt made, a payer, no decline). */
1564
+ export function trialEndCharge(ctx: HandlerContext, sub: Row, discounts: Row[] | undefined): { due: number; pays: boolean; declined?: ReturnType<typeof declineFor> } {
1565
+ const subtotal = dueAfter(ctx, sub, subscriptionItemEntries(ctx.params.items));
1566
+ // the discount the subscription carries after this update takes this invoice (renewals.ts draftSubscriptionInvoice)
1567
+ const discount = (discounts ?? (sub.discounts as Row[] | undefined) ?? [])[0];
1568
+ const coupon = discount && typeof discount === 'object' ? (discount.coupon as Row | undefined) : undefined;
1569
+ const due = subtotal - (coupon ? applyCouponDiscount(subtotal, coupon) : 0);
1570
+ const payer = trialPayer(ctx, sub);
1571
+ const attempt = ctx.params.payment_behavior !== 'default_incomplete';
1572
+ const declined = payer && attempt ? declineFor(finder(ctx), { payment_method: payer }) : undefined;
1573
+ return { due, pays: attempt && !!payer && !declined, ...(declined ? { declined } : {}) };
1574
+ }
1575
+
1576
+ /** A new item named with neither `price` nor `price_data`: "One of `price` or `price_data` is required"
1577
+ * (docs.stripe.com/api/subscriptions/update). */
1578
+ export function noItemPrice(ctx: HandlerContext): Response {
1579
+ return fail(ctx, 'Missing required param: items[price].', 400, 'parameter_missing');
1580
+ }
1581
+
1582
+ /** A subscription asked to fail when its first invoice cannot be paid at once (payment_behavior=error_if_incomplete):
1583
+ * the card's decline, or no payment method to charge (docs.stripe.com/api/subscriptions/create#create_subscription-payment_behavior). */
1584
+ export function firstPaymentRefused(ctx: HandlerContext, declined: Parameters<typeof cardError>[0] | null | undefined): Response {
1585
+ return declined ? send(ctx, cardError(declined)) : fail(ctx, 'This customer has no attached payment source or default payment method.', 400, 'resource_missing');
1586
+ }
1587
+
1588
+ /** An update's `items`: "A list of up to 20 subscription items, each with an attached price"
1589
+ * (docs.stripe.com/api/subscriptions/update): an entry naming an item's `id` changes that item (its price, quantity,
1590
+ * metadata) or, with `deleted` ("A flag that, if set to `true`, will delete the specified item"), removes it; "If you
1591
+ * omit `id`, the API adds a new subscription item rather than updating the existing one". Each takes `price` or
1592
+ * `price_data`, "Data used to generate a new Price object inline. One of `price` or `price_data` is required", and
1593
+ * "When changing a subscription item's price, `quantity` is set to 1 unless a `quantity` parameter is provided". An
1594
+ * inline price is made as the manage-prices guide says: "By default, prices created with `price_data` are effectively
1595
+ * archived (they're marked as `active=false`)" (docs.stripe.com/products-prices/manage-prices).
1596
+ * Where the documentation stops and the twin decides: the update is all or nothing, every entry checked before any is
1597
+ * written (Stripe answers one error for the request); the refusals are worded by the twin. */
1598
+ export async function applyItemChanges(ctx: HandlerContext, subId: string, entries: Row[]): Promise<Response | undefined> {
1599
+ // every entry is checked first: a refusal leaves the subscription as it was
1600
+ const refused = itemChangesRefused(ctx, subId, entries);
1601
+ if (refused) return refused;
1602
+ return writeItemChanges(ctx, subId, entries);
1603
+ }
1604
+
1605
+ /** The refusal of an update's `items`, checked entry by entry before any is written (applyItemChanges). */
1606
+ export function itemChangesRefused(ctx: HandlerContext, subId: string, entries: Row[]): Response | undefined {
1607
+ for (const entry of entries) {
1608
+ const priceRef = typeof entry.price === 'string' && entry.price ? entry.price : undefined;
1609
+ if (priceRef && !ctx.get('price', priceRef)) return fail(ctx, `No such price: '${priceRef}'`, 400, 'resource_missing');
1610
+ const inline = !priceRef && entry.price_data && typeof entry.price_data === 'object' ? (entry.price_data as Row) : undefined;
1611
+ if (inline) {
1612
+ const refused = refuseInlinePrice(ctx, inline);
1613
+ if (refused) return refused;
1614
+ }
1615
+ if (typeof entry.id === 'string' && entry.id) {
1616
+ const si = ctx.get('subscription_item', entry.id);
1617
+ if (!si || si.subscription !== subId) return fail(ctx, `No such subscription_item: '${entry.id}'`, 400, 'resource_missing');
1618
+ } else if (!priceRef && !inline) return noItemPrice(ctx);
1619
+ }
1620
+ return undefined;
1621
+ }
1622
+
1623
+ /** An update's checked `items`, written. */
1624
+ export async function writeItemChanges(ctx: HandlerContext, subId: string, entries: Row[]): Promise<undefined> {
1625
+ for (const entry of entries) {
1626
+ const inline = typeof entry.price === 'string' && entry.price ? undefined : entry.price_data && typeof entry.price_data === 'object' ? (entry.price_data as Row) : undefined;
1627
+ const priceRef = typeof entry.price === 'string' && entry.price ? entry.price : inline ? await inlinePrice(ctx, inline) : undefined;
1628
+ // only a price that changes resets the quantity; one naming the item's own price leaves it
1629
+ const was = typeof entry.id === 'string' && entry.id ? ctx.get('subscription_item', entry.id)?.price : undefined;
1630
+ const wasId = typeof was === 'string' ? was : was && typeof was === 'object' ? String((was as Row).id ?? '') : undefined;
1631
+ const quantity = entry.quantity !== undefined ? { quantity: Math.max(0, Math.trunc(Number(entry.quantity) || 0)) } : priceRef && priceRef !== wasId ? { quantity: 1 } : {};
1632
+ const metadata = entry.metadata && typeof entry.metadata === 'object' ? { metadata: entry.metadata } : {};
1633
+ if (typeof entry.id === 'string' && entry.id) {
1634
+ if (asBool(entry.deleted)) { await ctx.write('subscription_item', entry.id, { deleted: true }, 'subscription_item.delete'); continue; }
1635
+ await ctx.write('subscription_item', entry.id, { ...(priceRef ? { price: priceRef } : {}), ...quantity, ...metadata }, 'subscription_item.update');
1636
+ } else {
1637
+ await created(ctx, 'subscription_item', { subscription: subId, ...subscriptionItemPrice(priceRef, finder(ctx)), quantity: 1, ...quantity, ...metadata }, { livemode: false, metadata: {}, discounts: [], tax_rates: [], billing_thresholds: null });
1638
+ }
1639
+ }
1640
+ await syncItems(ctx, subId);
1641
+ return undefined;
1642
+ }
1643
+
1644
+ /** A subscription item's `price_data` Stripe would refuse: its required `currency`, `product` (an existing one) and
1645
+ * `recurring[interval]`, and "Only one of `unit_amount` and `unit_amount_decimal` can be set". */
1646
+ export function refuseInlinePrice(ctx: HandlerContext, d: Row): Response | undefined {
1647
+ for (const name of ['currency', 'product']) if (typeof d[name] !== 'string' || !d[name]) return fail(ctx, `Missing required param: items[price_data][${name}].`, 400, 'parameter_missing');
1648
+ if (!ctx.get('product', String(d.product))) return fail(ctx, `No such product: '${String(d.product)}'`, 400, 'resource_missing');
1649
+ const interval = (d.recurring as Row | undefined)?.interval;
1650
+ if (!['day', 'week', 'month', 'year'].includes(String(interval))) return fail(ctx, 'Missing required param: items[price_data][recurring][interval].', 400, 'parameter_missing');
1651
+ if (d.unit_amount !== undefined && d.unit_amount_decimal !== undefined) return fail(ctx, 'Only one of `unit_amount` and `unit_amount_decimal` can be set.', 400, 'parameter_invalid');
1652
+ return undefined;
1653
+ }
1654
+
1655
+ /** The inline Price a `price_data` generates: recurring, archived (active=false). Answers its id. */
1656
+ export async function inlinePrice(ctx: HandlerContext, d: Row): Promise<string> {
1657
+ const r = d.recurring as Row;
1658
+ const decimal = d.unit_amount_decimal !== undefined ? String(d.unit_amount_decimal) : String(Math.trunc(Number(d.unit_amount) || 0));
1659
+ const price = await created(ctx, 'price', {
1660
+ product: d.product, currency: String(d.currency).toLowerCase(),
1661
+ // a decimal amount that is not a whole number of the smallest unit has no integer unit_amount (as semantics/plans.ts)
1662
+ unit_amount: d.unit_amount_decimal !== undefined ? (Number.isInteger(Number(d.unit_amount_decimal)) ? Number(d.unit_amount_decimal) : null) : Math.trunc(Number(d.unit_amount) || 0), unit_amount_decimal: decimal,
1663
+ 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 },
1664
+ tax_behavior: typeof d.tax_behavior === 'string' ? d.tax_behavior : 'unspecified',
1665
+ }, { active: false, livemode: false, billing_scheme: 'per_unit', type: 'recurring', metadata: {}, lookup_key: null, nickname: null });
1666
+ return String(price.id);
1667
+ }
1668
+
1669
+ // ── items: each write keeps the parent subscription's items.data in step, rebuilt from the live items ──
1670
+
1671
+ /** A new subscription's items, each its own subscription_item as Stripe keeps it (/v1/subscription_items/{id}). */
1672
+ export async function storeItems(ctx: HandlerContext, subId: string, items: Row[]): Promise<void> {
1673
+ for (const item of items) {
1674
+ if (typeof item.id !== 'string' || ctx.get('subscription_item', item.id)) continue;
1675
+ const { plan: _plan, price, ...fields } = item;
1676
+ await ctx.write('subscription_item', item.id, { ...fields, price: typeof price === 'object' && price ? (price as Row).id : price, subscription: subId }, 'subscription_item.create');
1677
+ }
1678
+ }
1679
+
1680
+ export async function syncItems(ctx: HandlerContext, subId: string): Promise<void> {
1681
+ if (!subId) return;
1682
+ await ctx.write(SUB, subId, {}, 'subscription.items_synced');
1683
+ }
1684
+
1685
+ export const listMissing = (ctx: HandlerContext, id: string, status = 404): Response => fail(ctx, `No such radar value list: '${id}'`, status, 'resource_missing');
1686
+
1687
+ /** A value list's list_items, "List of items contained within this value list" (the served spec), kept in step with
1688
+ * its items, at the address the fixture names (/v1/radar/value_list_items?value_list=…). */
1689
+ export async function radarSyncItems(ctx: HandlerContext, listId: string): Promise<void> {
1690
+ const data = newest(ctx, VLI).filter((i) => i.value_list === listId);
1691
+ await ctx.write(VL, listId, { list_items: { object: 'list', data, has_more: false, url: `/v1/radar/value_list_items?value_list=${listId}` } }, 'radar.value_list.items_synced');
1692
+ }
1693
+
1694
+ /** An authorization as every egress carries it: its card embedded in full. */
1695
+ export function withCard(ctx: HandlerContext, a: Row): Row {
1696
+ return ctx.expand(AUTH, a);
1697
+ }
1698
+
1699
+ export const authMissing = (ctx: HandlerContext, id: string): Response => fail(ctx, `No such authorization: '${id}'`, 404, 'resource_missing');
1700
+
1701
+ /** A capture: an Issuing transaction (negative: it debits) and the issuing balance's debit. */
1702
+ export async function capture(ctx: HandlerContext, authId: string, auth: Row, amountCents: number): Promise<{ txnId: string; btId: string }> {
1703
+ const currency = typeof auth.currency === 'string' ? auth.currency : 'usd';
1704
+ const bt = await created(ctx, 'balance_transaction', {}, {
1705
+ amount: -amountCents, currency, fee: 0, net: -amountCents, type: 'issuing_transaction',
1706
+ status: 'available', balance_type: 'issuing', reporting_category: 'issuing_transaction',
1707
+ available_on: nowUnix(ctx.occurredAt), fee_details: [],
1708
+ });
1709
+ const txn = await created(ctx, TXN, { authorization: authId, card: auth.card, cardholder: auth.cardholder }, {
1710
+ type: 'capture', amount: -amountCents, currency,
1711
+ merchant_amount: -amountCents, merchant_currency: typeof auth.merchant_currency === 'string' ? auth.merchant_currency : currency,
1712
+ merchant_data: auth.merchant_data ?? merchantData(undefined),
1713
+ livemode: false, metadata: {}, dispute: null, wallet: null, network_data: null,
1714
+ amount_details: null, purchase_details: null, balance_transaction: bt.id,
1715
+ });
1716
+ return { txnId: String(txn.id), btId: String(bt.id) };
1717
+ }
1718
+
1719
+ /** Issuing refusals of a request no story sends: an unknown card status or authorization method, and a capture of an
1720
+ * authorization that is not approved and pending. */
1721
+ export function issuingRefusal(ctx: HandlerContext, what: 'card_status' | 'authorization_method' | 'not_capturable', auth?: Row): Response {
1722
+ switch (what) {
1723
+ case 'card_status': return fail(ctx, "Invalid status: must be one of 'active', 'inactive', or 'canceled'.", 400, 'parameter_invalid_string_enum');
1724
+ case 'authorization_method': return fail(ctx, "Invalid authorization_method: must be one of 'chip', 'contactless', 'keyed_in', 'online', or 'swipe'.", 400, 'parameter_invalid_string_enum');
1725
+ case 'not_capturable': return fail(ctx, `This authorization cannot be captured because it is not an approved pending authorization (status: ${String(auth?.status)}, approved: ${auth?.approved === true}).`, 400);
1726
+ }
1727
+ }
1728
+
1729
+ /** A partial approval's refusal: "You can specify the amount you want to approve by setting the amount in the webhook
1730
+ * response body or the approve call" only "When an authorization is partially authorized, the is_amount_controllable
1731
+ * field on the authorization request is set to true" (docs.stripe.com/issuing/purchases/authorizations), and never a
1732
+ * credit. Answers the refusal, or undefined when the amount may be approved. */
1733
+ export function partialApprovalRefused(ctx: HandlerContext, auth: Row): Response | undefined {
1734
+ if (Math.trunc(Number(ctx.params.amount) || 0) <= 0) return fail(ctx, 'Invalid integer: amount must be a positive integer.', 400, 'parameter_invalid_integer');
1735
+ if ((auth.pending_request as Row | null | undefined)?.is_amount_controllable !== true) return fail(ctx, 'amount may only be provided when the authorization was presented with is_amount_controllable.', 400);
1736
+ return undefined;
1737
+ }
1738
+
1739
+ // the (deprecated but real) API decision on a pending authorization: it lands in request_history
1740
+ // as the webhook's answer would, consumes the pending request, and an approval captures
1741
+ export function issuingDecide(approved: boolean): Handler {
1742
+ return async (ctx) => {
1743
+ const id = at(ctx, 'authorization');
1744
+ const auth = ctx.get(AUTH, id);
1745
+ if (!auth) return authMissing(ctx, id);
1746
+ const finalized = { status: 400, code: 'authorization_already_finalized', message: `This authorization has already been finalized (status ${String(auth.status)}).` };
1747
+ // a webhook-approved authorization is decided, though still pending
1748
+ if (auth.approved === true && auth.status === 'pending') return ctx.refuse(finalized);
1749
+ const refused = ctx.legal(AUTH, 'status', approved ? 'PostIssuingAuthorizationsAuthorizationApprove' : 'PostIssuingAuthorizationsAuthorizationDecline', auth.status, undefined, id);
1750
+ if (refused) return ctx.refuse(refused);
1751
+ // a partial approval only for an amount-controllable presentment, and never a credit
1752
+ const badAmount = approved && ctx.params.amount !== undefined ? partialApprovalRefused(ctx, auth) : undefined;
1753
+ if (badAmount) return badAmount;
1754
+ const decidedAmount = approved && ctx.params.amount !== undefined ? Math.trunc(Number(ctx.params.amount) || 0) : Number(auth.amount) || 0;
1755
+ const currency = typeof auth.currency === 'string' ? auth.currency : 'usd';
1756
+ const history = Array.isArray(auth.request_history) ? (auth.request_history as Row[]) : [];
1757
+ // an approval holds the funds and waits for the merchant's capture; a decline closes it
1758
+ // (docs.stripe.com/issuing/purchases/authorizations, docs.stripe.com/issuing/funding/balance)
1759
+ const hold = approved ? await holdFor(ctx, id, decidedAmount, currency) : null;
1760
+ return ctx.reply(withCard(ctx, await ctx.write(AUTH, id, {
1761
+ status: approved ? 'pending' : 'closed', approved, pending_request: null,
1762
+ ...(approved && ctx.params.amount !== undefined ? { amount: decidedAmount } : {}),
1763
+ request_history: [...history, historyEntry({
1764
+ amount: decidedAmount, currency, approved, reason: approved ? 'webhook_approved' : 'webhook_declined', reasonMessage: null,
1765
+ createdSec: Number(nowUnix(ctx.occurredAt)), merchantAmount: Number(auth.merchant_amount) || decidedAmount,
1766
+ merchantCurrency: typeof auth.merchant_currency === 'string' ? auth.merchant_currency : currency,
1767
+ })],
1768
+ ...(hold ? { balance_transactions: [...(Array.isArray(auth.balance_transactions) ? auth.balance_transactions : []), hold.id] } : {}),
1769
+ }, 'issuing_authorization.updated'),
1770
+ ));
1771
+ };
1772
+ }
1773
+
1774
+ /** Time's decision on a real-time request no one answered, caught up to the World's clock: once its window has ended,
1775
+ * a pending authorization whose issuing_authorization.request went unanswered, and that no approve or decline decided,
1776
+ * is decided automatically, its request_history reason webhook_timeout ("webhook_timeout for all other failure modes",
1777
+ * the same page, beside webhook_error for a response Stripe cannot process).
1778
+ * Where the documentation stops and the twin decides: the timeout setting is the Dashboard's, which the API does not
1779
+ * answer, and the twin's is to decline; the window is measured in World time from the authorization's created. */
1780
+ export async function lapseRealtimeRequests(ctx: HandlerContext): Promise<void> {
1781
+ const now = Number(nowUnix(ctx.occurredAt));
1782
+ const requested = new Set(ctx.rows('event').filter((e) => e.type === 'issuing_authorization.request').map((e) => String(((e.data as Row | undefined)?.object as Row | undefined)?.id ?? '')));
1783
+ for (const a of ctx.rowsRaw(AUTH)) {
1784
+ if (a.status !== 'pending' || a.approved === true || !a.pending_request || !requested.has(String(a.id))) continue;
1785
+ const at = Number(a.created) + REALTIME_WINDOW_SECONDS;
1786
+ if (now < at) continue;
1787
+ const id = String(a.id);
1788
+ const currency = typeof a.currency === 'string' ? a.currency : 'usd';
1789
+ const amount = Number(a.amount) || 0;
1790
+ ctx.legal(AUTH, 'status', ctx.call.operation.id, 'pending', 'closed', id, 'time');
1791
+ await ctx.write(AUTH, id, {
1792
+ status: 'closed', approved: false, pending_request: null,
1793
+ request_history: [...(Array.isArray(a.request_history) ? (a.request_history as Row[]) : []), historyEntry({
1794
+ amount, currency, approved: false, reason: 'webhook_timeout', reasonMessage: null, createdSec: at,
1795
+ merchantAmount: Number(a.merchant_amount) || amount, merchantCurrency: typeof a.merchant_currency === 'string' ? a.merchant_currency : currency,
1796
+ })],
1797
+ }, 'issuing_authorization.updated');
1798
+ }
1799
+ }
1800
+
1801
+ /** An approval's hold: "If you approve the authorization, we deduct the `amount` from your Issuing balance and hold it
1802
+ * in reserve until the authorization is either captured, voided, or expired without capture"
1803
+ * (docs.stripe.com/issuing/purchases/authorizations). */
1804
+ export async function holdFor(ctx: HandlerContext, authId: string, amount: number, currency: string): Promise<Row> {
1805
+ return created(ctx, 'balance_transaction', {}, {
1806
+ amount: -amount, currency, fee: 0, net: -amount, type: 'issuing_authorization_hold',
1807
+ status: 'available', balance_type: 'issuing', reporting_category: 'issuing_authorization_hold',
1808
+ available_on: nowUnix(ctx.occurredAt), fee_details: [], source: authId,
1809
+ });
1810
+ }
1811
+
1812
+ /** An authorization approved as it is presented, its amount held: approved and pending until the merchant captures. */
1813
+ export async function approvedAtOnce(ctx: HandlerContext, subject: Row, fields: Row, held: number, entry: Row): Promise<Response> {
1814
+ const hold = await holdFor(ctx, String(subject.id), held, String(fields.currency ?? 'usd'));
1815
+ return ctx.reply(withCard(ctx, await created(ctx, AUTH, subject, { ...fields, amount: held, status: 'pending', approved: true, pending_request: null, request_history: [entry], balance_transactions: [hold.id] })));
1816
+ }
1817
+
1818
+ /** An authorization as the platform's real-time answer makes it: approved (in part, only when the request was
1819
+ * amount-controllable), declined, or declined for an answer Stripe cannot read (docs.stripe.com/issuing/controls/
1820
+ * real-time-authorizations: the response's `approved` and `amount`; webhook_error "if we can't process your response"). */
1821
+ export async function answeredByEndpoint(ctx: HandlerContext, outcome: { kind: 'approved'; amount?: number } | { kind: 'declined' } | { kind: 'error'; message: string }, o: {
1822
+ subject: Row; fields: Row; amount: number; controllable: boolean; entry: (e: { amount: number; approved: boolean; reason: string; reasonMessage: string | null }) => Row;
1823
+ decline: (reason: string, message: string | null) => Promise<Response>;
1824
+ }): Promise<Response> {
1825
+ if (outcome.kind === 'approved') {
1826
+ const held = o.controllable && outcome.amount !== undefined ? outcome.amount : o.amount;
1827
+ return approvedAtOnce(ctx, o.subject, o.fields, held, o.entry({ amount: held, approved: true, reason: 'webhook_approved', reasonMessage: null }));
1828
+ }
1829
+ return outcome.kind === 'declined' ? o.decline('webhook_declined', null) : o.decline('webhook_error', outcome.message);
1830
+ }
1831
+
1832
+ /** Time's settling of held refunds, caught up to the World's clock: each refund held for want of balance, oldest first,
1833
+ * is made at the first moment the account's available balance covers it (the funds that come due then), its debit
1834
+ * written then, as `refund.updated`. Where the documentation stops and the twin decides: a held refund is made the
1835
+ * moment the balance covers it. */
1836
+ export async function settleHeldRefunds(ctx: HandlerContext): Promise<void> {
1837
+ const now = Number(nowUnix(ctx.occurredAt));
1838
+ const heldRefunds = ctx.rowsRaw('refund').filter((r) => r.status === 'pending' && r.pending_reason === 'insufficient_funds').sort((a, b) => Number(a.created) - Number(b.created));
1839
+ for (const r of heldRefunds) await settleOneHeldRefund(ctx, r, now);
1840
+ }
1841
+
1842
+ /** A pending refund can enter this surface only through the vendor-backed refund refresh. */
1843
+ export async function settleOneHeldRefund(ctx: HandlerContext, r: Row, now: number): Promise<void> {
1844
+ const account = typeof r._account === 'string' ? r._account : undefined;
1845
+ const currency = String(r.currency ?? 'usd');
1846
+ const amount = Number(r.amount) || 0;
1847
+ const when = [Number(r.created), ...fundsDueBetween(ctx, account, currency, Number(r.created), now)].find((t) => availableAt(ctx, account, currency, t) >= amount);
1848
+ if (when === undefined) return;
1849
+ const c = await at_(ctx)(when);
1850
+ c.legal('refund', 'status', ctx.call.operation.id, 'pending', 'succeeded', String(r.id), 'vendor');
1851
+ const bt = await settleRefund(c, String(r.id), amount, currency, account ?? null);
1852
+ await c.write('refund', String(r.id), { status: 'succeeded', pending_reason: null, balance_transaction: bt }, 'refund.updated');
1853
+ }
1854
+
1855
+ export const accountMissing = (ctx: HandlerContext, id: string, status = 404): Response => fail(ctx, `No such account: '${id}'`, status, 'resource_missing');
1856
+
1857
+ /** The account, tombstones included where the hand-written routes looked past a deletion. */
1858
+ export const accountRow = (ctx: HandlerContext, id: string): Row | undefined => ctx.row('account', id, { withDeleted: true });
1859
+
1860
+ /** Stripe's review of a Custom account once the platform gives it something: with nothing left due, its requested
1861
+ * capabilities become active and it can take charges and receive payouts; with something due again (its bank account
1862
+ * removed), they stop. Where the documentation stops and the twin decides: test-mode verification is immediate, and
1863
+ * it owes what customRequirements lists. An Express or Standard account is onboarded on Stripe's own pages. */
1864
+ export async function connectReview(ctx: HandlerContext, id: string): Promise<void> {
1865
+ const account = resourceView(ctx, 'account', id);
1866
+ if (account && account.type === 'custom') await reviewCustom(ctx, id, account);
1867
+ }
1868
+
1869
+ /** The review of a Custom account (see review). */
1870
+ export async function reviewCustom(ctx: HandlerContext, id: string, account: Row): Promise<void> {
1871
+ const bank = ctx.rows('external_account').some((e) => e.account === id);
1872
+ const persons = ctx.rows('person').filter((p) => p.account === id);
1873
+ const { now: due, later } = customRequirements(account, bank, persons);
1874
+ // one account, one review: "If a connected account has both card_payments and transfers, and the status of either one
1875
+ // is inactive, then both capabilities are disabled" (docs.stripe.com/connect/account-capabilities)
1876
+ const ready = due.length === 0;
1877
+ const capabilities: Row = {};
1878
+ for (const [key, was] of Object.entries((account.capabilities as Row | undefined) ?? {})) {
1879
+ const to = ready ? 'active' : 'inactive';
1880
+ // Capabilities are entries of the account's map, not standalone stored subjects.
1881
+ capabilities[key] = to;
1882
+ }
1883
+ const requirements = { ...((account.requirements as Row | undefined) ?? {}), currently_due: due, eventually_due: [...due, ...later], past_due: [], disabled_reason: ready ? null : 'requirements.past_due', current_deadline: null };
1884
+ await ctx.write('account', id, { capabilities, requirements, charges_enabled: ready, payouts_enabled: ready, details_submitted: ready }, ready ? 'account.updated' : 'account.update');
1885
+ }
1886
+
1887
+ /** A transfer to an account whose transfers capability is not active. */
1888
+ export function transfersInactive(ctx: HandlerContext): Response {
1889
+ return ctx.refuse({ status: 400, code: 'insufficient_capabilities_for_transfer', param: 'destination', message: 'Your destination account needs to have at least one of the following capabilities enabled: transfers, crypto_transfers, legacy_payments' });
1890
+ }
1891
+
1892
+ /** A transfer from a charge whose funds have settled: "With a source_transaction, the transfer request returns success
1893
+ * regardless of your available balance if the related charge hasn't settled yet" (docs.stripe.com/connect/separate-
1894
+ * charges-and-transfers), so a settled one comes out of the available balance. */
1895
+ export function settledSourceRefused(ctx: HandlerContext, amount: number, currency: string): Response | undefined {
1896
+ return refusePayout(ctx, amount, currency, undefined);
1897
+ }
1898
+
1899
+ /** An earlier version's payment_method_types refused: the first value that names no payment method type the
1900
+ * operation takes, in the intent's words or (a Checkout Session) the enum's. */
1901
+ export function unknownMethodType(ctx: HandlerContext, opId: string, given: unknown): Response | undefined {
1902
+ const values = Array.isArray(given) ? given : given && typeof given === 'object' ? Object.values(given) : [given];
1903
+ const checkout = opId === 'PostCheckoutSessions';
1904
+ const known = checkout ? CHECKOUT_METHOD_TYPES : PAYMENT_METHOD_TYPES;
1905
+ const i = values.findIndex((v) => !known.includes(String(v)));
1906
+ if (i < 0) return undefined;
1907
+ if (checkout) {
1908
+ const param = `payment_method_types[${i}]`;
1909
+ return ctx.refuse({ status: 400, param, message: `Invalid ${param}: must be one of ${oneOf(known)}` });
1910
+ }
1911
+ const doc = TYPES_DOC[opId.startsWith('PostSetupIntents') ? 'setup_intents' : 'payment_intents'];
1912
+ return ctx.refuse({ status: 400, param: 'payment_method_types', message: `The payment method type "${String(values[i])}" is invalid. See ${doc} for the full list of supported payment method types.` });
1913
+ }
1914
+
1915
+ /** payment_method_types refused where the caller's version removed it. */
1916
+ export const noLongerSupported = (ctx: HandlerContext, version: string): Response =>
1917
+ ctx.refuse({ status: 400, code: 'payment_method_types_no_longer_supported', param: 'payment_method_types', message: `payment_method_types is no longer supported in API version ${version}. Use allowed_payment_method_types, or dynamic payment methods, instead.` });
1918
+
1919
+ /** Stripe's refusal of a request whose top-level parameters the operation does not take, or undefined. */
1920
+ export function refuseParameters(ctx: HandlerContext): Response | undefined {
1921
+ const op = OPS.get(ctx.call.operation.id);
1922
+ if (!op) return undefined;
1923
+ const pinned = ctx.call.request.headers.get('stripe-version');
1924
+ const params = ctx.params;
1925
+ if (pinned && pinned < SERVED_VERSION) {
1926
+ if (!TYPED.has(op.id)) return undefined;
1927
+ if (params.payment_method_types === undefined) return undefined;
1928
+ if (REMOVED_TYPES.has(op.id) && methodTypesRemoved(pinned)) return noLongerSupported(ctx, pinned);
1929
+ return unknownMethodType(ctx, op.id, params.payment_method_types);
1930
+ }
1931
+ const method = ctx.call.request.method.toUpperCase();
1932
+ const known = new Set([...(op.query ?? []), ...(op.body ?? [])].map((p) => p.name).concat(DOCUMENTED[op.id] ?? []));
1933
+ if (REMOVED_TYPES.has(op.id) && params.payment_method_types !== undefined) return noLongerSupported(ctx, pinned ?? SERVED_VERSION);
1934
+ for (const name of Object.keys(params)) {
1935
+ if (!known.has(name)) return ctx.refuse({ status: 400, code: 'parameter_unknown', param: name, message: `Received unknown parameter: ${name}` });
1936
+ }
1937
+ const required = method === 'GET' || method === 'DELETE' ? (op.query ?? []) : (op.body ?? []);
1938
+ for (const p of required) {
1939
+ if (p.required && params[p.name] === undefined) return ctx.refuse({ status: 400, code: 'parameter_missing', param: p.name, message: `Missing required param: ${p.name}.` });
1940
+ }
1941
+ return undefined;
1942
+ }
1943
+
1944
+ /** An answer in the shape of the API version the caller is served (./version.ts), a webhook endpoint's `secret` shown
1945
+ * only at its creation ("Only returned at creation", docs.stripe.com/api/webhook_endpoints/object). */
1946
+ export async function rendered(request: Request, res: Response): Promise<Response> {
1947
+ const creates = request.method === 'POST' && new URL(request.url).pathname.replace(/\/+$/, '') === '/v1/webhook_endpoints';
1948
+ const pinned = request.headers.get('stripe-version');
1949
+ if (!(res.headers.get('content-type') ?? '').includes('json')) return res;
1950
+ if (!servesCurrent(pinned) && creates) return res;
1951
+ const expand = await expandOf(request.clone());
1952
+ const body = await res.json();
1953
+ const answered = creates ? body : withoutEndpointSecret(body);
1954
+ return new Response(JSON.stringify(servesCurrent(pinned) ? render(answered, pinned, expand) : answered), { status: res.status, statusText: res.statusText, headers: res.headers });
1955
+ }
1956
+
1957
+
1958
+
1959
+ /** Write a draft's line list and the totals derived from it. */
1960
+ export async function writeLines(ctx: HandlerContext, invoiceId: string, next: Row[]): Promise<Row> {
1961
+ const subtotal = sumLines(next);
1962
+ const lines = { object: 'list', data: next, has_more: false, total_count: next.length, url: `/v1/invoices/${invoiceId}/lines` };
1963
+ return ctx.write(INV, invoiceId, { lines, subtotal, total: subtotal, amount_due: subtotal, amount_remaining: subtotal }, 'invoice.update');
1964
+ }
1965
+
1966
+
1967
+
1968
+ /** A customer's pending invoice item, made before the invoice, pulled onto it (docs.stripe.com/api/invoices/create#create_invoice-pending_invoice_items_behavior). */
1969
+ export async function pullPendingItem(ctx: HandlerContext, invoiceId: string, inv: Row, item: Row, now: number): Promise<void> {
1970
+ const current = ctx.get(INV, invoiceId) ?? inv;
1971
+ await writeLines(ctx, invoiceId, [...linesOf(current), {
1972
+ id: `il_${String(item.id)}`, object: 'line_item', type: 'invoiceitem', amount: Number(item.amount) || 0, currency: String(current.currency ?? item.currency ?? 'usd'),
1973
+ quantity: Number(item.quantity) || 1, proration: false, invoice_item: item.id, price: item.price ?? null,
1974
+ period: item.period ?? { start: now, end: now }, description: (item.description as string) ?? null,
1975
+ discountable: item.discountable ?? true, discounts: [], livemode: false, metadata: item.metadata ?? {}, tax_amounts: [], tax_rates: [],
1976
+ }]);
1977
+ await ctx.write('invoiceitem', String(item.id), { invoice: invoiceId }, 'invoiceitem.update');
1978
+ }
1979
+
1980
+
1981
+
1982
+ /** The intent the path names, and the machine's refusal when this operation may not move it. */
1983
+ export function paymentIntentsLoad(ctx: HandlerContext, operationId: string): { pi: Record<string, unknown> } | { answer: Response } {
1984
+ const pi = ctx.id ? ctx.get(PI, ctx.id) : undefined;
1985
+ if (!pi) return { answer: ctx.notFound(PI, String(ctx.id)) };
1986
+ const refusal = ctx.legal(PI, 'status', operationId, pi.status);
1987
+ return refusal ? { answer: ctx.refuse(refusal) } : { pi };
1988
+ }
1989
+
1990
+
1991
+
1992
+ /** A transfer reversal's two entries: back into the platform's balance, out of the destination's. */
1993
+ export async function settleTransferReversal(ctx: HandlerContext, reversalId: string, amount: number, currency: string, destination: string): Promise<string> {
1994
+ const platform = await write(ctx, { amount, currency, fee: 0, net: amount, type: 'transfer_refund', reporting_category: 'transfer_reversal', source: reversalId, available_on: Number(nowUnix(ctx.occurredAt)) }, null);
1995
+ await write(ctx, { amount: -amount, currency, fee: 0, net: -amount, type: 'payment_refund', reporting_category: 'refund', source: reversalId, available_on: Number(nowUnix(ctx.occurredAt)) }, destination);
1996
+ return platform;
1997
+ }
1998
+
1999
+
2000
+
2001
+ /** A capture=false charge refunded while uncaptured: its authorization is released by the refund, as it would be
2002
+ * "automatically refunded if uncaptured" (spec/openapi.json.gz, `capture_before`). The charge stays uncaptured and
2003
+ * becomes refunded in full, a Refund records the release, and no money moves: none was ever received. Written as
2004
+ * charge.refunded. Where the documentation stops and the twin decides: Stripe's basil change ("Partially capturing or
2005
+ * canceling payments no longer creates a Refund", docs.stripe.com/changelog/basil/2025-03-31/remove-refund-from-partial-
2006
+ * capture-and-payment-cancellation-flow) names partial capture and cancellation, not a refund asked for, so a refund
2007
+ * still makes one; its reason is null and it has no balance transaction. Answers the Refund. */
2008
+ export async function releaseAuthorization(ctx: HandlerContext, ch: Row, operationId: string, given: Row = {}): Promise<Row> {
2009
+ const id = String(ch.id);
2010
+ const full = Number(ch.amount) || 0;
2011
+ if (ch.refunded !== true) ctx.legal('charge', 'refunded', operationId, 'false', 'true', id);
2012
+ const refund = await created(ctx, 'refund', { ...given, charge: id, amount: full - (Number(ch.amount_refunded) || 0), currency: String(ch.currency ?? 'usd') }, {
2013
+ status: 'succeeded', metadata: {}, reason: null, receipt_number: null,
2014
+ balance_transaction: null, source_transfer_reversal: null, transfer_reversal: null, ...refundDestination(ch, 'reversal'),
2015
+ ...(typeof ch.payment_intent === 'string' ? { payment_intent: ch.payment_intent } : {}),
2016
+ });
2017
+ await ctx.write('charge', id, { amount_refunded: full, refunded: true }, 'charge.refunded');
2018
+ return refund;
2019
+ }
2020
+
2021
+
2022
+
2023
+ /** A PaymentIntent's authorization released by its cancel: "For PaymentIntents with a `status` of `requires_capture`, the
2024
+ * remaining `amount_capturable` is automatically refunded" (docs.stripe.com/api/payment_intents/cancel), and since basil
2025
+ * a cancellation makes no Refund: "`amount_captured` will be 0 instead of `nil` in payment cancellation flows",
2026
+ * "`amount_refunded` will no longer be updated by these actions", "`refunded` will no longer be `true` for payment
2027
+ * cancellation flows", and no charge.refunded is sent (docs.stripe.com/changelog/basil/2025-03-31/remove-refund-from-
2028
+ * partial-capture-and-payment-cancellation-flow). The charge stays uncaptured; nothing is written as an event. */
2029
+ export async function cancelAuthorization(ctx: HandlerContext, ch: Row): Promise<Row> {
2030
+ return ctx.write('charge', String(ch.id), { amount_captured: 0 }, 'charge.authorization_released');
2031
+ }
2032
+
2033
+
2034
+
2035
+ /**
2036
+ * Create a refund and land it on its charge. An omitted amount is the whole remaining balance. The
2037
+ * charge's write records `charge.refunded`, the event Stripe sends carrying the charge's new totals;
2038
+ * `refunded` is true only at the full amount.
2039
+ */
2040
+ /** A reason Stripe does not take (docs.stripe.com/api/refunds/create#create_refund-reason). */
2041
+ export function badReason(ctx: HandlerContext): Response {
2042
+ return fail(ctx, `Invalid refund reason: must be one of ${[...REFUND_REASONS].join(', ')}.`, 400, 'parameter_invalid_string_enum');
2043
+ }
2044
+
2045
+
2046
+
2047
+ /** A bank-transfer payment refunded with origin=customer_balance goes back to the cash balance
2048
+ * (docs.stripe.com/payments/customer-balance/refunding). */
2049
+ export async function backToCashBalance(ctx: HandlerContext, customer: string, currency: string, amount: number, refundId: string): Promise<void> {
2050
+ const before = ctx.rows('customer_cash_balance_transaction').filter((t) => t.customer === customer && String(t.currency) === currency).reduce((n, t) => n + (Number(t.net_amount) || 0), 0);
2051
+ await created(ctx, 'customer_cash_balance_transaction', { customer }, { currency, type: 'refunded_from_payment', net_amount: amount, ending_balance: before + amount, refunded_from_payment: { refund: refundId }, livemode: false });
2052
+ }
2053
+
2054
+
2055
+
2056
+ /** A capture=false charge's refund, which releases its whole authorization (see the header). */
2057
+ export async function refundUncaptured(ctx: HandlerContext, charge: Row, amount: number, remaining: number, params: Row): Promise<Response> {
2058
+ if (amount < remaining) return fail(ctx, `Charge ${String(charge.id)} is uncaptured: refunding it releases the whole authorization (${remaining}).`, 400, 'amount_too_small');
2059
+ const { reason, metadata } = params;
2060
+ return ctx.reply(await releaseAuthorization(ctx, charge, ctx.call.operation.id, { ...(reason !== undefined ? { reason } : {}), ...(metadata !== undefined ? { metadata } : {}) }));
2061
+ }
2062
+
2063
+
2064
+
2065
+ export async function refundCharge(ctx: HandlerContext, charge: Row, params: Row): Promise<Response> {
2066
+ const chargeId = String(charge.id);
2067
+ const total = Number(charge.amount) || 0;
2068
+ const already = Number(charge.amount_refunded) || 0;
2069
+ const remaining = Math.max(0, total - already);
2070
+ const amount = params.amount === undefined ? remaining : Math.trunc(Number(params.amount));
2071
+ if (!Number.isFinite(amount) || amount <= 0) return fail(ctx, 'Invalid integer: amount must be a positive integer.', 400, 'parameter_invalid_integer');
2072
+ if (amount > remaining) return fail(ctx, `Refund amount (${amount}) is greater than unrefunded amount on charge (${remaining}).`, 400, 'amount_too_large');
2073
+ if (params.reason !== undefined && !REFUND_REASONS.has(String(params.reason))) return badReason(ctx);
2074
+ // an authorization is released, never refunded out of the balance: a PaymentIntent's by cancelling the intent, a
2075
+ // capture=false charge's by its refund (refundUncaptured)
2076
+ if (charge.captured === false && typeof charge.payment_intent === 'string') return fail(ctx, `Charge ${chargeId} is uncaptured and can't be refunded directly; cancel its PaymentIntent (${charge.payment_intent}) to release the authorization.`, 400, 'payment_intent_unexpected_state');
2077
+ if (charge.captured === false) return refundUncaptured(ctx, charge, amount, remaining, params);
2078
+ const refunded = already + amount;
2079
+ // the refund that takes the rest refunds the charge: the charge machine's move under this operation
2080
+ if (refunded >= total && charge.refunded !== true) {
2081
+ const refused = ctx.legal('charge', 'refunded', ctx.call.operation.id, 'false', 'true', chargeId);
2082
+ if (refused) return ctx.refuse(refused);
2083
+ }
2084
+ // a refund has no `livemode` field; it debits the balance at once
2085
+ const refundId = ctx.mint('refund');
2086
+ const currency = String(charge.currency ?? 'usd');
2087
+ const pi = typeof charge.payment_intent === 'string' ? ctx.get('payment_intent', charge.payment_intent) : undefined;
2088
+ const byTransfer = ((pi?.payment_method_types as unknown[] | undefined) ?? []).includes('customer_balance');
2089
+ const toCashBalance = byTransfer && params.origin === 'customer_balance';
2090
+ const awaitsBank = byTransfer && !toCashBalance;
2091
+ const now = Number(nowUnix(ctx.occurredAt));
2092
+ const customer = typeof charge.customer === 'string' ? charge.customer : typeof pi?.customer === 'string' ? pi.customer : undefined;
2093
+ const emailTo = typeof params.instructions_email === 'string' ? params.instructions_email : (customer ? ctx.get('customer', customer)?.email : undefined) ?? null;
2094
+ // "Refunds use your available Stripe balance (not including pending amounts). If your available balance doesn't
2095
+ // cover the amount of the refund, Stripe holds the refund as pending for card transactions ... until your Stripe
2096
+ // balance becomes sufficient" (docs.stripe.com/refunds): the refund waits, moving no money, until the balance covers
2097
+ // it (settleHeldRefunds). Where the documentation stops and the twin decides: a refund of another payment method
2098
+ // short of balance is not modelled (Stripe fails it); the twin makes it as a card's.
2099
+ const account = actingAccount(ctx);
2100
+ const isCard = (charge.payment_method_details as Row | null | undefined)?.type === 'card';
2101
+ const held = !awaitsBank && !toCashBalance && isCard && availableAt(ctx, account, currency, now) < amount;
2102
+ if (held) ctx.legal('refund', 'status', ctx.call.operation.id, 'succeeded', 'pending', refundId);
2103
+ const bt = awaitsBank || held ? null : await settleRefund(ctx, refundId, amount, currency);
2104
+ if (toCashBalance && customer) await backToCashBalance(ctx, customer, currency, amount, refundId);
2105
+ const { reverse_transfer: reverseTransfer, refund_application_fee: refundFee, instructions_email: _email, origin: _origin, ...stored } = params;
2106
+ const share = (whole: number): number => Math.round((whole * amount) / (total || 1));
2107
+ let transferReversal: string | null = null;
2108
+ const transfer = typeof charge.transfer === 'string' ? ctx.get('transfer', charge.transfer) : undefined;
2109
+ if (reverseTransfer !== undefined && asBool(reverseTransfer) && transfer) {
2110
+ const back = Math.min(share(Number(transfer.amount) || 0), (Number(transfer.amount) || 0) - (Number(transfer.amount_reversed) || 0));
2111
+ const trr = await created(ctx, 'transfer_reversal', { amount: back, currency, transfer: transfer.id }, { balance_transaction: null, destination_payment_refund: null, source_refund: refundId, metadata: {} });
2112
+ const trrBt = await settleTransferReversal(ctx, String(trr.id), back, currency, String(transfer.destination));
2113
+ const reversal = await ctx.write('transfer_reversal', String(trr.id), { balance_transaction: trrBt }, 'transfer_reversal.updated');
2114
+ const reversed = (Number(transfer.amount_reversed) || 0) + back;
2115
+ const list = (transfer.reversals as Row | undefined) ?? { object: 'list', data: [], has_more: false, total_count: 0 };
2116
+ const data = [...(((list.data as unknown[]) ?? [])), reversal];
2117
+ await ctx.write('transfer', String(transfer.id), { amount_reversed: reversed, reversed: reversed >= (Number(transfer.amount) || 0), reversals: { ...list, data, total_count: data.length, url: `/v1/transfers/${String(transfer.id)}/reversals` } }, 'transfer.reversed');
2118
+ transferReversal = String(trr.id);
2119
+ }
2120
+ const fee = typeof charge.application_fee === 'string' ? ctx.get('application_fee', charge.application_fee) : undefined;
2121
+ if (refundFee !== undefined && asBool(refundFee) && fee) {
2122
+ const feeTotal = Number(fee.amount) || 0;
2123
+ const back = Math.min(share(feeTotal), feeTotal - (Number(fee.amount_refunded) || 0));
2124
+ const done = (Number(fee.amount_refunded) || 0) + back;
2125
+ if (!(done >= feeTotal && fee.refunded !== true && ctx.legal('application_fee', 'refunded', ctx.call.operation.id, 'false', 'true', String(fee.id)))) {
2126
+ const fr = await created(ctx, 'fee_refund', { amount: back, currency, fee: fee.id }, { balance_transaction: null, metadata: {} });
2127
+ const list = (fee.refunds as Row | undefined) ?? { object: 'list', data: [], has_more: false, total_count: 0 };
2128
+ const data = [...(((list.data as unknown[]) ?? [])), fr];
2129
+ await ctx.write('application_fee', String(fee.id), { amount_refunded: done, refunded: done >= feeTotal, refunds: { ...list, data, total_count: data.length, url: `/v1/application_fees/${String(fee.id)}/refunds` } }, 'application_fee.refunded');
2130
+ }
2131
+ }
2132
+ const body = await created(ctx, 'refund', {
2133
+ ...stored, id: refundId, charge: chargeId, amount, currency, transfer_reversal: transferReversal,
2134
+ ...(typeof charge.payment_intent === 'string' ? { payment_intent: charge.payment_intent } : {}),
2135
+ }, {
2136
+ status: awaitsBank ? 'requires_action' : held ? 'pending' : 'succeeded', metadata: {}, reason: null, receipt_number: null,
2137
+ ...(held ? { pending_reason: 'insufficient_funds', _account: account ?? null } : {}),
2138
+ balance_transaction: bt, source_transfer_reversal: null,
2139
+ // a bank transfer's refund goes to the customer's bank (a us_bank_transfer) or back to its cash balance
2140
+ // (customer_cash_balance): "If this is a `us_bank_transfer` refund, this hash contains the transaction specific details",
2141
+ // "If this is a `customer_cash_balance` refund ..." (docs.stripe.com/api/refunds/object, destination_details)
2142
+ ...(byTransfer
2143
+ ? { destination_details: toCashBalance ? { type: 'customer_cash_balance', customer_cash_balance: {} } : { type: 'us_bank_transfer', us_bank_transfer: { reference: null, reference_status: null } } }
2144
+ : refundDestination(charge, 'refund')),
2145
+ ...(awaitsBank ? { next_action: { type: 'display_details', display_details: { email_sent: { email_sent_at: now, email_sent_to: emailTo }, expires_at: now + 30 * 86_400 } } } : {}),
2146
+ });
2147
+ await ctx.write('charge', chargeId, { amount_refunded: refunded, refunded: refunded >= total }, 'charge.refunded');
2148
+ return ctx.reply(body);
2149
+ }
2150
+
2151
+
2152
+
2153
+ /** The charge a refund aims at: named directly, or reached through its PaymentIntent. */
2154
+ export function target(ctx: HandlerContext): Row | Response {
2155
+ const chargeId = typeof ctx.params.charge === 'string' ? ctx.params.charge : '';
2156
+ const piId = typeof ctx.params.payment_intent === 'string' ? ctx.params.payment_intent : '';
2157
+ if (!chargeId && !piId) return fail(ctx, 'One of `charge` or `payment_intent` is required.', 400, 'parameter_missing');
2158
+ if (chargeId) return ctx.get('charge', chargeId) ?? fail(ctx, `No such charge: '${chargeId}'`, 404, 'resource_missing');
2159
+ const pi = ctx.get('payment_intent', piId);
2160
+ if (!pi) return fail(ctx, `No such payment_intent: '${piId}'`, 404, 'resource_missing');
2161
+ const latest = typeof pi.latest_charge === 'string' ? pi.latest_charge : undefined;
2162
+ const ch = (latest ? ctx.get('charge', latest) : undefined) ?? newest(ctx, 'charge').find((c) => c.payment_intent === piId);
2163
+ return ch ?? fail(ctx, `This PaymentIntent does not have a successful charge to refund. Its status is ${String(pi.status)}.`, 400, 'payment_intent_unexpected_state');
2164
+ }
2165
+
2166
+
2167
+ /** A pending invoice item contributes to a preview only on its customer's next invoice. */
2168
+ // source: spec:PostInvoicesCreatePreview "including subscription renewal charges, invoice item charges, etc."
2169
+ export function pendingPreviewItem(ii: Row, customer: string, currency: string, now: number): Row | undefined {
2170
+ if (ii.customer !== customer || ii.invoice) return undefined;
2171
+ return {
2172
+ id: `il_${String(ii.id)}`, object: 'line_item', type: 'invoiceitem', amount: Number(ii.amount) || 0, currency,
2173
+ quantity: Number(ii.quantity) || 1, proration: false, invoice_item: ii.id,
2174
+ period: ii.period ?? { start: now, end: now }, description: (ii.description as string) ?? null,
2175
+ };
2176
+ }
2177
+
2178
+ export function pendingPreviewLines(ctx: HandlerContext, customer: string, currency: string, now: number): Row[] {
2179
+ return newest(ctx, 'invoiceitem', 'date').flatMap(ii => { const line = pendingPreviewItem(ii, customer, currency, now); return line ? [line] : []; });
2180
+ }