@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,883 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { pauseResumesAt } from "../../data/pause";
6
+ import type { PurchaseEnvironment } from "../../data/purchase";
7
+ import type { PurchaseStatus } from "../../data/status";
8
+ import { decodeSubjectReference } from "../../data/subject";
9
+ import { PaymentsVerificationFailedError } from "../../error/errors";
10
+ import type { UnboundProviderEvent } from "../contract";
11
+
12
+ /**
13
+ * Paddle's objects, and the mapping from what its events say to what this package stores.
14
+ *
15
+ * ## Two rows per subscription, exactly as Lemon Squeezy has
16
+ *
17
+ * Paddle splits money from state at the source. A `transaction.*` event carries a **transaction**: one
18
+ * billing period's charge, its totals, its status. A `subscription.*` event carries a **subscription**: a
19
+ * status, a billing period, a scheduled change, and no money. So there is a **state row** keyed on
20
+ * `sub_…` and a **money row** per `txn_…`, distinguished by `PurchaseRole`, and each is monotonic on
21
+ * exactly one provider clock.
22
+ *
23
+ * Paddle's ids are globally prefixed and distinct — `txn_`, `sub_`, `adj_`, `ctm_` — so unlike Lemon
24
+ * Squeezy nothing needs namespacing before it can be a row's identity.
25
+ *
26
+ * **A subscription transaction is born `expired`, not `active`, and this is a departure from the issue's
27
+ * event table stated out loud.** The issue maps `transaction.completed` to `active`. On a one-off that is
28
+ * right and this module does it. On a subscription it would be a row with no expiry saying "paid and
29
+ * current" forever — and a never-expiring `active` row outranks the subscription's own state row, so a
30
+ * canceled subscriber would keep their entitlement for good. The paid period is real and it has closed:
31
+ * `expired` grants nothing and still credits a `grants` clause, which is what makes two renewals credit
32
+ * twice. This is the trap `lemonSqueezy/objects.ts` documents at length; Paddle has it identically.
33
+ *
34
+ * ## The environment is the account, not a field on the payload
35
+ *
36
+ * The issue says `data.mode` is `sandbox` or `live`. It is not. Verified against a live sandbox: `mode`
37
+ * appears on a **discount**, where its values are `standard` and `custom`, and it is absent from customers
38
+ * and transactions entirely. Paddle Billing partitions sandbox from live by *account* — a separate host,
39
+ * a separate API key, a separate set of notification destinations — so the environment a purchase belongs
40
+ * to is the environment this deployment's credentials point at, and nothing in a payload can contradict
41
+ * it. That is the same arrangement Stripe has, and the opposite of Lemon Squeezy's single namespace.
42
+ *
43
+ * ## The environment fence, and why `custom_data` alone cannot be one
44
+ *
45
+ * `dev` is not publicly routable, so a dev checkout's webhooks land at `staging` — both point at one
46
+ * Paddle sandbox, and a sandbox has one set of notification destinations. So a delivery stamped for
47
+ * another environment is the normal case here, not an anomaly, and it projects nothing and returns 200.
48
+ *
49
+ * The issue argues the stamp needs no proof, because "`Paddle.Checkout.open` refuses `customData`
50
+ * alongside `transactionId`, and the server always creates the transaction." **That constraint does not
51
+ * exist.** Two things were measured against the live sandbox on 2026-08-13, both from a page holding
52
+ * nothing but the publishable client token this rail ships to every browser that loads a paywall:
53
+ *
54
+ * - `Paddle.Checkout.open({ items: [{ priceId, quantity }], customData: {…} })` opens and completes with
55
+ * no server involved at all, and Paddle stores the page's `custom_data` verbatim.
56
+ * - `Paddle.Checkout.open({ transactionId, customData: {…} })` does not throw, is not refused, and
57
+ * **replaces** the `custom_data` the server wrote when the transaction was created. Same transaction id,
58
+ * `origin` still `"api"`, owner now whoever the page said.
59
+ *
60
+ * Both recordings are in `fixtures/browserForged.ts` and `objects.test.ts` gates on them. So
61
+ * `custom_data.pithy_user` is a string a browser can write on either form of the call, and binding
62
+ * ownership on it would let anyone attach a purchase to any account, permanently, because
63
+ * `linkProviderAccount` never rebinds and the first pairing wins.
64
+ *
65
+ * The Lemon Squeezy rail met the same problem and answered it with a MAC. So does this one: see
66
+ * {@link accountReferenceProof}. Without the proof the two stamped values are worth nothing, and with it
67
+ * they are worth exactly what a value only this deployment's server could have produced is worth.
68
+ */
69
+
70
+ /**
71
+ * The `custom_data` key carrying the subject reference. Literal snake_case, as the issue specifies.
72
+ *
73
+ * **The name says `user` and the value no longer has to be one, and that mismatch is deliberate.** What
74
+ * travels here is now `encodeSubjectReference`'s output — `user:ada`, `organization:acme` — because a
75
+ * purchase can be held by an organization. The obvious tidy-up is to rename the key to match. It is the one
76
+ * thing that must not happen: this string is a wire contract with Paddle, not an identifier of ours. A
77
+ * checkout stamped `pithy_user` today is a transaction sitting open in somebody's browser, a subscription
78
+ * renewing next month, and a `custom_data` object Paddle stores verbatim for the life of the customer. A
79
+ * renamed key reads nothing on any of them — every in-flight purchase comes back naming nobody, permanently,
80
+ * with a `linkProviderAccount` that never rebinds behind it. The name is frozen; the meaning moved.
81
+ */
82
+ export const PADDLE_CUSTOM_ACCOUNT = "pithy_user";
83
+
84
+ /** The `custom_data` key carrying this deployment's environment — the shared-sandbox fence. */
85
+ export const PADDLE_CUSTOM_ENV = "pithy_env";
86
+
87
+ /**
88
+ * The `custom_data` key carrying the proof that **this deployment's server** wrote the two above.
89
+ *
90
+ * Without it the other two prove nothing: `Paddle.Checkout.open` accepts `customData` with only the
91
+ * publishable client token — beside an `items[]` array *and* beside a `transactionId`, where it overwrites
92
+ * what the server wrote — both key names are exported constants in an open-source package, and the
93
+ * environment value is one of three. Requiring them asks an attacker to guess nothing.
94
+ *
95
+ * A MAC is the only part a stranger cannot produce.
96
+ */
97
+ export const PADDLE_CUSTOM_PROOF = "pithy_ref_proof";
98
+
99
+ /**
100
+ * Paddle stores `custom_data` verbatim — it does not normalize keys the way Lemon Squeezy snake_cases
101
+ * them. The `pithy_` prefix is still load-bearing: this object is shared with whatever an adopter puts in
102
+ * it, and a prefix is what stops a collision being silent rather than being a wrong owner.
103
+ */
104
+ const CustomData = z
105
+ .record(z.string(), z.unknown())
106
+ .describe("Arbitrary key/value data stored verbatim by Paddle, shared with whatever the adopter puts there.");
107
+
108
+ /** The envelope every webhook delivery and every swept event arrives in. */
109
+ export const PaddleEvent = z
110
+ .object({
111
+ event_id: z
112
+ .string()
113
+ .min(1)
114
+ .describe(
115
+ "Paddle's own id for this event — `evt_…`. The dedup key, and the one the webhook path and the events sweep share, because the stream carries no notification_id.",
116
+ ),
117
+ event_type: z.string().min(1).describe("Which event this is — `transaction.completed`, `subscription.canceled`."),
118
+ occurred_at: z
119
+ .string()
120
+ .min(1)
121
+ .describe(
122
+ "When the event happened, from the envelope. This is `providerEventAt`, never the entity's `updated_at`: a transaction's clock lives in the transaction's domain, and stamping it onto a subscription's watermark discards the `subscription.activated` that follows.",
123
+ ),
124
+ data: z.record(z.string(), z.unknown()).describe("The entity the event is about."),
125
+ })
126
+ .loose()
127
+ .describe("One Paddle event, as a webhook delivers it and as the events stream returns it.");
128
+ export type PaddleEvent = z.infer<typeof PaddleEvent>;
129
+
130
+ /** One transaction line item, narrowed to the price it names. */
131
+ const PaddleTransactionItem = z
132
+ .object({
133
+ price: z
134
+ .object({ id: z.string().optional().describe("The price sold — this rail's SKU.") })
135
+ .loose()
136
+ .optional()
137
+ .describe("The price object, which is where a transaction carries its SKU."),
138
+ price_id: z.string().optional().describe("The price id, on the shapes that carry it flat."),
139
+ })
140
+ .loose()
141
+ .describe("One line of a transaction. Single-product checkouts have exactly one.");
142
+
143
+ /** An adjustment's fields — a refund, a credit, or a chargeback, which on this rail can arrive unsolicited. */
144
+ export const PaddleAdjustment = z
145
+ .object({
146
+ id: z.string().min(1).describe("The adjustment — `adj_…`."),
147
+ action: z
148
+ .string()
149
+ .min(1)
150
+ .describe(
151
+ "What it does — `refund`, `credit`, `chargeback`, `chargeback_reverse`, `chargeback_warning`, `chargeback_warning_reverse`, `credit_reverse`.",
152
+ ),
153
+ status: z.string().nullish().describe("`pending_approval`, `approved`, `rejected`, or `reversed`."),
154
+ transaction_id: z.string().min(1).describe("The transaction adjusted. The only key that names a row of ours."),
155
+ subscription_id: z.string().nullish().describe("The subscription behind it, when there is one."),
156
+ customer_id: z.string().nullish().describe("The customer adjusted."),
157
+ currency_code: z.string().nullish().describe("The currency the totals are in."),
158
+ totals: z
159
+ .object({
160
+ total: z.string().nullish().describe("How much was adjusted, in the currency's lowest denomination."),
161
+ })
162
+ .loose()
163
+ .nullish()
164
+ .describe(
165
+ "What the adjustment came to. Summed with every other approved adjustment against the transaction, and that sum is what tells a full refund from a partial one.",
166
+ ),
167
+ created_at: z.string().nullish().describe("When Paddle raised the adjustment."),
168
+ updated_at: z.string().nullish().describe("The adjustment's own clock. Deliberately not used as a watermark."),
169
+ })
170
+ .loose()
171
+ .describe("A Paddle adjustment. As merchant of record Paddle issues these on its own, with no local write first.");
172
+ export type PaddleAdjustment = z.infer<typeof PaddleAdjustment>;
173
+
174
+ /** A transaction's fields, as an event and the API both present them. */
175
+ export const PaddleTransaction = z
176
+ .object({
177
+ id: z.string().min(1).describe("The transaction — `txn_…`. This row's identity forever."),
178
+ status: z.string().min(1).describe("Paddle's own status vocabulary, normalized by `transactionStatus`."),
179
+ customer_id: z.string().nullish().describe("The Paddle customer charged — this rail's `providerAccountId`."),
180
+ subscription_id: z
181
+ .string()
182
+ .nullish()
183
+ .describe("The subscription this transaction bills, or null for a one-off. The family key."),
184
+ discount_id: z.string().nullish().describe("The discount applied, when one was."),
185
+ origin: z
186
+ .string()
187
+ .nullish()
188
+ .describe(
189
+ "How the transaction came to exist — `web`, `subscription_recurring`, `api`. Recorded rather than acted on: it is undocumented enough that a fence on it would be a guess.",
190
+ ),
191
+ items: z.array(PaddleTransactionItem).optional().describe("The lines sold. The first carries this rail's SKU."),
192
+ details: z
193
+ .object({
194
+ totals: z
195
+ .object({
196
+ grand_total: z
197
+ .string()
198
+ .optional()
199
+ .describe("What was charged, in the currency's lowest denomination, as a string."),
200
+ currency_code: z.string().optional().describe("The currency, uppercase ISO 4217."),
201
+ })
202
+ .loose()
203
+ .optional()
204
+ .describe("The money, as Paddle computed it."),
205
+ })
206
+ .loose()
207
+ .optional()
208
+ .describe("The computed totals. Paddle is the authority on every figure here; nothing recomputes one."),
209
+ billing_period: z
210
+ .object({
211
+ starts_at: z.string().nullish().describe("When the period this transaction bills began."),
212
+ ends_at: z.string().nullish().describe("When it ends."),
213
+ })
214
+ .loose()
215
+ .nullish()
216
+ .describe("The billing period a subscription transaction covers. Absent on a one-off."),
217
+ currency_code: z.string().nullish().describe("The transaction's currency, when it carries one flat."),
218
+ custom_data: CustomData.nullish().describe("What this deployment's server stamped at checkout, echoed back."),
219
+ checkout: z
220
+ .object({ url: z.string().nullish().describe("The hosted checkout page, when the account has a payment link.") })
221
+ .loose()
222
+ .nullish()
223
+ .describe("Where a hosted checkout lives. Null unless the account has a default payment link set."),
224
+ adjustments: z
225
+ .array(PaddleAdjustment)
226
+ .optional()
227
+ .describe(
228
+ "Every adjustment raised against this transaction, returned when the read asks `include=adjustments`. The whole reason it is asked for: a full refund delivered as two partial adjustments is only full when they are summed.",
229
+ ),
230
+ created_at: z.string().min(1).describe("When Paddle recorded the transaction."),
231
+ updated_at: z.string().nullish().describe("The transaction's own clock. Deliberately not used as a watermark."),
232
+ })
233
+ .loose()
234
+ .describe("A Paddle transaction — one billing period's charge, or a one-off purchase.");
235
+ export type PaddleTransaction = z.infer<typeof PaddleTransaction>;
236
+
237
+ /**
238
+ * A change Paddle will apply later, exactly as it sits on a subscription.
239
+ *
240
+ * Lifted out of {@link PaddleSubscription}, where it was inline, because two readers now ask it questions
241
+ * and a second inline copy is how the two field lists drift apart: {@link subscriptionResumesAt} asks when
242
+ * a paused subscription comes back, and {@link subscriptionPendingChange} asks what is pending at all. One
243
+ * declaration, one parse, both readers.
244
+ *
245
+ * **`items` is declared because Paddle sends it.** It arrived `null` on every change recorded so far —
246
+ * the pause and resume of 2026-08-15, the cancel of 2026-08-28 — and nothing reads it. Declaring a field
247
+ * the payload carries costs a line; discovering it later inside a `.loose()` passthrough that nobody wrote
248
+ * down costs an afternoon.
249
+ */
250
+ export const PaddleScheduledChange = z
251
+ .object({
252
+ action: z.string().optional().describe("`cancel`, `pause`, or `resume`."),
253
+ effective_at: z
254
+ .string()
255
+ .nullish()
256
+ .describe(
257
+ "When it takes effect. On a `resume` action against a paused subscription this is the resume date itself — verified live, see `subscriptionResumesAt`. On a `cancel` it is when access ends, and it is the only place that date exists: `next_billed_at` is null on a subscription with a cancellation scheduled, recorded 2026-08-28.",
258
+ ),
259
+ resume_at: z
260
+ .string()
261
+ .nullish()
262
+ .describe(
263
+ "When a paused subscription should resume. Paddle documents it as set only on a `pause` action, and a subscription carrying one is still `active` — so it is read for completeness rather than as the field a paused row is filled from.",
264
+ ),
265
+ items: z
266
+ .array(z.unknown())
267
+ .nullish()
268
+ .describe("The items the change applies to. Null on every change recorded so far; carried, not read."),
269
+ })
270
+ .loose()
271
+ .describe(
272
+ "A change Paddle will apply later — the shape both readers of `scheduled_change` parse through, so neither can drift from the other.",
273
+ );
274
+ export type PaddleScheduledChange = z.infer<typeof PaddleScheduledChange>;
275
+
276
+ /** A subscription's fields, as an event and the API both present them. */
277
+ export const PaddleSubscription = z
278
+ .object({
279
+ id: z.string().min(1).describe("The subscription — `sub_…`. The state row's identity, and the family key."),
280
+ status: z.string().min(1).describe("Paddle's own status vocabulary, normalized by `subscriptionStatus`."),
281
+ customer_id: z.string().nullish().describe("The Paddle customer — this rail's `providerAccountId`."),
282
+ items: z
283
+ .array(
284
+ z
285
+ .object({
286
+ price: z
287
+ .object({ id: z.string().optional().describe("The price — `pri_…`. This rail's SKU.") })
288
+ .loose()
289
+ .optional()
290
+ .describe("The price this item is billed at."),
291
+ status: z.string().optional().describe("The item's own status, which can differ from the subscription's."),
292
+ })
293
+ .loose()
294
+ .describe("One priced item on the subscription."),
295
+ )
296
+ .optional()
297
+ .describe("The priced items. The first carries this rail's SKU."),
298
+ current_billing_period: z
299
+ .object({
300
+ starts_at: z.string().nullish().describe("When the current period began."),
301
+ ends_at: z.string().nullish().describe("When it ends — what a cancellation's `expiresAt` becomes."),
302
+ })
303
+ .loose()
304
+ .nullish()
305
+ .describe("The period currently paid for. Null while a subscription is trialing or paused."),
306
+ scheduled_change: PaddleScheduledChange.nullish().describe(
307
+ "A change Paddle will apply later. A subscription with a scheduled cancel is still `active` today — the distinction between `canceled` and a scheduled change is the whole difference between access now and access until the period ends.",
308
+ ),
309
+ trial_dates: z
310
+ .object({
311
+ starts_at: z.string().nullish().describe("When the trial began."),
312
+ ends_at: z.string().nullish().describe("When the trial ends — a trialing subscription's `expiresAt`."),
313
+ })
314
+ .loose()
315
+ .nullish()
316
+ .describe("The trial window, when this subscription has one. Its end is a trialing subscription's expiry."),
317
+ next_billed_at: z.string().nullish().describe("When the next charge falls due, when one is going to."),
318
+ canceled_at: z.string().nullish().describe("When it was canceled, if it was."),
319
+ paused_at: z.string().nullish().describe("When it was paused, if it was."),
320
+ discount: z
321
+ .object({
322
+ id: z.string().optional().describe("The discount in force."),
323
+ starts_at: z.string().nullish().describe("When the discount started applying."),
324
+ ends_at: z.string().nullish().describe("When the discount stops applying, or null for one that runs forever."),
325
+ })
326
+ .loose()
327
+ .nullish()
328
+ .describe(
329
+ "The discount in force, when one is. Carries an id and no code, so a screen names the date rather than the code.",
330
+ ),
331
+ custom_data: CustomData.nullish().describe(
332
+ "What Paddle copied from the transaction that created this subscription. The stamp, on every later event.",
333
+ ),
334
+ created_at: z.string().min(1).describe("When the subscription began."),
335
+ updated_at: z.string().nullish().describe("The subscription's own clock. Deliberately not used as a watermark."),
336
+ })
337
+ .loose()
338
+ .describe("A Paddle subscription — the standing of a recurring purchase, carrying no charge.");
339
+ export type PaddleSubscription = z.infer<typeof PaddleSubscription>;
340
+
341
+ /**
342
+ * A money figure Paddle quotes, as it sends one: a **string**, in the currency's lowest denomination, and
343
+ * **signed**.
344
+ *
345
+ * The sign is the part a schema gets wrong, and it gets it wrong in the direction that throws in front of a
346
+ * customer. Recorded against the sandbox on 2026-08-28: previewing an upgrade returns
347
+ * `credit: { amount: "-380" }`, and previewing a downgrade returns a whole totals block below zero. A
348
+ * `.nonnegative()` anywhere on this path would refuse every real plan change while reading, in the source,
349
+ * like ordinary care. {@link minorAmount} already accepts a leading `-`; nothing else here needs to know.
350
+ *
351
+ * `amount` is required where `PaddleTransaction`'s totals are optional, and the difference is deliberate: a
352
+ * transaction's totals are a block this package reads one figure out of, while a quoted money object with no
353
+ * amount is not a partial reading — it is a shape change, and it should be loud.
354
+ */
355
+ const PaddleMoney = z
356
+ .object({
357
+ amount: z
358
+ .string()
359
+ .describe(
360
+ "How much, as an integer string in the currency's lowest denomination, signed. `-380` is a credit of $3.80.",
361
+ ),
362
+ currency_code: z.string().nullish().describe("The currency, uppercase ISO 4217, as Paddle sends it."),
363
+ })
364
+ .loose()
365
+ .describe("One money figure Paddle quoted. Never recomputed here — Paddle is the authority on every amount.");
366
+
367
+ /**
368
+ * One block of Paddle's computed totals, on a preview.
369
+ *
370
+ * **`grand_total` is not the headline, and on a downgrade it is actively misleading.** Recorded 2026-08-28:
371
+ * previewing Team → Solo answers `grand_total: "0"` while the customer is owed 6581, which is sitting in
372
+ * `credit_to_balance`. A screen wired to the totals says "You will be charged $0.00" — true, and it never
373
+ * mentions the money. What renders is `update_summary.result`; see {@link PaddleUpdateSummary}.
374
+ *
375
+ * Every field is optional because Paddle's blocks differ by mode and by direction, and every one may be
376
+ * negative because a downgrade's are: `subtotal: "-6045"`, `tax: "-536"`, `total: "-6581"`. `fee` and
377
+ * `earnings` arrive **null** on a preview — the money has not moved, so there is nothing to have earned —
378
+ * and they are declared rather than left to `.loose()` so a later reader finds null in the type instead of
379
+ * inferring absence.
380
+ *
381
+ * **Deliberately not shared with {@link PaddleTransaction}'s own `details.totals`.** Unifying them would
382
+ * widen that shape's public type — `string | undefined` becomes `string | null | undefined` on fields four
383
+ * modules already read — in the same commit that adds a preview, which is precisely the regression
384
+ * `objects.test.ts` guards against. The *treatment* is identical and that is what matters here: strings,
385
+ * unscaled, `.loose()`, no constraint on sign. One type in both places is the job of the step that needs
386
+ * one type in both places.
387
+ */
388
+ export const PaddleTotals = z
389
+ .object({
390
+ subtotal: z.string().nullish().describe("Before tax. Negative when the change is a credit."),
391
+ tax: z.string().nullish().describe("The tax on it, which is negative alongside a negative subtotal."),
392
+ discount: z.string().nullish().describe("What a discount took off, when one applies."),
393
+ total: z.string().nullish().describe("Subtotal plus tax, still signed."),
394
+ grand_total: z
395
+ .string()
396
+ .nullish()
397
+ .describe(
398
+ 'What Paddle will actually take today — and `"0"` on a downgrade, where the money owed is in `credit_to_balance` instead. Not the figure a screen quotes.',
399
+ ),
400
+ grand_total_tax: z.string().nullish().describe("The tax within the grand total."),
401
+ credit: z.string().nullish().describe("Credit consumed by this transaction."),
402
+ credit_to_balance: z
403
+ .string()
404
+ .nullish()
405
+ .describe(
406
+ "What lands on the customer's balance rather than on a card — where a downgrade's whole refund sits while `grand_total` reads zero.",
407
+ ),
408
+ balance: z.string().nullish().describe("What is left outstanding after credit is applied."),
409
+ fee: z.string().nullish().describe("Paddle's fee. Null on a preview: nothing has been charged to take one from."),
410
+ earnings: z.string().nullish().describe("The seller's share. Null on a preview, for the same reason."),
411
+ currency_code: z.string().nullish().describe("The currency every figure in this block is in."),
412
+ exchange_rate: z
413
+ .string()
414
+ .nullish()
415
+ .describe('The rate Paddle used, `"1"` when the currency is the account\'s own. Recorded, never applied here.'),
416
+ })
417
+ .loose()
418
+ .describe("One block of totals as Paddle computed it. Strings, signed, in the currency's lowest denomination.");
419
+ export type PaddleTotals = z.infer<typeof PaddleTotals>;
420
+
421
+ /**
422
+ * What the change comes to, and **the only part of a preview a screen should quote**.
423
+ *
424
+ * `credit` is the unused remainder of the old plan and arrives negative; `charge` is what the new plan costs
425
+ * for the rest of the period; `result` is Paddle's own reconciliation of the two, and it carries the word as
426
+ * well as the number — `{ action: "charge", amount: "6582" }` on the recorded upgrade,
427
+ * `{ action: "credit", amount: "6581" }` on the recorded downgrade.
428
+ *
429
+ * The alternative — reading `immediate_transaction.details.totals.grand_total` — is right on an upgrade and
430
+ * silently wrong on a downgrade, where it is `"0"`. That is why `result` is required here while the two
431
+ * halves it reconciles are not: a summary with no result is a summary nothing can be said from.
432
+ *
433
+ * `action` stays a plain string rather than an enum for the reason `status` does: an unknown value is
434
+ * refused by the reader that has to act on it, with a message naming what arrived, rather than by a parse
435
+ * failure that discards the rest of a valid response.
436
+ */
437
+ export const PaddleUpdateSummary = z
438
+ .object({
439
+ credit: PaddleMoney.nullish().describe(
440
+ "What the unused part of the current plan is worth back. Negative — it is money coming off.",
441
+ ),
442
+ charge: PaddleMoney.nullish().describe("What the new plan costs for the remainder of the period, before credit."),
443
+ result: PaddleMoney.extend({
444
+ action: z.string().describe("What happens on balance — `charge` or `credit`. The verb a screen uses."),
445
+ }).describe("The reconciliation of credit against charge: what happens, and how much. The renderable headline."),
446
+ })
447
+ .loose()
448
+ .describe("Paddle's own summary of what a plan change costs. The one place a downgrade's credit is stated plainly.");
449
+ export type PaddleUpdateSummary = z.infer<typeof PaddleUpdateSummary>;
450
+
451
+ /**
452
+ * A preview of a subscription change — what Paddle answers before anything is committed.
453
+ *
454
+ * **`immediate_transaction` is nullable, and the adopter's own downgrade policy is the case that makes it
455
+ * null.** Under `proration_billing_mode: "prorated_next_billing_period"` nothing settles today, so there is
456
+ * no transaction to describe and Paddle sends none; the whole answer is `update_summary` plus the recurring
457
+ * block. A shape requiring it would fail on every downgrade this package is designed to perform.
458
+ *
459
+ * **Only the preview's own keys are declared.** The response is a subscription entity *with* these three
460
+ * fields on it — the recordings are excerpts of exactly those fields, and this shape claims no more than was
461
+ * measured. `.loose()` carries the rest through, and a caller wanting the entity parses the same body a
462
+ * second time through {@link PaddleSubscription}, which is the shape that knows what an entity requires.
463
+ */
464
+ export const PaddleSubscriptionPreview = z
465
+ .object({
466
+ update_summary: PaddleUpdateSummary.nullish().describe(
467
+ "What the change costs, reconciled. The headline, and the only figure a downgrade states honestly.",
468
+ ),
469
+ immediate_transaction: z
470
+ .object({
471
+ details: z
472
+ .object({ totals: PaddleTotals.nullish().describe("What today's charge comes to, if there is one.") })
473
+ .loose()
474
+ .nullish()
475
+ .describe("The computed detail of the transaction that would settle now."),
476
+ billing_period: z
477
+ .object({
478
+ starts_at: z.string().nullish().describe("When the prorated period being charged for begins — now."),
479
+ ends_at: z.string().nullish().describe("When it ends, which is where the existing paid period ends."),
480
+ })
481
+ .loose()
482
+ .nullish()
483
+ .describe("The period today's charge covers. The remainder of the period already running."),
484
+ })
485
+ .loose()
486
+ .nullish()
487
+ .describe(
488
+ "The transaction that would be raised immediately — **null whenever nothing settles today**, which is every change made under `prorated_next_billing_period`.",
489
+ ),
490
+ recurring_transaction_details: z
491
+ .object({ totals: PaddleTotals.nullish().describe("What each subsequent period will come to.") })
492
+ .loose()
493
+ .nullish()
494
+ .describe("What the subscription costs from the next renewal onward — the 'then' half of a quote."),
495
+ })
496
+ .loose()
497
+ .describe(
498
+ "Paddle's preview of a subscription change: what settles now, what recurs after, and what the change comes to on balance.",
499
+ );
500
+ export type PaddleSubscriptionPreview = z.infer<typeof PaddleSubscriptionPreview>;
501
+
502
+ /** A Paddle timestamp, or a refusal. Its API emits RFC-3339 throughout. */
503
+ export function at(value: string, what: string): Date {
504
+ const parsed = new Date(value);
505
+ if (Number.isNaN(parsed.getTime())) {
506
+ throw new PaymentsVerificationFailedError({ detail: `Paddle: ${what} is not a readable timestamp.` });
507
+ }
508
+ return parsed;
509
+ }
510
+
511
+ /** The same, tolerating an absent value. */
512
+ function maybeAt(value: string | null | undefined, what: string): Date | null {
513
+ return value === null || value === undefined || value === "" ? null : at(value, what);
514
+ }
515
+
516
+ /**
517
+ * A subscription's status, normalized.
518
+ *
519
+ * `trialing` grants, with `expiresAt` at the trial's end. That hands an entitlement to somebody who has
520
+ * paid nothing, which is what a trial *is* — and it is bounded, because the trial's end date is on the
521
+ * row. The issue asked for this to be an explicit decision rather than a table row, so: it is deliberate,
522
+ * and the bound is what makes it safe.
523
+ *
524
+ * An unknown status refuses rather than guessing, which is every other rail's rule too: a status this
525
+ * package has never seen is a shape change worth failing loudly on.
526
+ */
527
+ export function subscriptionStatus(status: string): PurchaseStatus {
528
+ switch (status) {
529
+ case "active":
530
+ case "trialing":
531
+ return "active";
532
+ case "past_due":
533
+ return "in_grace";
534
+ case "paused":
535
+ return "paused";
536
+ case "canceled":
537
+ return "canceled";
538
+ default:
539
+ throw new PaymentsVerificationFailedError({
540
+ detail: `Paddle: subscription status "${status}" is not one this build maps.`,
541
+ });
542
+ }
543
+ }
544
+
545
+ /**
546
+ * A transaction's status, normalized — and it depends on whether the transaction bills a subscription.
547
+ *
548
+ * **A one-off is money and state at once**, so a completed one is `active` with no expiry: that is a
549
+ * non-consumable bought and owned.
550
+ *
551
+ * **A subscription transaction is one closed billing period**, so a completed one is `expired`. It
552
+ * credits a `grants` clause — which is what makes N renewals credit exactly N times — and grants no
553
+ * access, because access is the state row's job. See the module doc for why `active` here would outlive
554
+ * a cancellation.
555
+ */
556
+ export function transactionStatus(status: string, subscription: boolean): PurchaseStatus {
557
+ switch (status) {
558
+ case "paid":
559
+ case "completed":
560
+ return subscription ? "expired" : "active";
561
+ case "past_due":
562
+ return "in_grace";
563
+ case "canceled":
564
+ // Terminated before anything cleared, so it credits nothing and no clawback follows one.
565
+ return "never_paid";
566
+ case "billed":
567
+ // An invoice raised under manual collection, which this rail does not sell through. The money is
568
+ // outstanding, so it neither grants nor credits.
569
+ return "on_hold";
570
+ case "draft":
571
+ case "ready":
572
+ return "on_hold";
573
+ default:
574
+ throw new PaymentsVerificationFailedError({
575
+ detail: `Paddle: transaction status "${status}" is not one this build maps.`,
576
+ });
577
+ }
578
+ }
579
+
580
+ /** The price id a transaction sold. Paddle carries it under `items[].price.id`; some shapes flatten it. */
581
+ export function transactionPriceId(transaction: PaddleTransaction): string {
582
+ const first = transaction.items?.[0];
583
+ return first?.price?.id ?? first?.price_id ?? "";
584
+ }
585
+
586
+ /** The price id a subscription is for. */
587
+ export function subscriptionPriceId(subscription: PaddleSubscription): string {
588
+ return subscription.items?.[0]?.price?.id ?? "";
589
+ }
590
+
591
+ /**
592
+ * An amount Paddle reported, as an integer in the currency's lowest denomination.
593
+ *
594
+ * Paddle sends amounts as **strings** and always in the lowest denomination, including for the
595
+ * zero-decimal currencies — ¥725 arrives as `"725"`, not `"72500"`, confirmed live. So this parses and
596
+ * never scales: a multiplication here would be this package computing money, which it does not do.
597
+ *
598
+ * Anything that is not a plain integer string is `null` rather than a guess. `Number.parseInt` on
599
+ * `"0.105"` answers `0`, which is a wrong amount rather than a missing one.
600
+ */
601
+ export function minorAmount(value: string | null | undefined): number | null {
602
+ if (typeof value !== "string" || !/^-?\d+$/.test(value)) return null;
603
+ const parsed = Number(value);
604
+ return Number.isSafeInteger(parsed) ? parsed : null;
605
+ }
606
+
607
+ /** A currency as this package stores it. Paddle sends uppercase; every other rail here is lowercase. */
608
+ export function currencyOf(value: string | null | undefined): string | null {
609
+ return typeof value === "string" && value !== "" ? value.toLowerCase() : null;
610
+ }
611
+
612
+ /**
613
+ * The money row a transaction implies.
614
+ *
615
+ * `providerEventAt` comes from the caller — the **envelope's** `occurred_at`, never `updated_at` — for
616
+ * the reason the envelope schema states at the field.
617
+ */
618
+ export function transactionEvent(
619
+ transaction: PaddleTransaction,
620
+ occurredAt: Date,
621
+ environment: PurchaseEnvironment,
622
+ ): UnboundProviderEvent {
623
+ const subscriptionId = transaction.subscription_id ?? null;
624
+ const status = transactionStatus(transaction.status, subscriptionId !== null);
625
+ const purchased = at(transaction.created_at, "a transaction's created_at");
626
+ return {
627
+ rail: "paddle",
628
+ providerTransactionId: transaction.id,
629
+ // The subscription is the family. Two renewals are two transactions chaining back to one `sub_…`,
630
+ // which is what makes a `grants` clause credit twice.
631
+ originalTransactionId: subscriptionId,
632
+ providerProductId: transactionPriceId(transaction),
633
+ role: "charge",
634
+ status,
635
+ environment,
636
+ purchasedAt: purchased,
637
+ // A subscription's money row is an already-closed window rather than null: a money row must never be
638
+ // the reason access is granted — that is the state row's job — and a past expiry says exactly that.
639
+ // A one-off has no expiry, because owning it is the entitlement.
640
+ expiresAt: subscriptionId === null ? null : purchased,
641
+ revokedAt: null,
642
+ amountMinor: minorAmount(transaction.details?.totals?.grand_total),
643
+ currency: currencyOf(transaction.details?.totals?.currency_code ?? transaction.currency_code),
644
+ providerEventAt: occurredAt,
645
+ payload: { ...transaction },
646
+ };
647
+ }
648
+
649
+ /**
650
+ * What Paddle says a paused subscription resumes at, verbatim — or nothing, for an open-ended pause.
651
+ *
652
+ * **The date is not in the field named after it**, which is the trap this function exists to hold. Recorded
653
+ * against a live sandbox on 2026-08-15: pausing immediately with `resume_at: "2026-10-01T00:00:00Z"` leaves
654
+ * `scheduled_change: { action: "resume", effective_at: "2026-10-01T00:00:00Z", resume_at: null }`. Paddle
655
+ * turns the request's `resume_at` into a scheduled *resume* whose `effective_at` is the date, and blanks
656
+ * `resume_at` — that field is populated only while a `pause` is scheduled and the subscription is still
657
+ * `active`, which is not a paused subscription. Pausing with no resume date leaves `scheduled_change: null`.
658
+ *
659
+ * So both spellings are read and the one that means resumption wins. `effective_at` is taken only from a
660
+ * `resume` action: on a `cancel` it is when access ends, and on a `pause` it is when the pause begins —
661
+ * writing either into a resume date would be a wrong date in front of a paying customer.
662
+ */
663
+ export function subscriptionResumesAt(subscription: PaddleSubscription): string | null {
664
+ const change = subscription.scheduled_change;
665
+ if (change === null || change === undefined) return null;
666
+ if (typeof change.resume_at === "string" && change.resume_at !== "") return change.resume_at;
667
+ return change.action === "resume" ? (change.effective_at ?? null) : null;
668
+ }
669
+
670
+ /** What Paddle is going to do to a subscription later, normalized — the three actions and their dates. */
671
+ export interface PaddlePendingChange {
672
+ /** Which of Paddle's three scheduled actions is pending. Narrowed here, so a reader switches exhaustively. */
673
+ action: "cancel" | "pause" | "resume";
674
+ /** When it happens, verbatim as Paddle sent it. A string, converted at the site that needs a `Date`. */
675
+ effectiveAt: string | null;
676
+ /** When a scheduled pause is due to end, on the one action that carries it. Null everywhere else. */
677
+ resumeAt: string | null;
678
+ }
679
+
680
+ /**
681
+ * What is pending on a subscription, or nothing.
682
+ *
683
+ * **The field a screen reaches for first is empty exactly when this one fills.** Recorded against the
684
+ * sandbox on 2026-08-28: `cancel({ effective_from: "next_billing_period" })` leaves `status: "active"`,
685
+ * `canceled_at: null` and — the part that surprises — **`next_billed_at: null`**, with the date living only
686
+ * on `scheduled_change.effective_at`. So "Renews on {next_billed_at}" goes blank on precisely the
687
+ * subscription whose end date the customer most needs to see, while the row still says `active`.
688
+ * `update(subscription, { scheduled_change: null })` withdraws the cancellation and puts the date back.
689
+ *
690
+ * An action this build does not map refuses rather than being read as one of the three. That is
691
+ * {@link subscriptionStatus}'s rule and it is the same argument: a new Paddle action quietly normalized into
692
+ * `cancel` is a wrong date, or a wrong sentence, in front of a paying customer — and a `scheduled_change`
693
+ * with no action at all is a shape change, not an absence, because Paddle sends the object or sends null.
694
+ *
695
+ * **This deliberately does not change what {@link subscriptionResumesAt} answers.** That function reads
696
+ * `effective_at` only on a `resume`, which is why a scheduled *cancel* is invisible to the projection today.
697
+ * The two now parse one declared shape — {@link PaddleScheduledChange} — so whichever step decides what the
698
+ * projection should carry about a pending cancellation reconciles them in one place, with both readings in
699
+ * front of it.
700
+ */
701
+ export function subscriptionPendingChange(subscription: PaddleSubscription): PaddlePendingChange | null {
702
+ const change = subscription.scheduled_change;
703
+ if (change === null || change === undefined) return null;
704
+
705
+ const action = change.action;
706
+ if (action !== "cancel" && action !== "pause" && action !== "resume") {
707
+ throw new PaymentsVerificationFailedError({
708
+ detail: `Paddle: scheduled change action "${action ?? ""}" is not one this build maps.`,
709
+ });
710
+ }
711
+ return { action, effectiveAt: change.effective_at ?? null, resumeAt: change.resume_at ?? null };
712
+ }
713
+
714
+ /**
715
+ * The state row a subscription implies. Carries no amount — a subscription is not a charge.
716
+ *
717
+ * `expiresAt` is the end of what has been paid for, and which field that is depends on the standing: a
718
+ * trial ends at its own date, everything else at the current billing period's end. A cancellation keeps
719
+ * the period already bought rather than taking it away, which is what `canceled` means in this package's
720
+ * status set.
721
+ */
722
+ export function subscriptionEvent(
723
+ subscription: PaddleSubscription,
724
+ occurredAt: Date,
725
+ environment: PurchaseEnvironment,
726
+ ): UnboundProviderEvent {
727
+ const status = subscriptionStatus(subscription.status);
728
+ const ends =
729
+ subscription.status === "trialing"
730
+ ? (subscription.trial_dates?.ends_at ?? subscription.current_billing_period?.ends_at ?? null)
731
+ : (subscription.current_billing_period?.ends_at ?? subscription.next_billed_at ?? null);
732
+ return {
733
+ rail: "paddle",
734
+ providerTransactionId: subscription.id,
735
+ // Uniform with the money rows, which name this same subscription as their family. A state row is its
736
+ // own family's head, so both columns carry the same value and the owner lookup matches on either.
737
+ originalTransactionId: subscription.id,
738
+ providerProductId: subscriptionPriceId(subscription),
739
+ role: "state",
740
+ status,
741
+ environment,
742
+ purchasedAt: at(subscription.created_at, "a subscription's created_at"),
743
+ expiresAt: maybeAt(ends, "a subscription's period end"),
744
+ revokedAt: null,
745
+ resumesAt: pauseResumesAt({ rail: "paddle", status, reported: subscriptionResumesAt(subscription) }),
746
+ amountMinor: null,
747
+ currency: null,
748
+ providerEventAt: occurredAt,
749
+ payload: { ...subscription },
750
+ };
751
+ }
752
+
753
+ /**
754
+ * Whether this delivery belongs to another deployment sharing the same Paddle sandbox.
755
+ *
756
+ * True only when both sides named an environment and they differ. An unstamped delivery is not fenced —
757
+ * a transaction created in the Paddle dashboard carries no `custom_data`, and fencing on absence would
758
+ * silently drop real sales — and a deployment that does not know its own `ENVIRONMENT` fences nothing,
759
+ * which is precisely how the other rails behave.
760
+ *
761
+ * **This is a fence, not an authorization.** It is read off unauthenticated `custom_data` on purpose: its
762
+ * job is to stop this deployment acting on another deployment's traffic, and the direction it can be
763
+ * abused in is a forger declining to be projected — which they achieve by not sending anything. Deciding
764
+ * *who owns* a purchase is {@link accountReferenceOf}'s job, and that one demands a MAC.
765
+ */
766
+ export function fencedOut(custom: Record<string, unknown> | null | undefined, deployment: string | undefined): boolean {
767
+ const stamped = custom?.[PADDLE_CUSTOM_ENV];
768
+ if (typeof stamped !== "string" || stamped === "" || deployment === undefined) return false;
769
+ return stamped !== deployment;
770
+ }
771
+
772
+ /**
773
+ * The account reference this deployment stamped into the checkout, or null.
774
+ *
775
+ * **Honored only when a MAC this deployment could have produced is beside it**, and that condition is the
776
+ * whole security of the field. `accountReference`'s contract says it is "a value this deployment's own
777
+ * server wrote and the store returned unchanged" — the route writes the provider-account link from it, and
778
+ * `linkProviderAccount` never rebinds, so the first pairing is permanent.
779
+ *
780
+ * `Paddle.Checkout.open` accepts `customData` with nothing but the publishable client token, and it does
781
+ * so on both forms of the call — beside an `items[]` array of price ids, and beside a `transactionId`,
782
+ * where the page's object replaces the one the server wrote. So `custom_data` on its own is **not**
783
+ * evidence our server wrote anything, and it is not evidence even when our server did: a stranger can put
784
+ * any string in it and permanently bind their Paddle customer to an account they chose. The env stamp does
785
+ * not save it either — the key names are exported constants in an open-source package and the value is one
786
+ * of three.
787
+ *
788
+ * A deployment that does not know its own `ENVIRONMENT` trusts no reference at all. That is the safe
789
+ * direction: the cost is a purchase that lands unbound and is repairable from the trail, where the other
790
+ * way round is an unauthenticated write into the account map that nothing ever undoes.
791
+ *
792
+ * **And the value must be the subject encoding, which the MAC alone does not make it.** A stamp can be
793
+ * authentic and still name nobody: a bare id is what this rail wrote before subjects existed, and reading
794
+ * one as a user would hand a purchase to whoever else holds that id. So the reference is put through
795
+ * `decodeSubjectReference` — the one decoder, never a split written here — and anything that is not exactly
796
+ * the encoding is refused in the same direction as everything else above. The string is returned rather than
797
+ * the pair because a rail reports `accountReference` as a string; the route decodes it again through the same
798
+ * function, and gets the same answer.
799
+ */
800
+ export async function accountReferenceOf(
801
+ custom: Record<string, unknown> | null | undefined,
802
+ deployment: string | undefined,
803
+ secret: string,
804
+ ): Promise<string | null> {
805
+ if (deployment === undefined) return null;
806
+
807
+ const reference = custom?.[PADDLE_CUSTOM_ACCOUNT];
808
+ const stampedEnv = custom?.[PADDLE_CUSTOM_ENV];
809
+ const proof = custom?.[PADDLE_CUSTOM_PROOF];
810
+ if (typeof reference !== "string" || reference === "") return null;
811
+ if (typeof stampedEnv !== "string" || typeof proof !== "string") return null;
812
+
813
+ // Fail closed on the shape before spending an HMAC on it. A reference that does not decode names nobody
814
+ // however well it is proven, so there is nothing a MAC could add.
815
+ if (decodeSubjectReference(reference) === undefined) return null;
816
+
817
+ // The environment is checked as part of the MAC's message rather than beside it, so a proof minted for
818
+ // staging cannot be replayed against production by editing one field.
819
+ if (stampedEnv !== deployment) return null;
820
+ return (await proofMatches(reference, deployment, secret, proof)) ? reference : null;
821
+ }
822
+
823
+ /**
824
+ * The proof this deployment stamps beside an account reference, and the only reason the reference is
825
+ * worth anything.
826
+ *
827
+ * An HMAC over the environment and the user id, keyed with a secret a stranger does not have. The message
828
+ * is domain-separated by a fixed prefix so this MAC can never be confused with the webhook-body signature
829
+ * that shares its key: the two answer different questions, and a value satisfying both would be a
830
+ * cross-protocol attack waiting to be found.
831
+ *
832
+ * The key is the notification destination's signing secret because it is already per-environment, already
833
+ * rotated with the rail, and already known only to this deployment and to Paddle — and Paddle is not the
834
+ * attacker here. The attacker is anyone who can load the paywall, which is everyone.
835
+ */
836
+ export async function accountReferenceProof(reference: string, deployment: string, secret: string): Promise<string> {
837
+ const key = await crypto.subtle.importKey(
838
+ "raw",
839
+ new TextEncoder().encode(secret),
840
+ { name: "HMAC", hash: "SHA-256" },
841
+ false,
842
+ ["sign"],
843
+ );
844
+ const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message(reference, deployment)));
845
+ return [...new Uint8Array(mac)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
846
+ }
847
+
848
+ /** Domain separation, so this MAC and the webhook body signature can never be mistaken for one another. */
849
+ const ACCOUNT_REFERENCE_DOMAIN = "pithy:paddle:account-reference:v1:";
850
+
851
+ /** What the MAC covers: the domain, the environment, and the subject — in that order, and never one of them. */
852
+ function message(reference: string, deployment: string): string {
853
+ return `${ACCOUNT_REFERENCE_DOMAIN}${deployment}:${reference}`;
854
+ }
855
+
856
+ /** Whether a stamped proof matches, compared in constant time by the runtime rather than by `===`. */
857
+ async function proofMatches(
858
+ reference: string,
859
+ deployment: string,
860
+ secret: string,
861
+ candidate: string,
862
+ ): Promise<boolean> {
863
+ const bytes = hexToBytes(candidate);
864
+ if (bytes === undefined) return false;
865
+ const key = await crypto.subtle.importKey(
866
+ "raw",
867
+ new TextEncoder().encode(secret),
868
+ { name: "HMAC", hash: "SHA-256" },
869
+ false,
870
+ ["verify"],
871
+ );
872
+ return await crypto.subtle.verify("HMAC", key, bytes, new TextEncoder().encode(message(reference, deployment)));
873
+ }
874
+
875
+ /** Decode hex, or undefined when it is not hex. */
876
+ function hexToBytes(value: string): Uint8Array | undefined {
877
+ if (value.length === 0 || value.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(value)) return undefined;
878
+ const bytes = new Uint8Array(value.length / 2);
879
+ for (let index = 0; index < bytes.length; index += 1) {
880
+ bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16);
881
+ }
882
+ return bytes;
883
+ }