@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,793 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import type { PaymentsPurchase } from "../../data/purchase";
6
+ import { renderMoney } from "../../data/renderMoney";
7
+ import {
8
+ type DeferredSubscriptionSettlement,
9
+ type SubscriptionChangeQuote as Quote,
10
+ type QuotedMoney,
11
+ SubscriptionChangeQuote,
12
+ type SubscriptionRecurringParts,
13
+ type SubscriptionSettlementParts,
14
+ SubscriptionStanding,
15
+ } from "../../data/subscription";
16
+ import { PaymentsProviderUnavailableError, PaymentsSubscriptionChangeRefusedError } from "../../error/errors";
17
+ import type { PaymentsPaddleCredentials } from "../../secret/registry";
18
+ import type { SubscriptionCancelInput, SubscriptionChangeInput } from "../contract";
19
+ import { type PaddleEnvironment, type PaddleHttpFetch, paddleHttpFetch, paddleJson } from "./api";
20
+ import {
21
+ currencyOf,
22
+ minorAmount,
23
+ PaddleSubscription,
24
+ PaddleSubscriptionPreview,
25
+ type PaddleTotals,
26
+ type PaddleUpdateSummary,
27
+ subscriptionPendingChange,
28
+ subscriptionStatus,
29
+ } from "./objects";
30
+
31
+ /**
32
+ * Changing a Paddle subscription from the server: read it, quote a move, make one, end it, un-end it.
33
+ *
34
+ * Written against real sandbox responses recorded on 2026-08-28 (#465), not against the documentation.
35
+ * Four of the rules below exist because a recording contradicted a design written from the docs, and each
36
+ * is stated where it is enforced rather than in a changelog.
37
+ *
38
+ * ## The three verbs Paddle needs, one of which this rail had never sent
39
+ *
40
+ * | What | Paddle |
41
+ * | --- | --- |
42
+ * | Read | `GET /subscriptions/{id}` |
43
+ * | Quote a change | `PATCH /subscriptions/{id}/preview` |
44
+ * | Make one, or withdraw a schedule | `PATCH /subscriptions/{id}` |
45
+ * | Cancel | `POST /subscriptions/{id}/cancel` |
46
+ *
47
+ * **Nothing in this rail had ever issued a PATCH.** Every call before #465 was a GET or a POST, and
48
+ * `paddleJson` defaults a request carrying a body to POST — so a preview built the obvious way is sent as
49
+ * `POST /subscriptions/{id}/preview`, which Paddle does not route. The failure arrives as an ordinary 4xx,
50
+ * which this rail maps to `payments/rail_not_configured`, and an operator is sent to check an API key that
51
+ * is perfectly fine. `method` is passed explicitly on every call here for that reason, and the tests assert
52
+ * the verb rather than the outcome.
53
+ *
54
+ * ## What this module will not do
55
+ *
56
+ * **It computes no money.** Every figure in a quote is a string Paddle sent, parsed by {@link minorAmount}
57
+ * and never scaled, summed, or checked. `data/subscription.ts` holds the longer argument; the short one is
58
+ * that a second answer to "what will this cost" is a second number for a customer to hold against their
59
+ * statement, and the statement is the one they will believe.
60
+ *
61
+ * **It does render one, and that is a different verb** (#465, 2026-08-28). Paddle's `subscriptions.preview`
62
+ * returns no formatted total at any depth — `formatted_totals` exists only on the pricing-preview endpoint,
63
+ * "for convenience" — so a quote leaves here as minor units or as nothing a screen can print. Every amount
64
+ * therefore carries `rendered` beside it, placed by `data/renderMoney.ts` in the locale the route resolved.
65
+ * The integer is Paddle's and is untouched; only its spelling is decided here.
66
+ *
67
+ * **It returns nothing projectable.** Every method answers a `SubscriptionStanding` or a
68
+ * `SubscriptionChangeQuote` and never an `UnboundProviderEvent`. The webhook owns `pithy_payments_purchases`;
69
+ * a rail that handed a route an event to write would be a second producer of a row the projection already
70
+ * owns, and the two would race on `providerEventAt` — the exact ordering defect that field exists to prevent.
71
+ *
72
+ * **It reads no client-supplied billing enum, because there is none to read.** The proration mode is chosen
73
+ * from the direction of the change (see {@link prorationModeFor}) and `on_payment_failure` is always
74
+ * `prevent_change`. Paddle's `do_not_bill` is a free upgrade, and it is unreachable here because there is
75
+ * nowhere for a caller to write it.
76
+ */
77
+
78
+ /** What every call here needs: the credentials, which Paddle account, and the transport. */
79
+ export interface PaddleSubscriptionOptions {
80
+ /** The rail's credentials. The API key is read at the point of need and never cached. */
81
+ credentials: PaymentsPaddleCredentials;
82
+ /** Which Paddle account to reach. */
83
+ environment: PaddleEnvironment;
84
+ /** The HTTP seam. Defaults to the runtime's `fetch`. */
85
+ transport?: PaddleHttpFetch;
86
+ }
87
+
88
+ /**
89
+ * What happens to a change that cannot be collected: nothing.
90
+ *
91
+ * `prevent_change` is Paddle's own default and it is sent anyway, on every write, so the invariant lives in
92
+ * this file rather than in Paddle's release notes. The alternative values let a subscription move to a plan
93
+ * whose first payment failed, which is an upgrade granted and unpaid — and it is not configurable here for
94
+ * the reason no billing enum is: a parameter is a thing a caller eventually sets.
95
+ */
96
+ const ON_PAYMENT_FAILURE = "prevent_change";
97
+
98
+ /** The two proration behaviors this rail routes between. Never a caller's choice. */
99
+ type ProrationMode = "prorated_immediately" | "prorated_next_billing_period";
100
+
101
+ /**
102
+ * A price as this rail compares two of them: what one unit costs, in what currency, on what cycle.
103
+ *
104
+ * The *list* unit price, deliberately — the same figure on both sides. A subscription carrying a discount
105
+ * still moves up or down by its list price, and comparing a discounted rate against an undiscounted one is
106
+ * how a downgrade reads as an upgrade for as long as the discount runs.
107
+ */
108
+ interface PricePoint {
109
+ /** The Paddle price — `pri_…`. */
110
+ priceId: string;
111
+ /** What one unit costs, in the currency's minor unit. */
112
+ amountMinor: number;
113
+ /** The currency, lowercase. Two prices in different currencies are not comparable. */
114
+ currency: string;
115
+ /** The billing cycle as one comparable string — `month:1` — or null when Paddle stated none. */
116
+ cycle: string | null;
117
+ }
118
+
119
+ /** A price's unit cost, as Paddle states one. */
120
+ const PaddleUnitPrice = z
121
+ .object({
122
+ amount: z
123
+ .string()
124
+ .describe("What one unit costs, as an integer string in the currency's lowest denomination. Never scaled here."),
125
+ currency_code: z.string().nullish().describe("The currency, uppercase ISO 4217, as Paddle sends it."),
126
+ })
127
+ .loose()
128
+ .describe("What one unit of a price costs. Compared against another price's, and never arithmetic'd.");
129
+
130
+ /**
131
+ * How often a price bills.
132
+ *
133
+ * Read only to answer one question — whether a change alters the billing frequency — because Paddle permits
134
+ * only immediate proration modes when it does. See {@link prorationModeFor}.
135
+ */
136
+ const PaddleBillingCycle = z
137
+ .object({
138
+ interval: z.string().describe("`day`, `week`, `month`, or `year`."),
139
+ frequency: z.number().describe("How many of that interval one billing period spans."),
140
+ })
141
+ .loose()
142
+ .describe("A price's billing period, as the two halves Paddle states it in.");
143
+
144
+ /**
145
+ * One line on a subscription, as this rail has to be able to reproduce it.
146
+ *
147
+ * Declared here rather than widened onto `PaddleSubscription` in `objects.ts`, whose item shape reads only
148
+ * the price id: four modules already depend on that type, and adding required-feeling fields to it in the
149
+ * commit that adds a subscription rail is how a shape used by the webhook parser changes for a reason the
150
+ * webhook parser has nothing to do with.
151
+ *
152
+ * **`quantity` is `unknown` on purpose.** A typed `z.number()` here would fail the whole item's parse when
153
+ * Paddle sent something else, and the refusal a caller reads would name the item rather than the quantity —
154
+ * where the quantity is precisely the thing this rail refuses to guess. It is checked below, by hand, so the
155
+ * sentence names it.
156
+ */
157
+ const PaddleItemLine = z
158
+ .object({
159
+ price: z
160
+ .object({
161
+ id: z.string().min(1).describe("The price this line bills at — `pri_…`."),
162
+ unit_price: PaddleUnitPrice.nullish().describe("What one unit costs. The left-hand side of a direction."),
163
+ billing_cycle: PaddleBillingCycle.nullish().describe("How often it bills, when Paddle stated it."),
164
+ })
165
+ .loose()
166
+ .describe("The price on this line, with the two facts a change needs from it."),
167
+ quantity: z.unknown().describe("How many. Read untyped so an unusable value refuses by name rather than by shape."),
168
+ })
169
+ .loose()
170
+ .describe("One priced line on a subscription — everything needed to send it back unchanged.");
171
+
172
+ /** A price entity, as `GET /prices/{id}` answers one. */
173
+ const PaddlePriceEntity = z
174
+ .object({
175
+ id: z.string().min(1).describe("The price — `pri_…`."),
176
+ unit_price: PaddleUnitPrice.nullish().describe("What one unit costs. The right-hand side of a direction."),
177
+ billing_cycle: PaddleBillingCycle.nullish().describe("How often it bills, when Paddle stated it."),
178
+ })
179
+ .loose()
180
+ .describe("A Paddle price, narrowed to what choosing a proration mode needs of one.");
181
+
182
+ /**
183
+ * A change preview, plus the one entity field a quote's dates come from.
184
+ *
185
+ * `next_billed_at` is not part of `PaddleSubscriptionPreview` because that shape claims only the three keys
186
+ * the recordings measured. A preview response is a whole subscription entity carrying those three, and the
187
+ * day the deferred credit lands is the entity's own `next_billed_at` — recorded `2026-09-15T11:42:21.789736Z`
188
+ * on the downgrade, and equal there to `next_transaction.billing_period.starts_at`. The entity field is the
189
+ * one read, because the other lives inside a block that is absent whenever nothing is deferred.
190
+ */
191
+ const PaddleChangePreview = PaddleSubscriptionPreview.extend({
192
+ next_billed_at: z
193
+ .string()
194
+ .nullish()
195
+ .describe("When the next invoice falls — the day a deferred credit lands, and the day a new rate starts."),
196
+ }).describe("Paddle's preview of a subscription change, with the entity date the quote's two moments come from.");
197
+
198
+ /** A subscription cannot be changed the way it was asked. 409, with the reason in `detail`. */
199
+ function refuse(detail: string): never {
200
+ throw new PaymentsSubscriptionChangeRefusedError({ detail });
201
+ }
202
+
203
+ /**
204
+ * Paddle answered something this build cannot read. 503, so the screen says it could not look.
205
+ *
206
+ * Not a refusal: nothing about the subscription is wrong, and telling a customer their plan cannot be
207
+ * changed because a response had an unfamiliar shape would be a true sentence about the wrong thing.
208
+ */
209
+ function unreadable(detail: string): never {
210
+ throw new PaymentsProviderUnavailableError({ detail });
211
+ }
212
+
213
+ /** A string worth reading, or null. `""` is not a date and not a currency. */
214
+ function orNull(value: string | null | undefined): string | null {
215
+ return value === null || value === undefined || value === "" ? null : value;
216
+ }
217
+
218
+ /**
219
+ * The subscription this purchase names, or null when it names none.
220
+ *
221
+ * Paddle's ids are globally prefixed, so the row's own key says what it is — `refreshPaddlePurchase`'s rule,
222
+ * and for the same reason: a money row's family key is `sub_…`, and falling back to it would act on a
223
+ * subscription from a row that is not one.
224
+ */
225
+ function subscriptionIdOf(purchase: PaymentsPurchase): string | null {
226
+ const id = purchase.providerTransactionId;
227
+ return id.startsWith("sub_") ? id : null;
228
+ }
229
+
230
+ /** The subscription, or a refusal. Used by the write verbs, which have nothing to do without it. */
231
+ async function loadSubscription(id: string, options: PaddleSubscriptionOptions): Promise<PaddleSubscription> {
232
+ const subscription = await readOne(id, options);
233
+ if (subscription === undefined) {
234
+ refuse(`Paddle has no subscription ${id}, so there is nothing on it to change.`);
235
+ }
236
+ return subscription;
237
+ }
238
+
239
+ /** The subscription, or `undefined` when Paddle has none. */
240
+ async function readOne(id: string, options: PaddleSubscriptionOptions): Promise<PaddleSubscription | undefined> {
241
+ const answer = await paddleJson(options.transport ?? paddleHttpFetch, `/subscriptions/${encodeURIComponent(id)}`, {
242
+ what: `subscription ${id}`,
243
+ apiKey: options.credentials.apiKey,
244
+ environment: options.environment,
245
+ absentOn404: true,
246
+ });
247
+ if (answer === undefined) return undefined;
248
+ const parsed = PaddleSubscription.safeParse(answer.data);
249
+ if (!parsed.success) unreadable(`Paddle answered for subscription ${id} in a shape this build cannot read.`);
250
+ return parsed.data;
251
+ }
252
+
253
+ /**
254
+ * The one line this subscription bills, and the quantity to send back — or a refusal naming what stopped it.
255
+ *
256
+ * **Paddle's update replaces the items array.** An item omitted from the request is removed from the
257
+ * subscription, and a quantity left out of an item Paddle has never seen is a quantity Paddle picks. So both
258
+ * failures are silent writes to somebody's billing: a five-seat subscription rewritten as `quantity: 1` is
259
+ * four seats canceled, and a two-item subscription rewritten as one item is an add-on deleted. Neither is
260
+ * distinguishable afterwards from a change the customer asked for.
261
+ *
262
+ * A 409 is recoverable by a human in a minute. A dropped add-on is discovered on an invoice. So anything
263
+ * this rail cannot reproduce exactly, it refuses.
264
+ */
265
+ function currentLine(subscription: PaddleSubscription): { point: PricePoint; quantity: number } {
266
+ const items = subscription.items ?? [];
267
+ if (items.length !== 1) {
268
+ refuse(
269
+ `Paddle subscription ${subscription.id} carries ${items.length} items, and this rail rewrites the whole array on a change. Reproducing anything but a single line would mean guessing which to keep.`,
270
+ );
271
+ }
272
+
273
+ const parsed = PaddleItemLine.safeParse(items[0]);
274
+ if (!parsed.success) {
275
+ refuse(
276
+ `Paddle subscription ${subscription.id} carries an item this rail cannot reproduce: ${parsed.error.message}`,
277
+ );
278
+ }
279
+ const line = parsed.data;
280
+
281
+ if (typeof line.quantity !== "number" || !Number.isSafeInteger(line.quantity) || line.quantity < 1) {
282
+ refuse(
283
+ `Paddle subscription ${subscription.id} states a quantity of ${JSON.stringify(line.quantity)} on price ${line.price.id}, and a rewritten items array has to carry it back exactly. Guessing one over- or under-charges a real card.`,
284
+ );
285
+ }
286
+
287
+ return { point: pricePointOf(line.price.id, line.price, `subscription ${subscription.id}`), quantity: line.quantity };
288
+ }
289
+
290
+ /** A comparable price point, or a refusal — a direction cannot be read from an amount that is not there. */
291
+ function pricePointOf(
292
+ priceId: string,
293
+ price: { unit_price?: { amount: string; currency_code?: string | null } | null; billing_cycle?: unknown },
294
+ where: string,
295
+ ): PricePoint {
296
+ const amountMinor = minorAmount(price.unit_price?.amount);
297
+ const currency = currencyOf(price.unit_price?.currency_code);
298
+ if (amountMinor === null || currency === null) {
299
+ refuse(
300
+ `Paddle states no readable unit price for ${priceId} on ${where}, so this rail cannot tell an upgrade from a downgrade. Charging immediately on a guess takes money a downgrading customer is owed.`,
301
+ );
302
+ }
303
+ const cycle = PaddleBillingCycle.safeParse(price.billing_cycle);
304
+ return {
305
+ priceId,
306
+ amountMinor,
307
+ currency,
308
+ cycle: cycle.success ? `${cycle.data.interval}:${cycle.data.frequency}` : null,
309
+ };
310
+ }
311
+
312
+ /** The price being moved to. Needs `price.read` on the key, which the refusal names. */
313
+ async function readTargetPrice(priceId: string, options: PaddleSubscriptionOptions): Promise<PricePoint> {
314
+ const answer = await paddleJson(options.transport ?? paddleHttpFetch, `/prices/${encodeURIComponent(priceId)}`, {
315
+ what: `price ${priceId} (this key needs the price.read permission)`,
316
+ apiKey: options.credentials.apiKey,
317
+ environment: options.environment,
318
+ });
319
+ const parsed = PaddlePriceEntity.safeParse(answer?.data);
320
+ if (!parsed.success) unreadable(`Paddle answered for price ${priceId} in a shape this build cannot read.`);
321
+ return pricePointOf(priceId, parsed.data, "the price being moved to");
322
+ }
323
+
324
+ /**
325
+ * Which of Paddle's two behaviors a change routes to. **This chooses a behavior; it does not compute money.**
326
+ *
327
+ * The settled policy (2026-08-28), and the direction is the whole input:
328
+ *
329
+ * - **Up, or sideways at the same price — `prorated_immediately`.** The customer is charged the difference
330
+ * now, and the recorded upgrade takes 6582 today.
331
+ * - **Down — `prorated_next_billing_period`.** The tier moves now and the credit lands on the next invoice.
332
+ * The recorded downgrade owes 6558 on 15 September and settles nothing today.
333
+ *
334
+ * **Equal counts as up**, and the boundary is deliberate. A move between two prices of the same size settles
335
+ * nothing either way, and deferring it would leave a plan changed today with a phantom line waiting on an
336
+ * invoice a month out.
337
+ *
338
+ * **A currency mismatch refuses rather than comparing.** 600 EUR against 11000 USD is not a comparison, and
339
+ * the wrong answer either takes money from a downgrading customer today or defers a charge the store
340
+ * expected to collect.
341
+ *
342
+ * **A change of billing frequency is immediate whatever the direction, because Paddle allows nothing else.**
343
+ * Its documentation is explicit that only `prorated_immediately`, `full_immediately` and `do_not_bill` are
344
+ * accepted when the billing cycle changes, so a monthly→annual downgrade sent deferred is a 400 — which this
345
+ * rail maps to `rail_not_configured`, sending an operator to check a key that is fine. `do_not_bill` is a
346
+ * free change and unreachable here, so `prorated_immediately` is the only mode left; the recorded downgrade
347
+ * under it settles `grand_total: "0"` with the whole credit on the customer's balance, which is the honest
348
+ * outcome rather than a surprise charge. It fires only when Paddle stated both cycles: an unstated one is
349
+ * not evidence of a difference.
350
+ */
351
+ function prorationModeFor(current: PricePoint, target: PricePoint): ProrationMode {
352
+ if (current.currency !== target.currency) {
353
+ refuse(
354
+ `Paddle prices ${current.priceId} in ${current.currency} and ${target.priceId} in ${target.currency}, and two currencies do not order. This rail picks a proration mode from the direction of the change, and there is no direction to read.`,
355
+ );
356
+ }
357
+ if (current.cycle !== null && target.cycle !== null && current.cycle !== target.cycle) return "prorated_immediately";
358
+ return target.amountMinor >= current.amountMinor ? "prorated_immediately" : "prorated_next_billing_period";
359
+ }
360
+
361
+ /** The body both writes send, and the one the preview sends — identical, as Paddle's own guidance requires. */
362
+ function changeBody(priceId: string, quantity: number, mode: ProrationMode): Record<string, unknown> {
363
+ return {
364
+ // The **complete** array. Paddle removes anything omitted, and `currentLine` has already refused any
365
+ // subscription whose lines this rail could not reproduce exactly.
366
+ items: [{ price_id: priceId, quantity }],
367
+ proration_billing_mode: mode,
368
+ on_payment_failure: ON_PAYMENT_FAILURE,
369
+ };
370
+ }
371
+
372
+ /** Where a subscription stands, from an entity Paddle answered with. */
373
+ function standingOf(subscription: PaddleSubscription): SubscriptionStanding {
374
+ const pending = subscriptionPendingChange(subscription);
375
+ const currency = subscription.currency_code;
376
+ const parsed = SubscriptionStanding.safeParse({
377
+ status: subscriptionStatus(subscription.status),
378
+ currency: currencyOf(typeof currency === "string" ? currency : null),
379
+ currentPeriodEndsAt: orNull(subscription.current_billing_period?.ends_at),
380
+ nextBilledAt: orNull(subscription.next_billed_at),
381
+ scheduledChange:
382
+ pending === null
383
+ ? null
384
+ : { action: pending.action, effectiveAt: pending.effectiveAt, resumesAt: pending.resumeAt },
385
+ });
386
+ if (!parsed.success) {
387
+ unreadable(
388
+ `Paddle described subscription ${subscription.id} in a way this build cannot state: ${parsed.error.message}`,
389
+ );
390
+ }
391
+ return parsed.data;
392
+ }
393
+
394
+ /**
395
+ * What settles, from Paddle's own reconciliation of the change.
396
+ *
397
+ * `update_summary.result` and nothing else. The alternative — the immediate transaction's totals — is right
398
+ * on an upgrade and silently wrong on a downgrade, where `grand_total` is `"0"` while 6581 sits in
399
+ * `credit_to_balance`: a screen wired to it says "you will be charged $0.00" and never mentions the money.
400
+ *
401
+ * An action this build does not map refuses rather than being rendered as itself, which is `subscriptionStatus`'s
402
+ * rule. A third verb quietly read as a charge is the wrong direction in front of a paying customer.
403
+ *
404
+ * **`DeferredSubscriptionSettlement`, not `SubscriptionSettlement`** — the union without `nothing`. What
405
+ * Paddle summarized is always a charge or a credit; `nothing` is a statement about *today* that this function
406
+ * has no standing to make, and typing it wider let a `nothing` compile into `nextInvoice.settlement`, which
407
+ * is a row about no money, dated. The narrower union is what refused it, at build time (#465).
408
+ *
409
+ * **The whole summary rather than its `result`, since #96.** The reconciliation Paddle performed is three
410
+ * figures and the net is one of them; a function handed only the net cannot report the two it reconciles, and
411
+ * the screen that showed it had a customer asking why $48.83 was neither of the prices in front of them. The
412
+ * halves are read here, beside the net, from the same object — never carried in separately, which is how they
413
+ * would come to describe a different change than the amount they sit under.
414
+ */
415
+ function settlementOf(summary: PaddleUpdateSummary, locale: string | undefined): DeferredSubscriptionSettlement {
416
+ const result = summary.result;
417
+ if (result.action !== "charge" && result.action !== "credit") {
418
+ unreadable(
419
+ `Paddle summarized a subscription change as "${result.action}", which is not an outcome this build maps.`,
420
+ );
421
+ }
422
+ const amountMinor = minorAmount(result.amount);
423
+ const currency = currencyOf(result.currency_code);
424
+ if (amountMinor === null || currency === null) {
425
+ unreadable(
426
+ `Paddle summarized a subscription change as ${result.action} of ${JSON.stringify(result.amount)} ${JSON.stringify(result.currency_code)}, which is not an amount this build can state.`,
427
+ );
428
+ }
429
+ return {
430
+ outcome: result.action,
431
+ amount: quotedMoney(amountMinor, currency, locale, result.action),
432
+ madeUpOf: partsOf(summary, locale),
433
+ };
434
+ }
435
+
436
+ /**
437
+ * The charge and the credit the net reconciles, or null when Paddle did not state both in full.
438
+ *
439
+ * **Null is the whole of the failure handling here, and that is deliberate.** Everywhere else in this file an
440
+ * unreadable figure throws, because everywhere else the figure *is* the answer. This one is an explanation of
441
+ * an answer that already parsed: refusing the quote because a supporting number lacked a currency would take a
442
+ * working confirmation screen away over a detail the customer never asked for. `update_summary.credit` arrived
443
+ * as `{ amount: "-6936" }` with no `currency_code` on a real recording, so this is a shape Paddle sends.
444
+ *
445
+ * **No currency is borrowed from the net.** It is the one shortcut available and it renders a guess as a
446
+ * price. A missing half is reported as missing.
447
+ */
448
+ function partsOf(summary: PaddleUpdateSummary, locale: string | undefined): SubscriptionSettlementParts | null {
449
+ const charge = quotedOrNull(summary.charge?.amount, summary.charge?.currency_code, locale);
450
+ const credit = quotedOrNull(summary.credit?.amount, summary.credit?.currency_code, locale);
451
+ return charge === null || credit === null ? null : { charge, credit };
452
+ }
453
+
454
+ /**
455
+ * One supporting figure, or null when it is not a stateable amount. **Never throws** — see {@link partsOf}.
456
+ *
457
+ * The amount and the currency arrive separately because the two blocks this reads carry them that way:
458
+ * `update_summary` puts a `currency_code` on each half, and a totals block states one for the whole block.
459
+ * Taking a money object would have made the second call site build one, which is a shape invented to fit a
460
+ * signature rather than to describe anything.
461
+ */
462
+ function quotedOrNull(
463
+ amount: string | null | undefined,
464
+ currencyCode: string | null | undefined,
465
+ locale: string | undefined,
466
+ ): QuotedMoney | null {
467
+ if (amount === null || amount === undefined) return null;
468
+ const amountMinor = minorAmount(amount);
469
+ const currency = currencyOf(currencyCode);
470
+ if (amountMinor === null || currency === null) return null;
471
+ const rendered = renderMoney(amountMinor, currency, locale);
472
+ return rendered === null ? null : { amountMinor, currency, rendered };
473
+ }
474
+
475
+ /**
476
+ * One figure, with the string a screen shows it as.
477
+ *
478
+ * **Rendering is not the same check as parsing, and this is where the second one fails.** `currencyOf`
479
+ * lowercases whatever Paddle sent and answers null only for an empty string, so a store answering
480
+ * `currency_code: "dollars"` reaches here with an amount that parses and a currency nothing can put a
481
+ * symbol on. `renderMoney` answers null for it, and null is refused the way every other unreadable figure
482
+ * in this file is — a shape change reported as one, rather than a `RangeError` out of `Intl` arriving at a
483
+ * customer's confirmation screen as a 500.
484
+ */
485
+ function quotedMoney(amountMinor: number, currency: string, locale: string | undefined, what: string): QuotedMoney {
486
+ const rendered = renderMoney(amountMinor, currency, locale);
487
+ if (rendered === null) {
488
+ unreadable(
489
+ `Paddle stated the ${what} of a subscription change in ${JSON.stringify(currency)}, which is not a currency this build can name. The amount parses and cannot be shown to anybody.`,
490
+ );
491
+ }
492
+ return { amountMinor, currency, rendered };
493
+ }
494
+
495
+ /**
496
+ * A quote, from a preview response.
497
+ *
498
+ * **`update_summary.result` says *what* the change is worth; whether an `immediate_transaction` exists says
499
+ * *when* it lands.** That is the rule the recordings established and the one a two-part quote could not hold:
500
+ * the deferred downgrade answers `immediate_transaction: null` *and* `result: { action: "credit", amount:
501
+ * "6558" }` at the same time. Read `result` as today's headline and the screen promises money the customer
502
+ * will look for and not find; read the missing transaction as the whole answer and 65.58 dollars disappear
503
+ * from the quote.
504
+ *
505
+ * A missing summary is a shape change and not a free change, so it throws rather than settling `nothing`.
506
+ * "This costs you nothing" is a sentence that must come from Paddle, never from a field being absent.
507
+ */
508
+ function quoteOf(data: unknown, priceId: string, locale: string | undefined): SubscriptionChangeQuote {
509
+ const preview = PaddleChangePreview.safeParse(data);
510
+ if (!preview.success) unreadable(`Paddle previewed a move to ${priceId} in a shape this build cannot read.`);
511
+ const answer = preview.data;
512
+ const summary = answer.update_summary;
513
+ if (summary === null || summary === undefined) {
514
+ unreadable(
515
+ `Paddle previewed a move to ${priceId} with no \`update_summary\`, so what the change costs is unstated. A quote cannot be built from its absence.`,
516
+ );
517
+ }
518
+ const settlement = settlementOf(summary, locale);
519
+ const settlesToday = answer.immediate_transaction !== null && answer.immediate_transaction !== undefined;
520
+ const nextBilledAt = orNull(answer.next_billed_at);
521
+
522
+ if (!settlesToday && nextBilledAt === null) {
523
+ unreadable(
524
+ `Paddle previewed a move to ${priceId} that settles nothing today and named no next billing date, so the ${settlement.outcome} of ${settlement.amount.amountMinor} has no day to land on.`,
525
+ );
526
+ }
527
+
528
+ const quote: Quote = {
529
+ settlesToday: settlesToday ? settlement : { outcome: "nothing" },
530
+ nextInvoice: settlesToday || nextBilledAt === null ? null : { settlement, at: new Date(nextBilledAt) },
531
+ recurring: recurringOf(answer, nextBilledAt, priceId, locale),
532
+ };
533
+ const parsed = SubscriptionChangeQuote.safeParse(quote);
534
+ if (!parsed.success) {
535
+ unreadable(`Paddle's preview of a move to ${priceId} does not state a quote: ${parsed.error.message}`);
536
+ }
537
+ return parsed.data;
538
+ }
539
+
540
+ /**
541
+ * What the subscription pays each period after the change, or null when nothing renews.
542
+ *
543
+ * **The two nulls this separates are not the same claim, and collapsing them costs one of them.** No next
544
+ * billing date is Paddle saying the subscription is ending — reachable today, because a preview may be asked
545
+ * for on a subscription with a cancellation already scheduled, where `next_billed_at` is blank and the
546
+ * status is still `active` (recorded 2026-08-28). That is a sentence a screen writes, so it is a null. A
547
+ * *stated* renewal date with no readable recurring block is the provider declining to answer, which is a
548
+ * shape change and throws — because the alternative is inventing a figure, and the invented one is zero.
549
+ */
550
+ function recurringOf(
551
+ answer: z.output<typeof PaddleChangePreview>,
552
+ nextBilledAt: string | null,
553
+ priceId: string,
554
+ locale: string | undefined,
555
+ ): Quote["recurring"] {
556
+ if (nextBilledAt === null) return null;
557
+
558
+ const totals = answer.recurring_transaction_details?.totals;
559
+ const amountMinor = minorAmount(totals?.grand_total ?? totals?.total);
560
+ const currency = currencyOf(totals?.currency_code);
561
+ if (amountMinor === null || currency === null) {
562
+ unreadable(
563
+ `Paddle previewed a move to ${priceId} renewing on ${nextBilledAt} without saying what it pays then. A rail with no recurring answer has a shape change to report, not a null to invent.`,
564
+ );
565
+ }
566
+ return {
567
+ amount: quotedMoney(amountMinor, currency, locale, "renewal"),
568
+ startsAt: new Date(nextBilledAt),
569
+ madeUpOf: recurringPartsOf(totals, locale),
570
+ };
571
+ }
572
+
573
+ /**
574
+ * The base and the tax the renewal figure is the sum of, or null when Paddle did not state both in full.
575
+ *
576
+ * **Null rather than a throw, for {@link partsOf}'s reason exactly**: the renewal amount has already been
577
+ * read and refused if unreadable, and this explains that amount rather than being it. A preview whose
578
+ * totals block omits `tax` is a screen with one figure instead of three, not a plan change a customer
579
+ * cannot make.
580
+ *
581
+ * **`subtotal` and not `total - tax`.** Paddle states the base, after any discount; deriving it would be
582
+ * this package computing money, and it would be right only for as long as `discount` stays `"0"`.
583
+ */
584
+ function recurringPartsOf(
585
+ totals: z.output<typeof PaddleTotals> | null | undefined,
586
+ locale: string | undefined,
587
+ ): SubscriptionRecurringParts | null {
588
+ const beforeTax = quotedOrNull(totals?.subtotal, totals?.currency_code, locale);
589
+ const tax = quotedOrNull(totals?.tax, totals?.currency_code, locale);
590
+ return beforeTax === null || tax === null ? null : { beforeTax, tax };
591
+ }
592
+
593
+ /**
594
+ * Where this subscription stands now, or `undefined` when Paddle has nothing to say about this purchase.
595
+ *
596
+ * Read live, because the one fact it exists to report is the one a projected row does not carry: with a
597
+ * cancellation scheduled Paddle answers `status: "active"`, `canceled_at: null` and `next_billed_at: null`
598
+ * (recorded 2026-08-28). Two of those say the subscription is fine and the third says nothing, so the end
599
+ * date lives only on `scheduled_change.effective_at` — and a webhook announcing it can be dropped.
600
+ */
601
+ export async function readPaddleStanding(
602
+ purchase: PaymentsPurchase,
603
+ options: PaddleSubscriptionOptions,
604
+ ): Promise<SubscriptionStanding | undefined> {
605
+ const id = subscriptionIdOf(purchase);
606
+ if (id === null) return undefined;
607
+ const subscription = await readOne(id, options);
608
+ return subscription === undefined ? undefined : standingOf(subscription);
609
+ }
610
+
611
+ /**
612
+ * What moving to this price would cost, as Paddle previews it.
613
+ *
614
+ * **A read, so the no-op rule does not apply.** Previewing the plan already held is a question with an honest
615
+ * answer and asking it takes nothing; skipping the call would mean inventing a recurring figure to fill the
616
+ * quote with, which is the one thing this package will not do.
617
+ *
618
+ * The item refusals *do* apply, because the preview body is the update body: a preview built from a rewritten
619
+ * items array quotes a change that is not the one the update would make.
620
+ *
621
+ * A subscription with a change already scheduled is not refused here. Refusing a read on the grounds of state
622
+ * would hide the figures from the screen that has to explain why the move cannot be made yet, and `changePlan`
623
+ * is where the write is stopped.
624
+ *
625
+ * `locale` is the reader the figures are rendered for, and it is a parameter of this method alone: the other
626
+ * four verbs answer a standing, which carries no amount. Absent, the rendering falls back to the kit's own
627
+ * locale rather than failing — see `data/renderMoney.ts`.
628
+ */
629
+ export async function previewPaddleChange(
630
+ input: SubscriptionChangeInput,
631
+ options: PaddleSubscriptionOptions,
632
+ locale?: string,
633
+ ): Promise<SubscriptionChangeQuote> {
634
+ const id = subscriptionIdOf(input.purchase);
635
+ if (id === null) refuse(`Purchase ${input.purchase.id} names no Paddle subscription, so there is nothing to quote.`);
636
+
637
+ const subscription = await loadSubscription(id, options);
638
+ const { point, quantity } = currentLine(subscription);
639
+ const target = await readTargetPrice(input.providerProductId, options);
640
+ const mode = prorationModeFor(point, target);
641
+
642
+ const answer = await paddleJson(
643
+ options.transport ?? paddleHttpFetch,
644
+ `/subscriptions/${encodeURIComponent(id)}/preview`,
645
+ {
646
+ what: `a preview of subscription ${id} moving to ${input.providerProductId}`,
647
+ apiKey: options.credentials.apiKey,
648
+ environment: options.environment,
649
+ // Explicit, because `paddleJson` would otherwise POST a request carrying a body — and Paddle does not
650
+ // route a POST here. See the module doc.
651
+ method: "PATCH",
652
+ body: changeBody(input.providerProductId, quantity, mode),
653
+ },
654
+ );
655
+ return quoteOf(answer?.data, input.providerProductId, locale);
656
+ }
657
+
658
+ /**
659
+ * Move the subscription to a different plan, and answer where it now stands.
660
+ *
661
+ * The answer is Paddle's, not a prediction: the screen that just wrote renders what the store says rather than
662
+ * what the request asked for, and the recorded responses are why — a cancel leaves `status` at `active`, and
663
+ * an update's response is the only place the resulting standing exists.
664
+ *
665
+ * **The plan already held is a success and writes nothing.** These verbs sit behind a network, callers retry,
666
+ * and a second delivery of the same instruction must not become a second proration. A 409 for the state the
667
+ * caller asked for would also simply be wrong — the subscription is how they wanted it. The subscription is
668
+ * still re-read, because the standing that comes back has to be the store's; the rule is about not *writing*
669
+ * twice, and a read takes and gives nothing.
670
+ *
671
+ * **A subscription with a change already scheduled is refused.** Honoring the move means discarding the
672
+ * pending action, and Paddle's own refusal for it arrives as an ordinary 4xx that this rail maps to
673
+ * `rail_not_configured` — a refusal naming the wrong thing. Withdraw the schedule first; that is what
674
+ * {@link keepPaddleSubscription} is for, and it is a separate act the audit trail records separately.
675
+ */
676
+ export async function changePaddlePlan(
677
+ input: SubscriptionChangeInput,
678
+ options: PaddleSubscriptionOptions,
679
+ ): Promise<SubscriptionStanding> {
680
+ const id = subscriptionIdOf(input.purchase);
681
+ if (id === null) refuse(`Purchase ${input.purchase.id} names no Paddle subscription, so there is nothing to change.`);
682
+
683
+ const subscription = await loadSubscription(id, options);
684
+ const { point, quantity } = currentLine(subscription);
685
+ if (point.priceId === input.providerProductId) return standingOf(subscription);
686
+
687
+ const pending = subscriptionPendingChange(subscription);
688
+ if (pending !== null) {
689
+ refuse(
690
+ `Paddle subscription ${id} is already scheduled to ${pending.action} on ${pending.effectiveAt ?? "an unstated date"}, and moving it to ${input.providerProductId} would discard that. Withdraw the scheduled change first.`,
691
+ );
692
+ }
693
+
694
+ const target = await readTargetPrice(input.providerProductId, options);
695
+ const mode = prorationModeFor(point, target);
696
+
697
+ const answer = await paddleJson(options.transport ?? paddleHttpFetch, `/subscriptions/${encodeURIComponent(id)}`, {
698
+ what: `subscription ${id} moving to ${input.providerProductId}`,
699
+ apiKey: options.credentials.apiKey,
700
+ environment: options.environment,
701
+ method: "PATCH",
702
+ body: changeBody(input.providerProductId, quantity, mode),
703
+ });
704
+ return standingAnswered(answer?.data, id);
705
+ }
706
+
707
+ /**
708
+ * Stop the subscription renewing, and answer where it now stands.
709
+ *
710
+ * `at_period_end` is Paddle's `next_billing_period` and the settled policy: the tier holds until the paid
711
+ * period runs out. `now` is `immediately`, and it exists because support occasionally has to end one today —
712
+ * and because a policy with no legitimate exit gets departed from by a direct provider call nothing audits.
713
+ *
714
+ * **The no-op is per timing, not per verb.** A subscription scheduled to end on the 15th is not a subscription
715
+ * that ended today, so `now` against a scheduled cancel is a real request and reaches Paddle. What is already
716
+ * true is what is skipped: a period-end cancel on one already scheduled, and an immediate cancel on one the
717
+ * store has already ended.
718
+ */
719
+ export async function cancelPaddleSubscription(
720
+ input: SubscriptionCancelInput,
721
+ options: PaddleSubscriptionOptions,
722
+ ): Promise<SubscriptionStanding> {
723
+ const id = subscriptionIdOf(input.purchase);
724
+ if (id === null) refuse(`Purchase ${input.purchase.id} names no Paddle subscription, so there is nothing to cancel.`);
725
+
726
+ const subscription = await loadSubscription(id, options);
727
+ const pending = subscriptionPendingChange(subscription);
728
+ const alreadyScheduled = input.timing === "at_period_end" && pending?.action === "cancel";
729
+ const alreadyEnded = input.timing === "now" && subscriptionStatus(subscription.status) === "canceled";
730
+ if (alreadyScheduled || alreadyEnded) return standingOf(subscription);
731
+
732
+ const answer = await paddleJson(
733
+ options.transport ?? paddleHttpFetch,
734
+ `/subscriptions/${encodeURIComponent(id)}/cancel`,
735
+ {
736
+ what: `canceling subscription ${id}`,
737
+ apiKey: options.credentials.apiKey,
738
+ environment: options.environment,
739
+ method: "POST",
740
+ body: { effective_from: input.timing === "now" ? "immediately" : "next_billing_period" },
741
+ },
742
+ );
743
+ return standingAnswered(answer?.data, id);
744
+ }
745
+
746
+ /**
747
+ * Withdraw a scheduled cancellation, so the subscription renews after all.
748
+ *
749
+ * **It withdraws a cancellation, and only a cancellation.** Paddle has no verb for that: the update clears
750
+ * `scheduled_change` *wholesale*, and that field also holds a scheduled pause and a scheduled resume. A rail
751
+ * that simply sent the clear would silently un-pause a paused subscription — the customer's account restarts
752
+ * billing, on a request that said nothing about pausing. So the subscription is re-read and anything but a
753
+ * pending `cancel` is refused, with what was actually scheduled named in `detail`.
754
+ *
755
+ * The check cannot move to the route: the route holds a projected row, and the pending action lives only at
756
+ * the store.
757
+ *
758
+ * **Nothing scheduled is the no-op, not a refusal.** The subscription already renews, which is what the caller
759
+ * asked for, and a retry after a successful withdrawal is exactly that request arriving twice.
760
+ */
761
+ export async function keepPaddleSubscription(
762
+ purchase: PaymentsPurchase,
763
+ options: PaddleSubscriptionOptions,
764
+ ): Promise<SubscriptionStanding> {
765
+ const id = subscriptionIdOf(purchase);
766
+ if (id === null) refuse(`Purchase ${purchase.id} names no Paddle subscription, so there is nothing to withdraw.`);
767
+
768
+ const subscription = await loadSubscription(id, options);
769
+ const pending = subscriptionPendingChange(subscription);
770
+ if (pending === null) return standingOf(subscription);
771
+ if (pending.action !== "cancel") {
772
+ refuse(
773
+ `Paddle subscription ${id} is scheduled to ${pending.action}, not to cancel. Clearing the schedule is the only withdrawal Paddle offers and it clears the whole field, so honoring this would ${pending.action === "pause" ? "restart billing on a paused account" : "discard a scheduled resume"}.`,
774
+ );
775
+ }
776
+
777
+ const answer = await paddleJson(options.transport ?? paddleHttpFetch, `/subscriptions/${encodeURIComponent(id)}`, {
778
+ what: `withdrawing the scheduled cancellation of subscription ${id}`,
779
+ apiKey: options.credentials.apiKey,
780
+ environment: options.environment,
781
+ method: "PATCH",
782
+ // The whole field, because Paddle offers nothing narrower. What makes that safe is the refusal above.
783
+ body: { scheduled_change: null },
784
+ });
785
+ return standingAnswered(answer?.data, id);
786
+ }
787
+
788
+ /** The standing in a write's own response. Every write answers the entity, so none of them predicts one. */
789
+ function standingAnswered(data: unknown, id: string): SubscriptionStanding {
790
+ const parsed = PaddleSubscription.safeParse(data);
791
+ if (!parsed.success) unreadable(`Paddle answered a write to subscription ${id} in a shape this build cannot read.`);
792
+ return standingOf(parsed.data);
793
+ }