@pithy-sh/payments 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +157 -0
  3. package/dist/paddle-prices.iife.js +1 -0
  4. package/dist/pithy.manifest.json +68 -0
  5. package/docs/lemon-squeezy.md +167 -0
  6. package/docs/paddle.md +486 -0
  7. package/package.json +73 -0
  8. package/pithy.manifest.json +68 -0
  9. package/src/admin/coverage.ts +107 -0
  10. package/src/admin/read.ts +360 -0
  11. package/src/audit/actions.ts +214 -0
  12. package/src/capability.ts +377 -0
  13. package/src/client/api.ts +734 -0
  14. package/src/client/checkout.ts +252 -0
  15. package/src/client/fixtures/price-preview-de.json +54 -0
  16. package/src/client/fixtures/price-preview-gb.json +54 -0
  17. package/src/client/fixtures/price-preview-jp-yen.json +54 -0
  18. package/src/client/fixtures/price-preview-us-country-only.json +54 -0
  19. package/src/client/fixtures/price-preview-us-new-york.json +54 -0
  20. package/src/client/fixtures/pricePreview.ts +67 -0
  21. package/src/client/hooks.ts +590 -0
  22. package/src/client/paddle.ts +733 -0
  23. package/src/client/paddleCache.ts +265 -0
  24. package/src/client/paddlePrices.iife.ts +43 -0
  25. package/src/client/paddlePrices.ts +143 -0
  26. package/src/client/paddlePricesTag.ts +332 -0
  27. package/src/client/projection.ts +133 -0
  28. package/src/client/test-utils/cacheStore.ts +41 -0
  29. package/src/client/wholeUnits.ts +147 -0
  30. package/src/cloudflare-test.d.ts +16 -0
  31. package/src/config/config.ts +563 -0
  32. package/src/data/discount.ts +238 -0
  33. package/src/data/entitlement.ts +67 -0
  34. package/src/data/money.ts +75 -0
  35. package/src/data/pause.ts +148 -0
  36. package/src/data/providerAccount.ts +47 -0
  37. package/src/data/purchase.ts +131 -0
  38. package/src/data/rail.ts +74 -0
  39. package/src/data/reconcileRun.ts +208 -0
  40. package/src/data/renderMoney.ts +180 -0
  41. package/src/data/status.ts +67 -0
  42. package/src/data/subject.ts +148 -0
  43. package/src/data/subscription.ts +675 -0
  44. package/src/data/syncCursor.ts +44 -0
  45. package/src/data/tables.ts +61 -0
  46. package/src/data/webhookEvent.ts +196 -0
  47. package/src/entitlement/manual.ts +209 -0
  48. package/src/entitlement/resolver.ts +125 -0
  49. package/src/entitlement/subjectSeam.ts +171 -0
  50. package/src/error/errors.ts +421 -0
  51. package/src/grants/apply.ts +207 -0
  52. package/src/grants/clawback.ts +128 -0
  53. package/src/grants/ledgerSeam.ts +141 -0
  54. package/src/http/dispatch.ts +48 -0
  55. package/src/http/guards.ts +58 -0
  56. package/src/http/manifestConfig.ts +56 -0
  57. package/src/http/responses.ts +1147 -0
  58. package/src/http/routes.ts +2911 -0
  59. package/src/http/schemas.ts +577 -0
  60. package/src/http/scopes.ts +260 -0
  61. package/src/http/view.ts +188 -0
  62. package/src/http/webhookGuard.ts +341 -0
  63. package/src/index.ts +231 -0
  64. package/src/migrations/0001_purchases.ts +312 -0
  65. package/src/pricing/location.ts +175 -0
  66. package/src/pricing/visitor.ts +82 -0
  67. package/src/projection/event.ts +101 -0
  68. package/src/projection/orphans.ts +226 -0
  69. package/src/projection/owner.ts +247 -0
  70. package/src/projection/resolve.ts +57 -0
  71. package/src/projection/writer.ts +554 -0
  72. package/src/provision/resolvePaymentsConfig.ts +91 -0
  73. package/src/rails/apple/certs.ts +66 -0
  74. package/src/rails/apple/der.ts +249 -0
  75. package/src/rails/apple/fixtures/chain.ts +412 -0
  76. package/src/rails/apple/fixtures/did-fail-to-renew-grace.json +43 -0
  77. package/src/rails/apple/fixtures/did-renew.json +48 -0
  78. package/src/rails/apple/fixtures/one-time-charge.json +32 -0
  79. package/src/rails/apple/fixtures/refund.json +33 -0
  80. package/src/rails/apple/fixtures/subscribed-initial-buy-sandbox.json +38 -0
  81. package/src/rails/apple/fixtures/test.json +14 -0
  82. package/src/rails/apple/http.ts +128 -0
  83. package/src/rails/apple/jws.ts +123 -0
  84. package/src/rails/apple/notification.ts +357 -0
  85. package/src/rails/apple/rail.ts +114 -0
  86. package/src/rails/apple/serverApi.ts +412 -0
  87. package/src/rails/apple/verify.ts +93 -0
  88. package/src/rails/apple/x509.ts +424 -0
  89. package/src/rails/contract.ts +939 -0
  90. package/src/rails/google/fixtures/play-product-purchased.json +14 -0
  91. package/src/rails/google/fixtures/play-subscription-active.json +23 -0
  92. package/src/rails/google/fixtures/push.ts +152 -0
  93. package/src/rails/google/fixtures/rtdn-one-time-purchased.json +11 -0
  94. package/src/rails/google/fixtures/rtdn-subscription-renewed.json +11 -0
  95. package/src/rails/google/fixtures/rtdn-subscription-revoked.json +11 -0
  96. package/src/rails/google/fixtures/rtdn-test.json +8 -0
  97. package/src/rails/google/fixtures/rtdn-voided-purchase.json +11 -0
  98. package/src/rails/google/http.ts +124 -0
  99. package/src/rails/google/jwt.ts +127 -0
  100. package/src/rails/google/oidc.ts +308 -0
  101. package/src/rails/google/playApi.ts +598 -0
  102. package/src/rails/google/rail.ts +174 -0
  103. package/src/rails/google/rtdn.ts +331 -0
  104. package/src/rails/google/verify.ts +127 -0
  105. package/src/rails/lemonSqueezy/api.ts +192 -0
  106. package/src/rails/lemonSqueezy/checkout.ts +186 -0
  107. package/src/rails/lemonSqueezy/discounts.ts +166 -0
  108. package/src/rails/lemonSqueezy/fixtures/events.ts +180 -0
  109. package/src/rails/lemonSqueezy/objects.ts +511 -0
  110. package/src/rails/lemonSqueezy/portal.ts +82 -0
  111. package/src/rails/lemonSqueezy/pricing.ts +124 -0
  112. package/src/rails/lemonSqueezy/rail.ts +137 -0
  113. package/src/rails/lemonSqueezy/read.ts +72 -0
  114. package/src/rails/lemonSqueezy/refresh.ts +86 -0
  115. package/src/rails/lemonSqueezy/signature.ts +119 -0
  116. package/src/rails/lemonSqueezy/verify.ts +45 -0
  117. package/src/rails/lemonSqueezy/webhook.ts +326 -0
  118. package/src/rails/paddle/adjustments.ts +94 -0
  119. package/src/rails/paddle/api.ts +266 -0
  120. package/src/rails/paddle/checkout.ts +240 -0
  121. package/src/rails/paddle/discounts.ts +176 -0
  122. package/src/rails/paddle/events.ts +285 -0
  123. package/src/rails/paddle/fixtures/browserForged.ts +54 -0
  124. package/src/rails/paddle/fixtures/pausedSubscription.ts +62 -0
  125. package/src/rails/paddle/objects.ts +883 -0
  126. package/src/rails/paddle/portal.ts +130 -0
  127. package/src/rails/paddle/rail.ts +219 -0
  128. package/src/rails/paddle/read.ts +108 -0
  129. package/src/rails/paddle/recorded.ts +83 -0
  130. package/src/rails/paddle/refresh.ts +153 -0
  131. package/src/rails/paddle/refund.ts +297 -0
  132. package/src/rails/paddle/signature.ts +225 -0
  133. package/src/rails/paddle/subscription.ts +793 -0
  134. package/src/rails/paddle/verify.ts +120 -0
  135. package/src/rails/paddle/webhook.ts +405 -0
  136. package/src/rails/providers.ts +168 -0
  137. package/src/rails/stripe/api.ts +253 -0
  138. package/src/rails/stripe/checkout.ts +175 -0
  139. package/src/rails/stripe/discounts.ts +169 -0
  140. package/src/rails/stripe/fixtures/event-charge-refunded.json +23 -0
  141. package/src/rails/stripe/fixtures/event-invoice-paid.json +19 -0
  142. package/src/rails/stripe/fixtures/event-session-completed-payment.json +27 -0
  143. package/src/rails/stripe/fixtures/event-session-completed-subscription.json +27 -0
  144. package/src/rails/stripe/fixtures/event-subscription-canceled.json +43 -0
  145. package/src/rails/stripe/fixtures/event-subscription-created.json +42 -0
  146. package/src/rails/stripe/fixtures/event-subscription-deleted.json +44 -0
  147. package/src/rails/stripe/fixtures/events.ts +111 -0
  148. package/src/rails/stripe/objects.ts +642 -0
  149. package/src/rails/stripe/portal.ts +45 -0
  150. package/src/rails/stripe/pricing.ts +123 -0
  151. package/src/rails/stripe/rail.ts +139 -0
  152. package/src/rails/stripe/refresh.ts +79 -0
  153. package/src/rails/stripe/signature.ts +127 -0
  154. package/src/rails/stripe/verify.ts +105 -0
  155. package/src/rails/stripe/webhook.ts +79 -0
  156. package/src/secret/registry.ts +275 -0
  157. package/src/seeds/example.ts +181 -0
  158. package/src/version.generated.ts +16 -0
  159. package/src/workflows/paddleSweep.ts +717 -0
  160. package/src/workflows/railAccess.ts +87 -0
  161. package/src/workflows/reconcile.ts +648 -0
  162. package/src/workflows/report.ts +64 -0
  163. package/src/workflows/retryPolicy.ts +40 -0
  164. package/src/workflows/specs.ts +143 -0
  165. package/src/workflows/worker.ts +166 -0
  166. package/src/workflows/workerConfig.ts +73 -0
  167. package/src/workflows/wrangler.jsonc +55 -0
@@ -0,0 +1,2911 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database } from "@cloudflare/workers-types";
5
+ import { zValidator } from "@hono/zod-validator";
6
+ import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
7
+ import { safeEmit } from "@pithy-sh/core/src/controlPlane/audit/actions";
8
+ import type { ControlPlaneContext } from "@pithy-sh/core/src/controlPlane/context";
9
+ import { requireControlPlane } from "@pithy-sh/core/src/controlPlane/http/guard";
10
+ import { InternalError, NotFoundError, PithyError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
11
+ import { validationHook } from "@pithy-sh/core/src/http/validation";
12
+ import { resolveWorkflowBinding } from "@pithy-sh/core/src/workflow/dispatch";
13
+ import type { SecretsStoreEnv } from "@pithy-sh/secrets/src/env/bindings";
14
+ import { sharedSecretsStore } from "@pithy-sh/secrets/src/sharedSecretsStore";
15
+ import type { Context, Hono } from "hono";
16
+ import { listEntitlements, listPurchases, listReconcileRuns, listSubscriptions, readEntitlements } from "../admin/read";
17
+ import { type PaymentsAuditAction, PaymentsAuditActions } from "../audit/actions";
18
+ import {
19
+ type PaymentsCatalogEntry,
20
+ type PaymentsConfig,
21
+ type PaymentsStripeSettings,
22
+ providerProductId,
23
+ railEnabled,
24
+ resolveProduct,
25
+ } from "../config/config";
26
+ import type { PaymentsEntitlement } from "../data/entitlement";
27
+ import type { PurchaseEnvironment } from "../data/purchase";
28
+ import { PaymentsPurchase } from "../data/purchase";
29
+ import { PAYMENTS_HOSTED_RAILS, type PaymentsRail } from "../data/rail";
30
+ import type { PurchaseStatus } from "../data/status";
31
+ import {
32
+ decodeSubjectReference,
33
+ encodeSubjectReference,
34
+ type PaymentsSubject,
35
+ type PaymentsSubjectType,
36
+ sameSubject,
37
+ } from "../data/subject";
38
+ import {
39
+ nextSubscriptionEvent,
40
+ type RefundRequest,
41
+ type SubscriptionChangeQuote,
42
+ type SubscriptionStanding,
43
+ } from "../data/subscription";
44
+ import { PAYMENTS_PURCHASES_TABLE, paymentsDatabase } from "../data/tables";
45
+ import { WEBHOOK_EVENT_ORPHANED } from "../data/webhookEvent";
46
+ import { grantEntitlement, revokeEntitlement } from "../entitlement/manual";
47
+ import { type PaymentsSubjectSeam, requirePaymentsSubject, resolvePaymentsSubject } from "../entitlement/subjectSeam";
48
+ import {
49
+ PaymentsEntitlementNotInCatalogError,
50
+ PaymentsProductNotFoundError,
51
+ PaymentsRailNotConfiguredError,
52
+ PaymentsReceiptAlreadyOwnedError,
53
+ PaymentsReconcileNotProvisionedError,
54
+ PaymentsSubscriptionChangeRefusedError,
55
+ } from "../error/errors";
56
+ import { fulfillPurchase } from "../grants/apply";
57
+ import { repairOrphanedEvents } from "../projection/orphans";
58
+ import { linkProviderAccount, providerAccountForSubject, resolveNotificationOwner } from "../projection/owner";
59
+ import { resolveEntitlements } from "../projection/resolve";
60
+ import { type PurchaseProjection, projectPurchase } from "../projection/writer";
61
+ import {
62
+ type CheckoutRail,
63
+ isCheckoutRail,
64
+ isDiscountRail,
65
+ isPricingRail,
66
+ isRefundRail,
67
+ isSubscriptionRail,
68
+ noteText,
69
+ type PaymentsRailProvider,
70
+ type RefundRail,
71
+ type SubscriptionRail,
72
+ } from "../rails/contract";
73
+ import { type RailTrustOptions, resolveRailProvider } from "../rails/providers";
74
+ import { PAYMENTS_PROVIDER_SECRET, paymentsSecretsRegistry } from "../secret/registry";
75
+ import { PAYMENTS_CAPABILITY, paymentsWorkflows } from "../workflows/specs";
76
+ import { requireAuth } from "./guards";
77
+ import type {
78
+ PaymentsAdminCatalogResponse,
79
+ PaymentsAdminDiscountsResponse,
80
+ PaymentsAdminEntitlementsResponse,
81
+ PaymentsAdminPurchasesResponse,
82
+ PaymentsAdminReconcileRunStarted,
83
+ PaymentsAdminReconcileRunsResponse,
84
+ PaymentsAdminSubjectEntitlementsResponse,
85
+ PaymentsAdminSubscriptionsResponse,
86
+ PaymentsCheckoutHandoffResponse,
87
+ PaymentsDiscountResponse,
88
+ PaymentsEntitlementResponse,
89
+ PaymentsEntitlementsResponse,
90
+ PaymentsPortalHandoffResponse,
91
+ PaymentsPricingEnvelope,
92
+ PaymentsPricingResponse,
93
+ PaymentsPurchaseResponse,
94
+ PaymentsQuotedFrom,
95
+ PaymentsRefundRequest,
96
+ PaymentsRefundResponse,
97
+ PaymentsRestoreResponse,
98
+ PaymentsSubscriptionQuote,
99
+ PaymentsSubscriptionQuoteResponse,
100
+ PaymentsSubscriptionResponse,
101
+ PaymentsSubscriptionStandingResponse,
102
+ PaymentsSubscriptionView,
103
+ } from "./responses";
104
+ import {
105
+ AdminDiscountsQuery,
106
+ AdminEntitlementsQuery,
107
+ AdminPurchasesQuery,
108
+ AdminReconcileRunsQuery,
109
+ AdminSubjectParam,
110
+ AdminSubscriptionsQuery,
111
+ AppleWebhookNotification,
112
+ CheckoutRequest,
113
+ DiscountCreateRequest,
114
+ EntitlementGrantRequest,
115
+ EntitlementRevokeRequest,
116
+ GoogleWebhookNotification,
117
+ LemonSqueezyWebhookNotification,
118
+ PaddleWebhookNotification,
119
+ PurchaseSubmission,
120
+ RestoreRequest,
121
+ StripeWebhookNotification,
122
+ SubscriptionCancelRequest,
123
+ SubscriptionChangeRequest,
124
+ SubscriptionPreviewRequest,
125
+ } from "./schemas";
126
+ import {
127
+ PAYMENTS_CATALOG_READ_SCOPE,
128
+ PAYMENTS_DISCOUNT_CREATE_SCOPE,
129
+ PAYMENTS_DISCOUNT_READ_SCOPE,
130
+ PAYMENTS_ENTITLEMENT_GRANT_SCOPE,
131
+ PAYMENTS_ENTITLEMENT_REVOKE_SCOPE,
132
+ PAYMENTS_ENTITLEMENTS_READ_SCOPE,
133
+ PAYMENTS_PURCHASES_READ_SCOPE,
134
+ PAYMENTS_RECONCILE_READ_SCOPE,
135
+ PAYMENTS_RECONCILE_RUN_SCOPE,
136
+ PAYMENTS_SUBSCRIPTIONS_READ_SCOPE,
137
+ } from "./scopes";
138
+ import {
139
+ adminCatalogView,
140
+ adminEntitlementView,
141
+ adminPurchaseView,
142
+ adminReconcileRunView,
143
+ entitlementView,
144
+ purchaseView,
145
+ } from "./view";
146
+ import { completeWebhook, requireSignedWebhook, verifiedWebhook } from "./webhookGuard";
147
+
148
+ /**
149
+ * The payments routes, their declared verification strategies, and what each accepts.
150
+ *
151
+ * **This list is prose, and it had rotted.** It named sixteen of the twenty-one routes mounted before #465 —
152
+ * `GET /pricing`, two webhook rails and both discount routes had landed without a line here — which is the
153
+ * same failure the README's Routes table had before a gate held it, and for the same reason: a list that is
154
+ * merely *near* the code is a list nothing compares. It was brought level with the registrations as part of
155
+ * #465 and every mounted route is below, but **nothing holds it there**: the gated inventories are the
156
+ * README's Routes table and the `mountedRoutes` pin in `routeContract.test.ts`, checked in both directions
157
+ * against the real registrations. Read those as the record; edit this one by hand and expect it to rot again.
158
+ *
159
+ * POST /payments/purchases → verify a receipt, project it (bearer | session) json: PurchaseSubmission
160
+ * GET /payments/entitlements → the caller's entitlements (bearer | session) —
161
+ * POST /payments/restore → rebind store history (bearer | session) json: RestoreRequest
162
+ * GET /payments/pricing → what the caller's subscription pays, and when that changes
163
+ * (bearer | session) —
164
+ * POST /payments/checkout → a hosted checkout, on whichever rail sells the product
165
+ * (bearer | session) json: CheckoutRequest
166
+ * POST /payments/portal → a billing-portal session (bearer | session) —
167
+ * GET /payments/subscription → where the caller's subscription stands, read live
168
+ * (bearer | session) —
169
+ * POST /payments/subscription/preview → what a move would cost (bearer | session) json: SubscriptionPreviewRequest
170
+ * POST /payments/subscription/change → move it onto a catalog product (bearer | session) json: SubscriptionChangeRequest
171
+ * POST /payments/subscription/cancel → stop it renewing (bearer | session) json: SubscriptionCancelRequest
172
+ * POST /payments/subscription/keep → withdraw a scheduled cancel (bearer | session) —
173
+ * POST /payments/subscription/refund → ask for its payments back (bearer | session) —
174
+ * POST /payments/webhooks/apple → ASSN V2 (signed-webhook) json: AppleWebhookNotification
175
+ * POST /payments/webhooks/google → Play RTDN via Pub/Sub push (signed-webhook) json: GoogleWebhookNotification
176
+ * POST /payments/webhooks/stripe → Stripe events (signed-webhook) json: StripeWebhookNotification
177
+ * POST /payments/webhooks/lemon-squeezy → Lemon Squeezy events (signed-webhook) json: LemonSqueezyWebhookNotification
178
+ * POST /payments/webhooks/paddle → Paddle events (signed-webhook) json: PaddleWebhookNotification
179
+ * POST /payments/entitlements/grant → comp or repair an entitlement (control-plane) json: EntitlementGrantRequest
180
+ * POST /payments/entitlements/revoke → take one back (control-plane) json: EntitlementRevokeRequest
181
+ *
182
+ * GET /payments/admin/catalog → what this project sells (control-plane: payments:catalog:read) —
183
+ * GET /payments/admin/purchases → the purchase log, paged (control-plane: payments:purchases:read) query: AdminPurchasesQuery
184
+ * GET /payments/admin/subscriptions → the purchases that renew (control-plane: payments:subscriptions:read) query: AdminSubscriptionsQuery
185
+ * GET /payments/admin/entitlements → the entitlement model, paged (control-plane: payments:entitlements:read) query: AdminEntitlementsQuery
186
+ * GET /payments/admin/entitlements/:subjectType/:subjectId
187
+ * → one subject's entitlements (control-plane: payments:entitlements:read) param: AdminSubjectParam
188
+ * GET /payments/admin/reconcile-runs → the reconciliation run log (control-plane: payments:reconcile:read) query: AdminReconcileRunsQuery
189
+ * GET /payments/admin/discounts → the codes this project issued (control-plane: payments:discounts:read) query: AdminDiscountsQuery
190
+ * POST /payments/admin/discounts → mint one at a store (control-plane: payments:discounts:create) json: DiscountCreateRequest
191
+ *
192
+ * **The reads exist because the writes did.** Payments shipped `entitlements/grant` and
193
+ * `entitlements/revoke` with no read beside them, so a management client could comp an entitlement and take
194
+ * one back and could never list one — and a dashboard's Purchases, Entitlements and Subscriptions panes
195
+ * computed *absent* against a live manifest and dropped out of the rail entirely. Not blocked, not refused:
196
+ * absent, which no grant and no seed can repair, because there was no route to grant a scope to (#247).
197
+ *
198
+ * **They sit under `admin/` because the player surface owns the bare paths.** `GET ${base}/entitlements` is
199
+ * already a bearer route reading the caller's own; a control-plane read at the same address would sit behind
200
+ * whichever Hono matched first, with a route's gate decided by registration order. The extra segment makes
201
+ * the two sets disjoint by construction.
202
+ *
203
+ * **One `POST` among them, and it is the exception that states the rule.** Every `admin/` route is a read
204
+ * except `POST ${base}/admin/discounts`, which mints a code at a store — a write with a cost attached, so it
205
+ * carries its own scope, `payments:discounts:create`, granted separately from the `payments:discounts:read`
206
+ * on the listing beside it, and its own audit event. The three writes this capability offers a management
207
+ * client are grant, revoke and mint; each is separately scoped, and nothing here widens any of them.
208
+ *
209
+ * That one path serving two methods is also why `routeContract.test.ts` pins `METHOD /path` rather than the
210
+ * path: a `POST ${base}/admin/purchases` mounted beside the read would be a write nobody declared, sitting
211
+ * on a path the pin already contained.
212
+ *
213
+ * **The two control-plane routes are the only way an entitlement appears without money moving**, and that is
214
+ * the whole reason they are gated the way they are. Each wears core's `requireControlPlane()` and nothing
215
+ * else: an EdDSA-signed token on the `pithy-control-plane` header, verified against a public key the adopter
216
+ * registered, bound to one connection, one environment, one request body and one use — and carrying the single
217
+ * scope that route needs, `payments:entitlements:grant` or `payments:entitlements:revoke`. The two are granted
218
+ * separately, so a refund tool cannot comp and a comp tool cannot revoke.
219
+ *
220
+ * **`requireAuth()` is deliberately absent from those two lines, and adding it would break them.** A
221
+ * management client is not a user of this app; the seam never populates `c.var.auth`, precisely so that a
222
+ * control-plane credential cannot satisfy an ordinary `requireAuth()` anywhere in the tree. An auth gate here
223
+ * would deny every legitimate management call and no credential could fix it. With the seam not composed at
224
+ * all the gate raises `controlplane/not_connected`, so the routes are shut rather than open. Both writes are
225
+ * audited to the management client, because the trail is the only record of who decided an account should have
226
+ * something nobody paid for.
227
+ *
228
+ * `POST /payments/purchases` and `/restore` serve every rail with no branch of their own: the rail a caller names
229
+ * selects a verifier through `resolveRailProvider`, and everything past that point is the normalized event. A
230
+ * rail arriving is one entry in `rails/providers.ts`, not a case in a handler.
231
+ *
232
+ * **No `public` routes, ever.** Every caller is either an authenticated user acting on their own purchases or a
233
+ * machine proving authenticity. Turnstile has nothing to gate here.
234
+ *
235
+ * **`signed-webhook` is one strategy over three unrelated mechanisms** — Apple signs a JWS against its
236
+ * certificate chain, Google's Pub/Sub push carries an OIDC token verified with an audience check, Stripe sends
237
+ * an HMAC. Each has its own verifier, which is why these are literal routes rather than one `:rail`: a single
238
+ * route line could not carry three of them, and the rail a caller *claims* is not something to route on.
239
+ *
240
+ * **`/checkout` and `/portal` are Stripe's alone, and they are not a fourth strategy.** Apple and Google
241
+ * purchases happen inside a store SDK before the server hears of them, so there is no session for Pithy to
242
+ * create; Stripe's are ones Pithy initiates. The routes narrow to {@link CheckoutRail} rather than branching on
243
+ * the rail name, so a rail is never asked for something it does not do — and with Stripe off they raise
244
+ * `payments/rail_not_configured`, which reads as "that payment method is not available here" rather than as a
245
+ * broken endpoint.
246
+ *
247
+ * **The five subscription routes are one surface with one rule: nothing a caller sends names anything.**
248
+ * The subscription comes from that caller's own purchase rows, the rail from the row it found, and the
249
+ * store's price from the catalog by logical product id. A body-named subscription moves somebody else's — a
250
+ * claim this capability could not check, because it holds no members table by design — and a body-named
251
+ * price moves a customer onto a plan this project does not sell, at a price it did not set. Neither is
252
+ * refused by a check; both are unreachable because there is nowhere to write them.
253
+ *
254
+ * **The read shipped before the four verbs, deliberately and in its own step.** `GET ${base}/subscription`
255
+ * is the answer to the #247 paragraph below, applied before the mistake could repeat: a capability that can
256
+ * cancel a subscription and cannot report the cancellation ships the half that creates the support ticket,
257
+ * and Paddle leaves `status` at `active` with a blank next billing date when one is scheduled.
258
+ *
259
+ * **None of the four writes touches the projection.** The webhook owns the purchase row; a route that wrote
260
+ * it too would be a second producer of one row, and the two disagree the first time a webhook is late. So
261
+ * each verb answers the *store's* own report of where the subscription now stands, rather than a prediction
262
+ * of it — a prediction is how a customer sees a plan they are not on.
263
+ *
264
+ * **Validators sit after the guards on every route line.** A validator ahead of a guard turns a 401 into a 400
265
+ * and tells an unauthenticated caller which of its requests were well-formed. Config-backed resolution stays in
266
+ * the handler and raises its own domain 404 — a schema constrains a string, it never replaces a lookup.
267
+ *
268
+ * ## What the handlers never trust
269
+ *
270
+ * The **product** comes from the verified payload's SKU, never from the request. A client-supplied product id
271
+ * would let a caller present a cheap receipt as an expensive product. The **owner** comes from the subject
272
+ * seam on the authed routes and from the provider-account map on the webhook, never from a body. The
273
+ * **environment** comes from this deployment's own `ENVIRONMENT` var, never from the payload: inferring it
274
+ * from what the store said is exactly what lets a sandbox purchase grant a real entitlement.
275
+ *
276
+ * ## Who a request is about: one question, one implementation
277
+ *
278
+ * Every route here that touches a holder asks the subject seam, and asks it in one of exactly two ways.
279
+ * A **read** calls {@link resolvePaymentsSubject}: nobody resolved is an empty or denied read, never a 500 and
280
+ * never a guess, because a gate that resolved *something* when it could not tell who was asking would hand one
281
+ * holder's plan to whoever asked next. A **write** calls {@link requirePaymentsSubject}, which refuses with
282
+ * `payments/subject_unresolved` (403): a purchase, a restore, a checkout and a portal session each need a row
283
+ * key, and a guessed key attributes real money to the wrong holder.
284
+ *
285
+ * **Nothing here falls back to `c.var.auth.userId`, in either mode.** Under `billingSubject: "user"` the
286
+ * fallback would be identical to the seam's own default and would therefore look harmless; under
287
+ * `"organization"` it would silently key a company's subscription to whichever employee happened to be signed
288
+ * in. One expression, in `entitlement/subjectSeam.ts`, is what keeps the gate and the routes answering the
289
+ * same question — a second copy is a second policy, and the two disagree the day one of them is edited.
290
+ *
291
+ * **No player-facing request names a subject, and no player-facing response publishes one.** A body or a query
292
+ * that could name a holder is a body that could name somebody else's, and this capability has nothing to check
293
+ * that claim against: it has no members table, by design. The control-plane surfaces are the deliberate
294
+ * exception, and the exception is the feature — support acting on another holder's account is what they are
295
+ * for, which is why each sits behind a default-denied scoped credential and is audited. `schemas.ts` states
296
+ * the same rule from the request side, and `routeContract.test.ts` asserts it as a property over every schema
297
+ * and every response this capability declares, rather than route by route.
298
+ */
299
+ export interface PaymentsRoutesOptions {
300
+ /** The resolved catalog. */
301
+ config: PaymentsConfig;
302
+ /** Where the routes mount. Defaults to the config's own `basePath`. */
303
+ basePath?: string;
304
+ /** The clock. Injected so verification windows and stored timestamps are deterministic in tests. */
305
+ now?: () => Date;
306
+ /**
307
+ * Certificate roots to accept *in addition to* each rail's pinned ones. Absent in production, and additive
308
+ * only — no caller can narrow the trust set. See {@link RailTrustOptions} for the two callers that need it.
309
+ */
310
+ trust?: RailTrustOptions;
311
+ /**
312
+ * Which subject a caller is acting for: the mode, and the adopter's resolver. See
313
+ * `entitlement/subjectSeam.ts`.
314
+ *
315
+ * **Handed in whole rather than assembled here**, because `payments()` builds exactly one of these and
316
+ * gives the same object to the entitlement middleware and to these routes. Two constructions are two
317
+ * policies, and the two disagree the day one of them is edited.
318
+ *
319
+ * **The resolver rides on this rather than on `PaymentsConfig`, and that is a hard constraint rather than a
320
+ * preference.** `provision/resolvePaymentsConfig.ts` serializes the resolved config into the
321
+ * `PAYMENTS_CONFIG` var and `workflows/worker.ts` parses it back, so a function cannot survive the round
322
+ * trip. The serializable half of the decision — `billingSubject`, which mode this project bills in — is
323
+ * config; the callable half is a composition-time option.
324
+ *
325
+ * The consequence is worth stating out loud: **a Workflow runs with no adopter resolver.** The reconcile and
326
+ * Paddle sweep hosts parse the config and never see this, so anything in them needing a holder reads it from
327
+ * the stored row that already carries the pair, and never from the seam.
328
+ *
329
+ * Omitted means the config's own mode with no adopter resolver — the authenticated caller under
330
+ * `billingSubject: "user"`, and nobody at all under `"organization"`. The default is the *same* derivation
331
+ * `payments()` makes for a project that supplies none, never a different one, and `payments()` refuses to
332
+ * compose organization billing without a resolver rather than reaching this state.
333
+ */
334
+ subject?: PaymentsSubjectSeam;
335
+ }
336
+
337
+ /** The app `DB` binding, or a wiring failure. Payments cannot resolve anything without it. */
338
+ function database(c: Context<PithyHonoEnv>): D1Database {
339
+ const binding = (c.env as Record<string, unknown>).DB as D1Database | undefined;
340
+ if (!binding) {
341
+ throw new InternalError({
342
+ message: "Payments is not configured.",
343
+ action: "Bind a D1 database named DB in wrangler.jsonc.",
344
+ detail: "Payments requires a `DB` D1 binding; none was present on env.",
345
+ });
346
+ }
347
+ return binding;
348
+ }
349
+
350
+ /**
351
+ * This deployment's store environment, from the `ENVIRONMENT` var.
352
+ *
353
+ * Only a Worker deployed to `prod` is production. Every other value — `staging`, `dev`, a var nobody
354
+ * set — is sandbox, because the failure directions are not symmetric: treating production as sandbox loses a
355
+ * purchase that reconciliation repairs, while treating sandbox as production hands out real entitlements for
356
+ * test transactions. That is the single most common in-app-purchase security defect there is, and the default
357
+ * is what decides it.
358
+ */
359
+ function deploymentEnvironment(c: Context<PithyHonoEnv>): PurchaseEnvironment {
360
+ return (c.env as Record<string, unknown>).ENVIRONMENT === "prod" ? "production" : "sandbox";
361
+ }
362
+
363
+ /**
364
+ * This deployment's `ENVIRONMENT` var, verbatim, or undefined.
365
+ *
366
+ * Deliberately *not* {@link deploymentEnvironment}'s two-valued answer. A rail sharing one store across
367
+ * every environment — Lemon Squeezy, whose test mode is a flag on an object rather than a separate store —
368
+ * fences its webhooks on this, and `dev` and `staging` both evaluate to `sandbox`, so fencing on that would
369
+ * separate neither. The two are different questions: one asks whether real money moved, this asks which
370
+ * deployment is speaking.
371
+ */
372
+ function deploymentName(c: Context<PithyHonoEnv>): string | undefined {
373
+ const value = (c.env as Record<string, unknown>).ENVIRONMENT;
374
+ return typeof value === "string" && value !== "" ? value : undefined;
375
+ }
376
+
377
+ /**
378
+ * The locale a figure in this response is rendered for.
379
+ *
380
+ * **The translator seam, not the request.** `c.var.t` is always present — core seeds a translator over the
381
+ * baked English when nothing composes `@pithy-sh/i18n`, and the capability replaces it with one negotiated
382
+ * from the URL, the reader's account, a cookie and `Accept-Language` — so this is the same locale every
383
+ * other string in the same response is written in. `@pithy-sh/email` sets the precedent, building a
384
+ * per-recipient translator from a stored locale rather than a second rule of its own.
385
+ *
386
+ * **Not a request field and not a header read here.** A locale a caller can name in a body is a locale a
387
+ * caller sets, and these are the routes whose rule is that nothing a caller sends names anything.
388
+ * `Accept-Language` is already one of the four inputs the negotiation weighs, and reading it directly would
389
+ * put the money in one language and the sentence around it in another.
390
+ *
391
+ * `formattingLocale` rather than `catalogLocale`: it is the tag `Intl` is meant to be handed, region and
392
+ * all, so an `es-AR` reader gets `es-AR` grouping whether or not anybody wrote Spanish copy. The seam
393
+ * documents that split at the field.
394
+ */
395
+ function readerLocale(c: Context<PithyHonoEnv>): string {
396
+ return c.var.t.formattingLocale;
397
+ }
398
+
399
+ /**
400
+ * The subject filter on a management listing: the pair, or nothing at all.
401
+ *
402
+ * `AdminPurchasesQuery` and its siblings carry the two halves as two optional query fields with a both-or-
403
+ * neither refinement, because a query string is flat. This is the one place that pair is reassembled, and the
404
+ * `&&` is what makes an id with no kind resolve to *no filter* rather than to half of one. The refinement has
405
+ * already refused that request with a 400 — a listing narrowed on `subject_id` alone would hand a client
406
+ * asking about a person the rows of an organization that happens to share the id — so this is belt and braces
407
+ * on the one narrowing where being wrong discloses somebody else's commerce.
408
+ */
409
+ function subjectFilter(query: { subjectType?: PaymentsSubjectType; subjectId?: string }): PaymentsSubject | undefined {
410
+ return query.subjectType !== undefined && query.subjectId !== undefined
411
+ ? { subjectType: query.subjectType, subjectId: query.subjectId }
412
+ : undefined;
413
+ }
414
+
415
+ /**
416
+ * The verified management client behind a control-plane call. `requireControlPlane()` has run on every route
417
+ * that calls this, so a null context is a wiring mistake rather than an unverified request — hence
418
+ * `InternalError`, not a 401.
419
+ *
420
+ * Deliberately not the subject seam. A management client has no user row and no session, so there is nothing
421
+ * here to read off `c.var.auth` and nothing for a resolver to answer; keeping the two apart is what stops a
422
+ * control-plane caller from being recorded as, or mistaken for, a user of this app.
423
+ */
424
+ function controlPlaneCaller(c: Context<PithyHonoEnv>): ControlPlaneContext {
425
+ const caller = c.var.controlPlane;
426
+ if (!caller) {
427
+ throw new InternalError({
428
+ detail: "requireControlPlane() must run before a payments handler reads the management caller.",
429
+ });
430
+ }
431
+ return caller;
432
+ }
433
+
434
+ /** Every rail's credentials, read through the one reader at the point of need. */
435
+ async function credentials(c: Context<PithyHonoEnv>) {
436
+ const secrets = await sharedSecretsStore(c.env as unknown as SecretsStoreEnv, paymentsSecretsRegistry);
437
+ return secrets.get(PAYMENTS_PROVIDER_SECRET);
438
+ }
439
+
440
+ /**
441
+ * The catalog product a caller named, or a 404. The catalog is config rather than rows, so an id nothing maps
442
+ * is a missing resource — and the lookup stays in the handler, where it can raise its own domain error, rather
443
+ * than being folded into a request schema built from the configured key set.
444
+ */
445
+ function product(config: PaymentsConfig, id: string): PaymentsCatalogEntry {
446
+ const found = resolveProduct(config, id);
447
+ if (found === undefined) throw new PaymentsProductNotFoundError({ detail: `No product "${id}" is configured.` });
448
+ return found;
449
+ }
450
+
451
+ /**
452
+ * The statuses a subscription can still be *acted on* in — the filter {@link changeableSubscription} applies
453
+ * before it counts anything.
454
+ *
455
+ * The set is "there is something left to do to this", not "this grants access", and the two differ at both
456
+ * ends. It is stated positively rather than as a list of exclusions so that a status added later is inert
457
+ * here until somebody decides it belongs, which is the safe direction: an unknown status silently joining an
458
+ * actionable set is how a dead row starts competing with a live subscription.
459
+ *
460
+ * - **`active`** — the ordinary case.
461
+ * - **`in_grace`** — a renewal that failed inside the retry window. The subscription exists at the store and
462
+ * a customer with a bounced card is precisely the customer who wants to downgrade or cancel.
463
+ * - **`on_hold`** — the same, past the retry window. No rail implementing {@link SubscriptionRail} writes it
464
+ * today; it is here because a subscription nobody has ended is still one somebody may want to end.
465
+ * - **`canceled`** — auto-renew off with the paid period still running. It **grants access**
466
+ * ({@link ACCESS_GRANTING_STATUSES}), so a holder in it still has what they paid for, and it is the only
467
+ * status `keepSubscription` can start from on the rails that write it while the schedule is pending.
468
+ * - **`paused`** — suspended, not ended. Canceling a paused subscription is legitimate; a store that will
469
+ * not move one answers `payments/subscription_change_refused` with its own reason, which is a better
470
+ * sentence than this query pretending the subscription is not there.
471
+ *
472
+ * **The four that are absent are endings**, and every one of them is an ending the *store* declared:
473
+ * `expired` (a period we were paid for, over), `never_paid` (terminated before money moved), `refunded` and
474
+ * `revoked` (taken back). None of them can be changed, canceled or un-canceled, and counting one is how a
475
+ * holder with a history of subscriptions becomes ambiguous forever.
476
+ *
477
+ * **Refusing on state is not this set's job.** A store that will not move a particular subscription is the
478
+ * rail's 409, carrying the store's own reason; this filter only decides whether there is a subscription to
479
+ * ask about at all. Widening the difference between the two is how a customer is told they have no
480
+ * subscription when what is true is that this one cannot be upgraded today.
481
+ */
482
+ const SUBSCRIPTION_ACTIONABLE_STATUSES: readonly PurchaseStatus[] = [
483
+ "active",
484
+ "in_grace",
485
+ "on_hold",
486
+ "canceled",
487
+ "paused",
488
+ ];
489
+
490
+ /**
491
+ * Which subscription a change verb may act on — none, exactly one, or more than one.
492
+ *
493
+ * **A discriminated answer rather than `PaymentsPurchase | undefined`**, because the three cases are three
494
+ * different sentences to a caller: nothing to change, here it is, and *this server will not choose for you*.
495
+ * Collapsing the last into either of the others is the defect this whole helper exists to make impossible —
496
+ * silently picking one of two live subscriptions moves a plan the customer did not name.
497
+ */
498
+ export type SubscriptionTarget =
499
+ | {
500
+ /** The caller holds no subscription this server can act on. */
501
+ found: "none";
502
+ }
503
+ | {
504
+ /** Exactly one, and it is theirs. */
505
+ found: "one";
506
+ /** The row a {@link SubscriptionRail} is handed. The whole row — see `SubscriptionChangeInput.purchase`. */
507
+ purchase: PaymentsPurchase;
508
+ }
509
+ | {
510
+ /** More than one, so no verb can proceed without being told which. */
511
+ found: "many";
512
+ /** Every candidate, newest event first — so a refusal can say how many and on which rails. */
513
+ purchases: readonly PaymentsPurchase[];
514
+ };
515
+
516
+ /**
517
+ * The one subscription this subject may change, cancel or keep — resolved from their own rows, never from a
518
+ * request.
519
+ *
520
+ * ## Why neither existing answer was reusable
521
+ *
522
+ * `ownSubscriptionIds` returns *every* subscription row a subject holds, at any status, and that is right for
523
+ * what it feeds: a portal request hands the whole list to Paddle, which sorts out which are live. A write
524
+ * verb cannot hand a list to anything — it needs one — and expired, refunded and superseded rows are all in
525
+ * that list.
526
+ *
527
+ * `GET {base}/pricing` takes `rows.find(r => r.role === "state") ?? rows[0]` from an unfiltered, time-ordered
528
+ * read. Two things are wrong with that here. The order is not a filter, so the newest row of a holder who
529
+ * canceled a year ago is still a year-old cancellation; and `?? rows[0]` can land on a money row, whose
530
+ * `providerTransactionId` is Paddle's `txn_…` rather than a `sub_…`. Pricing survives it because
531
+ * `readPricing` answers `undefined` for a row it cannot address. A cancel verb would not: it would ask a
532
+ * store to end a subscription by a transaction id, and the useful outcome is the one where that fails.
533
+ *
534
+ * ## The three rules
535
+ *
536
+ * **One: the head row, never a period of it.** A subscription's money rows carry the family in
537
+ * `originalTransactionId` and their own invoice or transaction id in `providerTransactionId`; the row that
538
+ * *is* the subscription carries the same value in both — Paddle's and Lemon Squeezy's state builders say so
539
+ * at the field, and it is what `subscriptionIdOf` relies on when it demands a `sub_` prefix. So the predicate
540
+ * is `originalTransactionId = providerTransactionId`, compared in SQL, which also excludes a one-off (whose
541
+ * family is null, and `null = anything` is not true) without a second clause. It is structural rather than a
542
+ * prefix test, so it needs no edit when a rail is added — and a rail whose rows never satisfy it resolves to
543
+ * `none`, which is an honest "there is nothing here this server can change" rather than a wrong row handed
544
+ * to a store.
545
+ *
546
+ * `UNIQUE (rail, providerTransactionId)` then does the rest of the work: a family has at most one head row,
547
+ * so surviving rows are subscriptions one-for-one and there is no de-duplication to get wrong.
548
+ *
549
+ * **Two: a cancellation whose period has run out is over.** `canceled` is in the actionable set because the
550
+ * holder still has what they paid for — but only until `expiresAt`, and the `expired` webhook that would
551
+ * overwrite the row can be late or dropped. Without this, every resubscriber is permanently ambiguous:
552
+ * their old, dead cancellation competes with the subscription they are currently paying for, and they are
553
+ * locked out of managing it. The check is deliberately **only** on `canceled`, because that is the one
554
+ * status whose `expiresAt` is a final date the store has already committed to. On every other actionable
555
+ * status the date is a rolling period end that each renewal moves, and dropping a row on it would tell a
556
+ * paying customer they have no subscription for as long as one renewal webhook is late.
557
+ *
558
+ * **Three: both halves of the subject, always.** `user:acme` and `organization:acme` are two holders and
559
+ * nothing in the kit keeps the id namespaces disjoint, so an id-only filter would let either act on the
560
+ * other's subscription. The pair arrives resolved from the seam, under whichever mode the project bills in.
561
+ *
562
+ * @param d1 The purchases database.
563
+ * @param subject The holder, as the subject seam resolved them — both halves.
564
+ * @param now The clock, for rule two. Passed rather than read, so a test can stand either side of an expiry.
565
+ */
566
+ export async function changeableSubscription(
567
+ d1: D1Database,
568
+ subject: PaymentsSubject,
569
+ now: Date,
570
+ ): Promise<SubscriptionTarget> {
571
+ const rows = await paymentsDatabase(d1)
572
+ .selectFrom(PAYMENTS_PURCHASES_TABLE)
573
+ // Every column: a rail is handed the whole row, because what identifies a subscription at its store
574
+ // differs per rail and some of it survives only in the payload. See `SubscriptionChangeInput.purchase`.
575
+ .selectAll()
576
+ .where("subjectType", "=", subject.subjectType)
577
+ .where("subjectId", "=", subject.subjectId)
578
+ .where("type", "=", "subscription")
579
+ .where("status", "in", SUBSCRIPTION_ACTIONABLE_STATUSES)
580
+ // Rule one, in SQL. A null family is not equal to anything, so one-off purchases fall out here too.
581
+ .whereRef("originalTransactionId", "=", "providerTransactionId")
582
+ // Newest first, and `id` to break a tie, so a `many` answer is stable rather than whatever D1 returns.
583
+ .orderBy("providerEventAt", "desc")
584
+ .orderBy("id", "desc")
585
+ .execute();
586
+
587
+ // Parsed, not read raw. A D1 row is a boundary like any other, and `expiresAt` is an epoch integer until
588
+ // the codec makes it a date — comparing the number against a `Date` is the kind of quiet nonsense this
589
+ // package validates at every edge to prevent.
590
+ const candidates = rows
591
+ .map((row) => PaymentsPurchase.parse(row))
592
+ .filter((purchase) => !cancellationRanOut(purchase, now));
593
+
594
+ const [only, ...rest] = candidates;
595
+ if (only === undefined) return { found: "none" };
596
+ if (rest.length > 0) return { found: "many", purchases: candidates };
597
+ return { found: "one", purchase: only };
598
+ }
599
+
600
+ /**
601
+ * Every payment made on one subscription, as this holder's own rows record them — what a refund acts on.
602
+ *
603
+ * ## Why a refund needs a different query from every other verb
604
+ *
605
+ * The other four act on the subscription, and {@link changeableSubscription} resolves *the* row that is one.
606
+ * A refund cannot: **no store refunds a subscription.** Every refund attaches to a transaction, and a
607
+ * subscription is a family of them — so this returns the money rows, which is a set, and the caller raises
608
+ * one refund each.
609
+ *
610
+ * The set is ordinary rather than exotic. A customer who joined on Solo at 6.00, upgraded to Team on day 10
611
+ * for a 65.82 proration and cancels on day 13 has paid twice. An adopter whose policy gives them their money
612
+ * back owes both, and a query returning one would quietly keep 65.82 of somebody's money.
613
+ *
614
+ * ## The three rules
615
+ *
616
+ * **One: money rows only, stated twice.** `role = 'charge'` is the discriminator Lemon Squeezy forced into
617
+ * existence, and the second clause — `providerTransactionId <> originalTransactionId` — is the structural
618
+ * form of the same statement for the rails that write `charge` for everything: the row that *is* the
619
+ * subscription carries its family key in both columns. Either alone is right on some rail and wrong on
620
+ * another, and what they exclude is the head row, whose provider id is a `sub_…`. Sending that to a store is
621
+ * asking it to refund a subscription, which is not a thing it can do.
622
+ *
623
+ * **Two: both halves of the subject, always.** `user:acme` and `organization:acme` are two holders and
624
+ * nothing keeps the id namespaces disjoint, so an id-only filter would refund one holder's payments on the
625
+ * other's request. The same rule every query here follows, and here it is the difference between a refund
626
+ * and a theft.
627
+ *
628
+ * **Three: the family, and only this rail.** A holder may have paid on two stores; the rail comes from the
629
+ * subscription row that was resolved, never from a request.
630
+ *
631
+ * ## What is deliberately absent
632
+ *
633
+ * **A window, and a status filter.** How long a customer has to ask for their money back is the *adopter's*
634
+ * policy — the kit must not hard-code fourteen days, or any number — so every payment on the subscription is
635
+ * returned and the adopter's screen decides which button exists. And nothing here filters on a status of
636
+ * *ours*: whether a payment can still be refunded is the **store's** answer, read live from the transaction's
637
+ * own adjustments, and a projected row is a lagging copy of it. Filtering here would drop a payment on a
638
+ * stale row and call the result a complete refund.
639
+ *
640
+ * Ordered oldest first and tie-broken by id, so the report a caller gets back is in the order the money was
641
+ * taken and is reproducible across two identical requests.
642
+ */
643
+ export async function refundablePayments(
644
+ d1: D1Database,
645
+ subject: PaymentsSubject,
646
+ subscription: PaymentsPurchase,
647
+ ): Promise<readonly PaymentsPurchase[]> {
648
+ const family = subscription.originalTransactionId ?? subscription.providerTransactionId;
649
+ const rows = await paymentsDatabase(d1)
650
+ .selectFrom(PAYMENTS_PURCHASES_TABLE)
651
+ // Every column, for `SubscriptionChangeInput.purchase`'s reason: a rail is handed the whole row.
652
+ .selectAll()
653
+ .where("subjectType", "=", subject.subjectType)
654
+ .where("subjectId", "=", subject.subjectId)
655
+ .where("rail", "=", subscription.rail)
656
+ .where("type", "=", "subscription")
657
+ .where("originalTransactionId", "=", family)
658
+ .where("role", "=", "charge")
659
+ // The head row, structurally. A `sub_…` sent to a refund endpoint is a request no store can honor.
660
+ .whereRef("providerTransactionId", "!=", "originalTransactionId")
661
+ .orderBy("providerEventAt", "asc")
662
+ .orderBy("id", "asc")
663
+ .execute();
664
+ // Parsed, not read raw. A D1 row is a boundary like any other.
665
+ return rows.map((row) => PaymentsPurchase.parse(row));
666
+ }
667
+
668
+ /** Rule two: a cancellation the paid period has already outrun. See {@link changeableSubscription}. */
669
+ function cancellationRanOut(purchase: PaymentsPurchase, now: Date): boolean {
670
+ if (purchase.status !== "canceled" || purchase.expiresAt === null) return false;
671
+ return purchase.expiresAt.getTime() <= now.getTime();
672
+ }
673
+
674
+ /**
675
+ * More than one subscription, on every one of the five routes — the read included.
676
+ *
677
+ * **A refusal rather than a choice, and it is the same refusal on the read as on the writes.** The read's
678
+ * envelope holds one subscription or none, so `many` has no honest encoding in it: null says the caller
679
+ * holds nothing, which is false, and picking one renders somebody's other plan beside a cancel button
680
+ * that would end a third thing. `changeableSubscription` exists to make that silent pick impossible, and
681
+ * this is where its third answer becomes a sentence.
682
+ *
683
+ * 409 rather than 400: the request is well-formed and the conflict is with the state of the account, which
684
+ * is what tells a client to re-read rather than to re-word. `detail` counts them and names the rails —
685
+ * throw-site context, stripped by the codec — and the message names nothing about anybody's billing.
686
+ */
687
+ function tooManySubscriptions(purchases: readonly PaymentsPurchase[]): PithyError {
688
+ return new PaymentsSubscriptionChangeRefusedError({
689
+ message: "There is more than one subscription on this account.",
690
+ action: "Open the billing portal to manage them there. This route acts on one subscription and will not choose.",
691
+ detail: `${purchases.length} actionable subscriptions resolved for this holder, on ${[
692
+ ...new Set(purchases.map((purchase) => purchase.rail)),
693
+ ].join(", ")}. No verb may pick one.`,
694
+ });
695
+ }
696
+
697
+ /**
698
+ * The one subscription a verb acts on, or the refusal that says why there is not one.
699
+ *
700
+ * The `none` case is a 404 rather than a payments-domain refusal, for the reason `POST {base}/portal`
701
+ * gives for the same shape: the rail is configured and working, and the resource simply does not exist.
702
+ * `GET {base}/subscription` does **not** call this — a read has an honest empty answer and `null` is it.
703
+ */
704
+ function requireOneSubscription(target: SubscriptionTarget): PaymentsPurchase {
705
+ if (target.found === "many") throw tooManySubscriptions(target.purchases);
706
+ if (target.found === "none") {
707
+ throw new NotFoundError({
708
+ message: "No subscription to manage.",
709
+ action: "Buy a subscription first, then change or cancel it here.",
710
+ detail: "The caller holds no subscription in a status this server can act on.",
711
+ });
712
+ }
713
+ return target.purchase;
714
+ }
715
+
716
+ /**
717
+ * One subscription as its own holder reads it — the standing the store reported, plus the two facts a
718
+ * screen cannot derive.
719
+ *
720
+ * **`productId` is a parameter rather than read off the purchase row, and that is not tidiness.** After a
721
+ * plan change the row still names the old plan: the webhook owns that row and has not arrived yet
722
+ * (invariant 2 — no route writes the projection). A view built from the row would answer the change route
723
+ * with the plan the customer just left, on the screen that renders what it wrote. So the read passes the
724
+ * row's product and `change` passes the one the store just confirmed.
725
+ *
726
+ * **`nextEvent` is derived here rather than by the client**, because the precedence is not obvious and
727
+ * every client would have to rediscover it: a scheduled change wins over the next billing date, since
728
+ * Paddle blanks that date the moment a cancellation is scheduled. `nextSubscriptionEvent` is the one
729
+ * implementation, in `data/subscription.ts`; a client cannot call it, because it takes `Date`s.
730
+ */
731
+ function subscriptionView(productId: string, standing: SubscriptionStanding): PaymentsSubscriptionView {
732
+ const event = nextSubscriptionEvent(standing);
733
+ return {
734
+ productId,
735
+ status: standing.status,
736
+ currency: standing.currency,
737
+ currentPeriodEndsAt: standing.currentPeriodEndsAt?.toISOString() ?? null,
738
+ nextBilledAt: standing.nextBilledAt?.toISOString() ?? null,
739
+ scheduledChange:
740
+ standing.scheduledChange === null
741
+ ? null
742
+ : {
743
+ action: standing.scheduledChange.action,
744
+ effectiveAt: standing.scheduledChange.effectiveAt.toISOString(),
745
+ resumesAt: standing.scheduledChange.resumesAt?.toISOString() ?? null,
746
+ },
747
+ nextEvent:
748
+ event.kind === "unknown" ? { kind: "unknown", at: null } : { kind: event.kind, at: event.at.toISOString() },
749
+ };
750
+ }
751
+
752
+ /**
753
+ * The store's own price for a catalog product, on the rail the subscription actually lives at.
754
+ *
755
+ * **This is the whole of "no body-named price".** A caller names the logical product — the key in
756
+ * `products` — and this resolves it, server-side, on a rail read from the caller's own purchase row. A
757
+ * `pri_…` in a request body would move a customer onto a plan this project does not sell, at a price it did
758
+ * not set, and nothing here could refuse it: the catalog is the only statement of what is for sale.
759
+ *
760
+ * A 404 on the **product** when the rail has no SKU for it, never on the rail, for `/checkout`'s reason:
761
+ * the rail is available and this product simply is not one of the things it sells. Naming which of the two
762
+ * it was would describe the deployment to a stranger.
763
+ */
764
+ function subscriptionPrice(entry: PaymentsCatalogEntry, rail: PaymentsRail): string {
765
+ const sku = providerProductId(entry.product, rail);
766
+ if (sku === undefined) {
767
+ throw new PaymentsProductNotFoundError({
768
+ detail: `Product "${entry.id}" declares no ${rail} price, so a subscription at that store cannot be moved onto it.`,
769
+ });
770
+ }
771
+ return sku;
772
+ }
773
+
774
+ /**
775
+ * Whether a plan change is a change at all — the route's own reading of the no-op, and the only thing that
776
+ * decides whether an audit row is written.
777
+ *
778
+ * **The rail answers the no-op and reports nothing about it.** `changePlan` returns the current standing
779
+ * either way, with no flag, because a standing is a subscription's state and "did anything happen" is not
780
+ * one of its fields. So the route cannot learn it from the answer and has to decide from what it holds.
781
+ *
782
+ * What it holds is the purchase row, which names the catalog product the store last told this deployment
783
+ * the subscription was for. Equal to the product asked for means the caller is asking for the plan their
784
+ * own subscription is already on, which is exactly the retry the no-op exists to absorb.
785
+ *
786
+ * **The gap, stated rather than left to be discovered.** The row lags the store by one webhook. During that
787
+ * window a caller repeating a change they already made can find the row still naming the old plan, and this
788
+ * answers `true` for a call the rail then no-ops — one audit row for a change that did not happen on that
789
+ * request. It cannot go the other way: the row only ever names a plan the store has already reported, so a
790
+ * genuine move is never recorded as a no-op and the trail never goes silent about a real change. Of the two
791
+ * directions to be wrong in, a duplicate row that a reader can date against the store's own history is
792
+ * recoverable, and a missing one is not.
793
+ */
794
+ function changedPlan(purchase: PaymentsPurchase, productId: string): boolean {
795
+ return purchase.productId !== productId;
796
+ }
797
+
798
+ /**
799
+ * Whether a verb changed anything — the no-op test for `cancel` and `keep`, read off the store's own
800
+ * before and after rather than off a rule copied out of the rail.
801
+ *
802
+ * **The rail reports no such flag, and the fact this needs is not in the purchase row.** A cancellation
803
+ * scheduled for the end of the period is exactly the thing a projected row cannot carry — Paddle leaves
804
+ * `status` at `active` and blanks `next_billed_at` — so the route reads the standing before it writes.
805
+ * That costs one `GET` beside the one the rail makes, and it buys the only thing that keeps the trail
806
+ * honest: an audit row is written when something actually happened and not when a caller retried.
807
+ *
808
+ * **A comparison of outcomes, not a second copy of the rail's rules.** Restating "already scheduled to
809
+ * cancel" and "already ended" here would be two statements of one policy, and they disagree the day one is
810
+ * edited. This asks a narrower question the rail cannot get wrong: after the call, does the store describe
811
+ * the subscription differently than it did before? A no-op is defined by its answer.
812
+ *
813
+ * The views are compared rather than the standings, because a view is what a screen renders and both sides
814
+ * are built by the same function in the same field order. `before === undefined` counts as changed, and is
815
+ * unreachable in practice: a rail that cannot address the purchase refuses the write rather than returning
816
+ * from it.
817
+ */
818
+ function standingMoved(before: SubscriptionStanding | undefined, after: SubscriptionStanding): boolean {
819
+ if (before === undefined) return true;
820
+ // The product id is irrelevant to this comparison and identical on both sides — no verb here changes it.
821
+ return JSON.stringify(subscriptionView("", before)) !== JSON.stringify(subscriptionView("", after));
822
+ }
823
+
824
+ /**
825
+ * A quote on the wire: the store's own three figures, with the dates rendered.
826
+ *
827
+ * Nothing is computed, summed or netted — `data/subscription.ts` holds the argument, and the short form is
828
+ * that a second answer to "what will this cost" is a second number for a customer to hold against their
829
+ * statement. The settlements cross verbatim because they already are what a screen renders: a direction
830
+ * and a magnitude, with the direction as the discriminant so the amount cannot be reached without it.
831
+ *
832
+ * **The money is already rendered when it gets here, and that is why this function does not render it.**
833
+ * `QuotedMoney` requires the string, so the figure exists from the moment the quote does — an adopter
834
+ * calling the rail directly gets the same sentence this response carries, rather than a formatting rule
835
+ * that lives only on the HTTP path. What this layer supplies is the *locale*, resolved from the translator
836
+ * seam at the route and handed down through `RailRequestContext`. See `data/renderMoney.ts`.
837
+ */
838
+ function quoteView(quote: SubscriptionChangeQuote): PaymentsSubscriptionQuote {
839
+ return {
840
+ settlesToday: quote.settlesToday,
841
+ nextInvoice:
842
+ quote.nextInvoice === null
843
+ ? null
844
+ : { settlement: quote.nextInvoice.settlement, at: quote.nextInvoice.at.toISOString() },
845
+ recurring:
846
+ quote.recurring === null
847
+ ? null
848
+ : {
849
+ amount: quote.recurring.amount,
850
+ startsAt: quote.recurring.startsAt.toISOString(),
851
+ madeUpOf: quote.recurring.madeUpOf,
852
+ },
853
+ };
854
+ }
855
+
856
+ /**
857
+ * A refund report on the wire: what became of each payment, and nothing that identifies one.
858
+ *
859
+ * **Every id is dropped and no amount is added.** The store's adjustment id and our own purchase id both
860
+ * stay server-side — a store identifier never crosses a bearer response, and an id published here is a field
861
+ * a request grows next — and the store's refusal sentence is throw-site context that names transactions and
862
+ * account facts. What a screen renders is how many payments were asked about and where each stands; the ids
863
+ * and the reasons are in the audit trail, where an operator is the reader.
864
+ *
865
+ * The order is the server's resolution order and the length is the input's, so a partial arrives as a
866
+ * countable list rather than as a success with something missing.
867
+ */
868
+ function refundView(refund: RefundRequest): PaymentsRefundRequest {
869
+ return {
870
+ outcomes: refund.outcomes.map((outcome) =>
871
+ outcome.outcome === "failed" ? { outcome: "failed" } : { outcome: outcome.outcome, status: outcome.status },
872
+ ),
873
+ };
874
+ }
875
+
876
+ /**
877
+ * Record a management read.
878
+ *
879
+ * **Every read, not only the writes.** A credential quietly paging every account's purchases leaves no
880
+ * other trace anywhere, and the customer's ability to reconstruct what a dashboard did with the access
881
+ * they granted is the whole promise of the control plane. Counts, filters and identifiers only: no row, no
882
+ * amount, no provider identifier. Copying the purchase log into the audit trail would make a second
883
+ * purchase log with weaker access rules than the first.
884
+ */
885
+ async function recordRead(
886
+ c: Context<PithyHonoEnv>,
887
+ action: PaymentsAuditAction,
888
+ resourceId: string | null,
889
+ metadata: Record<string, unknown>,
890
+ ): Promise<void> {
891
+ const who = controlPlaneCaller(c);
892
+ await c.var.emit({
893
+ action,
894
+ outcome: "success",
895
+ // A management client, not a user of this app — and the actor is the token's `sub`, so the trail names
896
+ // which person at the dashboard looked, not merely "the dashboard".
897
+ actorType: "control-plane",
898
+ actorId: who.subject,
899
+ resourceType: "payments_read",
900
+ resourceId,
901
+ requestId: c.req.header("cf-ray"),
902
+ ip: c.req.header("cf-connecting-ip"),
903
+ userAgent: c.req.header("user-agent"),
904
+ metadata: { connectionId: who.connectionId, ...metadata },
905
+ });
906
+ }
907
+
908
+ export function registerPaymentsRoutes(options: PaymentsRoutesOptions): (app: Hono<PithyHonoEnv>) => void {
909
+ const { config } = options;
910
+ const base = options.basePath ?? config.basePath;
911
+ const clock = options.now ?? (() => new Date());
912
+ const trust = options.trust ?? {};
913
+ /** The seam `payments()` built, or the resolver-less one this config implies. See {@link PaymentsRoutesOptions.subject}. */
914
+ const seam: PaymentsSubjectSeam = options.subject ?? { billingSubject: config.billingSubject };
915
+
916
+ /**
917
+ * Stripe's hosted-flow return URLs, or the 404 that says the rail is off.
918
+ *
919
+ * Reachable only when `rails.stripe` is false — the catalog refuses to parse with the rail on and the block
920
+ * absent, so a deploy is where that is caught. This is the "Stripe is not enabled here" answer, and it costs
921
+ * no secret read to give.
922
+ */
923
+ function stripeSettings(): PaymentsStripeSettings {
924
+ if (config.stripe === undefined) {
925
+ throw new PaymentsRailNotConfiguredError({
926
+ detail: "The stripe rail is off in this project's config, so there are no hosted flows to start.",
927
+ });
928
+ }
929
+ return config.stripe;
930
+ }
931
+
932
+ /**
933
+ * Where a hosted checkout returns the browser, for whichever rail is taking the money.
934
+ *
935
+ * Each rail carries its own pair, because each returns to a different page: a Stripe success URL holds a
936
+ * `{CHECKOUT_SESSION_ID}` token the return page posts back, and a Lemon Squeezy one cannot — that rail has
937
+ * no submittable receipt, so its page shows a pending state and waits for the webhook.
938
+ */
939
+ function returnUrls(rail: PaymentsRail): { successUrl: string; cancelUrl?: string } {
940
+ if (rail === "paddle") {
941
+ if (config.paddle === undefined) {
942
+ throw new PaymentsRailNotConfiguredError({
943
+ detail: "The paddle rail is off in this project's config, so there are no checkout flows to start.",
944
+ });
945
+ }
946
+ // The cancel URL is optional here and required for Stripe, and the asymmetry is the store's: an
947
+ // overlay a buyer closes leaves them exactly where they were, with nowhere to be sent.
948
+ return { successUrl: config.paddle.successUrl, cancelUrl: config.paddle.cancelUrl };
949
+ }
950
+ if (rail === "lemonSqueezy") {
951
+ if (config.lemonSqueezy === undefined) {
952
+ throw new PaymentsRailNotConfiguredError({
953
+ detail: "The lemonSqueezy rail is off in this project's config, so there are no hosted flows to start.",
954
+ });
955
+ }
956
+ // No cancel URL: that store's checkout has no cancel destination to send one to.
957
+ return { successUrl: config.lemonSqueezy.successUrl };
958
+ }
959
+ const settings = stripeSettings();
960
+ return { successUrl: settings.successUrl, cancelUrl: settings.cancelUrl };
961
+ }
962
+
963
+ /**
964
+ * Which hosted-checkout rail this request is for.
965
+ *
966
+ * A product declares its rails by carrying their blocks, so the candidates are the checkout-capable rails
967
+ * this project has enabled *and* this product is listed on. One candidate is the common case and needs no
968
+ * request field. Two, with the caller naming neither, is refused: picking one would decide who takes a
969
+ * customer's money on their behalf, and an adopter selling on two rails means to offer the choice.
970
+ *
971
+ * The candidate list is `PAYMENTS_HOSTED_RAILS`, and it is not written out here: this route and the
972
+ * scaffolded screens ask the same question, and a fifth copy of the answer is what #336 was.
973
+ * `PAYMENTS_HOSTED_RAILS` is ordered, which is the only thing that order does — when a product sells on
974
+ * two rails and the caller named neither the request is refused, so nothing here resolves silently; the
975
+ * order exists to make the refusal's message deterministic.
976
+ */
977
+ function checkoutRailFor(entry: PaymentsCatalogEntry, requested: PaymentsRail | undefined): PaymentsRail {
978
+ const enabled = PAYMENTS_HOSTED_RAILS.filter((rail) => railEnabled(config, rail));
979
+
980
+ // No hosted-checkout rail at all. That is a fact about the deployment rather than about the product —
981
+ // a mobile-only project sells through Apple and Google and has no browser flow to start — so it is the
982
+ // rail refusal, and it costs no credential read and no round-trip to give.
983
+ if (enabled.length === 0) {
984
+ throw new PaymentsRailNotConfiguredError({
985
+ detail:
986
+ "No hosted-checkout rail is on in this project's config, so there are no hosted flows to start. Enable `rails.stripe`, `rails.lemonSqueezy`, or `rails.paddle`.",
987
+ });
988
+ }
989
+
990
+ const candidates = enabled.filter((rail) => providerProductId(entry.product, rail) !== undefined);
991
+
992
+ if (requested !== undefined) {
993
+ if (candidates.includes(requested)) return requested;
994
+ // Named a rail this product is not sold on, or one this project has turned off. A 404 on the product
995
+ // rather than the rail: naming which of the two it was would describe the deployment to a stranger.
996
+ throw new PaymentsProductNotFoundError({
997
+ detail: `Product "${entry.id}" cannot be bought through ${requested} here — the rail is off, or the product declares no ${requested} block.`,
998
+ });
999
+ }
1000
+
1001
+ const [only, ...rest] = candidates;
1002
+ if (only === undefined) {
1003
+ throw new PaymentsProductNotFoundError({
1004
+ detail: `Product "${entry.id}" declares no enabled hosted-checkout rail, so it cannot be bought through this route.`,
1005
+ });
1006
+ }
1007
+ if (rest.length > 0) {
1008
+ throw new ValidationError({
1009
+ message: "Choose how to pay.",
1010
+ action: `Send \`rail\` as one of: ${candidates.join(", ")}.`,
1011
+ detail: `Product "${entry.id}" is sold through ${candidates.join(" and ")}, and the request named neither.`,
1012
+ });
1013
+ }
1014
+ return only;
1015
+ }
1016
+
1017
+ /** One rail, narrowed to the interface that creates hosted sessions. */
1018
+ async function checkoutRail(
1019
+ c: Context<PithyHonoEnv>,
1020
+ rail: PaymentsRail,
1021
+ ): Promise<PaymentsRailProvider & CheckoutRail> {
1022
+ const provider = resolveRailProvider(rail, config, await credentials(c), trust);
1023
+ if (!isCheckoutRail(provider)) {
1024
+ // Structural rather than a name check, so a future rail that initiates purchases needs no edit here and one
1025
+ // that does not can never be asked.
1026
+ throw new PaymentsRailNotConfiguredError({
1027
+ detail: `The ${provider.rail} rail does not create hosted sessions.`,
1028
+ });
1029
+ }
1030
+ return provider;
1031
+ }
1032
+
1033
+ /**
1034
+ * One rail, narrowed to the interface that manages a subscription from the server.
1035
+ *
1036
+ * {@link checkoutRail}'s shape and {@link checkoutRail}'s reason: structural, never a rail-name check, so
1037
+ * a rail that gains the ability needs no edit here and one that has not can never be asked. The guard
1038
+ * ANDs all five verbs — a rail missing any one of them is simply not a subscription rail, and the answer
1039
+ * is `payments/rail_not_configured` rather than a `TypeError` thrown mid-cancellation on a route that has
1040
+ * already written an audit row.
1041
+ *
1042
+ * **Which rail is never a request field.** It is the rail on the caller's own purchase row: a subscription
1043
+ * that exists lives at exactly one store, and a rail a caller could name could only ever be the wrong
1044
+ * store asked about somebody's subscription. That is why the argument comes from
1045
+ * {@link changeableSubscription} and from nowhere else.
1046
+ *
1047
+ * The refusal is honest on the mobile rails rather than a bug: an Apple or Google subscription is changed
1048
+ * inside the store's own UI, on the device, and there is no server call that does it.
1049
+ */
1050
+ async function subscriptionRail(
1051
+ c: Context<PithyHonoEnv>,
1052
+ rail: PaymentsRail,
1053
+ ): Promise<PaymentsRailProvider & SubscriptionRail> {
1054
+ const provider = resolveRailProvider(rail, config, await credentials(c), trust);
1055
+ if (!isSubscriptionRail(provider)) {
1056
+ throw new PaymentsRailNotConfiguredError({
1057
+ detail: `The ${provider.rail} rail does not manage subscriptions from the server. A subscription on that store is changed inside the store's own UI, on the device.`,
1058
+ });
1059
+ }
1060
+ return provider;
1061
+ }
1062
+
1063
+ /**
1064
+ * One rail, narrowed to the interface that refunds at its store.
1065
+ *
1066
+ * Its own narrowing rather than a sixth verb on {@link subscriptionRail}, because the two abilities are
1067
+ * independent in both directions: Google Play refunds from the server and changes no plan from it, and
1068
+ * Apple's only refund endpoint is a lookup. Asking one guard for both would refuse a rail that can do the
1069
+ * thing being asked for.
1070
+ *
1071
+ * Structural, like the others, and the rail still comes from the caller's own purchase row rather than
1072
+ * from a request — a rail a caller could name could only ever be the wrong store asked about somebody
1073
+ * else's money.
1074
+ */
1075
+ async function refundRail(c: Context<PithyHonoEnv>, rail: PaymentsRail): Promise<PaymentsRailProvider & RefundRail> {
1076
+ const provider = resolveRailProvider(rail, config, await credentials(c), trust);
1077
+ if (!isRefundRail(provider)) {
1078
+ throw new PaymentsRailNotConfiguredError({
1079
+ detail: `The ${provider.rail} rail does not refund from the server. A refund on that store is asked for through the store itself.`,
1080
+ });
1081
+ }
1082
+ return provider;
1083
+ }
1084
+
1085
+ /**
1086
+ * The store's own subscription ids this caller holds on a rail — the family keys of their own rows.
1087
+ *
1088
+ * Read from the projection rather than from the store, and always for the subject the seam resolved. What
1089
+ * it feeds is a portal request for authenticated cancel links, so the set has to be exactly what this
1090
+ * holder owns: a wider one would mint somebody else's cancel button, and there is no request field that
1091
+ * could widen it.
1092
+ *
1093
+ * `originalTransactionId` rather than `providerTransactionId`, because a subscription's money rows name
1094
+ * the family there and the state row names itself there too — so one column answers for both.
1095
+ */
1096
+ async function ownSubscriptionIds(
1097
+ c: Context<PithyHonoEnv>,
1098
+ rail: PaymentsRail,
1099
+ subject: PaymentsSubject,
1100
+ ): Promise<readonly string[]> {
1101
+ const rows = await paymentsDatabase(database(c))
1102
+ .selectFrom(PAYMENTS_PURCHASES_TABLE)
1103
+ .select(["originalTransactionId"])
1104
+ // Both halves, as everywhere: an id-only predicate would mint `user:acme` a cancel link for whatever
1105
+ // `organization:acme` is paying for.
1106
+ .where("subjectType", "=", subject.subjectType)
1107
+ .where("subjectId", "=", subject.subjectId)
1108
+ .where("rail", "=", rail)
1109
+ .where("type", "=", "subscription")
1110
+ .orderBy("providerEventAt", "desc")
1111
+ .execute();
1112
+ const ids = new Set<string>();
1113
+ for (const row of rows) if (typeof row.originalTransactionId === "string") ids.add(row.originalTransactionId);
1114
+ return [...ids];
1115
+ }
1116
+
1117
+ /** The subject's store account on a rail, or null. What keeps one buyer to one Stripe customer. */
1118
+ async function accountFor(
1119
+ c: Context<PithyHonoEnv>,
1120
+ rail: PaymentsRail,
1121
+ subject: PaymentsSubject,
1122
+ ): Promise<string | null> {
1123
+ return (await providerAccountForSubject(paymentsDatabase(database(c)), rail, subject)) ?? null;
1124
+ }
1125
+
1126
+ /**
1127
+ * Verify one receipt through its rail and project it against the subject. Shared by submit and restore.
1128
+ *
1129
+ * **The subject is a parameter, not resolved here**, and that is deliberate: `/restore` submits a batch, and
1130
+ * a seam call per receipt would ask the adopter's resolver the same question fifty times — and would let the
1131
+ * answer change halfway through a batch, filing one client's history under two holders.
1132
+ */
1133
+ async function submit(
1134
+ c: Context<PithyHonoEnv>,
1135
+ subject: PaymentsSubject,
1136
+ rail: PaymentsRail,
1137
+ receipt: string,
1138
+ ): Promise<PurchaseProjection> {
1139
+ const now = clock();
1140
+ const d1 = database(c);
1141
+ const provider = resolveRailProvider(rail, config, await credentials(c), trust);
1142
+ // **The deployment travels with the clock**, and on one rail it is load-bearing rather than incidental.
1143
+ // Paddle's `verify` honors a submitted transaction's ownership stamp only when a MAC keyed on this
1144
+ // deployment's own secret verifies beside it — and the environment is *inside* that MAC's message, so a
1145
+ // rail handed no deployment can prove nothing and refuses every submission. The other rails ignore it.
1146
+ const verified = await provider.verify(receipt, { now, deployment: deploymentName(c) });
1147
+
1148
+ // A purchase this deployment initiated names its own purchaser, and a submission from anyone else is refused
1149
+ // before it is projected. Only Stripe and Paddle set this — see `VerifiedPurchase.accountReference` for why
1150
+ // an app-supplied identifier like Apple's `appAccountToken` deliberately does not.
1151
+ //
1152
+ // **A reference that does not decode is refused, not ignored.** It is bytes from a store, so it may be a
1153
+ // bare id — the shape every pre-subject client sent — or a kind this build does not know, and neither names
1154
+ // this caller. `sameSubject` answers false for `undefined` against anything, so the one comparison covers
1155
+ // the malformed case and the wrong-holder case together, and covers both halves of the pair: `user:acme`
1156
+ // submitting `organization:acme`'s purchase is refused exactly as a stranger's is.
1157
+ if (verified.accountReference) {
1158
+ const stamped = decodeSubjectReference(verified.accountReference);
1159
+ if (!sameSubject(stamped, subject)) {
1160
+ throw new PaymentsReceiptAlreadyOwnedError({
1161
+ detail: `${rail} purchase ${verified.event.providerTransactionId} was started for ${stamped === undefined ? "a reference this build cannot read" : encodeSubjectReference(stamped)}; ${encodeSubjectReference(subject)} submitted it.`,
1162
+ });
1163
+ }
1164
+ }
1165
+
1166
+ // `return await`, not `return`. Returning a promise from an async function makes this frame *adopt* the
1167
+ // rejection instead of raising it, and the workerd runtime then reports the adopted promise as an
1168
+ // unhandled rejection even though Hono's `onError` answers the request correctly. A refusal here is normal
1169
+ // traffic — a stale receipt, a sandbox transaction — so it must not read as a runtime fault in a log.
1170
+ const projection = await projectPurchase(
1171
+ d1,
1172
+ // Both halves, off the one resolved subject. The event carries them flat because a row does, and
1173
+ // spreading the pair is what keeps a kind from one place beside an id from another.
1174
+ { ...verified.event, ...subject },
1175
+ { config, environment: deploymentEnvironment(c), now },
1176
+ );
1177
+
1178
+ // The account link comes **after** the projection, not before it.
1179
+ //
1180
+ // On Apple and Google `providerAccountId` is `appAccountToken` / `obfuscatedAccountId` — a value the app
1181
+ // chose. Writing the link first meant any receipt this deployment refused still minted a permanent
1182
+ // ownership record: a Sandbox transaction is free and is rejected by the environment check *inside* the
1183
+ // projection, so an attacker could bind an unlimited number of identifiers at no cost and then collect the
1184
+ // notifications of whoever those identifiers really belonged to. Requiring a projected purchase first means
1185
+ // a binding costs a real purchase on the deployment's own environment, and leaves a row naming who made it.
1186
+ if (verified.providerAccountId) {
1187
+ const bound = await linkProviderAccount(d1, rail, verified.providerAccountId, subject, { now });
1188
+ // The binding never rebinds, so a disagreement means somebody else claimed this store account first.
1189
+ // That is legitimate often enough (a shared device, a reinstall against a new Pithy account) that
1190
+ // refusing would hand an attacker a way to lock the real owner out — so the first binding stands, this
1191
+ // caller keeps the purchase they just projected, and the collision is recorded for somebody to look at.
1192
+ // The purchases that were waiting on exactly this link. On Apple and Google the client submission is
1193
+ // the *only* link event there is — no webhook on those rails carries an account reference — so a rail
1194
+ // that cannot replay its own recorded payload is answered by a no-op here rather than by an omission.
1195
+ await repairOrphans(c, rail, now);
1196
+ if (!sameSubject(bound, subject)) {
1197
+ await c.var.emit({
1198
+ action: PaymentsAuditActions.providerAccountContested,
1199
+ outcome: "denied",
1200
+ severity: "warning",
1201
+ // The **person** who submitted, not the holder they act for. An organization does not press a
1202
+ // button; somebody at it does, and a trail that recorded the company here would answer "who did
1203
+ // this" with a name nobody can be asked about it.
1204
+ actorType: "user",
1205
+ actorId: c.var.auth?.userId,
1206
+ resourceType: "provider_account",
1207
+ resourceId: `${rail}:${verified.providerAccountId}`,
1208
+ // Two keys per subject, never one joined string. A trail is queried by equality on a column, and a
1209
+ // `user:ada` that has to be split before it can be compared is a column nobody filters correctly
1210
+ // twice. `encodeSubjectReference` is for the single-field slots a *store* gives us; this is ours.
1211
+ metadata: {
1212
+ rail,
1213
+ boundToType: bound.subjectType,
1214
+ boundToId: bound.subjectId,
1215
+ claimedByType: subject.subjectType,
1216
+ claimedById: subject.subjectId,
1217
+ },
1218
+ });
1219
+ }
1220
+ }
1221
+
1222
+ await fulfill(c, projection);
1223
+ return projection;
1224
+ }
1225
+
1226
+ /**
1227
+ * Credit what the purchase bought, and reverse it where a refund and the catalog both ask.
1228
+ *
1229
+ * A no-op for every product with no `grants` clause, and it resolves the optional `@pithy-sh/ledger` import
1230
+ * only when one is present — so a project selling nothing but features never reaches the package. A failure
1231
+ * here is deliberately left to propagate: the credit's ref is stable, so a retry settles it, and swallowing
1232
+ * it would lose a purchase's currency with nothing anywhere to read.
1233
+ */
1234
+ /**
1235
+ * Project the orphans a link just made resolvable. Never throws: see {@link repairOrphanedEvents}.
1236
+ *
1237
+ * The rail provider is built here rather than passed, because `replay` is the one thing the repair needs
1238
+ * from a rail and building one costs a secrets read the handler has already paid for on every path that
1239
+ * reaches this.
1240
+ */
1241
+ async function repairOrphans(c: Context<PithyHonoEnv>, rail: PaymentsRail, now: Date): Promise<void> {
1242
+ const provider = resolveRailProvider(rail, config, await credentials(c), trust);
1243
+ const replay = provider.replay?.bind(provider);
1244
+ if (replay === undefined) return;
1245
+ const repaired = await repairOrphanedEvents(database(c), rail, {
1246
+ config,
1247
+ environment: deploymentEnvironment(c),
1248
+ now,
1249
+ replay: (payload) => replay(payload, { now, deployment: deploymentName(c) }),
1250
+ fulfill: (projection) => fulfill(c, projection),
1251
+ });
1252
+ for (const providerEventId of repaired.projected) {
1253
+ await c.var.emit({
1254
+ action: PaymentsAuditActions.webhookReceived,
1255
+ outcome: "success",
1256
+ actorType: "service",
1257
+ actorId: rail,
1258
+ metadata: { rail, providerEventId, projected: true, repaired: "orphan" },
1259
+ });
1260
+ }
1261
+ }
1262
+
1263
+ async function fulfill(c: Context<PithyHonoEnv>, projection: PurchaseProjection): Promise<void> {
1264
+ await fulfillPurchase(database(c), projection, {
1265
+ config,
1266
+ emit: c.var.emit,
1267
+ now: () => clock().getTime(),
1268
+ });
1269
+ }
1270
+
1271
+ return (app) => {
1272
+ // The management surface: control-plane, read-only, under `admin/`. Its paths are disjoint from the
1273
+ // player surface by construction, so registering it first is presentation rather than semantics.
1274
+
1275
+ /**
1276
+ * CONTROL PLANE. What this project sells — the catalog, with the entitlement keys each product grants.
1277
+ *
1278
+ * **The read that makes a comp control possible.** Without it a management client offering "give this
1279
+ * person an entitlement" has nothing to populate a list from, so it offers a text box — and a text box
1280
+ * beside a free-text key is how an operator who means `pro` types `pr` and gets a success. The
1281
+ * validation on `entitlements/grant` is the other half, and it is the half that matters: this makes a
1282
+ * good control possible, that makes a bad one impossible.
1283
+ *
1284
+ * Its own scope. Reading what a project sells is not reading what anybody bought — it names no account
1285
+ * and no transaction, and would answer identically against a database with no rows in it — so a tool
1286
+ * that needs a dropdown can hold this and nothing else.
1287
+ *
1288
+ * No query at all, and none to add: the catalog is small, fixed at deploy, and has no page. What
1289
+ * crosses is decided by `adminCatalogView` and asserted as an invariant rather than as a field list.
1290
+ */
1291
+ app.get(`${base}/admin/catalog`, requireControlPlane(PAYMENTS_CATALOG_READ_SCOPE), async (c) => {
1292
+ const view = adminCatalogView(config);
1293
+ await recordRead(c, PaymentsAuditActions.catalogRead, null, {
1294
+ products: view.enabled ? view.products.length : 0,
1295
+ });
1296
+ return c.json(view satisfies PaymentsAdminCatalogResponse, 200);
1297
+ });
1298
+
1299
+ /**
1300
+ * CONTROL PLANE. The purchase log, paged, newest first.
1301
+ *
1302
+ * The filters narrow and never widen: there is no shape of this request that reaches a column the
1303
+ * projection does not return, and no filter that turns the response into something a wider scope
1304
+ * would have been needed for. `environment` is unfiltered by default on purpose — hiding sandbox
1305
+ * transactions would hide the single thing an operator most needs to notice on a production board.
1306
+ */
1307
+ app.get(
1308
+ `${base}/admin/purchases`,
1309
+ requireControlPlane(PAYMENTS_PURCHASES_READ_SCOPE),
1310
+ zValidator("query", AdminPurchasesQuery, validationHook),
1311
+ async (c) => {
1312
+ const query = c.req.valid("query");
1313
+ const subject = subjectFilter(query);
1314
+ const page = await listPurchases(database(c), { ...query, subject });
1315
+ // The audit row's `resourceId` is one column, so the pair is encoded for it — the same encoding a
1316
+ // store's single-field slot gets, and the only place in this file one is written. The **filters** stay
1317
+ // two keys, because those are what a trail is queried by.
1318
+ await recordRead(
1319
+ c,
1320
+ PaymentsAuditActions.purchasesRead,
1321
+ subject === undefined ? null : encodeSubjectReference(subject),
1322
+ {
1323
+ returned: page.items.length,
1324
+ resumed: query.cursor !== undefined,
1325
+ filters: {
1326
+ subjectType: subject?.subjectType ?? null,
1327
+ subjectId: subject?.subjectId ?? null,
1328
+ rail: query.rail ?? null,
1329
+ status: query.status ?? null,
1330
+ environment: query.environment ?? null,
1331
+ },
1332
+ },
1333
+ );
1334
+ return c.json(
1335
+ {
1336
+ purchases: page.items.map(adminPurchaseView),
1337
+ nextCursor: page.nextCursor,
1338
+ } satisfies PaymentsAdminPurchasesResponse,
1339
+ 200,
1340
+ );
1341
+ },
1342
+ );
1343
+
1344
+ /**
1345
+ * CONTROL PLANE. The purchases that renew, paged, newest first.
1346
+ *
1347
+ * Its own scope, granted separately from the purchase log's. A renewal or churn tool needs to know who
1348
+ * is still paying and when their period ends; it has no business reading what everybody ever bought,
1349
+ * and an adopter who wants to hand out only the narrower half must have a way to say so. The narrowing
1350
+ * is applied by `listSubscriptions` rather than by a `type` filter the request could name, so the
1351
+ * wider read is not reachable through this scope by construction.
1352
+ */
1353
+ app.get(
1354
+ `${base}/admin/subscriptions`,
1355
+ requireControlPlane(PAYMENTS_SUBSCRIPTIONS_READ_SCOPE),
1356
+ zValidator("query", AdminSubscriptionsQuery, validationHook),
1357
+ async (c) => {
1358
+ const query = c.req.valid("query");
1359
+ const subject = subjectFilter(query);
1360
+ const page = await listSubscriptions(database(c), { ...query, subject });
1361
+ await recordRead(
1362
+ c,
1363
+ PaymentsAuditActions.subscriptionsRead,
1364
+ subject === undefined ? null : encodeSubjectReference(subject),
1365
+ {
1366
+ returned: page.items.length,
1367
+ resumed: query.cursor !== undefined,
1368
+ filters: {
1369
+ subjectType: subject?.subjectType ?? null,
1370
+ subjectId: subject?.subjectId ?? null,
1371
+ status: query.status ?? null,
1372
+ },
1373
+ },
1374
+ );
1375
+ return c.json(
1376
+ {
1377
+ subscriptions: page.items.map(adminPurchaseView),
1378
+ nextCursor: page.nextCursor,
1379
+ } satisfies PaymentsAdminSubscriptionsResponse,
1380
+ 200,
1381
+ );
1382
+ },
1383
+ );
1384
+
1385
+ /**
1386
+ * CONTROL PLANE. The entitlement model, paged — what accounts hold, and why.
1387
+ *
1388
+ * `granted` is resolved here against `expiresAt`, exactly as the gate the adopter's own app calls
1389
+ * resolves it. A dashboard that recomputed it from the stored flag would eventually disagree with that
1390
+ * gate, and the customer would believe the dashboard.
1391
+ */
1392
+ app.get(
1393
+ `${base}/admin/entitlements`,
1394
+ requireControlPlane(PAYMENTS_ENTITLEMENTS_READ_SCOPE),
1395
+ zValidator("query", AdminEntitlementsQuery, validationHook),
1396
+ async (c) => {
1397
+ const query = c.req.valid("query");
1398
+ const now = clock();
1399
+ const subject = subjectFilter(query);
1400
+ const page = await listEntitlements(database(c), { ...query, subject });
1401
+ await recordRead(
1402
+ c,
1403
+ PaymentsAuditActions.entitlementsRead,
1404
+ subject === undefined ? null : encodeSubjectReference(subject),
1405
+ {
1406
+ returned: page.items.length,
1407
+ resumed: query.cursor !== undefined,
1408
+ filters: {
1409
+ subjectType: subject?.subjectType ?? null,
1410
+ subjectId: subject?.subjectId ?? null,
1411
+ entitlement: query.entitlement ?? null,
1412
+ },
1413
+ },
1414
+ );
1415
+ return c.json(
1416
+ {
1417
+ entitlements: page.items.map((row) => adminEntitlementView(row, now)),
1418
+ nextCursor: page.nextCursor,
1419
+ } satisfies PaymentsAdminEntitlementsResponse,
1420
+ 200,
1421
+ );
1422
+ },
1423
+ );
1424
+
1425
+ /**
1426
+ * CONTROL PLANE. Everything one subject is entitled to, resolved now.
1427
+ *
1428
+ * **Two path segments, because the address is a pair.** The table is keyed
1429
+ * `UNIQUE (subjectType, subjectId, entitlement)`, and nothing in the kit keeps an organization id from
1430
+ * equalling some user's — so a route addressed by the id alone would answer about whichever holder
1431
+ * happened to carry it. Both segments are validated: an unknown kind is a 400 naming the two that exist,
1432
+ * which is what a malformed address deserves, where the encoded-reference form would have been a 404
1433
+ * reading as a holder who is simply not here.
1434
+ *
1435
+ * Unpaginated, because the key above admits at most one row per entitlement. A subject holding nothing is
1436
+ * an empty list rather than a 404: an entitlement row appears with the first purchase that grants one, so
1437
+ * its absence is not a missing holder — and a 404 would make this surface an existence oracle for user and
1438
+ * organization ids alike.
1439
+ */
1440
+ app.get(
1441
+ `${base}/admin/entitlements/:subjectType/:subjectId`,
1442
+ requireControlPlane(PAYMENTS_ENTITLEMENTS_READ_SCOPE),
1443
+ zValidator("param", AdminSubjectParam, validationHook),
1444
+ async (c) => {
1445
+ const subject = c.req.valid("param");
1446
+ const now = clock();
1447
+ const rows = await readEntitlements(database(c), subject);
1448
+ await recordRead(c, PaymentsAuditActions.entitlementsRead, encodeSubjectReference(subject), {
1449
+ subjectType: subject.subjectType,
1450
+ subjectId: subject.subjectId,
1451
+ returned: rows.length,
1452
+ });
1453
+ return c.json(
1454
+ {
1455
+ ...subject,
1456
+ entitlements: rows.map((row) => adminEntitlementView(row, now)),
1457
+ } satisfies PaymentsAdminSubjectEntitlementsResponse,
1458
+ 200,
1459
+ );
1460
+ },
1461
+ );
1462
+
1463
+ /**
1464
+ * CONTROL PLANE. What reconciliation has done — the passes this deployment has run, newest first.
1465
+ *
1466
+ * The compensating control for a delivery mechanism that is known to fail leaves a trace, so an adopter
1467
+ * can tell a healthy integration from one whose cron stopped firing. An empty page is a real answer and
1468
+ * the loudest one this route gives.
1469
+ *
1470
+ * Its own scope: a run names no account, no transaction and no amount, so a health monitor can be granted
1471
+ * exactly this without acquiring the purchase log.
1472
+ */
1473
+ app.get(
1474
+ `${base}/admin/reconcile-runs`,
1475
+ requireControlPlane(PAYMENTS_RECONCILE_READ_SCOPE),
1476
+ zValidator("query", AdminReconcileRunsQuery, validationHook),
1477
+ async (c) => {
1478
+ const query = c.req.valid("query");
1479
+ const page = await listReconcileRuns(database(c), query);
1480
+ await recordRead(c, PaymentsAuditActions.reconcileRunsRead, null, {
1481
+ returned: page.items.length,
1482
+ resumed: query.cursor !== undefined,
1483
+ filters: { rail: query.rail ?? null, environment: query.environment ?? null },
1484
+ });
1485
+ return c.json(
1486
+ {
1487
+ runs: page.items.map(adminReconcileRunView),
1488
+ nextCursor: page.nextCursor,
1489
+ } satisfies PaymentsAdminReconcileRunsResponse,
1490
+ 200,
1491
+ );
1492
+ },
1493
+ );
1494
+
1495
+ /**
1496
+ * CONTROL PLANE WRITE. Start a reconciliation pass now.
1497
+ *
1498
+ * **The counterpart to the read above, and a different power.** Reading the log says whether the nightly
1499
+ * repair has been firing; this one calls the store, walks the catalog and writes entitlements — granting
1500
+ * what a dropped webhook never granted, revoking what a missed cancellation left standing. Its own scope,
1501
+ * so the health monitor that alarms on a stopped cron cannot move anybody's access.
1502
+ *
1503
+ * **It answers a start, never an outcome.** The pass is a Workflow — durable, retried, outliving this
1504
+ * request by minutes — so there is nothing true to say here about what it repaired. The tally lands in the
1505
+ * run log, joined to this instance by its id.
1506
+ *
1507
+ * **A missing binding refuses rather than degrading.** `triggerWorkflow` skips an `optional` job with a
1508
+ * warning, which is right for a background dispatch on a request path that works without it: media
1509
+ * finalizes an upload and merely skips enrichment. It is wrong here. Somebody pressed a button to make a
1510
+ * pass happen, and answering 202 while nothing started is the failure this codebase spends its comments
1511
+ * avoiding — so the binding is resolved directly and its absence is a refusal naming what to deploy.
1512
+ *
1513
+ * Idempotent, like the pass itself. A second press while one is in flight starts nothing and says
1514
+ * `started: false`, which is a true sentence rather than a 409 that reads as a fault.
1515
+ */
1516
+ app.post(`${base}/admin/reconcile-runs`, requireControlPlane(PAYMENTS_RECONCILE_RUN_SCOPE), async (c) => {
1517
+ const spec = paymentsWorkflows.reconcile;
1518
+ const binding = resolveWorkflowBinding(c.env as Record<string, unknown>, {
1519
+ binding: spec.binding,
1520
+ capability: PAYMENTS_CAPABILITY,
1521
+ log: c.var.log,
1522
+ });
1523
+ if (!binding) {
1524
+ // Named as a provisioning fact rather than a fault of the request: the caller's scope was right, the
1525
+ // route exists, and the job's host is not deployed here. `action` is what a person does about it.
1526
+ throw new PaymentsReconcileNotProvisionedError({
1527
+ detail: `Dispatching a reconciliation pass needs a Workflow binding named ${spec.binding} on this Worker.`,
1528
+ });
1529
+ }
1530
+
1531
+ // `create` answers `unknown` — the binding is structural, matched by having the method at all, and a
1532
+ // loopback stand-in under `pithy dev` answers a different shape from the Workflows runtime. Narrowed
1533
+ // here rather than cast: an instance with no readable id is a pass that started and cannot be named,
1534
+ // which is `runId: null` and a true sentence, not a reason to fail the press.
1535
+ const instance: unknown = await binding.create({ params: {} });
1536
+ const runId =
1537
+ typeof instance === "object" && instance !== null && "id" in instance && typeof instance.id === "string"
1538
+ ? instance.id
1539
+ : null;
1540
+
1541
+ const who = controlPlaneCaller(c);
1542
+ await c.var.emit({
1543
+ action: PaymentsAuditActions.reconcileRunStarted,
1544
+ outcome: "success",
1545
+ // The token's `sub`, so the trail names the person at the console rather than the console. One
1546
+ // connection is normally shared by everybody using it, and this row is the join to every repair the
1547
+ // pass then made.
1548
+ actorType: "control-plane",
1549
+ actorId: who.subject,
1550
+ resourceType: "reconcile_run",
1551
+ resourceId: runId,
1552
+ metadata: { deployment: deploymentName(c) },
1553
+ });
1554
+
1555
+ return c.json({ started: true, runId } satisfies PaymentsAdminReconcileRunStarted, 202);
1556
+ });
1557
+
1558
+ /**
1559
+ * AUTHED WRITE. The purchaser's own app submitting what the store SDK gave it, so the buyer sees their
1560
+ * entitlement immediately rather than waiting for the webhook. A replay by its own owner is a 200 with the
1561
+ * existing purchase — the write path is idempotent, so a repeat is not an error. A receipt belonging to
1562
+ * somebody else is a 409, audited as denied.
1563
+ */
1564
+ app.post(`${base}/purchases`, requireAuth(), zValidator("json", PurchaseSubmission, validationHook), async (c) => {
1565
+ const input = c.req.valid("json");
1566
+ // Resolved before the store is called: a caller acting for nobody has no row to write, so refusing here
1567
+ // costs no round trip and cannot leave a charged customer with an unattributed purchase.
1568
+ const subject = await requirePaymentsSubject(c, seam);
1569
+ try {
1570
+ const projection = await submit(c, subject, input.rail, input.receipt);
1571
+ await c.var.emit({
1572
+ action: PaymentsAuditActions.purchaseVerified,
1573
+ outcome: "success",
1574
+ // The person who submitted, always — the subject they act for rides in the metadata below. Under
1575
+ // organization billing the two differ, and collapsing them would answer "who did this" with a
1576
+ // company rather than with somebody who can be asked.
1577
+ actorType: "user",
1578
+ actorId: c.var.auth?.userId,
1579
+ sessionId: c.var.auth?.sessionId,
1580
+ resourceType: "purchase",
1581
+ resourceId: projection.purchase.id,
1582
+ // Identifiers and outcomes only. Never the receipt: the trail is long-lived and queryable, and a
1583
+ // receipt is a bearer artifact. The holder is two keys, never one joined string.
1584
+ metadata: {
1585
+ rail: input.rail,
1586
+ subjectType: subject.subjectType,
1587
+ subjectId: subject.subjectId,
1588
+ productId: projection.product.id,
1589
+ status: projection.purchase.status,
1590
+ outcome: projection.outcome,
1591
+ },
1592
+ });
1593
+ return c.json(
1594
+ {
1595
+ purchase: purchaseView(projection),
1596
+ entitlements: projection.entitlements.map((row) =>
1597
+ entitlementView({ key: row.entitlement, active: row.active, expiresAt: row.expiresAt }),
1598
+ ),
1599
+ } satisfies PaymentsPurchaseResponse,
1600
+ 200,
1601
+ );
1602
+ } catch (cause) {
1603
+ // A refused submission is the security-relevant event, so it is recorded before the error propagates.
1604
+ await c.var.emit({
1605
+ action: PaymentsAuditActions.purchaseVerified,
1606
+ outcome: "denied",
1607
+ severity: "warning",
1608
+ actorType: "user",
1609
+ actorId: c.var.auth?.userId,
1610
+ sessionId: c.var.auth?.sessionId,
1611
+ metadata: {
1612
+ rail: input.rail,
1613
+ subjectType: subject.subjectType,
1614
+ subjectId: subject.subjectId,
1615
+ reason: cause instanceof PithyError ? cause.payload.code : "unknown",
1616
+ },
1617
+ });
1618
+ throw cause;
1619
+ }
1620
+ });
1621
+
1622
+ /**
1623
+ * AUTHED READ. Always the caller's own holder — the subject comes from the seam, never the request, so
1624
+ * there is no shape of this route that reads somebody else's entitlements. A pure read: repairing a stale
1625
+ * row is the reconciliation Workflow's job, and `expiresAt` is rechecked here on every request.
1626
+ *
1627
+ * **Nobody resolved is an empty list, not a refusal**, and that is the read half of the seam's rule. A
1628
+ * signed-in person with no organization selected holds nothing, which is what every gate in the kit
1629
+ * already answers for somebody who has bought nothing — and a 403 here would make a paywall render an
1630
+ * error where it should render the paywall.
1631
+ */
1632
+ app.get(`${base}/entitlements`, requireAuth(), async (c) => {
1633
+ const subject = await resolvePaymentsSubject(c, seam);
1634
+ if (subject === undefined) {
1635
+ return c.json({ entitlements: [] } satisfies PaymentsEntitlementsResponse, 200);
1636
+ }
1637
+ const entitlements = await resolveEntitlements(paymentsDatabase(database(c)), subject, clock());
1638
+ return c.json({ entitlements: entitlements.map(entitlementView) } satisfies PaymentsEntitlementsResponse, 200);
1639
+ });
1640
+
1641
+ /**
1642
+ * AUTHED WRITE. Restore Purchases, client-driven because only the device can enumerate what its store
1643
+ * account owns.
1644
+ *
1645
+ * One bad receipt fails the whole request rather than being skipped. That is deliberate: a client submits
1646
+ * *its own* history, so a receipt belonging to another account in the batch is an attack and a partial
1647
+ * success would hide it. Every receipt that did project stays projected — the writer is idempotent, so the
1648
+ * client simply retries.
1649
+ */
1650
+ app.post(`${base}/restore`, requireAuth(), zValidator("json", RestoreRequest, validationHook), async (c) => {
1651
+ const input = c.req.valid("json");
1652
+ // One resolution for the whole batch. A client submits *its own* history, so every receipt in it is
1653
+ // filed against one holder or the request is refused — asking the seam per receipt would let the answer
1654
+ // change mid-batch and split one store account across two.
1655
+ const subject = await requirePaymentsSubject(c, seam);
1656
+ const purchases: ReturnType<typeof purchaseView>[] = [];
1657
+ for (const receipt of input.receipts) purchases.push(purchaseView(await submit(c, subject, input.rail, receipt)));
1658
+
1659
+ const entitlements = await resolveEntitlements(paymentsDatabase(database(c)), subject, clock());
1660
+ await c.var.emit({
1661
+ action: PaymentsAuditActions.purchaseRestored,
1662
+ outcome: "success",
1663
+ actorType: "user",
1664
+ actorId: c.var.auth?.userId,
1665
+ sessionId: c.var.auth?.sessionId,
1666
+ metadata: {
1667
+ rail: input.rail,
1668
+ subjectType: subject.subjectType,
1669
+ subjectId: subject.subjectId,
1670
+ restored: purchases.length,
1671
+ },
1672
+ });
1673
+ return c.json(
1674
+ { purchases, entitlements: entitlements.map(entitlementView) } satisfies PaymentsRestoreResponse,
1675
+ 200,
1676
+ );
1677
+ });
1678
+
1679
+ /**
1680
+ * SIGNED WEBHOOK — Apple. App Store Server Notifications V2, signed as a JWS against Apple's pinned chain.
1681
+ */
1682
+ app.post(
1683
+ `${base}/webhooks/apple`,
1684
+ requireSignedWebhook("apple", { config, now: clock, trust }),
1685
+ zValidator("json", AppleWebhookNotification, validationHook),
1686
+ webhookHandler("apple"),
1687
+ );
1688
+
1689
+ /**
1690
+ * SIGNED WEBHOOK — Google. A Pub/Sub push whose OIDC token the guard verified, audience and all.
1691
+ *
1692
+ * The handler is the same one Apple's route uses, and that is the point: by the time it runs, the rail has
1693
+ * already turned Play's pointer into a normalized event through the Play Developer API, so there is nothing
1694
+ * Google-shaped left to branch on.
1695
+ */
1696
+ app.post(
1697
+ `${base}/webhooks/google`,
1698
+ requireSignedWebhook("google", { config, now: clock, trust }),
1699
+ zValidator("json", GoogleWebhookNotification, validationHook),
1700
+ webhookHandler("google"),
1701
+ );
1702
+
1703
+ /**
1704
+ * SIGNED WEBHOOK — Stripe. An HMAC in `Stripe-Signature`, over the exact received bytes and dated, which the
1705
+ * guard has checked inside the replay window before this validator ever parses the body.
1706
+ *
1707
+ * The same handler again. Stripe is the rail with no client-submission path of its own in the common case, so
1708
+ * this is where most Stripe purchases enter the system — and it is still nothing but a verified notification
1709
+ * by the time the handler runs.
1710
+ */
1711
+ app.post(
1712
+ `${base}/webhooks/stripe`,
1713
+ requireSignedWebhook("stripe", { config, now: clock, trust }),
1714
+ zValidator("json", StripeWebhookNotification, validationHook),
1715
+ webhookHandler("stripe"),
1716
+ );
1717
+
1718
+ /**
1719
+ * SIGNED WEBHOOK — Lemon Squeezy. A bare HMAC in `X-Signature`, over the exact received bytes, which the
1720
+ * guard has checked before this validator ever parses the body.
1721
+ *
1722
+ * **No timestamp, so no freshness window**, and that is a true fact about the scheme rather than an omission
1723
+ * here — `rails/lemonSqueezy/signature.ts` says so in its type by taking no clock. Replay protection is
1724
+ * entirely the guard's `UNIQUE (rail, providerEventId)` insert, and the projection's monotonic rule behind
1725
+ * it.
1726
+ *
1727
+ * The same handler again. This is where every Lemon Squeezy purchase enters the system, because that rail
1728
+ * has no client-submission path at all: its order ids are sequential integers, so no submitted receipt
1729
+ * could be trusted.
1730
+ */
1731
+ app.post(
1732
+ `${base}/webhooks/lemon-squeezy`,
1733
+ requireSignedWebhook("lemonSqueezy", { config, now: clock, trust }),
1734
+ zValidator("json", LemonSqueezyWebhookNotification, validationHook),
1735
+ webhookHandler("lemonSqueezy"),
1736
+ );
1737
+
1738
+ /**
1739
+ * SIGNED WEBHOOK — Paddle. `Paddle-Signature: ts=…;h1=…`, an HMAC-SHA256 over `${ts}:${exact received
1740
+ * bytes}`, which the guard has checked inside the freshness window before this validator ever parses the
1741
+ * body.
1742
+ *
1743
+ * **A different scheme from Stripe's, and a second verifier rather than a widened core primitive.** Core's
1744
+ * `signed-webhook` splits its header on `,` and joins its signed payload with `.`; Paddle uses `;` and
1745
+ * `:`. Neither is a parameter there, deliberately — `rails/paddle/signature.ts` says why at length, and
1746
+ * says it out loud because a verifier that "fits an existing shape" it does not fit is a verifier that
1747
+ * returns without comparing anything.
1748
+ *
1749
+ * The same handler again. Paddle purchases enter through here and through `/purchases`: unlike Lemon
1750
+ * Squeezy this rail has a submittable receipt, because a `txn_…` checked against a *proven* ownership
1751
+ * stamp is safe where an unguessable order id is not.
1752
+ */
1753
+ app.post(
1754
+ `${base}/webhooks/paddle`,
1755
+ requireSignedWebhook("paddle", { config, now: clock, trust }),
1756
+ zValidator("json", PaddleWebhookNotification, validationHook),
1757
+ webhookHandler("paddle"),
1758
+ );
1759
+
1760
+ /**
1761
+ * AUTHED WRITE — Stripe only. Create a hosted Checkout Session and hand back where to send the browser.
1762
+ *
1763
+ * Everything that decides what is bought and where the buyer is returned to comes from config or from the
1764
+ * seam, never from the body: the **price** from the catalog entry the product id resolves to, the **return
1765
+ * URLs** from `config.stripe`, and the **purchaser** from the subject seam. A client that could name a price
1766
+ * could buy Pro for the price of a coin pack; one that could name a return URL could send a paying customer
1767
+ * to a page it controls; one that could name a purchaser could attach its purchase to another account.
1768
+ *
1769
+ * The subject is resolved first, ahead of the catalog lookup: a caller acting for no holder has nothing to
1770
+ * be charged as, and refusing before the 404 keeps this from answering what a project sells to somebody it
1771
+ * cannot bill.
1772
+ */
1773
+ app.post(`${base}/checkout`, requireAuth(), zValidator("json", CheckoutRequest, validationHook), async (c) => {
1774
+ const input = c.req.valid("json");
1775
+ const subject = await requirePaymentsSubject(c, seam);
1776
+ const entry = product(config, input.productId);
1777
+ const rail = checkoutRailFor(entry, input.rail);
1778
+ const settings = returnUrls(rail);
1779
+ const sku = providerProductId(entry.product, rail);
1780
+ if (sku === undefined) {
1781
+ // In the catalog, but not sold here. A 404 on the product rather than on the rail: the rail is
1782
+ // available, this product simply is not one of the things it sells.
1783
+ throw new PaymentsProductNotFoundError({
1784
+ detail: `Product "${entry.id}" declares no ${rail} SKU, so it cannot be bought through hosted checkout.`,
1785
+ });
1786
+ }
1787
+
1788
+ const provider = await checkoutRail(c, rail);
1789
+ const handoff = await provider.createCheckoutSession(
1790
+ {
1791
+ providerProductId: sku,
1792
+ subscription: entry.product.type === "subscription",
1793
+ // The pair, so the rail stamps `encodeSubjectReference(subject)` into the checkout it creates and
1794
+ // the webhook that follows already names a holder this server chose.
1795
+ subject,
1796
+ // Reuse the buyer's existing store customer, so one buyer keeps one account and their billing portal
1797
+ // shows every purchase rather than only the last one's.
1798
+ providerAccountId: await accountFor(c, rail, subject),
1799
+ successUrl: settings.successUrl,
1800
+ cancelUrl: settings.cancelUrl,
1801
+ // Passed to the store unchanged. Pithy never computes a discounted amount and never checks a code
1802
+ // against anything of its own — the provider is the authority on what is owed.
1803
+ discountCode: input.discountCode,
1804
+ },
1805
+ { now: clock(), deployment: deploymentName(c) },
1806
+ );
1807
+
1808
+ await c.var.emit({
1809
+ action: PaymentsAuditActions.checkoutStarted,
1810
+ outcome: "success",
1811
+ actorType: "user",
1812
+ actorId: c.var.auth?.userId,
1813
+ sessionId: c.var.auth?.sessionId,
1814
+ resourceType: "product",
1815
+ resourceId: entry.id,
1816
+ metadata: {
1817
+ rail,
1818
+ subjectType: subject.subjectType,
1819
+ subjectId: subject.subjectId,
1820
+ productId: entry.id,
1821
+ subscription: entry.product.type === "subscription",
1822
+ // Whether one was used, never which. The trail is long-lived and a code is a commercial fact.
1823
+ discounted: input.discountCode !== undefined,
1824
+ },
1825
+ });
1826
+ // The handoff, verbatim. A `redirect` carries a URL; a `paddle` handoff carries the transaction the
1827
+ // browser opens in place, because that rail's overlay and inline modes never leave this page.
1828
+ return c.json(handoff satisfies PaymentsCheckoutHandoffResponse, 200);
1829
+ });
1830
+
1831
+ /**
1832
+ * AUTHED WRITE — Stripe only. Open the Billing Portal for the caller's own store account.
1833
+ *
1834
+ * No body at all, and that is the request contract: there is exactly one customer this caller may manage,
1835
+ * and it comes from the provider-account map. A `customer` field here would be the whole vulnerability —
1836
+ * any signed-in caller could open a session against somebody else's billing history and cancel it.
1837
+ */
1838
+ app.post(`${base}/portal`, requireAuth(), async (c) => {
1839
+ const subject = await requirePaymentsSubject(c, seam);
1840
+
1841
+ // Which rail this caller actually bought on, found by asking the account map rather than by taking it
1842
+ // from the request. Still no body: the caller names neither the customer nor the rail, so there is
1843
+ // nothing here anyone could point at somebody else's billing.
1844
+ const enabled = PAYMENTS_HOSTED_RAILS.filter((rail) => railEnabled(config, rail));
1845
+ // No hosted rail at all is a fact about the deployment, not about this caller — a mobile-only project
1846
+ // has no billing portal to open. Same refusal `/checkout` gives, rather than a 404 that reads as "you
1847
+ // have no account" to somebody who could never have had one.
1848
+ if (enabled.length === 0) {
1849
+ throw new PaymentsRailNotConfiguredError({
1850
+ detail:
1851
+ "No hosted-checkout rail is on in this project's config, so there is no billing portal to open. Enable `rails.stripe`, `rails.lemonSqueezy`, or `rails.paddle`.",
1852
+ });
1853
+ }
1854
+
1855
+ let found: { rail: PaymentsRail; providerAccountId: string } | undefined;
1856
+ for (const rail of enabled) {
1857
+ const providerAccountId = await accountFor(c, rail, subject);
1858
+ if (providerAccountId !== null) {
1859
+ found = { rail, providerAccountId };
1860
+ break;
1861
+ }
1862
+ }
1863
+
1864
+ if (found === undefined) {
1865
+ // Nothing has ever been bought through a hosted rail by this caller, so there is no billing to
1866
+ // manage. Not a payments-domain refusal: the rail is configured and working, the resource simply
1867
+ // does not exist.
1868
+ throw new NotFoundError({
1869
+ message: "No billing account yet.",
1870
+ action: "Buy a subscription first, then manage it here.",
1871
+ detail: `No hosted-rail provider account is linked to ${encodeSubjectReference(subject)}.`,
1872
+ });
1873
+ }
1874
+
1875
+ const provider = await checkoutRail(c, found.rail);
1876
+ const session = await provider.createPortalSession(
1877
+ {
1878
+ providerAccountId: found.providerAccountId,
1879
+ // Undefined for a rail whose portal takes no return parameter — Lemon Squeezy's is a signed,
1880
+ // expiring link with nowhere to go back to, and Paddle's takes none at all. The contract admits
1881
+ // that rather than have the rail silently drop a URL an adopter configured.
1882
+ returnUrl: found.rail === "stripe" ? stripeSettings().portalReturnUrl : undefined,
1883
+ // **From the caller's own rows, never from a body.** A store that mints per-subscription deep
1884
+ // links mints authenticated ones, so naming a subscription is naming somebody's cancel button.
1885
+ // There is still no request field: the route reads what this caller owns.
1886
+ subscriptionIds: await ownSubscriptionIds(c, found.rail, subject),
1887
+ },
1888
+ { now: clock(), deployment: deploymentName(c) },
1889
+ );
1890
+
1891
+ await c.var.emit({
1892
+ action: PaymentsAuditActions.portalOpened,
1893
+ outcome: "success",
1894
+ actorType: "user",
1895
+ actorId: c.var.auth?.userId,
1896
+ sessionId: c.var.auth?.sessionId,
1897
+ resourceType: "provider_account",
1898
+ resourceId: found.providerAccountId,
1899
+ metadata: { rail: found.rail, subjectType: subject.subjectType, subjectId: subject.subjectId },
1900
+ });
1901
+ return c.json(
1902
+ {
1903
+ url: session.url,
1904
+ ...(session.subscriptions === undefined ? {} : { subscriptions: [...session.subscriptions] }),
1905
+ } satisfies PaymentsPortalHandoffResponse,
1906
+ 200,
1907
+ );
1908
+ });
1909
+
1910
+ /**
1911
+ * CONTROL PLANE. Grant an entitlement by hand — a comped account, or the repair of a purchase that
1912
+ * verified and never projected.
1913
+ *
1914
+ * Support needs this on day one, and it is the more dangerous of the two: a caller that could reach it
1915
+ * could give itself anything the product sells. So it is a control-plane route and nothing else.
1916
+ * `requireControlPlane(PAYMENTS_ENTITLEMENT_GRANT_SCOPE)` establishes authenticity — signature, registered
1917
+ * key, connection, environment, token lifetime, body digest, single use — before anything below runs, and
1918
+ * denies unless the adopter granted *that* operation. Holding the revoke scope confers nothing here: the
1919
+ * seam matches scopes exactly, with no prefix rule. The validator sits behind the gate, so a well-formed
1920
+ * body is never confirmed to a caller who may not send one.
1921
+ *
1922
+ * The write is a repair of the read model, not a purchase: null provenance, no purchase row, and the
1923
+ * projection stays authoritative for any key the catalog sells. See `entitlement/manual.ts`.
1924
+ */
1925
+ /**
1926
+ * AUTHED READ — what the caller's own subscription pays, and when that changes.
1927
+ *
1928
+ * The half that stops a bill changing unannounced. A discount that lapses with nothing having said so
1929
+ * is, from a customer's seat, indistinguishable from a billing error — so a screen offering a
1930
+ * twelve-month rate has to be able to say when the twelve months end.
1931
+ *
1932
+ * The caller's own holder, always: the subscription is found from rows keyed on the subject the seam
1933
+ * resolved, and there is no request field naming one. Answers `null` when that holder has no subscription
1934
+ * a rail can price, which is a fact rather than a failure.
1935
+ *
1936
+ * **`quotedFrom` rides here because a browser cannot price a customer it cannot name.** Paddle quotes
1937
+ * in the browser — `PricePreview` runs from the visitor's own page — and with no customer id it
1938
+ * quotes from the visitor's IP, which is where a browser connected from and not where a card is
1939
+ * registered. The charge settles on the billing address, so `POST /payments/checkout` hands the rail
1940
+ * the customer id from the provider-account map; this publishes **the same read of the same row** so
1941
+ * the quote and the charge cannot resolve location differently. A caller with no store customer yet
1942
+ * gets null, quotes from their IP, and is told the figure is an estimate.
1943
+ *
1944
+ * Beside `pricing` rather than inside it, because the two are independent: somebody who has never
1945
+ * bought anything has no pricing and may still have no customer, and somebody mid-subscription has
1946
+ * both. Nesting one in the other would make the common case unreachable.
1947
+ */
1948
+ app.get(`${base}/pricing`, requireAuth(), async (c) => {
1949
+ const subject = await resolvePaymentsSubject(c, seam);
1950
+ // A read, so nobody resolved is the empty answer rather than a refusal — the same direction
1951
+ // `GET {base}/entitlements` takes, and for the same reason: a screen with no billing account selected
1952
+ // is quoting from nothing, which is a fact rather than a failure.
1953
+ if (subject === undefined)
1954
+ return c.json({ pricing: null, quotedFrom: null } satisfies PaymentsPricingEnvelope, 200);
1955
+ const db = paymentsDatabase(database(c));
1956
+
1957
+ // The one read, shared with `/checkout`. `accountFor` is what that route calls to decide who is
1958
+ // charged, so a screen quoting from this answer is quoting from the row that will be billed.
1959
+ const paddleCustomer = await accountFor(c, "paddle", subject);
1960
+ const quotedFrom: PaymentsQuotedFrom | null =
1961
+ paddleCustomer === null ? null : { rail: "paddle", providerAccountId: paddleCustomer };
1962
+ /** The envelope, so every exit below carries both facts rather than three of them carrying one. */
1963
+ const answer = (pricing: PaymentsPricingResponse | null) =>
1964
+ c.json({ pricing, quotedFrom } satisfies PaymentsPricingEnvelope, 200);
1965
+
1966
+ // The newest subscription row this caller holds on a rail that can price one. `role` matters: a money
1967
+ // row records a closed period and has no "next".
1968
+ const rows = await db
1969
+ .selectFrom(PAYMENTS_PURCHASES_TABLE)
1970
+ .selectAll()
1971
+ .where("subjectType", "=", subject.subjectType)
1972
+ .where("subjectId", "=", subject.subjectId)
1973
+ .where("type", "=", "subscription")
1974
+ .orderBy("providerEventAt", "desc")
1975
+ .execute();
1976
+ // The row that carries the subscription's *standing*, preferred over any receipt.
1977
+ //
1978
+ // On Lemon Squeezy a subscriber's newest rows are `charge` receipts — one per invoice — and a receipt
1979
+ // names a closed period no rail can price. Taking the newest of any role therefore returned null for
1980
+ // every LS subscriber while looking like it had worked. Every other rail writes `charge` for
1981
+ // everything and has no `state` row, so the fallback is what serves them.
1982
+ const row = rows.find((candidate) => candidate.role === "state") ?? rows[0];
1983
+ if (row === undefined) return answer(null);
1984
+
1985
+ const purchase = PaymentsPurchase.parse(row);
1986
+ const provider = resolveRailProvider(purchase.rail, config, await credentials(c), trust);
1987
+ if (!isPricingRail(provider)) return answer(null);
1988
+
1989
+ const pricing = await provider.readPricing(purchase, { now: clock(), deployment: deploymentName(c) });
1990
+ if (pricing === undefined) return answer(null);
1991
+
1992
+ return answer({
1993
+ currency: pricing.currency,
1994
+ currentAmountMinor: pricing.currentAmountMinor,
1995
+ listAmountMinor: pricing.listAmountMinor,
1996
+ discountCode: pricing.discountCode,
1997
+ discountEndsAt: pricing.discountEndsAt === null ? null : pricing.discountEndsAt.toISOString(),
1998
+ });
1999
+ });
2000
+
2001
+ /**
2002
+ * AUTHED READ — where the caller's own subscription stands, read live from the store.
2003
+ *
2004
+ * **This route ships before the four verbs beside it, and that ordering is the lesson of #247**, which
2005
+ * the module doc above records verbatim: payments shipped `entitlements/grant` and
2006
+ * `entitlements/revoke` with no read beside them, and a dashboard's panes computed *absent* against a
2007
+ * live manifest and dropped out of the rail entirely. A capability that can cancel a subscription and
2008
+ * cannot report the cancellation ships the half that creates the support ticket.
2009
+ *
2010
+ * **Live, never from the projected row, because the one fact this exists to report is the one a row
2011
+ * does not carry.** With a cancellation scheduled Paddle answers `status: "active"`, `canceled_at:
2012
+ * null` and `next_billed_at: null` (recorded 2026-08-28, #465): two of the three say the subscription
2013
+ * is fine and the third says nothing at all. The end date lives only on `scheduled_change.effective_at`,
2014
+ * and the webhook that would have announced it can be dropped.
2015
+ *
2016
+ * **`null` is a real answer and a common one** — somebody who has never bought anything, and somebody
2017
+ * whose store has nothing to say about the row. Not a 404: that would make this an existence oracle and
2018
+ * would read, to a screen, exactly like a Worker that could not be reached.
2019
+ *
2020
+ * The subject comes from the seam and the subscription from that subject's own rows, so there is no
2021
+ * shape of this request that reads somebody else's. Nobody resolved is the empty answer rather than a
2022
+ * refusal, which is the read half of the seam's rule and what `GET {base}/entitlements` already does.
2023
+ */
2024
+ app.get(`${base}/subscription`, requireAuth(), async (c) => {
2025
+ const subject = await resolvePaymentsSubject(c, seam);
2026
+ if (subject === undefined) return c.json({ subscription: null } satisfies PaymentsSubscriptionResponse, 200);
2027
+
2028
+ const target = await changeableSubscription(database(c), subject, clock());
2029
+ // Two subscriptions have no encoding in an envelope that holds one, and picking is the defect the
2030
+ // whole resolver exists to prevent. The refusal is the same one every verb gives.
2031
+ if (target.found === "many") throw tooManySubscriptions(target.purchases);
2032
+ if (target.found === "none") return c.json({ subscription: null } satisfies PaymentsSubscriptionResponse, 200);
2033
+
2034
+ const provider = await subscriptionRail(c, target.purchase.rail);
2035
+ const standing = await provider.readStanding(target.purchase, { now: clock(), deployment: deploymentName(c) });
2036
+ // A rail that cannot address this purchase, or a store that no longer knows it. A fact about the
2037
+ // purchase rather than a failure — a store that could not be *reached* raises
2038
+ // `payments/provider_unavailable` instead, so a screen distinguishes the two.
2039
+ if (standing === undefined) return c.json({ subscription: null } satisfies PaymentsSubscriptionResponse, 200);
2040
+
2041
+ return c.json(
2042
+ {
2043
+ subscription: subscriptionView(target.purchase.productId, standing),
2044
+ } satisfies PaymentsSubscriptionResponse,
2045
+ 200,
2046
+ );
2047
+ });
2048
+
2049
+ /**
2050
+ * AUTHED READ — what moving to one catalog product would cost, as the store previews it.
2051
+ *
2052
+ * **A read that discloses a price, and a `POST` only because it carries a body.** It commits nothing,
2053
+ * stores nothing and writes no audit row: a quote goes stale the moment the billing period moves, and a
2054
+ * persisted one is a price nobody is bound by.
2055
+ *
2056
+ * **The no-op rule does not apply to a preview.** Asking what the plan already held would cost is a
2057
+ * question with an honest answer, and a second preview is free. Borrowing the writes' short-circuit
2058
+ * would mean inventing a recurring figure to fill the quote with, which is the one thing this package
2059
+ * will not do.
2060
+ *
2061
+ * The subscription comes from the caller's own rows and the price from the catalog, exactly as on
2062
+ * `change` — the two routes share {@link SubscriptionPreviewRequest}, which *is*
2063
+ * {@link SubscriptionChangeRequest}, so a preview cannot quote a figure the commit then refuses.
2064
+ *
2065
+ * `requirePaymentsSubject`, not `resolvePaymentsSubject`, even though this is a read. The seam's rule
2066
+ * gives a read two honest answers — empty or denied — and a quote has no empty form: a figure is what
2067
+ * this route exists to produce. With no holder selected there is no subscription to quote, and
2068
+ * `payments/subject_unresolved` says exactly that, where a 404 would claim the caller has no
2069
+ * subscription anywhere.
2070
+ */
2071
+ app.post(
2072
+ `${base}/subscription/preview`,
2073
+ requireAuth(),
2074
+ zValidator("json", SubscriptionPreviewRequest, validationHook),
2075
+ async (c) => {
2076
+ const input = c.req.valid("json");
2077
+ const subject = await requirePaymentsSubject(c, seam);
2078
+ const purchase = requireOneSubscription(await changeableSubscription(database(c), subject, clock()));
2079
+ const entry = product(config, input.productId);
2080
+ const sku = subscriptionPrice(entry, purchase.rail);
2081
+
2082
+ const provider = await subscriptionRail(c, purchase.rail);
2083
+ const quote = await provider.previewChange(
2084
+ // The row, never an id off it: what identifies a subscription at its store differs per rail and
2085
+ // some of it survives only in the payload. See `SubscriptionChangeInput.purchase`.
2086
+ { purchase, providerProductId: sku },
2087
+ // The only rail call carrying a locale, because it is the only one whose answer carries money a
2088
+ // person reads. See `RailRequestContext.locale`.
2089
+ { now: clock(), deployment: deploymentName(c), locale: readerLocale(c) },
2090
+ );
2091
+ return c.json({ quote: quoteView(quote) } satisfies PaymentsSubscriptionQuoteResponse, 200);
2092
+ },
2093
+ );
2094
+
2095
+ /**
2096
+ * AUTHED WRITE — move the caller's own subscription onto one catalog product.
2097
+ *
2098
+ * **Nothing this route needs comes from the request but the product.** The subscription is resolved
2099
+ * from the caller's own purchase rows ({@link changeableSubscription}), the rail from that row, and the
2100
+ * store's price from the catalog. A body-named subscription moves somebody else's; a body-named price
2101
+ * moves a customer onto a plan this project does not sell; a body-named rail is the wrong store asked
2102
+ * about a subscription that does not live there. None of the three is refused by a check — each is
2103
+ * unreachable because there is nowhere to write it.
2104
+ *
2105
+ * **No proration mode and no `on_payment_failure`, for the same reason.** The rail picks the mode from
2106
+ * the *direction* of the change — up charges now, down defers the credit to the next invoice — and
2107
+ * always prevents a change that cannot be paid for. A mode a client could set is a mode a client would
2108
+ * eventually set to Paddle's `do_not_bill`, which is a free upgrade.
2109
+ *
2110
+ * **This route writes no purchase row.** The webhook owns that row; a route that also wrote it would be
2111
+ * a second producer of one row, and the two disagree the first time a webhook is late. So the response
2112
+ * is the store's own answer to where the subscription now stands, and the plan named on it is the one
2113
+ * the store just confirmed rather than the one the stale row still carries.
2114
+ *
2115
+ * **The no-op is a success and leaves no trail.** A change to the plan already held returns the current
2116
+ * standing without calling the provider — the rail's rule, and the route must not turn it into a 409.
2117
+ * The audit row is withheld with it: a retried write must not become a second proration, and it must
2118
+ * not become a second row asserting a change that did not happen either. The route decides that from
2119
+ * its own row rather than from the rail, which reports no such flag, and the residual gap is stated at
2120
+ * {@link changedPlan}.
2121
+ */
2122
+ app.post(
2123
+ `${base}/subscription/change`,
2124
+ requireAuth(),
2125
+ zValidator("json", SubscriptionChangeRequest, validationHook),
2126
+ async (c) => {
2127
+ const input = c.req.valid("json");
2128
+ const subject = await requirePaymentsSubject(c, seam);
2129
+ const purchase = requireOneSubscription(await changeableSubscription(database(c), subject, clock()));
2130
+ const entry = product(config, input.productId);
2131
+ const sku = subscriptionPrice(entry, purchase.rail);
2132
+ // Decided *before* the call, from the row as it stands. See {@link changedPlan}.
2133
+ const changed = changedPlan(purchase, entry.id);
2134
+
2135
+ const provider = await subscriptionRail(c, purchase.rail);
2136
+ const standing = await provider.changePlan(
2137
+ { purchase, providerProductId: sku },
2138
+ { now: clock(), deployment: deploymentName(c) },
2139
+ );
2140
+
2141
+ if (changed) {
2142
+ await c.var.emit({
2143
+ action: PaymentsAuditActions.subscriptionPlanChanged,
2144
+ outcome: "success",
2145
+ // The person who asked, always — the holder they act for rides in the metadata. Under
2146
+ // organization billing the two differ, and collapsing them answers "who did this" with a
2147
+ // company rather than with somebody who can be asked about it.
2148
+ actorType: "user",
2149
+ actorId: c.var.auth?.userId,
2150
+ sessionId: c.var.auth?.sessionId,
2151
+ resourceType: "purchase",
2152
+ resourceId: purchase.id,
2153
+ // Both plans, in both vocabularies, and no money. A quote is not what was charged, and copying
2154
+ // an amount here would make a second, weaker ledger beside the purchases table.
2155
+ metadata: {
2156
+ rail: purchase.rail,
2157
+ subjectType: subject.subjectType,
2158
+ subjectId: subject.subjectId,
2159
+ productId: entry.id,
2160
+ fromProductId: purchase.productId,
2161
+ providerProductId: sku,
2162
+ fromProviderProductId: purchase.providerProductId,
2163
+ },
2164
+ });
2165
+ }
2166
+
2167
+ return c.json(
2168
+ // `entry.id`, not the row's product: the store has applied the move and the row has not caught up.
2169
+ { subscription: subscriptionView(entry.id, standing) } satisfies PaymentsSubscriptionStandingResponse,
2170
+ 200,
2171
+ );
2172
+ },
2173
+ );
2174
+
2175
+ /**
2176
+ * AUTHED WRITE — stop the caller's own subscription renewing.
2177
+ *
2178
+ * **`at_period_end` is the settled policy and the timing is still stated, never defaulted.** The two
2179
+ * timings are different things to buy — keep the period already paid for, or lose it today — and a body
2180
+ * that omitted the field would be choosing one of them by silence. `now` exists because support
2181
+ * occasionally has to end a subscription today, and because a policy with no legitimate exit gets
2182
+ * departed from by a direct provider call nothing audits.
2183
+ *
2184
+ * The subscription is the caller's own, resolved from their rows; nothing in the body names one. The
2185
+ * customer's word is what crosses, and the rail translates it — Paddle's `next_billing_period` does not
2186
+ * parse here, so a client that stopped translating fails loudly rather than sending a string Paddle
2187
+ * happens to accept.
2188
+ *
2189
+ * **The no-op is a success and is not audited.** A cancellation already scheduled for the timing asked
2190
+ * for is the state the caller wanted, so there is nothing to refuse; and a retried cancel must not
2191
+ * leave a trail claiming two of them. The standing is read before the write and compared afterwards —
2192
+ * see {@link standingMoved} for why that is the honest test and not a second copy of the rail's rule.
2193
+ *
2194
+ * Writes no purchase row, as every route here does not: the webhook owns it.
2195
+ */
2196
+ app.post(
2197
+ `${base}/subscription/cancel`,
2198
+ requireAuth(),
2199
+ zValidator("json", SubscriptionCancelRequest, validationHook),
2200
+ async (c) => {
2201
+ const input = c.req.valid("json");
2202
+ const subject = await requirePaymentsSubject(c, seam);
2203
+ const purchase = requireOneSubscription(await changeableSubscription(database(c), subject, clock()));
2204
+
2205
+ const provider = await subscriptionRail(c, purchase.rail);
2206
+ const context = { now: clock(), deployment: deploymentName(c) };
2207
+ // Read before written, because a scheduled cancellation is the one fact the purchase row cannot
2208
+ // carry and therefore the one thing that decides whether this request changed anything.
2209
+ const before = await provider.readStanding(purchase, context);
2210
+ const standing = await provider.cancelSubscription({ purchase, timing: input.timing }, context);
2211
+
2212
+ if (standingMoved(before, standing)) {
2213
+ await c.var.emit({
2214
+ action: PaymentsAuditActions.subscriptionCanceled,
2215
+ outcome: "success",
2216
+ actorType: "user",
2217
+ actorId: c.var.auth?.userId,
2218
+ sessionId: c.var.auth?.sessionId,
2219
+ resourceType: "purchase",
2220
+ resourceId: purchase.id,
2221
+ // The timing asked for, and the day it lands. That day is the answer to "when does this person
2222
+ // lose access", and on a scheduled cancellation it exists nowhere else — the status still says
2223
+ // active and the next billing date has gone blank.
2224
+ metadata: {
2225
+ rail: purchase.rail,
2226
+ subjectType: subject.subjectType,
2227
+ subjectId: subject.subjectId,
2228
+ productId: purchase.productId,
2229
+ timing: input.timing,
2230
+ effectiveAt: standing.scheduledChange?.effectiveAt.toISOString() ?? null,
2231
+ },
2232
+ });
2233
+ }
2234
+
2235
+ return c.json(
2236
+ {
2237
+ subscription: subscriptionView(purchase.productId, standing),
2238
+ } satisfies PaymentsSubscriptionStandingResponse,
2239
+ 200,
2240
+ );
2241
+ },
2242
+ );
2243
+
2244
+ /**
2245
+ * AUTHED WRITE — withdraw a scheduled cancellation, so the caller's subscription renews after all.
2246
+ *
2247
+ * **No body at all, and no validator, exactly as `POST {base}/portal` has none.** Which subscription is
2248
+ * the server's answer, and there is nothing to say about it but *do not*. A `z.object({})` would read as
2249
+ * a refusal of everything and be neither — Zod strips rather than refuses — while making a POST with no
2250
+ * body a 400 on a route that wants none.
2251
+ *
2252
+ * **It withdraws a cancellation and only a cancellation, and that check is the rail's.** Paddle offers
2253
+ * no narrower verb: the update clears `scheduled_change` wholesale, and that field also holds a
2254
+ * scheduled pause and a scheduled resume — so a rail that simply sent the clear would restart billing
2255
+ * on a paused account, on a request that said nothing about pausing. The rail re-reads and refuses
2256
+ * anything but a pending `cancel`. **The check cannot move here:** this route holds a projected row, and
2257
+ * the pending action lives only at the store.
2258
+ *
2259
+ * **Its own route and its own audit action, not an outcome on the cancellation.** The two are separate
2260
+ * acts by possibly separate actors, and the pair is what a dispute is reconstructed from. Folded
2261
+ * together, the trail asserts a cancellation and holds nothing saying it was taken back.
2262
+ *
2263
+ * A subscription with nothing scheduled is the no-op rather than a refusal — it already renews, which
2264
+ * is what the caller asked for — and it writes no audit row, for {@link standingMoved}'s reason.
2265
+ */
2266
+ app.post(`${base}/subscription/keep`, requireAuth(), async (c) => {
2267
+ const subject = await requirePaymentsSubject(c, seam);
2268
+ const purchase = requireOneSubscription(await changeableSubscription(database(c), subject, clock()));
2269
+
2270
+ const provider = await subscriptionRail(c, purchase.rail);
2271
+ const context = { now: clock(), deployment: deploymentName(c) };
2272
+ const before = await provider.readStanding(purchase, context);
2273
+ const standing = await provider.keepSubscription(purchase, context);
2274
+
2275
+ if (standingMoved(before, standing)) {
2276
+ await c.var.emit({
2277
+ action: PaymentsAuditActions.subscriptionCancelWithdrawn,
2278
+ outcome: "success",
2279
+ actorType: "user",
2280
+ actorId: c.var.auth?.userId,
2281
+ sessionId: c.var.auth?.sessionId,
2282
+ resourceType: "purchase",
2283
+ resourceId: purchase.id,
2284
+ metadata: {
2285
+ rail: purchase.rail,
2286
+ subjectType: subject.subjectType,
2287
+ subjectId: subject.subjectId,
2288
+ productId: purchase.productId,
2289
+ },
2290
+ });
2291
+ }
2292
+
2293
+ return c.json(
2294
+ { subscription: subscriptionView(purchase.productId, standing) } satisfies PaymentsSubscriptionStandingResponse,
2295
+ 200,
2296
+ );
2297
+ });
2298
+
2299
+ /**
2300
+ * AUTHED WRITE — ask the store to give this subscription's payments back.
2301
+ *
2302
+ * **A request, and the response never says otherwise.** Paddle holds most live refunds at
2303
+ * `pending_approval` until a person there reviews them, so nothing here revokes an entitlement, writes a
2304
+ * purchase row, or touches a projection. The approval, when it comes, arrives as a webhook, and
2305
+ * `rails/paddle/adjustments.ts` and the projection writer are the only things that act on it. Revoking
2306
+ * on the *request* would take access from a customer whose refund the store then rejects, leaving them
2307
+ * with neither the money nor the product.
2308
+ *
2309
+ * **No body, exactly as `keep` has none.** Which subscription is the server's answer, which payments is
2310
+ * the server's answer, and how much is not a question anyone may ask: every refund raised here is for a
2311
+ * transaction's whole total. A body naming a transaction refunds a stranger's money; a body naming an
2312
+ * amount is a self-service withdrawal; a body naming a reason writes free text into the adopter's own
2313
+ * back office. None of the three is refused by a check — each is unreachable because there is nowhere to
2314
+ * write it.
2315
+ *
2316
+ * **It refunds every payment on the subscription, and applies no window.** How many days a customer has
2317
+ * to ask is the *adopter's* commercial policy, and a kit that hard-coded fourteen would be wrong for the
2318
+ * second adopter. The kit makes the refund possible; the adopter's screen decides which button exists.
2319
+ * See {@link refundablePayments}.
2320
+ *
2321
+ * **A partial cannot be silent.** The rail refuses the whole request before it writes anything and
2322
+ * reports every outcome once it has — {@link RefundRail} holds the argument — and the response carries
2323
+ * one entry per payment, so a caller counting entries and a caller counting their own payments get the
2324
+ * same number.
2325
+ *
2326
+ * **The audit row is the only record that a refund was asked for**, until and unless the store approves
2327
+ * one. It is withheld when nothing was raised, which is the retry: a request repeated against refunds
2328
+ * already standing is the state the caller wanted, and a trail claiming two of them is worse than one
2329
+ * claiming none.
2330
+ */
2331
+ app.post(`${base}/subscription/refund`, requireAuth(), async (c) => {
2332
+ const subject = await requirePaymentsSubject(c, seam);
2333
+ const purchase = requireOneSubscription(await changeableSubscription(database(c), subject, clock()));
2334
+ // The rail is narrowed **before** the payments are resolved, and the order is the answer a caller
2335
+ // gets. "This store does not refund from the server" is true of every payment on the subscription and
2336
+ // is what an Apple or Google subscriber needs to hear; "there is no payment to refund" would be the
2337
+ // same 409 they would get if they had never paid, and it would send them looking for the wrong thing.
2338
+ const provider = await refundRail(c, purchase.rail);
2339
+ const payments = await refundablePayments(database(c), subject, purchase);
2340
+ if (payments.length === 0) {
2341
+ // A 409 rather than an empty report, and rather than a 404. The subscription is there and the
2342
+ // request is well-formed; what is absent is a payment to refund, which is a fact about the account's
2343
+ // present state. An empty report would read as "refunded, nothing to do".
2344
+ throw new PaymentsSubscriptionChangeRefusedError({
2345
+ message: "There is no payment on this subscription to refund.",
2346
+ action: "Check the subscription's payment history. A refund attaches to a payment, not to a plan.",
2347
+ detail: `No charge rows resolved on ${purchase.rail} for the family this subscription heads.`,
2348
+ });
2349
+ }
2350
+
2351
+ const refund = await provider.requestRefunds(
2352
+ {
2353
+ purchases: payments,
2354
+ // Composed here and never read from a body: it is written into the adopter's own store console,
2355
+ // where a person reads it as a statement about their business. The catalog product and nothing
2356
+ // else — no customer text, no identifier a store does not already hold.
2357
+ reason: `Refund requested by the subscriber for "${purchase.productId}".`,
2358
+ },
2359
+ { now: clock(), deployment: deploymentName(c) },
2360
+ );
2361
+
2362
+ const raised = refund.outcomes.filter((outcome) => outcome.outcome === "requested");
2363
+ if (raised.length > 0) {
2364
+ await c.var.emit({
2365
+ action: PaymentsAuditActions.subscriptionRefundRequested,
2366
+ outcome: "success",
2367
+ // The person who asked, always — the holder they act for rides in the metadata.
2368
+ actorType: "user",
2369
+ actorId: c.var.auth?.userId,
2370
+ sessionId: c.var.auth?.sessionId,
2371
+ resourceType: "purchase",
2372
+ resourceId: purchase.id,
2373
+ // Counts and the store's own handles on the money in flight. **No amount**: how much anybody is
2374
+ // getting back is the store's later decision, and a figure here would assert something nobody has
2375
+ // agreed to, in the one table nothing corrects.
2376
+ metadata: {
2377
+ rail: purchase.rail,
2378
+ subjectType: subject.subjectType,
2379
+ subjectId: subject.subjectId,
2380
+ productId: purchase.productId,
2381
+ payments: refund.outcomes.length,
2382
+ requested: raised.length,
2383
+ alreadyRequested: refund.outcomes.filter((outcome) => outcome.outcome === "already_requested").length,
2384
+ failed: refund.outcomes.filter((outcome) => outcome.outcome === "failed").length,
2385
+ adjustmentIds: raised.map((outcome) => outcome.adjustmentId),
2386
+ },
2387
+ });
2388
+ }
2389
+
2390
+ return c.json({ refund: refundView(refund) } satisfies PaymentsRefundResponse, 200);
2391
+ });
2392
+
2393
+ /**
2394
+ * CONTROL PLANE. The discount codes this project has issued.
2395
+ *
2396
+ * Read from the store rather than from a table of ours: the store is where a code actually exists, and a
2397
+ * local mirror would be a second answer that drifts the first time somebody uses the dashboard.
2398
+ *
2399
+ * Its own scope, narrower than minting. A pane that lists what was issued does not need the power to
2400
+ * issue. Never reaches a browser — the set of codes an adopter has issued is a commercial fact, and the
2401
+ * client projection draws the same line here it draws for SKUs.
2402
+ */
2403
+ app.get(
2404
+ `${base}/admin/discounts`,
2405
+ requireControlPlane(PAYMENTS_DISCOUNT_READ_SCOPE),
2406
+ zValidator("query", AdminDiscountsQuery, validationHook),
2407
+ async (c) => {
2408
+ const { rail } = c.req.valid("query");
2409
+ const provider = resolveRailProvider(rail, config, await credentials(c), trust);
2410
+ if (!isDiscountRail(provider)) {
2411
+ throw new PaymentsRailNotConfiguredError({ detail: `The ${provider.rail} rail does not mint discounts.` });
2412
+ }
2413
+
2414
+ const discounts = await provider.listDiscounts({ now: clock(), deployment: deploymentName(c) });
2415
+
2416
+ // Through `recordRead`, like every other management read: its own action, the caller's `sub` as the
2417
+ // actor, and a count rather than the rows.
2418
+ await recordRead(c, PaymentsAuditActions.discountsRead, rail, { rail, count: discounts.length });
2419
+
2420
+ return c.json({ discounts: [...discounts] } satisfies PaymentsAdminDiscountsResponse, 200);
2421
+ },
2422
+ );
2423
+
2424
+ /**
2425
+ * CONTROL PLANE. Mint a discount code at one store.
2426
+ *
2427
+ * **Its own scope**, `payments:discounts:create`, granted separately from the entitlement writes.
2428
+ * Comping somebody an entitlement and creating a code that reduces what everybody holding it pays are
2429
+ * different powers with different blast radii, and a tool that needs one must not acquire the other.
2430
+ *
2431
+ * **The kit provides the verb; the adopter provides the policy.** This creates the object at the store
2432
+ * and answers with what it made. Who may be offered a code, what it is worth, where that offer is
2433
+ * recorded and when it stops being advertised are commercial decisions with a company's pricing behind
2434
+ * them — a capability that guessed at them would be wrong for the second adopter.
2435
+ *
2436
+ * Applying a code does **not** go through here and does not require this scope. An adopter whose codes
2437
+ * are minted by hand in a provider dashboard is fully served by `/checkout`'s `discountCode`.
2438
+ */
2439
+ app.post(
2440
+ `${base}/admin/discounts`,
2441
+ requireControlPlane(PAYMENTS_DISCOUNT_CREATE_SCOPE),
2442
+ zValidator("json", DiscountCreateRequest, validationHook),
2443
+ async (c) => {
2444
+ const input = c.req.valid("json");
2445
+ const provider = resolveRailProvider(input.rail, config, await credentials(c), trust);
2446
+ if (!isDiscountRail(provider)) {
2447
+ // Structural, so a rail that gains the ability needs no edit here and one that never will can
2448
+ // never be asked.
2449
+ throw new PaymentsRailNotConfiguredError({
2450
+ detail: `The ${provider.rail} rail does not mint discounts.`,
2451
+ });
2452
+ }
2453
+
2454
+ const created = await provider.createDiscount(input.terms, { now: clock() });
2455
+
2456
+ const who = controlPlaneCaller(c);
2457
+ await c.var.emit({
2458
+ action: PaymentsAuditActions.discountCreated,
2459
+ outcome: "success",
2460
+ // The token's `sub`, so the trail names which person at the dashboard minted it rather than merely
2461
+ // "the dashboard" — one connection is normally shared by everybody using that console.
2462
+ actorType: "control-plane",
2463
+ actorId: who.subject,
2464
+ resourceType: "discount",
2465
+ resourceId: created.providerDiscountId,
2466
+ // What was minted and what it is worth, and the **store's id** rather than the code itself. The
2467
+ // code is a live bearer value: anyone who can read the trail could redeem it, and an audit trail
2468
+ // is queryable, long-lived, and read by more people than can mint. The id finds it in the
2469
+ // dashboard, which is where somebody entitled to see the code already is.
2470
+ metadata: {
2471
+ rail: input.rail,
2472
+ connectionId: who.connectionId,
2473
+ amount:
2474
+ created.terms.amount.kind === "percent"
2475
+ ? `${created.terms.amount.percent}%`
2476
+ : `${created.terms.amount.amountMinor} ${created.terms.amount.currency}`,
2477
+ duration: created.terms.duration.kind,
2478
+ },
2479
+ });
2480
+
2481
+ return c.json(
2482
+ {
2483
+ code: created.code,
2484
+ providerDiscountId: created.providerDiscountId,
2485
+ rail: input.rail,
2486
+ } satisfies PaymentsDiscountResponse,
2487
+ 200,
2488
+ );
2489
+ },
2490
+ );
2491
+
2492
+ app.post(
2493
+ `${base}/entitlements/grant`,
2494
+ requireControlPlane(PAYMENTS_ENTITLEMENT_GRANT_SCOPE),
2495
+ zValidator("json", EntitlementGrantRequest, validationHook),
2496
+ async (c) => {
2497
+ const input = c.req.valid("json");
2498
+ const caller = controlPlaneCaller(c);
2499
+ // **A grant under the kind this project does not bill is refused here**, and this is the one check
2500
+ // that genuinely belongs at the edge rather than at the write. `grantEntitlement` reads a config to
2501
+ // check the entitlement key against the catalog; the kind is a different question with a different
2502
+ // answer, and `revokeEntitlement` takes no config at all — so pushing this down would either grow a
2503
+ // parameter the revoke must never have, or apply to both. The asymmetry is deliberate and is the
2504
+ // same one the catalog check makes: a grant is constrained, a revoke stays legal forever, because a
2505
+ // revoke that a config edit made impossible would strand whoever still holds the row.
2506
+ //
2507
+ // The failure it prevents is the invisible kind #300 exists for. A comp written as `user:ada` in a
2508
+ // project that bills organizations lands in the table, answers 200, and is read by nothing: every
2509
+ // gate resolves the caller's *organization*, so the person is still locked out and the row says
2510
+ // otherwise. Not audited as a denial, unlike the catalog miss — there is nothing to enumerate here,
2511
+ // because `billingSubject` is one bit, fixed at deploy, and the message names it outright.
2512
+ if (input.subjectType !== config.billingSubject) {
2513
+ throw new ValidationError({
2514
+ message: `This project bills ${config.billingSubject}s, not ${input.subjectType}s.`,
2515
+ action: "Send `subjectType` as the kind this project bills, or change `billingSubject` in pithy.config.ts.",
2516
+ detail: `A grant to a ${input.subjectType} here writes a row keyed on a kind no read path resolves, so the holder stays unentitled and the table says otherwise.`,
2517
+ });
2518
+ }
2519
+ // The catalog check lives in `grantEntitlement`, at the write (#305). This route no longer decides
2520
+ // whether a key means something — it reports the refusal, which is a different job and the only one
2521
+ // an edge should have. Anything else here would be a second copy of the rule, and a second copy is
2522
+ // how the first one stops being the rule.
2523
+ let granted: PaymentsEntitlement;
2524
+ try {
2525
+ granted = await grantEntitlement(
2526
+ database(c),
2527
+ config,
2528
+ {
2529
+ subjectType: input.subjectType,
2530
+ subjectId: input.subjectId,
2531
+ entitlement: input.entitlement,
2532
+ expiresAt: input.expiresAt ?? null,
2533
+ },
2534
+ { now: clock() },
2535
+ );
2536
+ } catch (cause) {
2537
+ // The refusal is the outcome worth recording here, and it was the only one not recorded. A
2538
+ // credential scoped only to grant can otherwise enumerate this project's entitlement vocabulary
2539
+ // one key at a time — 400 for a miss, 200 for a hit — and leave the customer nothing to read
2540
+ // afterwards. One refusal is a typo; a run of them against one connection is somebody mapping
2541
+ // what a project sells.
2542
+ //
2543
+ // `safeEmit` for the same reason the webhook guard uses it: the 400 is already decided, and an
2544
+ // audit write that threw would answer 500 for a failing store and 400 for a healthy one.
2545
+ if (cause instanceof PaymentsEntitlementNotInCatalogError) {
2546
+ await safeEmit(
2547
+ c.var.emit,
2548
+ {
2549
+ action: PaymentsAuditActions.entitlementGranted,
2550
+ outcome: "denied",
2551
+ severity: "warning",
2552
+ actorType: "service",
2553
+ actorId: c.var.controlPlane?.connectionId ?? "control-plane",
2554
+ resourceType: "entitlement",
2555
+ resourceId: input.entitlement,
2556
+ // The route and the submitted key, and nothing else the caller supplied. The key is safe to
2557
+ // record — `EntitlementKey` has already bounded it to `^[a-z][a-z0-9_]*$` at 64 characters —
2558
+ // and it is the only field that makes a run of refusals legible. Never the catalog: the
2559
+ // defined set goes in `detail`, which the codec strips, and must not be copied into a
2560
+ // queryable, long-lived trail.
2561
+ metadata: { route: "entitlements/grant", entitlement: input.entitlement },
2562
+ },
2563
+ c.var.log,
2564
+ );
2565
+ }
2566
+ throw cause;
2567
+ }
2568
+ await c.var.emit({
2569
+ action: PaymentsAuditActions.entitlementGranted,
2570
+ outcome: "success",
2571
+ // Notable, not routine: nothing else in this package writes an entitlement no store paid for.
2572
+ severity: "warning",
2573
+ // A management client, not a user of this app — so `control-plane`, and the actor is the token's
2574
+ // `sub`: which person at the dashboard did this, not merely "the dashboard". No `sessionId`, because
2575
+ // a control-plane call creates none and a null one would read as a correlation that got lost.
2576
+ actorType: "control-plane",
2577
+ actorId: caller.subject,
2578
+ resourceType: "entitlement",
2579
+ resourceId: granted.id,
2580
+ // The holder is the queryable fact — "what has been comped to this subject" is the question the
2581
+ // trail gets asked. The connection joins it to *which* management client, per adopter and per
2582
+ // environment, which the actor's own id space cannot answer on its own.
2583
+ metadata: {
2584
+ connectionId: caller.connectionId,
2585
+ // Two keys, never one joined string: "what has been comped to this holder" is a query, and a
2586
+ // trail is queried by equality on a column.
2587
+ subjectType: input.subjectType,
2588
+ subjectId: input.subjectId,
2589
+ entitlement: input.entitlement,
2590
+ expiresAt: granted.expiresAt?.toISOString() ?? null,
2591
+ },
2592
+ });
2593
+ return c.json(
2594
+ {
2595
+ entitlement: entitlementView({
2596
+ key: granted.entitlement,
2597
+ active: granted.active,
2598
+ expiresAt: granted.expiresAt,
2599
+ }),
2600
+ } satisfies PaymentsEntitlementResponse,
2601
+ 200,
2602
+ );
2603
+ },
2604
+ );
2605
+
2606
+ /**
2607
+ * CONTROL PLANE. Revoke an entitlement by hand — a chargeback, an abuse decision, a comp withdrawn.
2608
+ *
2609
+ * Immediate, because the read model is what every gate hits: the account loses access on its next
2610
+ * request. Idempotent, and legal against an account that never held the key — the inactive row is the
2611
+ * record that somebody decided it, and support tooling should not have to ask first.
2612
+ *
2613
+ * Its own scope, `payments:entitlements:revoke`, granted separately from grant's. Taking paid access away
2614
+ * from a live customer and handing product out for free are different mistakes with different victims, and
2615
+ * an adopter's refund tooling has no business being able to make the second one.
2616
+ */
2617
+ app.post(
2618
+ `${base}/entitlements/revoke`,
2619
+ requireControlPlane(PAYMENTS_ENTITLEMENT_REVOKE_SCOPE),
2620
+ zValidator("json", EntitlementRevokeRequest, validationHook),
2621
+ async (c) => {
2622
+ const input = c.req.valid("json");
2623
+ const caller = controlPlaneCaller(c);
2624
+ // **No billing-mode check here, and its absence is the design.** `revokeEntitlement` takes no config,
2625
+ // deliberately — see `entitlement/manual.ts` — so it has nothing to check a kind against, and giving
2626
+ // it one would make a `billingSubject` change as irreversible as a catalog edit: every row written
2627
+ // under the old kind would become unrevokable, on accounts that still hold it. A grant is
2628
+ // constrained and a revoke is not, exactly as with the catalog key.
2629
+ const revoked = await revokeEntitlement(
2630
+ database(c),
2631
+ { subjectType: input.subjectType, subjectId: input.subjectId, entitlement: input.entitlement },
2632
+ { now: clock() },
2633
+ );
2634
+ await c.var.emit({
2635
+ action: PaymentsAuditActions.entitlementRevoked,
2636
+ outcome: "success",
2637
+ severity: "warning",
2638
+ // Same attribution as the grant: the management client's own subject, and the connection it called
2639
+ // on. See that handler for why neither is a user id and why there is no session.
2640
+ actorType: "control-plane",
2641
+ actorId: caller.subject,
2642
+ resourceType: "entitlement",
2643
+ resourceId: revoked.id,
2644
+ metadata: {
2645
+ connectionId: caller.connectionId,
2646
+ subjectType: input.subjectType,
2647
+ subjectId: input.subjectId,
2648
+ entitlement: input.entitlement,
2649
+ },
2650
+ });
2651
+ return c.json(
2652
+ {
2653
+ entitlement: entitlementView({
2654
+ key: revoked.entitlement,
2655
+ active: revoked.active,
2656
+ expiresAt: revoked.expiresAt,
2657
+ }),
2658
+ } satisfies PaymentsEntitlementResponse,
2659
+ 200,
2660
+ );
2661
+ },
2662
+ );
2663
+ };
2664
+
2665
+ /**
2666
+ * The webhook handler, once, for every rail.
2667
+ *
2668
+ * By the time it runs the guard has verified authenticity, recorded the delivery, and short-circuited a
2669
+ * redelivery it already processed — and the rail has produced a normalized event or explained why it could
2670
+ * not. So this is entirely about what to do with a verified notification, and none of it is rail-specific:
2671
+ * three stores, three unrelated proofs, one projection.
2672
+ *
2673
+ * **Always 200 once authenticity is established.** Every provider retries a non-2xx, and every refusal that
2674
+ * can be reached here is deterministic — an unmapped SKU, a sandbox transaction against production, a
2675
+ * notification with nobody to project it against. Retrying those produces the identical refusal forever, so a
2676
+ * 5xx would buy a retry storm and no repair. The recorded row with its reason is what makes the failure
2677
+ * visible, queryable, and replayable instead. A failure that is *not* deterministic never gets here: the guard
2678
+ * lets `payments/provider_unavailable` through with its own status, so an unreachable store is a non-2xx the
2679
+ * provider will redeliver.
2680
+ */
2681
+ function webhookHandler(rail: PaymentsRail) {
2682
+ return async (c: Context<PithyHonoEnv>) => {
2683
+ const { notification, eventRowId } = verifiedWebhook(c);
2684
+ const now = clock();
2685
+ const d1 = database(c);
2686
+
2687
+ /**
2688
+ * Record the delivery's outcome and put it on the audit trail. One shape for every non-projecting path.
2689
+ *
2690
+ * **`note` and `error` are the same sentence to an operator and opposite states in the row**, so the
2691
+ * call sites below say which they mean rather than letting the presence of a reason decide. A note is
2692
+ * why nothing was *ever* going to project — the row is finished. An error is why this attempt did not
2693
+ * — the row stays repairable. See {@link completeWebhook}; #339 is what happens when the two are one.
2694
+ */
2695
+ const acknowledge = async (outcome: {
2696
+ note?: string;
2697
+ error?: string;
2698
+ reason?: string;
2699
+ severity?: "warning";
2700
+ }): Promise<Response> => {
2701
+ await completeWebhook(
2702
+ d1,
2703
+ eventRowId,
2704
+ outcome.error !== undefined
2705
+ ? { at: now, error: outcome.error }
2706
+ : outcome.note !== undefined
2707
+ ? { at: now, note: outcome.note }
2708
+ : { at: now },
2709
+ );
2710
+ await c.var.emit({
2711
+ action: PaymentsAuditActions.webhookReceived,
2712
+ outcome: outcome.reason === undefined ? "success" : "failure",
2713
+ ...(outcome.severity === undefined ? {} : { severity: outcome.severity }),
2714
+ actorType: "service",
2715
+ actorId: rail,
2716
+ metadata: {
2717
+ rail,
2718
+ providerEventId: notification.providerEventId,
2719
+ projected: false,
2720
+ ...(outcome.reason === undefined ? {} : { reason: outcome.reason }),
2721
+ },
2722
+ });
2723
+ return c.json({ received: true, projected: false }, 200);
2724
+ };
2725
+
2726
+ // Bind the store account to a user, when the notification carried both halves — before anything else,
2727
+ // because the pairing is worth keeping even for a delivery that projects nothing. On a rail Pithy
2728
+ // initiates, this is the *only* place the map is ever written: a Stripe webhook arrives carrying `cus_…`,
2729
+ // and the `client_reference_id` beside it is the reference `/checkout` set from the authenticated buyer.
2730
+ // `linkProviderAccount` never rebinds, so the first pairing wins and a later session cannot steal it.
2731
+ //
2732
+ // **The reference decodes or it names nobody.** It is a string that made a round trip through somebody
2733
+ // else's system, so a bare id — the shape every pre-subject client sent — and a kind this build does
2734
+ // not know both answer `undefined`, and `undefined` writes no link at all. Reading a bare id as a user
2735
+ // would bind a store account to whoever happens to hold that id, which is the one guess this whole
2736
+ // design exists to refuse.
2737
+ const stamped =
2738
+ notification.accountReference === null || notification.accountReference === undefined
2739
+ ? undefined
2740
+ : decodeSubjectReference(notification.accountReference);
2741
+ if (notification.providerAccountId && stamped) {
2742
+ await linkProviderAccount(d1, rail, notification.providerAccountId, stamped, { now });
2743
+ await repairOrphans(c, rail, now);
2744
+ // And the pairing is worth acting on, not only keeping. An orphan is a purchase that arrived before
2745
+ // its owner was knowable, and this link is the event that makes it knowable — the one signal no
2746
+ // store redelivers on. See `projection/orphans.ts`; #341 is the ten sweeps that never projected.
2747
+ }
2748
+
2749
+ /**
2750
+ * A refund reported by order id alone — Play's voided-purchase notification, the only one shaped this way.
2751
+ *
2752
+ * The rail cannot resolve it: Play's one-time lookup wants a product id the notification does not carry.
2753
+ * But the product is not Play's to supply. A Google purchase is keyed by its order id, so the row this
2754
+ * refund is about is one indexed read away, and it already knows its own product, owner, and family. The
2755
+ * notification is authentic and says the order was voided; that is the same statement Apple's `REFUND`
2756
+ * makes, and it needs no second opinion from the store.
2757
+ *
2758
+ * A void naming an order we never projected is genuinely unresolvable — a purchase from before this
2759
+ * capability was installed, or one that never reached us — so it is recorded with its reason, which is
2760
+ * what makes it findable later.
2761
+ */
2762
+ if (!notification.event && notification.voidedOrderId) {
2763
+ const voided = await paymentsDatabase(d1)
2764
+ .selectFrom(PAYMENTS_PURCHASES_TABLE)
2765
+ .selectAll()
2766
+ .where("rail", "=", rail)
2767
+ .where("providerTransactionId", "=", notification.voidedOrderId)
2768
+ .executeTakeFirst();
2769
+ if (voided) {
2770
+ const row = PaymentsPurchase.parse(voided);
2771
+ const projection = await projectPurchase(
2772
+ d1,
2773
+ {
2774
+ ...row,
2775
+ status: "refunded",
2776
+ revokedAt: now,
2777
+ // The store's own event time, so the monotonic rule orders this against whatever else arrives.
2778
+ providerEventAt: now,
2779
+ payload: notification.payload,
2780
+ },
2781
+ { config, environment: deploymentEnvironment(c), now },
2782
+ );
2783
+ await fulfill(c, projection);
2784
+ await completeWebhook(d1, eventRowId, { at: now });
2785
+ await c.var.emit({
2786
+ action: PaymentsAuditActions.webhookReceived,
2787
+ outcome: "success",
2788
+ actorType: "service",
2789
+ actorId: rail,
2790
+ resourceType: "purchase",
2791
+ resourceId: projection.purchase.id,
2792
+ metadata: {
2793
+ rail,
2794
+ providerEventId: notification.providerEventId,
2795
+ productId: projection.product.id,
2796
+ status: projection.purchase.status,
2797
+ outcome: projection.outcome,
2798
+ projected: true,
2799
+ },
2800
+ });
2801
+ return c.json({ received: true, projected: true, outcome: projection.outcome }, 200);
2802
+ }
2803
+ return await acknowledge({
2804
+ error: `${noteText(notification.note) ?? "voided purchase"} — no purchase is stored under that order id.`,
2805
+ reason: "orphaned",
2806
+ severity: "warning",
2807
+ });
2808
+ }
2809
+
2810
+ // Authentic, and about no transaction. A test notification, a consumption request, a type the store
2811
+ // shipped after this package did. Where that needs an explanation the rail supplies one as a note.
2812
+ //
2813
+ // **A `note`, never an `error`, and these two branches are one state.** The rail has read the
2814
+ // notification and reported there is no transaction in it; a redelivery of the same bytes gets the
2815
+ // same answer, so the row is finished whether or not the rail explained itself. Recording the
2816
+ // explanation as an error was #339 — it left the row outstanding, which is this table's drift signal
2817
+ // and the guard's short-circuit both, on the deliveries that had nothing to repair.
2818
+ if (!notification.event) {
2819
+ const note = notification.note;
2820
+ if (note === null || note === undefined) return await acknowledge({});
2821
+ // **Which kind of note decides the state, and that is #341.** A note the delivered bytes state is
2822
+ // terminal, because the same bytes get the same answer from this build for ever. A note a *read*
2823
+ // produced is not: the read could have raced the purchase it asked about, or been made with a
2824
+ // credential that has since rotated, and finishing the row on it answered the redelivery that would
2825
+ // have carried the better answer with `duplicate`. See `NotificationNote`.
2826
+ return "stated" in note
2827
+ ? await acknowledge({ note: note.stated, reason: "unresolvable", severity: "warning" })
2828
+ : await acknowledge({ error: note.read, reason: "unresolvable", severity: "warning" });
2829
+ }
2830
+
2831
+ const subject = await resolveNotificationOwner(paymentsDatabase(d1), rail, {
2832
+ providerAccountId: notification.providerAccountId,
2833
+ providerTransactionId: notification.event.providerTransactionId,
2834
+ originalTransactionId: notification.event.originalTransactionId,
2835
+ });
2836
+ if (!subject) {
2837
+ // Orphaned: nothing this server established names a holder, and no reference the store echoed back
2838
+ // decoded to one. No number of retries will conjure a link, so the row is what makes it repairable —
2839
+ // and nothing is projected, because the alternative to knowing is guessing, and a guess here grants
2840
+ // one customer's subscription to another.
2841
+ return await acknowledge({
2842
+ // The marker, not prose. An account linking has to be able to find exactly the rows that were
2843
+ // waiting on it, and this is the one condition it repairs — see `WEBHOOK_EVENT_ORPHANED`.
2844
+ error: `${WEBHOOK_EVENT_ORPHANED} no subject could be resolved for this notification`,
2845
+ reason: "orphaned",
2846
+ severity: "warning",
2847
+ });
2848
+ }
2849
+
2850
+ let projection: PurchaseProjection;
2851
+ try {
2852
+ projection = await projectPurchase(
2853
+ d1,
2854
+ { ...notification.event, ...subject },
2855
+ { config, environment: deploymentEnvironment(c), now },
2856
+ );
2857
+ } catch (cause) {
2858
+ const reason = cause instanceof PithyError ? cause.payload.code : "unknown";
2859
+ return await acknowledge({
2860
+ error:
2861
+ cause instanceof PithyError
2862
+ ? `${reason}: ${cause.payload.detail ?? cause.payload.message}`
2863
+ : "projection failed",
2864
+ reason,
2865
+ severity: "warning",
2866
+ });
2867
+ }
2868
+
2869
+ // A second event the same notification implied — the subscription's standing, where the one above was a
2870
+ // charge. Only Lemon Squeezy sends one, and only on a refund: the invoice row goes `refunded` so the
2871
+ // ledger claws back, and this stops the subscription granting, because the buyer has their money back.
2872
+ //
2873
+ // Projected with the same owner, and outside the catch above on purpose: it is the half that revokes
2874
+ // access, so a failure must reach the store as a non-2xx and be redelivered rather than be acknowledged
2875
+ // as handled. Never fulfilled — a `state` row refuses both credit and clawback by role.
2876
+ if (notification.stateEvent) {
2877
+ await projectPurchase(
2878
+ d1,
2879
+ { ...notification.stateEvent, ...subject },
2880
+ { config, environment: deploymentEnvironment(c), now },
2881
+ );
2882
+ }
2883
+
2884
+ // Fulfillment sits outside that catch, and the asymmetry is deliberate. Every refusal the catch answers
2885
+ // is deterministic — an unmapped SKU, a sandbox transaction — so a retry reproduces it and a 200 is the
2886
+ // only sane answer. A fulfillment fault is transient by construction: a refused clawback is an outcome
2887
+ // rather than a throw, and a missing ledger fails at assembly. So it must reach the provider as a
2888
+ // non-2xx and be redelivered, which the stable grant ref makes free.
2889
+ await fulfill(c, projection);
2890
+
2891
+ await completeWebhook(d1, eventRowId, { at: now });
2892
+ await c.var.emit({
2893
+ action: PaymentsAuditActions.webhookReceived,
2894
+ outcome: "success",
2895
+ actorType: "service",
2896
+ actorId: rail,
2897
+ resourceType: "purchase",
2898
+ resourceId: projection.purchase.id,
2899
+ metadata: {
2900
+ rail,
2901
+ providerEventId: notification.providerEventId,
2902
+ productId: projection.product.id,
2903
+ status: projection.purchase.status,
2904
+ outcome: projection.outcome,
2905
+ projected: true,
2906
+ },
2907
+ });
2908
+ return c.json({ received: true, projected: true, outcome: projection.outcome }, 200);
2909
+ };
2910
+ }
2911
+ }