@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,939 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { CreatedDiscount, DiscountTerms, SubscriptionPricing } from "../data/discount";
5
+ import type { PaymentsPurchase } from "../data/purchase";
6
+ import type { PaymentsRail } from "../data/rail";
7
+ import type { PaymentsSubject } from "../data/subject";
8
+ import type {
9
+ RefundRequest,
10
+ SubscriptionCancelTiming,
11
+ SubscriptionChangeQuote,
12
+ SubscriptionStanding,
13
+ } from "../data/subscription";
14
+ import type { ProviderEventInput } from "../projection/event";
15
+
16
+ /**
17
+ * The one contract every rail implements, and the one it deliberately does not.
18
+ *
19
+ * A rail does three things: it verifies a receipt a client submitted, it parses a notification the store
20
+ * pushed, and it re-reads the current state of a purchase already projected. All three produce the same
21
+ * normalized event, so the projection writer never learns which store it came from — that is what lets one
22
+ * idempotent writer serve three stores and one entitlement resolve across them.
23
+ *
24
+ * ## Why `refresh` is on the shared contract and `createCheckoutSession` is not
25
+ *
26
+ * Every store can be asked what a purchase looks like now, and every store's answer is worth having: webhooks
27
+ * are dropped, push subscriptions are misconfigured, signing keys rotate, and a subscription can lapse with no
28
+ * notification arriving at all. So `refresh` is not a Stripe-shaped method the other two would stub — it is the
29
+ * question the reconciliation Workflow exists to ask, and all three rails answer it for real. A rail that
30
+ * cannot answer for one *particular* purchase returns `undefined`, which is a fact about that purchase rather
31
+ * than a hole in the rail.
32
+ *
33
+ * ## Why Stripe's session creation is a separate interface
34
+ *
35
+ * Stripe additionally creates the purchase. That asymmetry is real, and this contract does not paper over
36
+ * it. An Apple or Google purchase has **already happened inside the store SDK** by the time the server hears
37
+ * of it: the app presented the sheet, the user paid, and the server's job begins at a receipt. A Stripe
38
+ * purchase is one Pithy initiates, by creating a hosted Checkout Session and sending the browser to it.
39
+ *
40
+ * A single abstraction covering both would force Apple and Google to declare a `createCheckoutSession` with
41
+ * no meaning for them, and every such method is a lie a future maintainer has to read past — worse, one
42
+ * somebody eventually calls. So {@link CheckoutRail} is its own interface, and a rail that initiates
43
+ * purchases implements both. Nothing needs to widen when a fourth rail arrives on either side.
44
+ *
45
+ * ## Why managing a subscription is a third interface, and what changed to allow it
46
+ *
47
+ * {@link SubscriptionRail} is the same split for the same reason: a StoreKit or Play subscription is changed
48
+ * inside the store's own UI, on the customer's device, and a rail that had to declare `changePlan` to satisfy
49
+ * the shared contract would declare a method that cannot be written.
50
+ *
51
+ * It is also **the amendment of 2026-08-28 to #79's locked decision 2**, and the distinction is narrow enough
52
+ * to be worth stating precisely, because the docs it corrects said the wider thing. The kit may *invoke* a
53
+ * plan change and pass the provider's own figures through unmodified. It still never *computes* proration,
54
+ * tax, or SCA: no amount in {@link SubscriptionChangeQuote} is derived from another amount, nothing multiplies
55
+ * a price by a fraction of a period, and nothing checks the provider's arithmetic. The line is between asking
56
+ * a store what a change costs and answering that question ourselves — the second is a number a customer holds
57
+ * against their statement, and the statement is the one they will believe.
58
+ *
59
+ * ## Why a rail cannot name an owner
60
+ *
61
+ * Everything a rail returns is an {@link UnboundProviderEvent} — a provider event with **neither half** of the
62
+ * subject pair. That is a type-level fact, not a convention: a webhook arrives carrying the store's own
63
+ * identifier and nothing else, so a rail that could fill an owner in could be talked into filling in the wrong
64
+ * one. Binding happens in exactly one place, the route, from the authenticated caller through the configured
65
+ * subject seam or from the provider-account map.
66
+ *
67
+ * Omitting *both* halves is what makes the guarantee hold. A rail that could name a `subjectId` and leave the
68
+ * kind to be supplied from config would be pairing an id from a store with a type from somewhere else — and
69
+ * nothing keeps an organization id from equalling some user's, so the two together would grant one holder's
70
+ * subscription to the other. A subject is read and written whole. See `data/subject.ts`.
71
+ *
72
+ * The one thing a rail may report about ownership is an {@link VerifiedNotification.accountReference} — the
73
+ * encoded subject reference *this deployment's own server* attached when it created the purchase, echoed back
74
+ * by the store. That is a claim about the purchase rather than a rail's verdict on who holds it, and it is
75
+ * what a rail with no client-submission path needs: a Stripe purchase is only ever heard about through a
76
+ * webhook, so the pairing of `cus_…` with a subject has to arrive with the notification or never at all.
77
+ */
78
+
79
+ /**
80
+ * A normalized provider event with its owner left out — **both halves of it**. The rail knows everything about
81
+ * the transaction and nothing about who it belongs to, and the shape says so.
82
+ *
83
+ * Derived by omission rather than declared beside {@link ProviderEventInput}, so a field added to the event is
84
+ * a field every rail may report, and the only fields a rail may never report are the two named here.
85
+ */
86
+ export type UnboundProviderEvent = Omit<ProviderEventInput, "subjectType" | "subjectId">;
87
+
88
+ /** What a rail needs from the request it is serving. The clock, and which deployment is asking. */
89
+ export interface RailRequestContext {
90
+ /** The clock, for certificate validity and freshness windows. Injected so verification is deterministic. */
91
+ now: Date;
92
+ /**
93
+ * This deployment's `ENVIRONMENT` var, verbatim — `dev`, `staging`, `prod` — or undefined.
94
+ *
95
+ * For a rail whose store is **one namespace across every environment**, which is the case for Lemon
96
+ * Squeezy: test mode is a flag on an object, not a separate store, so a `dev` deployment and a `staging`
97
+ * deployment pointed at one store both hear everything the other's buyers do. Such a rail stamps this
98
+ * value into the checkout it creates and reads it back off the webhook, and an event stamped for another
99
+ * deployment projects nothing.
100
+ *
101
+ * Deliberately the raw var and not `deploymentEnvironment`'s two-valued answer: that returns `sandbox`
102
+ * for `dev` and `staging` alike, so fencing on it would not separate the two environments this exists to
103
+ * separate. Optional because the other three stores partition by credential and have nothing to fence.
104
+ */
105
+ deployment?: string;
106
+ /**
107
+ * The locale a figure in the answer is rendered for — `Translator.formattingLocale`, region and all.
108
+ *
109
+ * **One method reads it: {@link SubscriptionRail.previewChange}**, whose answer carries `rendered` on
110
+ * every amount, and it is here rather than on {@link SubscriptionChangeInput} because a locale is a fact
111
+ * about the reader of a response, not about the subscription being changed. Every other call on this
112
+ * context — a webhook, a verification, a refresh — answers a machine, and a machine has no locale.
113
+ *
114
+ * **Never a request field, and never `Accept-Language` read directly.** A locale a client can name in a
115
+ * body is a locale a client sets, and the header is only one of four inputs the kit's own negotiation
116
+ * weighs — reading it here would render the money in a language the rest of the same response is not
117
+ * written in. The route resolves it from the translator seam, which is what `@pithy-sh/email` does per
118
+ * recipient and what `@pithy-sh/i18n` fills in per request.
119
+ *
120
+ * Optional, and its absence is not an error: `data/renderMoney.ts` states the fallback rather than
121
+ * guessing one, because a quote in the wrong language is recoverable and a quote with no figure is not.
122
+ */
123
+ locale?: string;
124
+ }
125
+
126
+ /**
127
+ * One inbound notification, as bytes and headers rather than as a `Request`.
128
+ *
129
+ * Bytes because every rail's authenticity check covers the **exact received body** — Apple signs it, Stripe
130
+ * HMACs it — and re-serializing a parsed object would change what was signed. Headers because two of the
131
+ * three rails put their proof in one.
132
+ *
133
+ * Deliberately not a `Request`: a request body is a stream that can be read once, and a rail reading it
134
+ * would leave the route's own validator with nothing. The guard reads it once through Hono's body cache and
135
+ * hands the same string here, so the signature and the handler see the identical bytes.
136
+ */
137
+ export interface WebhookDelivery {
138
+ /** The exact received body, decoded as UTF-8. */
139
+ body: string;
140
+ /** The delivery's headers — where Google's OIDC token and Stripe's signature live. */
141
+ headers: Headers;
142
+ }
143
+
144
+ /** A verified client submission: what was bought, and the store-account identifier to link it by. */
145
+ export interface VerifiedPurchase {
146
+ /** The transaction, normalized. The route binds the authenticated caller onto it. */
147
+ event: UnboundProviderEvent;
148
+ /**
149
+ * The store's own account identifier — Apple's `appAccountToken`, Google's `obfuscatedAccountId`, Stripe's
150
+ * customer id — or null when the purchase carried none. This is what a later webhook resolves a user
151
+ * through, so a submission that carries one is what stops that webhook arriving orphaned.
152
+ */
153
+ providerAccountId: string | null;
154
+ /**
155
+ * The **encoded subject reference** this deployment's own server attached when it created the purchase,
156
+ * echoed back by the store — Stripe's `client_reference_id`, which the `/checkout` route sets from the
157
+ * caller's resolved subject. `user:ada`, `organization:acme`: the pair, as `encodeSubjectReference` in
158
+ * `data/subject.ts` writes it, and read back only by `decodeSubjectReference`. Never split by hand — one
159
+ * encoding with one decoder is what keeps a value stamped by one code path readable by another.
160
+ *
161
+ * Set only by a rail whose purchases Pithy initiates. Apple's `appAccountToken` and Google's
162
+ * `obfuscatedAccountId` deliberately do **not** go here: those are set by the *app*, which may put anything
163
+ * in them, so treating one as an owner would make a client's choice of value decide who a purchase belongs
164
+ * to. This is the value a server wrote and a store returned unchanged.
165
+ *
166
+ * A string all the same, and untrusted all the same: it comes back as bytes from a store. **It decodes or
167
+ * it names nobody** — a bare id, the shape every pre-subject client sent, is not a user, and a kind this
168
+ * build does not know is not a kind. The route uses it to refuse a submission whose own reference names
169
+ * somebody else, before projecting it.
170
+ */
171
+ accountReference?: string | null;
172
+ }
173
+
174
+ /** A verified notification: authentic, recorded, and sometimes carrying no state change at all. */
175
+ export interface VerifiedNotification {
176
+ /**
177
+ * The rail's own event id — Apple's `notificationUUID`, Stripe's event id. `UNIQUE (rail,
178
+ * providerEventId)` is what makes a redelivery recognized rather than reprocessed, and all three providers
179
+ * deliver at-least-once.
180
+ */
181
+ providerEventId: string;
182
+ /** The notification as received, stored whole. What makes "why didn't this renew" answerable. */
183
+ payload: Record<string, unknown>;
184
+ /**
185
+ * The transaction the notification reports, or **null when it reports no transaction state**. Null is a
186
+ * successful outcome, not a failure: a test notification, a consumption request, a declined refund, and a
187
+ * type the store shipped after this package did are all authentic and all change nothing. The row is still
188
+ * recorded, so the reconciliation pass can see what arrived.
189
+ */
190
+ event: UnboundProviderEvent | null;
191
+ /** The store account identifier the notification carried, or null. */
192
+ providerAccountId: string | null;
193
+ /**
194
+ * A refund the rail reported by **order id alone**, with no state attached — or null.
195
+ *
196
+ * Google's voided-purchase notification is the only one shaped this way. It names an order and says it was
197
+ * refunded, and Play offers no lookup that turns it back into a purchase: the one-time endpoint takes the
198
+ * product id as a path segment, and the notification does not carry one. So the rail cannot produce an
199
+ * `event`, and for a while this was recorded and dropped — leaving a refunded non-consumable granting its
200
+ * entitlement forever.
201
+ *
202
+ * The missing product was never in Play, though. It is in **our** row: an order id is exactly what a Google
203
+ * purchase's `providerTransactionId` is. So the rail reports the order and the route, which owns the
204
+ * database, resolves it — the same division as everywhere else, where a rail knows the store and the route
205
+ * knows the projection. An authentic void is itself the refund record, in the same way Apple's `REFUND`
206
+ * notification is; nothing needs to re-ask the store what it already said.
207
+ */
208
+ voidedOrderId?: string | null;
209
+ /**
210
+ * The encoded subject reference this deployment attached when it created the purchase, echoed back — see
211
+ * {@link VerifiedPurchase.accountReference} for the encoding, and for why this is not the same as a rail
212
+ * naming an owner.
213
+ *
214
+ * On a rail with no client-submission path this is the **only** way the account map is ever populated: a
215
+ * Stripe webhook arrives carrying `cus_…`, and the pairing with a subject exists nowhere else. The route
216
+ * decodes it, writes the link from it, and `linkProviderAccount` never rebinds, so the first pairing wins.
217
+ *
218
+ * **It is ranked last in the owner trust order, and it fails closed.** Last because everything above it in
219
+ * `projection/owner.ts` is a fact this server established about a purchase it already projected, and this is
220
+ * a string that made a round trip through somebody else's system — Paddle's is stamped with a MAC precisely
221
+ * because a browser can overwrite it. Closed because `decodeSubjectReference` returns `undefined` for
222
+ * anything that is not exactly the encoding, and `undefined` means the event is recorded as an **orphan**,
223
+ * replayable when an account links, granting nothing. A decode miss is never read as a bare user id: that
224
+ * guess would attribute one customer's renewal to whoever happens to hold the id they sent.
225
+ */
226
+ accountReference?: string | null;
227
+ /**
228
+ * A second event the same notification implies, describing the **subscription's standing** where `event`
229
+ * describes a **charge** — or null, which is the case on every rail but one.
230
+ *
231
+ * Exists because one store reports the two separately and neither message names the other's key. A Lemon
232
+ * Squeezy `subscription_payment_*` webhook carries an invoice: the money, and nothing about whether the
233
+ * subscription is still live. Its `subscription_*` webhooks carry the standing and no money. So that rail
234
+ * writes two rows — see {@link PurchaseRole} — and a notification that changes both has to say both.
235
+ *
236
+ * The case that forces it is a refund. Lemon Squeezy is a merchant of record and issues refunds on its own,
237
+ * for a chargeback or a tax dispute, with no local write preceding it; the invoice row must go `refunded` so
238
+ * the ledger claws back, *and* the subscription must stop granting, because the buyer has their money back.
239
+ * One event could do one or the other, and doing only the first leaves a refunded subscriber with the
240
+ * feature.
241
+ *
242
+ * The route projects it with the same owner as `event` and after it. It is never fulfilled for credit — a
243
+ * `state` row is not a charge, so `purchaseIsPaid` and the clawback both refuse it by role.
244
+ */
245
+ stateEvent?: UnboundProviderEvent | null;
246
+ /**
247
+ * Why an authentic notification produced no event, when that is worth recording — written to the webhook row's
248
+ * `error` column so it is queryable and the reconciliation pass can act on it.
249
+ *
250
+ * Null when there is nothing to say, which is the common case: a test notification is exactly what it looks
251
+ * like. It earns its place on the rails that *can* be authentically told about a purchase they cannot resolve
252
+ * — Play's voided-purchase notification names no product, so the delivery is real, the refund is real, and
253
+ * neither is projectable without a lookup only the Workflow can make. A row recording that with its reason is
254
+ * the difference between a repairable gap and a silent drop.
255
+ *
256
+ * **Which of the two it is decides whether the row is finished** — see {@link NotificationNote}.
257
+ */
258
+ note?: NotificationNote | null;
259
+ }
260
+
261
+ /**
262
+ * Why there is no event, and **where that answer came from**. Two cases, because there are two.
263
+ *
264
+ * `{ stated }` — the delivered bytes say it. A partial refund that takes nothing away, a notification type
265
+ * this build does not act on, an order Play reports voided. The same bytes get the same answer from the same
266
+ * build for ever, so the row is **finished** and a redelivery is answered `duplicate`.
267
+ *
268
+ * `{ read }` — a call to the provider came back empty and this note is that answer. Play has no purchase
269
+ * under the token, Lemon Squeezy no longer knows the subscription, Paddle will not show the transaction an
270
+ * adjustment names. **Repairable**, and this is #341: three rails derived a note this way and it finished the
271
+ * row. A read that answers "no such thing" can be a race — an RTDN outrunning Play's own read-after-write, a
272
+ * key rotated mid-flight, a shared sandbox — so the second answer differs from the first, and the delivery
273
+ * that would have carried it was already being told it was a duplicate.
274
+ *
275
+ * **A note is safe as terminal only when it comes from a source that cannot have failed.** The delivered
276
+ * bytes are such a source; a read is not. So the provenance travels with the text rather than being inferred
277
+ * at the far end, and the two are one field so no rail can claim both.
278
+ *
279
+ * The same rule applies wherever a note finishes a row: the webhook handler and the Paddle events sweep both
280
+ * read this, and both treat `read` as a repairable failure rather than a completion.
281
+ */
282
+ export type NotificationNote =
283
+ | {
284
+ /** What the delivered bytes say. Terminal. */
285
+ readonly stated: string;
286
+ }
287
+ | {
288
+ /** What a provider read answered, and could answer differently next time. Repairable. */
289
+ readonly read: string;
290
+ };
291
+
292
+ /** The note's text, whichever kind it is. An operator reads the two the same way; the row does not. */
293
+ export function noteText(note: NotificationNote | null | undefined): string | undefined {
294
+ if (note === null || note === undefined) return undefined;
295
+ return "stated" in note ? note.stated : note.read;
296
+ }
297
+
298
+ /** Whether this note leaves the delivery repairable — true only for one a read produced. */
299
+ export function noteIsRepairable(note: NotificationNote | null | undefined): boolean {
300
+ return note !== null && note !== undefined && "read" in note;
301
+ }
302
+
303
+ /** Every rail. Verification of a client submission, parsing of a pushed notification, and a state re-read. */
304
+ export interface PaymentsRailProvider {
305
+ /** Which store this provider speaks for. */
306
+ readonly rail: PaymentsRail;
307
+ /**
308
+ * Verify a receipt or signed transaction a client submitted, exactly as the store SDK returned it. Throws
309
+ * `payments/invalid_receipt` when it cannot be read and `payments/verification_failed` when the store does
310
+ * not vouch for it.
311
+ */
312
+ verify(receipt: string, context: RailRequestContext): Promise<VerifiedPurchase>;
313
+ /**
314
+ * Verify and parse a pushed notification. Takes the delivery's bytes and headers rather than a parsed
315
+ * object, because authenticity lives in different places per rail — Apple signs the body, Google puts an
316
+ * OIDC token in a header, Stripe puts an HMAC in one — and every one of those covers the exact received
317
+ * bytes. Throws when authenticity cannot be established; the guard maps that to
318
+ * `payments/webhook_unverified`.
319
+ */
320
+ parseNotification(delivery: WebhookDelivery, context: RailRequestContext): Promise<VerifiedNotification>;
321
+ /**
322
+ * Re-read the current state of a purchase this deployment already projected — the reconciliation path.
323
+ *
324
+ * Takes the stored row rather than a handful of ids, because what identifies a purchase at its store differs
325
+ * per rail and some of it survives only in the payload: Apple keys a subscription family on
326
+ * `originalTransactionId`, Play keys one on the purchase token, and Stripe on `sub_…`. Handing the whole row
327
+ * over means adding a rail never widens this signature.
328
+ *
329
+ * **The returned event's `providerEventAt` is the clock, not a store timestamp**, and that is deliberate. A
330
+ * refresh is a read of the state *now*, so it is the freshest fact anyone has — dating it earlier would let
331
+ * the monotonic write rule discard the very repair the run exists to make.
332
+ *
333
+ * `undefined` means the store has nothing to say about this purchase: the rail cannot address it (a one-time
334
+ * purchase whose store offers no lookup), or the store no longer knows it. That is a normal answer and leaves
335
+ * the row exactly as it stood. A store that cannot be *reached* throws `payments/provider_unavailable`
336
+ * instead, which is what tells the Workflow to fail the step and retry rather than record a repair that
337
+ * never happened.
338
+ */
339
+ refresh(purchase: PaymentsPurchase, context: RailRequestContext): Promise<UnboundProviderEvent | undefined>;
340
+ /**
341
+ * Re-read a notification body this package already recorded, **without re-establishing authenticity**.
342
+ *
343
+ * The row it comes from was written by a delivery that verified, or by a sweep reading the store's own
344
+ * event stream over an authenticated connection, so the bytes are already vouched for. Re-verifying is not
345
+ * merely redundant — it is impossible: a signature covers headers this table never stored, and a JWS has a
346
+ * validity window that expired long ago.
347
+ *
348
+ * It exists for one caller: the repair that runs when an account links. An orphan is a purchase with
349
+ * nobody to project it against, and the only thing that changes is a `provider_accounts` row appearing
350
+ * later. Nothing about the event changes, so the payload is where the purchase is — which is precisely
351
+ * what {@link VerifiedNotification.payload} is stored whole for.
352
+ *
353
+ * **Optional, and a rail that cannot do it says so by not implementing it.** Apple and Google store a
354
+ * signed blob whose claims this build reads only through a verifier, and Lemon Squeezy's parse needs a live
355
+ * subscription read; a stub returning a half-read event would be worse than the absence. Those rails'
356
+ * orphans are repaired by the store's own redelivery, as they were.
357
+ *
358
+ * `undefined` means the payload is not one this rail can replay — a shape from an older build, an event
359
+ * type since dropped. That is a fact about the row, not a failure, and the repair leaves it exactly as it
360
+ * stood.
361
+ */
362
+ replay?(payload: Record<string, unknown>, context: RailRequestContext): Promise<VerifiedNotification | undefined>;
363
+ }
364
+
365
+ /** What a hosted checkout session needs. The catalog product is resolved before this is called. */
366
+ export interface CheckoutSessionInput {
367
+ /** The rail's own price identifier for the product being bought. */
368
+ providerProductId: string;
369
+ /** Whether this buys a recurring subscription or a one-off. Decides the hosted flow the store presents. */
370
+ subscription: boolean;
371
+ /**
372
+ * The subject the purchase is for — the authenticated caller, or the organization the project bills on
373
+ * their behalf, as the configured subject seam resolved it.
374
+ *
375
+ * Passed as the pair rather than an id so the rail stamps `encodeSubjectReference(input.subject)` into the
376
+ * checkout it creates, and the webhook that follows arrives already naming a holder this server chose. A
377
+ * rail that received an id alone would have to pair it with a kind from config to build that reference,
378
+ * which is the one pairing `data/subject.ts` forbids.
379
+ */
380
+ subject: PaymentsSubject;
381
+ /**
382
+ * The store account this caller already has, from the provider-account map, or null on a first purchase.
383
+ *
384
+ * Passing it is what keeps one buyer to one store account. Without it every checkout creates a fresh
385
+ * customer, and a user's second purchase would be invisible from the billing portal their first one made.
386
+ */
387
+ providerAccountId?: string | null;
388
+ /** Where the store sends the browser after a completed purchase. */
389
+ successUrl: string;
390
+ /**
391
+ * Where the store sends the browser if the purchase is abandoned, or undefined for a store that offers
392
+ * nowhere to abandon *to*.
393
+ *
394
+ * Optional for the same reason {@link PortalSessionInput.returnUrl} is: Lemon Squeezy's hosted checkout
395
+ * has no cancel destination — a buyer who backs out closes the tab — and a required field there would
396
+ * mean asking an adopter for a URL the rail then silently discarded.
397
+ */
398
+ cancelUrl?: string;
399
+ /**
400
+ * A discount code to apply, passed to the store **unchanged**, or undefined.
401
+ *
402
+ * The store calculates the price. Pithy never computes a discounted amount and never checks a code against
403
+ * anything of its own: the provider is the authority on what is owed, and a second calculation here would
404
+ * be a second answer to the one question a customer will check against their card statement.
405
+ *
406
+ * An invalid or expired code therefore fails at the store, and the rail turns that into
407
+ * `payments/discount_invalid` naming the code — distinctly from a payment failure, because a customer told
408
+ * "something went wrong" at checkout concludes their card was declined.
409
+ */
410
+ discountCode?: string;
411
+ }
412
+
413
+ /** What a billing-portal session needs: the store account whose subscription is being managed. */
414
+ export interface PortalSessionInput {
415
+ /** The store's own account identifier for the caller, resolved from the provider-account map. */
416
+ providerAccountId: string;
417
+ /**
418
+ * Where the store sends the browser when the user is done, or undefined for a store that offers nowhere
419
+ * to return to.
420
+ *
421
+ * Optional because one store genuinely is shaped that way, and saying so in the type is better than the
422
+ * alternative. Lemon Squeezy's customer portal is a signed, expiring link read off the customer object;
423
+ * it takes no return parameter, and the subscriber closes the tab. A required field there would leave
424
+ * the rail silently discarding a URL an adopter configured and believed in — the failure would be a
425
+ * button that goes nowhere rather than a type error. So the contract admits the gap, and a project
426
+ * running that rail alone is never asked for a URL nothing will use.
427
+ */
428
+ returnUrl?: string;
429
+ /**
430
+ * The store's own subscription ids this caller owns, for a store that mints per-subscription deep links.
431
+ *
432
+ * **Resolved by the route from the caller's own purchase rows, never from a request body.** `/portal`
433
+ * takes no body at all, and that is the request contract that makes the whole surface safe: a field here
434
+ * naming a subscription would let any signed-in caller ask for authenticated cancel links to somebody
435
+ * else's — the exact vulnerability {@link providerAccountId} coming from the account map exists to close.
436
+ *
437
+ * Undefined for the rails whose portal is one page for the whole account.
438
+ */
439
+ subscriptionIds?: readonly string[];
440
+ }
441
+
442
+ /**
443
+ * How a browser is handed to a store's payment page. Pithy never owns payment UI, SCA, or tax, and never
444
+ * *computes* proration.
445
+ *
446
+ * **"Never computes" is narrower than what this line used to say, and the narrowing is deliberate** (#79's
447
+ * locked decision 2, amended 2026-08-28). A checkout still names no amount and no quantity: the price is the
448
+ * price, and a checkout that could carry a figure is a checkout a client could put a figure on. What the kit
449
+ * may now do is *invoke* a plan change on an existing subscription and pass the provider's own preview through
450
+ * unmodified — see {@link SubscriptionRail}, which is a separate interface reached by a separate route, and
451
+ * which produces no figure of its own either.
452
+ *
453
+ * **A union, because one store has no URL to give.** Stripe and Lemon Squeezy both mint a hosted page and
454
+ * answer with its address, and for eleven months that was the whole shape — `{ url }`. Paddle's overlay and
455
+ * inline modes never leave the adopter's page: the server creates a transaction, and the browser opens it
456
+ * with Paddle.js against a publishable client token. There is no address to send anyone to, so a `{ url }`
457
+ * return could only be filled with a lie or with an empty string.
458
+ *
459
+ * `redirect` is what the two older rails return, unchanged in every field. The widening costs them nothing,
460
+ * and a screen that only ever handled a redirect keeps working by narrowing on `kind` — which is a compile
461
+ * error where it is missing rather than a runtime surprise.
462
+ */
463
+ export type CheckoutHandoff =
464
+ | {
465
+ /** A hosted page the browser is sent to. */
466
+ kind: "redirect";
467
+ /** The absolute URL to send the browser to. */
468
+ url: string;
469
+ }
470
+ | {
471
+ /** A transaction the browser opens with Paddle.js, over the adopter's own page. */
472
+ kind: "paddle";
473
+ /** The transaction the server created — `txn_…`. What `Paddle.Checkout.open` is given. */
474
+ transactionId: string;
475
+ /**
476
+ * The publishable client token Paddle.js initializes with — `live_…` or `test_…`.
477
+ *
478
+ * Publishable by design, exactly as a Stripe price id is: it is what a browser needs to open a
479
+ * checkout, and nothing about verification depends on its secrecy. The API key and the webhook
480
+ * signing secret are secrets and never appear here.
481
+ */
482
+ clientToken: string;
483
+ /** Which Paddle environment the token belongs to. `Paddle.Environment.set` takes it verbatim. */
484
+ environment: "sandbox" | "production";
485
+ /** Whether the checkout opens over the page or inside a container the screen provides. */
486
+ displayMode: "overlay" | "inline";
487
+ /**
488
+ * Where a buyer who paid is sent, from `config.paddle.successUrl`.
489
+ *
490
+ * On the handoff because Paddle.js takes it as `settings.successUrl` at the moment the checkout is
491
+ * opened, and that moment is in the browser. It still comes from config and never from a request:
492
+ * a client that could name a return URL could send a paying customer to a page it controls.
493
+ */
494
+ successUrl: string;
495
+ };
496
+
497
+ /** One subscription's authenticated portal deep links, as a store hands them back. */
498
+ export interface PortalSubscriptionLinks {
499
+ /** The store's own subscription id — `sub_…`. */
500
+ subscriptionId: string;
501
+ /** Where this subscription is canceled. */
502
+ cancel: string;
503
+ /** Where this subscription's payment method is changed. */
504
+ updatePaymentMethod: string;
505
+ }
506
+
507
+ /**
508
+ * A billing portal, as a store returns it.
509
+ *
510
+ * `url` is the overview page every rail has. `subscriptions` is the part only Paddle offers: authenticated
511
+ * deep links straight to one subscription's cancel and update-payment-method screens, so a subscription
512
+ * screen can render per-subscription actions rather than one "Manage billing" button.
513
+ *
514
+ * **Every URL here is a bearer credential for that customer's billing, and Paddle's is good for 24 hours.**
515
+ * Its overview link carries a `pga_` JWT whose `iat` and `exp` are 86400 seconds apart, with scopes
516
+ * covering `customer.subscription.update`, `customer.customer.update` and `customer.transaction.create` —
517
+ * verified against a live sandbox session, and materially different from the "single-use, short-lived" the
518
+ * issue described. So: never cached, never persisted, never logged, and never a redirect target something
519
+ * else could read out of a `Referer`.
520
+ */
521
+ export interface PortalHandoff {
522
+ /** The portal's overview page for this customer. */
523
+ url: string;
524
+ /** Per-subscription deep links, when the store offers them. Absent on the rails that do not. */
525
+ subscriptions?: readonly PortalSubscriptionLinks[];
526
+ }
527
+
528
+ /**
529
+ * The rails that *initiate* a purchase rather than merely hearing about one — Stripe, Lemon Squeezy, Paddle.
530
+ *
531
+ * Deliberately separate from {@link PaymentsRailProvider}: see the module doc. A rail implements both
532
+ * interfaces or only the first, and the route that needs a checkout session narrows to this one.
533
+ */
534
+ export interface CheckoutRail {
535
+ /** Create a checkout for one product, and say how the browser reaches it. */
536
+ createCheckoutSession(input: CheckoutSessionInput, context: RailRequestContext): Promise<CheckoutHandoff>;
537
+ /** Create a billing-portal session, where a subscriber manages or cancels. */
538
+ createPortalSession(input: PortalSessionInput, context: RailRequestContext): Promise<PortalHandoff>;
539
+ }
540
+
541
+ /** Whether a rail provider also initiates purchases. The one narrowing `/checkout` and `/portal` need. */
542
+ export function isCheckoutRail(provider: PaymentsRailProvider): provider is PaymentsRailProvider & CheckoutRail {
543
+ return (
544
+ typeof (provider as Partial<CheckoutRail>).createCheckoutSession === "function" &&
545
+ typeof (provider as Partial<CheckoutRail>).createPortalSession === "function"
546
+ );
547
+ }
548
+
549
+ /**
550
+ * A rail that can *mint* a discount, as distinct from one that can merely apply one.
551
+ *
552
+ * **Its own interface, and the separation is load-bearing rather than tidy.** Applying a code must work with
553
+ * creation unimplemented — an adopter whose codes are minted by hand in a provider dashboard is fully served
554
+ * by the apply half, and making that half depend on this one would hold it hostage to a surface they never
555
+ * asked for. So `discountCode` rides on {@link CheckoutSessionInput}, which every checkout rail already
556
+ * takes, and this is a separate question asked separately.
557
+ *
558
+ * It is also the more dangerous half. It writes money-affecting objects into a payment provider through one
559
+ * shape over two APIs that agree on the concept and disagree on nearly every field, and the failures are
560
+ * quiet ones that surface on a customer's statement — see `data/discount.ts` for the three that were
561
+ * designed against. Minting a discount is an administrative act with a cost attached, which is why the route
562
+ * behind it carries its own scope and its own audit event rather than riding on an existing one.
563
+ */
564
+ export interface DiscountRail {
565
+ /**
566
+ * Create a discount at the store and return what it made.
567
+ *
568
+ * Throws `payments/discount_invalid` when the store refuses the terms, with the store's own reason in
569
+ * `detail`. Nothing here validates the terms a second time — `DiscountTerms` has already refused the
570
+ * combinations that are wrong on their face, and the store owns the rest.
571
+ */
572
+ createDiscount(terms: DiscountTerms, context: RailRequestContext): Promise<CreatedDiscount>;
573
+ /**
574
+ * The discount codes this store holds, newest first.
575
+ *
576
+ * A management client that can mint a code must be able to see what it minted — otherwise a pane over
577
+ * them computes *absent* rather than blocked, which no grant repairs (#247). Read from the store rather
578
+ * than from a table of ours: the store is where a code actually exists, and a local mirror would be a
579
+ * second answer that drifts the first time somebody uses the dashboard.
580
+ *
581
+ * Never reaches a browser. The set of codes an adopter has issued is a commercial fact, and the client
582
+ * projection draws the same line here it draws for SKUs and the `grants` block.
583
+ */
584
+ listDiscounts(context: RailRequestContext): Promise<readonly ListedDiscount[]>;
585
+ }
586
+
587
+ /** One discount as a store lists it. Deliberately less than {@link CreatedDiscount} — a list is not a receipt. */
588
+ export interface ListedDiscount {
589
+ /** The code a customer enters. */
590
+ code: string;
591
+ /** The store's own id. */
592
+ providerDiscountId: string;
593
+ /** How much comes off, rendered for a person — `20%`, `500 usd`. The store's own figures. */
594
+ amount: string;
595
+ /** How many times it has been claimed, when the store reports it. */
596
+ redemptions: number | null;
597
+ }
598
+
599
+ /**
600
+ * A rail that can say what a subscription pays now, what it becomes, and when.
601
+ *
602
+ * Separate from {@link DiscountRail} because they are separate abilities: a rail that can apply a code an
603
+ * adopter minted by hand must be able to report the resulting rate, whether or not it can mint one. Separate
604
+ * from the shared contract because Apple and Google have no equivalent — a store SDK subscription's price
605
+ * changes are the store's business and it does not publish a "what this becomes" figure.
606
+ *
607
+ * **This is the half that stops a bill changing unannounced.** A capability that can apply a discount but
608
+ * cannot report its end date has shipped the half that creates the surprise: from a customer's seat, a rate
609
+ * that silently lapses is indistinguishable from a billing error.
610
+ */
611
+ export interface PricingRail {
612
+ /**
613
+ * What this subscription pays now, what it will pay, and when that changes — or `undefined` when the store
614
+ * has nothing to say about it, which is the same answer `refresh` gives for the same reason.
615
+ *
616
+ * Every figure comes from the store. Nothing multiplies a price by a percentage here or anywhere else.
617
+ */
618
+ readPricing(purchase: PaymentsPurchase, context: RailRequestContext): Promise<SubscriptionPricing | undefined>;
619
+ }
620
+
621
+ /** Whether a rail reports pricing. Structural, like the others. */
622
+ export function isPricingRail(provider: PaymentsRailProvider): provider is PaymentsRailProvider & PricingRail {
623
+ return typeof (provider as Partial<PricingRail>).readPricing === "function";
624
+ }
625
+
626
+ /**
627
+ * Whether a rail can mint **and** list discounts. Structural, so a rail gains the ability without an edit here.
628
+ *
629
+ * **It ANDs both methods, and until #465 it did not.** {@link DiscountRail} declares two, and a guard probing
630
+ * `createDiscount` alone narrowed a rail that could mint and not list into a type promising both — so
631
+ * `GET {base}/admin/discounts` passed its own guard and then called a method that was not there. That is a
632
+ * `TypeError` inside a handler, which reaches a management pane as a 500, where a rail that plainly cannot
633
+ * mint gets `rail_not_configured` and a pane that can say so. No rail ever hit it: all three hosted rails
634
+ * declare both. It was a defect waiting for the fourth, and it is the one every other guard here is written
635
+ * against by name.
636
+ */
637
+ export function isDiscountRail(provider: PaymentsRailProvider): provider is PaymentsRailProvider & DiscountRail {
638
+ const rail = provider as Partial<DiscountRail>;
639
+ return typeof rail.createDiscount === "function" && typeof rail.listDiscounts === "function";
640
+ }
641
+
642
+ /**
643
+ * Which subscription is being changed, and what it becomes. One row, one price, and nothing else.
644
+ *
645
+ * **There is no field naming a subscription, and that absence is the security boundary.** The subscription
646
+ * arrives as a {@link PaymentsPurchase} — a decoded row only this deployment's own database produces, already
647
+ * carrying the subject that owns it — exactly as {@link PaymentsRailProvider.refresh} and
648
+ * {@link PricingRail.readPricing} take one. A `subscriptionId` here would be a value a caller supplies, and a
649
+ * value a caller supplies is one they can point at somebody else's subscription. This capability holds no
650
+ * members table and no ownership graph of its own, so there is nothing it could check that claim against: the
651
+ * refusal has to be structural. It is the same rule {@link PortalSessionInput.subscriptionIds} states for
652
+ * reads, applied where the verb writes.
653
+ *
654
+ * The route's own job follows from that: resolve the row from the authenticated caller's purchases, never
655
+ * from a request body.
656
+ */
657
+ export interface SubscriptionChangeInput {
658
+ /**
659
+ * The stored purchase whose subscription is being changed — the projection's own row, already owned.
660
+ *
661
+ * The whole row rather than the ids off it, for {@link PaymentsRailProvider.refresh}'s reason: what
662
+ * identifies a subscription at its store differs per rail and some of it survives only in the payload. A
663
+ * rail added later never widens this signature.
664
+ */
665
+ purchase: PaymentsPurchase;
666
+ /**
667
+ * The rail's own price identifier for the plan being moved to — **exactly one, never a list**.
668
+ *
669
+ * Paddle's subscription update *replaces* the items array: an item omitted from the request is removed from
670
+ * the subscription. So a `readonly string[]` here would be a delete verb wearing an update verb's name, and
671
+ * the shape of the mistake is a caller passing the one price they are moving to and silently dropping every
672
+ * add-on beside it. **Building the complete item list the provider is sent is the rail's obligation**, from
673
+ * the subscription it re-read, and it is the rail's because that is where the provider's replace semantics
674
+ * are known.
675
+ *
676
+ * Named as {@link CheckoutSessionInput.providerProductId} is, and meaning what it means there: the store's
677
+ * own SKU or price id, resolved from the catalog before this is called. One vocabulary, so a reader does not
678
+ * have to work out whether two spellings are two things.
679
+ */
680
+ providerProductId: string;
681
+ }
682
+
683
+ /** Which subscription is being canceled, and when the customer stops. */
684
+ export interface SubscriptionCancelInput {
685
+ /** The stored purchase whose subscription is being canceled. See {@link SubscriptionChangeInput.purchase}. */
686
+ purchase: PaymentsPurchase;
687
+ /**
688
+ * When it takes effect, in the customer's terms — `at_period_end` under the settled policy.
689
+ *
690
+ * The rail translates into the store's spelling; Paddle's own `next_billing_period` does not parse here, so a
691
+ * rail that stopped translating fails loudly rather than sending a string Paddle happens to accept. See
692
+ * `data/subscription.ts` for why the customer's word is the one modeled.
693
+ */
694
+ timing: SubscriptionCancelTiming;
695
+ }
696
+
697
+ /**
698
+ * A rail whose subscriptions can be *changed* from the server: read, quoted, moved, ended, and un-ended.
699
+ *
700
+ * Separate from {@link CheckoutRail} because selling and managing are separate abilities — and separate from
701
+ * the shared contract for the reason the module doc gives: an Apple or Google subscription is changed inside
702
+ * the store's own UI, on the device, and no server call exists to do it.
703
+ *
704
+ * ## Five methods, and the guard demands all five
705
+ *
706
+ * {@link isSubscriptionRail} ANDs every one of them. That is the rule {@link isCheckoutRail} sets and
707
+ * {@link isDiscountRail} broke: it probed a single method of two and narrowed a half-implemented rail into a
708
+ * type that promised the other, which is a `TypeError` inside a handler rather than a refusal at its edge.
709
+ * That guard was fixed with this interface's arrival, and the shape is named here so nothing is written to
710
+ * match it again. The floor it sets is a rule about what may ship together:
711
+ *
712
+ * - **No destructive verb without the read that makes it legible.** A rail that could cancel and not report
713
+ * the cancellation ships the half that creates the support ticket: Paddle leaves `status` at `active` and
714
+ * blanks `next_billed_at` when a cancellation is scheduled (recorded 2026-08-28, #465), so a customer who
715
+ * canceled is told they will be billed again by every screen that reads the status.
716
+ * - **No change without the quote that makes it consented to.** A confirmation screen with no figure on it is
717
+ * a customer agreeing to an amount they were never shown, and the amount is real: the recorded upgrade
718
+ * charges 6582 immediately.
719
+ *
720
+ * ## What is deliberately absent from every method here
721
+ *
722
+ * **A proration mode, and any other billing enum.** The rail picks the mode from the *direction* of the
723
+ * change — an upgrade prorates immediately and charges now, a downgrade prorates into the next billing period
724
+ * — and `on_payment_failure` is always `prevent_change`. Neither is a parameter, because a parameter is a
725
+ * thing a client eventually sets, and the value a client would eventually set is Paddle's `do_not_bill`: a
726
+ * free upgrade. It is unreachable because there is nowhere to write it.
727
+ *
728
+ * ## The no-op is a success, not a refusal
729
+ *
730
+ * A change to the plan already held, and a cancellation of a subscription that is already scheduled to
731
+ * cancel, both answer the current standing **without calling the provider at all**. That is the retry answer:
732
+ * these verbs sit behind a network, callers retry, and a second delivery of the same intent must not become a
733
+ * second proration. A 409 for the state the caller asked for is also simply wrong — the subscription is how
734
+ * they wanted it.
735
+ */
736
+ export interface SubscriptionRail {
737
+ /**
738
+ * Where this subscription stands now — status, dates, and whatever is scheduled to happen to it — or
739
+ * `undefined` when the store has nothing to say about this purchase.
740
+ *
741
+ * `undefined` for {@link PaymentsRailProvider.refresh}'s reason and with its meaning: the rail cannot
742
+ * address this purchase, or the store no longer knows it. A store that cannot be *reached* throws
743
+ * `payments/provider_unavailable` instead, so a screen distinguishes "there is nothing to show" from "we
744
+ * could not look".
745
+ *
746
+ * Read live, never from a projected row. The one fact this exists to report — a scheduled cancellation —
747
+ * is precisely the one a purchase row does not carry, and a webhook announcing it can be dropped.
748
+ */
749
+ readStanding(purchase: PaymentsPurchase, context: RailRequestContext): Promise<SubscriptionStanding | undefined>;
750
+ /**
751
+ * What moving to this price would cost, as the provider previews it — the figures a customer confirms.
752
+ *
753
+ * Every amount comes from the store and none is derived here. See `data/subscription.ts` for what the
754
+ * recordings established about reading them, and in particular for why the answer has three parts.
755
+ *
756
+ * **A read, so the no-op rule does not apply to it.** A preview takes and gives nothing, a second one is
757
+ * free, and asking the provider what the plan already held would cost is a question with an honest answer.
758
+ * The no-op exists to stop a retried *write* from prorating twice; borrowing it here would mean inventing a
759
+ * recurring figure to fill the quote with, which is the one thing this package will not do.
760
+ *
761
+ * Throws rather than answering `undefined`. A quote that could be absent is a confirm button rendered
762
+ * beside nothing, and `payments/provider_unavailable` is what tells a screen to say so.
763
+ */
764
+ previewChange(input: SubscriptionChangeInput, context: RailRequestContext): Promise<SubscriptionChangeQuote>;
765
+ /**
766
+ * Move the subscription to a different plan, and answer where it now stands.
767
+ *
768
+ * **The rail chooses the proration mode from the direction of the change** — up charges immediately, down
769
+ * defers the credit to the next billing period — and nothing the caller sends influences it.
770
+ *
771
+ * Answers the resulting {@link SubscriptionStanding} rather than nothing, so the screen that just wrote can
772
+ * render what it wrote from the provider's own answer instead of predicting it. A prediction is how a
773
+ * customer sees a plan they are not on.
774
+ *
775
+ * A change to the plan already held is the no-op: the current standing, and no provider call. Anything the
776
+ * store refuses — a subscription that is paused, canceled, or past due — throws
777
+ * `payments/subscription_change_refused` (409) with the store's own reason in `detail`.
778
+ */
779
+ changePlan(input: SubscriptionChangeInput, context: RailRequestContext): Promise<SubscriptionStanding>;
780
+ /**
781
+ * Stop the subscription renewing, and answer where it now stands.
782
+ *
783
+ * `at_period_end` is the settled policy and the tier holds until the paid period runs out; `now` exists
784
+ * because support occasionally has to end one today, and because a policy with no legitimate exit gets
785
+ * departed from by a direct provider call nothing audits.
786
+ *
787
+ * A cancellation on a subscription already scheduled to cancel is the no-op: the current standing, and no
788
+ * provider call. A subscription the store will not cancel throws `payments/subscription_change_refused`.
789
+ */
790
+ cancelSubscription(input: SubscriptionCancelInput, context: RailRequestContext): Promise<SubscriptionStanding>;
791
+ /**
792
+ * Withdraw a scheduled cancellation, so the subscription renews after all.
793
+ *
794
+ * **It withdraws a cancellation, and only a cancellation.** Paddle offers no verb for that: the update
795
+ * clears `scheduled_change` *wholesale*, by setting it to null, and the field also holds a scheduled pause
796
+ * and a scheduled resume. So a rail that simply sent the clear would silently un-pause a paused subscription
797
+ * — the customer's account restarts billing, on a request that said nothing about pausing. The rail must
798
+ * **re-read the subscription first and refuse unless the pending action is `cancel`**, with
799
+ * `payments/subscription_change_refused` (409) naming what was actually scheduled. The check cannot move to
800
+ * the route: the route holds a projected row, and the pending action lives only at the store.
801
+ *
802
+ * A subscription with nothing scheduled is the no-op rather than a refusal: it already renews, which is what
803
+ * the caller asked for, and a retry after a successful withdrawal is exactly that request arriving twice.
804
+ *
805
+ * Its own method rather than `changePlan` to the same price, because the two are separate acts by possibly
806
+ * separate actors and the audit trail records them separately — a withdrawal folded into a plan change
807
+ * leaves a trail that asserts a cancellation and holds nothing saying it was taken back.
808
+ */
809
+ keepSubscription(purchase: PaymentsPurchase, context: RailRequestContext): Promise<SubscriptionStanding>;
810
+ }
811
+
812
+ /**
813
+ * Whether a rail can manage a subscription from the server. Structural, like the others — and **it ANDs all
814
+ * five methods**, which the others do not all do.
815
+ *
816
+ * Every guard in this file ANDs its whole interface now, which was not true when this one was written — see
817
+ * {@link isDiscountRail} for the shape and what it cost. A guard that probes one method narrows a
818
+ * partially-implemented rail into a type promising the rest, and the first thing anybody learns about the gap
819
+ * is a `TypeError` thrown mid-cancellation, on a route that has already written an audit row. Here a rail
820
+ * missing any one verb is simply not a subscription rail, and the route answers `rail_not_configured` — which
821
+ * is true, and which is a refusal rather than a half-applied change.
822
+ */
823
+ export function isSubscriptionRail(
824
+ provider: PaymentsRailProvider,
825
+ ): provider is PaymentsRailProvider & SubscriptionRail {
826
+ const rail = provider as Partial<SubscriptionRail>;
827
+ return (
828
+ typeof rail.readStanding === "function" &&
829
+ typeof rail.previewChange === "function" &&
830
+ typeof rail.changePlan === "function" &&
831
+ typeof rail.cancelSubscription === "function" &&
832
+ typeof rail.keepSubscription === "function"
833
+ );
834
+ }
835
+
836
+ /**
837
+ * Which payments are being refunded, and why — one set, no amount, and no identifier a caller could have
838
+ * written.
839
+ *
840
+ * **There is no transaction id here, and that absence is the security boundary**, exactly as
841
+ * {@link SubscriptionChangeInput} states it for a subscription. The payments arrive as
842
+ * {@link PaymentsPurchase} rows — decoded rows only this deployment's own database produces, each already
843
+ * carrying the subject that owns it — because a `txn_…` a caller supplies is a `txn_…` they can point at a
844
+ * stranger's money. This capability holds no ownership graph to check such a claim against, so the refusal
845
+ * has to be structural: the route resolves the rows from the authenticated caller's own purchases and there
846
+ * is nowhere in this shape to write anything else.
847
+ *
848
+ * **And there is no amount.** Every refund raised through this seam is for a transaction's whole total. A
849
+ * partial one is a different feature — it needs the store's own line-item ids, which nothing here holds —
850
+ * and an amount on a bearer route is a self-service withdrawal.
851
+ */
852
+ export interface RefundRequestInput {
853
+ /**
854
+ * The payments to refund — the caller's own purchase rows, one adjustment each.
855
+ *
856
+ * A set rather than one, because refunds attach to transactions and a subscription is a family of them:
857
+ * a customer who upgraded mid-period has paid twice and is owed both. The rail raises one refund per row
858
+ * and reports one outcome per row.
859
+ *
860
+ * **Order is the caller's and the report keeps it**, so a partial report is reproducible rather than
861
+ * whatever the store answered first.
862
+ */
863
+ purchases: readonly PaymentsPurchase[];
864
+ /**
865
+ * Why, in the operator's words — composed by the route, and **never read from a request body.**
866
+ *
867
+ * Every store requires one and shows it to a human in the merchant's own console. That is the argument
868
+ * for not taking it from the caller: a bearer request writing free text into an adopter's back office is
869
+ * a stranger writing into somebody's operational record, and there is nothing a customer could say there
870
+ * that the audit row and the store's own history do not already hold. An adopter who wants the
871
+ * customer's own words collects them on their own screen, where they belong to the adopter.
872
+ */
873
+ reason: string;
874
+ }
875
+
876
+ /**
877
+ * A rail that can ask its store to give a customer's money back.
878
+ *
879
+ * ## Its own interface, and not a sixth method on {@link SubscriptionRail}
880
+ *
881
+ * **The two abilities are independent in both directions, which is the whole argument.** Google Play has a
882
+ * server-side refund call and no server-side plan change — a subscription's plan is changed inside the
883
+ * store, on the device — so that rail could implement this and never implement {@link SubscriptionRail}.
884
+ * Apple is the other corner: refunds are Apple's own decision and the only server endpoint is a *lookup*,
885
+ * so a rail may be able to manage a subscription and have nothing to put here. An interface that demanded
886
+ * both would be one neither of them can satisfy.
887
+ *
888
+ * **And widening a guard that ANDs its methods is retroactive.** {@link isSubscriptionRail} requires all
889
+ * five verbs; a sixth would silently de-narrow every rail that already satisfies the five, and the first
890
+ * anybody would hear of it is `rail_not_configured` on a cancellation, from a release that changed nothing
891
+ * about canceling. A new interface costs one guard and breaks nothing that exists.
892
+ *
893
+ * **The floors differ too.** `SubscriptionRail`'s floor is *no destructive verb without the read that makes
894
+ * it legible* — the read being a standing. A refund's legibility read is a different one: the transactions
895
+ * and whatever adjustments already stand on them. Folding refunds in would make the ability to change a
896
+ * plan carry the power to move money back, which is a much larger power granted for a much smaller job.
897
+ *
898
+ * ## What a rail implementing this must guarantee
899
+ *
900
+ * - **It never claims money moved.** The answer is {@link RefundRequest}, whose every outcome is a request
901
+ * and whose statuses say so. Most live refunds sit at the store awaiting a human.
902
+ * - **It revokes nothing.** No entitlement, no purchase row, no projection. An approved refund arrives as a
903
+ * webhook and the projection acts on it — revoking on the *request* takes access from a customer whose
904
+ * refund the store then rejects, leaving them with neither the money nor the product.
905
+ * - **It refuses before it writes, and reports after.** Everything knowable in advance refuses the whole
906
+ * request with `payments/subscription_change_refused` and sends nothing; once one adjustment exists,
907
+ * every remaining failure is an outcome in the report. {@link RefundRequest} holds the long form.
908
+ * - **It bounds the set.** A set it cannot issue inside one request is refused before the first write, not
909
+ * discovered half way through.
910
+ *
911
+ * ## What is deliberately absent
912
+ *
913
+ * **An amount, a currency, a transaction id, and a window.** The first three are {@link RefundRequestInput}'s
914
+ * argument. The window is the *adopter's*: how many days a customer has to ask for their money back is a
915
+ * commercial policy with a company behind it, and a kit that hard-coded fourteen days would be wrong for the
916
+ * second adopter. The kit makes the refund possible; the adopter's screen decides which button exists.
917
+ */
918
+ export interface RefundRail {
919
+ /**
920
+ * Ask the store to refund these payments in full, and report what became of each.
921
+ *
922
+ * Throws `payments/subscription_change_refused` (409) when the request is refused before anything is
923
+ * sent, and `payments/provider_unavailable` (503) when the store could not be reached or answered in a
924
+ * shape this build cannot read — both only while nothing has been raised. After the first adjustment
925
+ * exists it answers, whatever else happened.
926
+ */
927
+ requestRefunds(input: RefundRequestInput, context: RailRequestContext): Promise<RefundRequest>;
928
+ }
929
+
930
+ /**
931
+ * Whether a rail can refund at its store. Structural, like the others.
932
+ *
933
+ * One method, so probing it *is* ANDing all of them — {@link isSubscriptionRail}'s rule, satisfied by
934
+ * arithmetic rather than by care. Stated because that stops being true the moment a second method is added
935
+ * here, and a guard left probing one of two is what {@link isDiscountRail} did until #465.
936
+ */
937
+ export function isRefundRail(provider: PaymentsRailProvider): provider is PaymentsRailProvider & RefundRail {
938
+ return typeof (provider as Partial<RefundRail>).requestRefunds === "function";
939
+ }