@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,176 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import type { CreatedDiscount, DiscountTerms } from "../../data/discount";
6
+ import { PaymentsDiscountInvalidError } from "../../error/errors";
7
+ import type { PaymentsPaddleCredentials } from "../../secret/registry";
8
+ import type { ListedDiscount } from "../contract";
9
+ import { type PaddleEnvironment, type PaddleHttpFetch, paddleHttpFetch, paddleJson } from "./api";
10
+
11
+ /**
12
+ * Minting a discount at Paddle, from the normalized terms.
13
+ *
14
+ * One object, like Lemon Squeezy's and unlike Stripe's pair: a Paddle discount carries the money, the
15
+ * duration, the redemption limit, the expiry and the code together.
16
+ *
17
+ * ## The two translations that decide whether a customer is charged correctly
18
+ *
19
+ * **Duration.** `recur: false` is `once`. `recur: true` with `maximum_recurring_intervals: null` is
20
+ * `forever`. `recur: true` with a number is `repeating`, and **Paddle counts billing periods** — the same
21
+ * unit `DiscountDuration` already uses, so the number passes through unconverted. This is the second rail
22
+ * where it does not convert; Stripe is the one where it must, because `duration_in_months` counts months.
23
+ * Round-tripped live: `recur: true, maximum_recurring_intervals: 12` came back as `12` on a percentage
24
+ * discount, which is twelve renewals and not twelve months.
25
+ *
26
+ * **Expiry.** `redeemableUntil` means the code can no longer be *claimed*. Paddle's `expires_at` stops
27
+ * redemption and leaves an existing subscriber's discount running, which is the same meaning — so it maps
28
+ * directly.
29
+ *
30
+ * ## The charset, which is Paddle's and narrower than the shared schema's
31
+ *
32
+ * `DiscountCode` allows `[A-Za-z0-9_-]` up to 64 characters and promises "a code this project mints is
33
+ * redeemable on either rail". Paddle enforces `^[a-zA-Z0-9]{1,32}$` — verified live, where
34
+ * `PITHY_RECON-25` was refused with a bare `"Invalid request."` and nothing else.
35
+ *
36
+ * **The shared schema is not narrowed, and the refusal lands here instead.** Narrowing `DiscountCode`
37
+ * would be a behavior change for every Stripe and Lemon Squeezy adopter, refusing codes those rails
38
+ * accept today, in service of a rail they do not run. So this rail checks its own rule and says what it
39
+ * is — the adopter reads a sentence naming the two characters Paddle will not take, rather than an
40
+ * opaque 400 from Paddle. `DiscountCode`'s promise is now conditional, and `docs/paddle.md` says so.
41
+ *
42
+ * ## Amount, and the currency that arrives at the customer
43
+ *
44
+ * `currency_code` is **uppercase** at Paddle where `DiscountAmount.currency` is lowercase — verified
45
+ * live. A fixed amount in a currency no restricted price uses is refused at creation naming both, because
46
+ * at Paddle it would fail at redemption, in front of whoever typed the code.
47
+ *
48
+ * Paddle sends and takes amounts as **strings** in the currency's lowest denomination, including for the
49
+ * zero-decimal currencies: ¥725 is `"725"`, not `"72500"`. Nothing here scales.
50
+ */
51
+
52
+ /** What creating or listing a discount needs. */
53
+ export interface PaddleDiscountOptions {
54
+ /** The rail's credentials. */
55
+ credentials: PaymentsPaddleCredentials;
56
+ /** Which Paddle account to reach. */
57
+ environment: PaddleEnvironment;
58
+ /**
59
+ * The currency this project's Paddle catalog prices in, when the project declared one.
60
+ *
61
+ * Only used to refuse a fixed discount in another currency before it reaches Paddle. Absent means the
62
+ * check cannot be made and Paddle's own judgment stands — better than inventing a currency.
63
+ */
64
+ storeCurrency?: string;
65
+ /** The HTTP seam. Defaults to the runtime's `fetch`. */
66
+ transport?: PaddleHttpFetch;
67
+ }
68
+
69
+ /** What Paddle accepts as a discount code. Narrower than the shared schema's — see the module doc. */
70
+ export const PADDLE_DISCOUNT_CODE = /^[a-zA-Z0-9]{1,32}$/;
71
+
72
+ /** A discount as Paddle returns it. */
73
+ const PaddleDiscount = z
74
+ .object({
75
+ id: z.string().min(1).describe("The discount — `dsc_…`."),
76
+ code: z.string().nullish().describe("The code a customer types, or null for one applicable only by id."),
77
+ type: z.string().nullish().describe("`percentage` or `flat`."),
78
+ amount: z.string().nullish().describe("The figure, as a string: a percentage, or a lowest-denomination amount."),
79
+ currency_code: z.string().nullish().describe("Uppercase ISO 4217, on a flat discount."),
80
+ times_used: z.number().nullish().describe("How many times it has been claimed."),
81
+ })
82
+ .loose()
83
+ .describe("A Paddle discount, as much of one as this package reads back.");
84
+
85
+ /** Create a discount and return the code a customer will type. */
86
+ export async function createPaddleDiscount(
87
+ terms: DiscountTerms,
88
+ options: PaddleDiscountOptions,
89
+ ): Promise<CreatedDiscount> {
90
+ if (terms.code !== undefined && !PADDLE_DISCOUNT_CODE.test(terms.code)) {
91
+ // Refused here rather than by Paddle, because Paddle's own refusal is the string "Invalid request."
92
+ // with no field named — an adopter reading that has nothing to act on.
93
+ throw new PaymentsDiscountInvalidError({
94
+ message: "Paddle won't take that code.",
95
+ action: "Use letters and digits only, up to 32 characters — no dashes and no underscores.",
96
+ detail: `Paddle enforces ^[a-zA-Z0-9]{1,32}$ on a discount code and refused "${terms.code}" with a bare "Invalid request.". The shared \`DiscountCode\` schema is deliberately wider, because narrowing it would refuse codes the Stripe and Lemon Squeezy rails accept today.`,
97
+ });
98
+ }
99
+
100
+ if (
101
+ terms.amount.kind === "fixed" &&
102
+ options.storeCurrency !== undefined &&
103
+ options.storeCurrency.toLowerCase() !== terms.amount.currency.toLowerCase()
104
+ ) {
105
+ throw new PaymentsDiscountInvalidError({
106
+ message: "That discount is in a currency this catalog does not sell in.",
107
+ action: `Create it in ${options.storeCurrency.toUpperCase()}, or use a percentage instead.`,
108
+ detail: `A fixed discount of ${terms.amount.amountMinor} ${terms.amount.currency.toUpperCase()} cannot apply to prices in ${options.storeCurrency.toUpperCase()}. Paddle accepts the object and refuses it at redemption, in front of the customer.`,
109
+ });
110
+ }
111
+
112
+ const body: Record<string, unknown> = {
113
+ // Paddle requires a description, and it is the internal label an adopter finds the discount by.
114
+ description: terms.code ?? "Pithy discount",
115
+ type: terms.amount.kind === "percent" ? "percentage" : "flat",
116
+ // A string, in the currency's lowest denomination, and never scaled. A percentage is a string too.
117
+ amount: terms.amount.kind === "percent" ? String(terms.amount.percent) : String(terms.amount.amountMinor),
118
+ // `recur: false` is `once`; `true` is `forever` unless a count narrows it. **Not converted** — Paddle
119
+ // counts billing periods, which is the unit the normalized terms already use.
120
+ recur: terms.duration.kind !== "once",
121
+ // A code supplied means a customer may type it; one minted without a code is applicable only by id.
122
+ enabled_for_checkout: terms.code !== undefined,
123
+ };
124
+ if (terms.amount.kind === "fixed") body.currency_code = terms.amount.currency.toUpperCase();
125
+ if (terms.code !== undefined) body.code = terms.code;
126
+ if (terms.duration.kind === "repeating") body.maximum_recurring_intervals = terms.duration.billingPeriods;
127
+ if (terms.maxRedemptions !== undefined) body.usage_limit = terms.maxRedemptions;
128
+ if (terms.redeemableUntil !== undefined) body.expires_at = terms.redeemableUntil.toISOString();
129
+
130
+ const answer = await paddleJson(options.transport ?? paddleHttpFetch, "/discounts", {
131
+ what: "a discount",
132
+ apiKey: options.credentials.apiKey,
133
+ environment: options.environment,
134
+ body,
135
+ });
136
+
137
+ const parsed = PaddleDiscount.safeParse(answer?.data);
138
+ const code = parsed.success ? parsed.data.code : null;
139
+ if (!parsed.success || typeof code !== "string" || code === "") {
140
+ throw new PaymentsDiscountInvalidError({
141
+ detail: "Paddle created a discount and returned no code for it.",
142
+ });
143
+ }
144
+
145
+ return { code, providerDiscountId: parsed.data.id, terms };
146
+ }
147
+
148
+ /** Every discount this account holds, newest first. Never reaches a browser — see {@link ListedDiscount}. */
149
+ export async function listPaddleDiscounts(options: PaddleDiscountOptions): Promise<readonly ListedDiscount[]> {
150
+ const answer = await paddleJson(options.transport ?? paddleHttpFetch, "/discounts", {
151
+ what: "the discount list",
152
+ apiKey: options.credentials.apiKey,
153
+ environment: options.environment,
154
+ query: [
155
+ ["order_by", "created_at[DESC]"],
156
+ ["per_page", "200"],
157
+ ],
158
+ });
159
+
160
+ const parsed = z.array(PaddleDiscount).safeParse(answer?.data);
161
+ if (!parsed.success) return [];
162
+
163
+ return parsed.data
164
+ .filter((discount): discount is typeof discount & { code: string } => typeof discount.code === "string")
165
+ .map((discount) => ({
166
+ code: discount.code,
167
+ providerDiscountId: discount.id,
168
+ // Paddle's own figures, rendered rather than recomputed. A percentage carries no currency and a flat
169
+ // amount is a lowest-denomination integer — neither is divided by anything here.
170
+ amount:
171
+ discount.type === "percentage"
172
+ ? `${discount.amount ?? "?"}%`
173
+ : `${discount.amount ?? "?"} ${(discount.currency_code ?? "").toLowerCase()}`.trim(),
174
+ redemptions: typeof discount.times_used === "number" ? discount.times_used : null,
175
+ }));
176
+ }
@@ -0,0 +1,285 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import type { PaymentsPaddleCredentials } from "../../secret/registry";
6
+ import type { VerifiedNotification } from "../contract";
7
+ import { type PaddleEnvironment, type PaddleHttpFetch, paddleHttpFetch, paddleJson } from "./api";
8
+ import { PaddleEvent } from "./objects";
9
+ import { PADDLE_ADJUSTMENTS_INCLUDE, readTransaction } from "./read";
10
+ import { recordedPayload } from "./recorded";
11
+ import { type ParsePaddleNotificationOptions, readPaddleEvent } from "./webhook";
12
+
13
+ /**
14
+ * The events sweep — the repair `refresh` cannot make.
15
+ *
16
+ * `refresh` re-reads rows this deployment already holds. A purchase whose webhook was never delivered and
17
+ * which no client submitted has no row, so it is invisible to `refresh` forever. Paddle publishes an
18
+ * account-wide event stream, so this rail can find those, and no other rail in this package can.
19
+ *
20
+ * **It does repair a delivery that arrived and failed, though that is not what it is for.** The sweep
21
+ * writes through the same table on the same `UNIQUE (rail, providerEventId)`, and since #337 both readers
22
+ * agree that a delivery which errored is still outstanding — `completeWebhook` leaves `processedAt` null
23
+ * beside its reason — so a swept event whose webhook failed is tried again rather than counted a
24
+ * duplicate. The primary repair for that case is the *next delivery*, on every rail; Paddle's replay
25
+ * endpoint reuses the same `event_id` and now reprocesses. What only this sweep can repair is a webhook
26
+ * that was never delivered at all — the case that has no row and is invisible to `refresh` forever.
27
+ *
28
+ * ## The stream is account-wide, and that is a hazard rather than a convenience
29
+ *
30
+ * Verified live: the assigned sandbox's own stream carries `api_key.created` and `client_token.created`.
31
+ * The `api_key` payload's `key` is redacted by Paddle; **the `client_token` payload's `token` is not.**
32
+ * Projecting every swept event through the webhook writer would fill `pithy_payments_webhook_events` with
33
+ * product, price, api-key and client-token rows — and persist live client tokens into a table an operator
34
+ * greps.
35
+ *
36
+ * There are therefore **two** controls, and only one of them is ours.
37
+ *
38
+ * The **query filter** asks Paddle for exactly {@link PADDLE_SWEPT_EVENT_TYPES}. It is sent in the form
39
+ * Paddle documents for an `array[string]` parameter — one key, values comma-separated,
40
+ * `event_type=a,b,c` — verified against the live sandbox API on 2026-08-13: filtering on two of the six
41
+ * types in that account's stream returned four events of exactly those two types, and an unrecognised
42
+ * value is refused with `Request does not pass validation` rather than ignored. An earlier build sent
43
+ * `event_type=a&event_type=b&…`, a repeated-key form Paddle documents nowhere; whether that form is read
44
+ * as an array, as one of its values, or not at all is not something this package can know.
45
+ *
46
+ * The **recording allowlist** is the control this package owns. A returned event whose type is not on
47
+ * that list yields a `null` notification, and the sweep writes no row for it at all. So an unhonoured
48
+ * filter costs a wasted page rather than a client token in D1, and the safety of this module does not
49
+ * rest on a query parameter being obeyed by someone else's service. See `recorded.ts` for the same
50
+ * argument on the webhook path, where there is no query to filter and the subscribed-event list is set
51
+ * by a human in Paddle's dashboard.
52
+ *
53
+ * ## The cursor, and the 90-day cliff
54
+ *
55
+ * `order_by=id[ASC]&after=<cursor>`, with the cursor persisted in `pithy_payments_sync_cursors`. It
56
+ * advances only past events fully projected, so a failure halts advancement and the step retries next run
57
+ * — for a bounded number of runs, after which the event is quarantined and the stream moves on. That bound
58
+ * belongs to the caller: see `workflows/paddleSweep.ts`, which owns the row and the attempt count.
59
+ *
60
+ * This function's own part in it is {@link SweptEvent.failure}: an event whose second read fails is
61
+ * returned carrying its failure rather than thrown, so the caller can halt at that one event instead of
62
+ * losing every healthy event on the page with it.
63
+ *
64
+ * **Paddle retains 90 days.** A cursor older than that can never be caught up — the events between it and
65
+ * the retention window are gone — so this reports a gap naming the window rather than silently restarting
66
+ * from the beginning and re-projecting three months of history.
67
+ */
68
+
69
+ /** How many events one page asks for. Paddle's maximum, so a backlog is walked in as few calls as possible. */
70
+ export const PADDLE_SWEEP_PAGE_SIZE = 200;
71
+
72
+ /** How long Paddle keeps an event. Confirmed in the API reference and in the endpoint's own description. */
73
+ export const PADDLE_EVENT_RETENTION_DAYS = 90;
74
+
75
+ /**
76
+ * Every event type the sweep asks for — exactly the ones {@link readPaddleEvent} can act on.
77
+ *
78
+ * A type absent here is a type never fetched, never recorded, and therefore never a client token in a
79
+ * table. Adding a projection to the event map means adding its type here; a test asserts the two agree,
80
+ * because a map that grew a case the sweep never fetches would repair through the webhook path and not
81
+ * through the sweep, which is precisely the asymmetry the sweep exists to remove.
82
+ */
83
+ export const PADDLE_SWEPT_EVENT_TYPES: readonly string[] = [
84
+ "transaction.paid",
85
+ "transaction.completed",
86
+ "transaction.payment_failed",
87
+ "transaction.past_due",
88
+ "transaction.canceled",
89
+ "transaction.updated",
90
+ "transaction.revised",
91
+ "transaction.billed",
92
+ "subscription.created",
93
+ "subscription.activated",
94
+ "subscription.resumed",
95
+ "subscription.trialing",
96
+ "subscription.past_due",
97
+ "subscription.paused",
98
+ "subscription.canceled",
99
+ "subscription.updated",
100
+ "subscription.imported",
101
+ "adjustment.created",
102
+ "adjustment.updated",
103
+ ];
104
+
105
+ /** The same list as a set, for the lookup that decides whether an event is recorded at all. */
106
+ const SWEPT_EVENT_TYPES: ReadonlySet<string> = new Set(PADDLE_SWEPT_EVENT_TYPES);
107
+
108
+ /** What one sweep page needs. */
109
+ export interface PaddleSweepOptions extends ParsePaddleNotificationOptions {
110
+ /** The rail's credentials. */
111
+ credentials: PaymentsPaddleCredentials;
112
+ /** Which Paddle account to sweep. */
113
+ environment: PaddleEnvironment;
114
+ /** The event id to resume after, or undefined to start at the oldest retained event. */
115
+ cursor?: string;
116
+ /** How many events to ask for. Defaults to Paddle's maximum. */
117
+ pageSize?: number;
118
+ /** The HTTP seam. Defaults to the runtime's `fetch`. */
119
+ transport?: PaddleHttpFetch;
120
+ }
121
+
122
+ /** One swept event: what it is, and what the shared event map made of it. */
123
+ export interface SweptEvent {
124
+ /** Paddle's own event id — the same key the webhook path records, because the stream carries no other. */
125
+ eventId: string;
126
+ /** The event type, for the operator reading a run's report. */
127
+ eventType: string;
128
+ /**
129
+ * What the event map made of it, or **null for a type this build does not record at all**.
130
+ *
131
+ * Null is not "projects nothing" — that is a notification whose `event` is null, and it still earns a
132
+ * row. Null here means the type is off {@link PADDLE_SWEPT_EVENT_TYPES}, so Paddle returned something
133
+ * the query asked it not to, and the caller must advance its cursor past the event without writing
134
+ * anything. The body is never even read, so there is nothing to leak. It is also null when
135
+ * {@link SweptEvent.failure} is set, because there is nothing the map could make of it.
136
+ */
137
+ notification: VerifiedNotification | null;
138
+ /**
139
+ * Why this event could not be read, and the body to record against it — or null when it read cleanly.
140
+ *
141
+ * **Carried rather than thrown, because a page is not an event.** Reading an `adjustment.created`
142
+ * reaches Paddle a second time for the transaction it names, and that read can fail on its own — an
143
+ * outage, a deleted transaction, a shape a later Paddle changed. Throwing out of this function threw
144
+ * away the whole page with it, including every event *ahead* of the bad one that had already read
145
+ * perfectly, and the caller's per-event failure handling never saw any of them.
146
+ *
147
+ * The body travels with the failure so the caller can still record a row for the event, and it goes
148
+ * through `recordedPayload` on the way — a write to `pithy_payments_webhook_events` is a write to
149
+ * `pithy_payments_webhook_events` whichever branch reached it. It is present only on this branch: a
150
+ * withheld type carries no payload here, which is the point of the allowlist.
151
+ */
152
+ failure: { cause: unknown; payload: Record<string, unknown> } | null;
153
+ }
154
+
155
+ /** One page of the sweep. */
156
+ export interface PaddleSweepPage {
157
+ /** The events on this page, oldest first. */
158
+ events: readonly SweptEvent[];
159
+ /** The cursor to resume after, or undefined when nothing was returned. */
160
+ cursor?: string;
161
+ /** Whether Paddle has more beyond this page. */
162
+ hasMore: boolean;
163
+ /**
164
+ * A gap this run cannot close, or null.
165
+ *
166
+ * Set when the sweep was asked to resume from a cursor Paddle no longer knows — which is what a cursor
167
+ * older than the retention window looks like. Reported rather than repaired: restarting from the
168
+ * beginning would re-project three months, and pretending the gap is not there would leave it forever.
169
+ */
170
+ gap: string | null;
171
+ }
172
+
173
+ /** Sweep one page of Paddle's event stream, projecting each event through the same map a webhook uses. */
174
+ export async function sweepPaddleEvents(options: PaddleSweepOptions): Promise<PaddleSweepPage> {
175
+ const query: [string, string][] = [
176
+ ["order_by", "id[ASC]"],
177
+ ["per_page", String(options.pageSize ?? PADDLE_SWEEP_PAGE_SIZE)],
178
+ // One key, values comma-separated. That is the form Paddle documents for every `array[string]` query
179
+ // parameter, and the form verified against the live API. It is a request, not a guarantee — the
180
+ // allowlist below is what makes an unhonoured filter harmless.
181
+ ["event_type", PADDLE_SWEPT_EVENT_TYPES.join(",")],
182
+ ];
183
+ if (options.cursor !== undefined) query.push(["after", options.cursor]);
184
+
185
+ let answer: Awaited<ReturnType<typeof paddleJson>>;
186
+ try {
187
+ answer = await paddleJson(options.transport ?? paddleHttpFetch, "/events", {
188
+ what: "the event stream",
189
+ apiKey: options.credentials.apiKey,
190
+ environment: options.environment,
191
+ query,
192
+ });
193
+ } catch (cause) {
194
+ // A cursor Paddle will not resume from is the retention cliff, and it is not a transient failure —
195
+ // retrying it forever would be a step that never succeeds. Reported as a gap the operator can act on.
196
+ if (options.cursor !== undefined && isUnknownCursor(cause)) {
197
+ return {
198
+ events: [],
199
+ cursor: options.cursor,
200
+ hasMore: false,
201
+ gap: `Paddle no longer knows event ${options.cursor}, so the sweep cannot resume from it. Paddle retains ${PADDLE_EVENT_RETENTION_DAYS} days of events, and anything between that cursor and the retention window is gone. Reconcile the affected purchases with \`pithy payments reconcile --rail paddle\`, then clear the cursor to restart from the oldest retained event.`,
202
+ };
203
+ }
204
+ throw cause;
205
+ }
206
+
207
+ const parsed = z.array(PaddleEvent).safeParse(answer?.data);
208
+ if (!parsed.success) {
209
+ return { events: [], cursor: options.cursor, hasMore: false, gap: null };
210
+ }
211
+
212
+ // An adjustment says how much came off and never what the original was, so the map cannot tell a full
213
+ // refund from a partial one without reading the transaction — and it refuses rather than guessing when
214
+ // no reader is supplied. This module owns the credentials, the account and the transport, so it builds
215
+ // the reader itself: a caller cannot forget what it was never asked for.
216
+ const read: ParsePaddleNotificationOptions = {
217
+ ...options,
218
+ readTransaction:
219
+ options.readTransaction ??
220
+ ((id) =>
221
+ readTransaction(
222
+ id,
223
+ {
224
+ credentials: options.credentials,
225
+ environment: options.environment,
226
+ transport: options.transport ?? paddleHttpFetch,
227
+ },
228
+ // The one read on this rail that needs the array, and therefore the one that needs the key to
229
+ // carry `adjustment.read`. See `read.ts`.
230
+ PADDLE_ADJUSTMENTS_INCLUDE,
231
+ )),
232
+ };
233
+
234
+ const events: SweptEvent[] = [];
235
+ for (const event of parsed.data) {
236
+ // The allowlist, applied before the body is looked at. A type the query asked Paddle to withhold and
237
+ // Paddle returned anyway is walked past, not recorded — see the module doc.
238
+ if (!SWEPT_EVENT_TYPES.has(event.event_type)) {
239
+ events.push({ eventId: event.event_id, eventType: event.event_type, notification: null, failure: null });
240
+ continue;
241
+ }
242
+ try {
243
+ events.push({
244
+ eventId: event.event_id,
245
+ eventType: event.event_type,
246
+ notification: await readPaddleEvent(event, read),
247
+ failure: null,
248
+ });
249
+ } catch (cause) {
250
+ // One event's second read failing is one event's problem. The page still returns, so everything
251
+ // ahead of this event keeps its projection and the caller's cursor can stop exactly here.
252
+ //
253
+ // **Through `recordedPayload`, like every other write to that table.** `recorded.ts` states the
254
+ // control without an exception — `PaddleEvent` is `.loose()`, and a body this build cannot name is a
255
+ // body it cannot vouch for. Today the swept list is a subset of the recorded list, so this returns the
256
+ // whole event either way; that subset is an invariant two lists happen to satisfy, not a guarantee
257
+ // this branch is entitled to assume. A control with one path around it is not a control.
258
+ events.push({
259
+ eventId: event.event_id,
260
+ eventType: event.event_type,
261
+ notification: null,
262
+ failure: { cause, payload: recordedPayload(event) },
263
+ });
264
+ }
265
+ }
266
+
267
+ return {
268
+ events,
269
+ // The last event on the page, and only when there was one: advancing past an empty page would move
270
+ // the cursor onto nothing.
271
+ cursor: events.at(-1)?.eventId ?? options.cursor,
272
+ hasMore: answer?.hasMore === true,
273
+ gap: null,
274
+ };
275
+ }
276
+
277
+ /** Whether Paddle's refusal is "I do not know that cursor" rather than a credential or an outage. */
278
+ function isUnknownCursor(cause: unknown): boolean {
279
+ if (typeof cause !== "object" || cause === null) return false;
280
+ const payload = (cause as { payload?: { code?: string; detail?: string } }).payload;
281
+ if (payload?.code !== "payments/rail_not_configured") return false;
282
+ // Paddle's own words. Narrow on purpose: a 401 or a 403 folds into the same code and is a rotated key,
283
+ // not a lost cursor, and reporting one as a retention gap would send an operator to the wrong page.
284
+ return /not_found|invalid_field|entity_not_found/i.test(payload.detail ?? "");
285
+ }
@@ -0,0 +1,54 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * Two `custom_data` objects a browser wrote onto real Paddle transactions, recorded from the live sandbox.
6
+ *
7
+ * Not invented. Both were produced on 2026-08-13 by a page holding nothing but a publishable client token,
8
+ * driving `Paddle.Checkout.open` and paying with `4242 4242 4242 4242`; both are what Paddle stored and
9
+ * handed back from `GET /transactions/{id}` afterwards. That distinction is the point of the file: a
10
+ * forgery a test author typed proves the reader agrees with the author, and a forgery Paddle stored proves
11
+ * the attack exists.
12
+ *
13
+ * They matter because #309's design rested on the opposite belief — that `custom_data` could only be
14
+ * written by the server, so an ownership stamp needed no proof. It rested on that belief being true of the
15
+ * `transactionId` form in particular. **It is true of neither.**
16
+ */
17
+
18
+ /**
19
+ * The `items[]` forgery: a checkout the page opened for a price the page chose, stamped by the page.
20
+ *
21
+ * Recorded from `txn_01kzybmw15rve4bhj3zsbf1k5d`, which Paddle recorded with `origin: "web"` and a paid
22
+ * total of `544`. No server was involved at any point. The `pithy_` key names are exported constants in an
23
+ * open-source package and the environment is one of three, so an attacker guesses nothing here — which is
24
+ * why the third key exists and why it is a MAC.
25
+ */
26
+ export const BROWSER_ITEMS_FORGERY: Record<string, unknown> = {
27
+ pithy_env: "prod",
28
+ pithy_user: "victim",
29
+ pithy_ref_proof: "0000000000000000000000000000000000000000000000000000000000000000",
30
+ };
31
+
32
+ /**
33
+ * The overwrite: a checkout opened for a transaction **the server created and stamped**, whose stamp the
34
+ * browser replaced on its way through.
35
+ *
36
+ * Recorded from `txn_01kzybt89pnvvnwgkabefsjy2z`. The server created it with
37
+ * `{ pithy_user: "server-owner", pithy_env: "prod", pithy_ref_proof: "aa…aa" }`; the page called
38
+ * `Paddle.Checkout.open({ transactionId, customData: { … } })` and paid, and this is what the transaction
39
+ * carried afterwards. Paddle kept `origin: "api"`, so even *that* field does not distinguish the two.
40
+ *
41
+ * **This is the measurement that settles the issue's open question.** Choosing `transactionId` over
42
+ * `items[]` is still right — it fixes the price and the buyer, which a page must never name — but it buys
43
+ * nothing at all for `custom_data`. The stamp is protected by the MAC and by nothing else, on either form.
44
+ *
45
+ * **And the overwrite lands on open, not on payment.** A second probe opened a checkout for a `draft`
46
+ * transaction and closed it without paying; the transaction's `custom_data` had already been replaced. So
47
+ * the stamp on any pending transaction is writable by anyone who knows its id — which is one more reason
48
+ * the field is a claim to be proved rather than a record to be read.
49
+ */
50
+ export const BROWSER_OVERWROTE_SERVER_STAMP: Record<string, unknown> = {
51
+ pithy_env: "prod",
52
+ pithy_user: "attacker",
53
+ pithy_ref_proof: "bb",
54
+ };
@@ -0,0 +1,62 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * What Paddle actually stores on a paused subscription, recorded from the live sandbox on 2026-08-15.
6
+ *
7
+ * Not invented. `sub_01kzybh940drg5p1dagqv4xj37` was paused through the API twice — once with a resume
8
+ * date, once without — and read back with `GET /subscriptions/{id}` each time. Every value below is from
9
+ * those responses: the pause fields from the paused reads, which were narrowed to exactly these, and the
10
+ * identity fields (`id`, `customer_id`, `created_at`, the item's price) from the same subscription, which
11
+ * was resumed afterwards and is active again. Nothing is here that was not read back.
12
+ *
13
+ * **The recording refutes the obvious fix.** #369 describes the Paddle half as "`scheduled_change.resume_at`
14
+ * is the same story", and a change keyed on that field would have shipped null for every paused Paddle
15
+ * subscription while reading as if it worked: pausing with `resume_at: "2026-10-01T00:00:00Z"` moves the
16
+ * date onto a **`resume` scheduled change's `effective_at`** and leaves `resume_at` **null**. That field
17
+ * carries a date only while a `pause` is scheduled and the subscription is still `active`, which is not a
18
+ * paused subscription at all.
19
+ *
20
+ * The pair also pins the other half of the design: an open-ended pause is `scheduled_change: null`, so a
21
+ * null resume date on a paused Paddle row is Paddle saying "indefinitely" rather than this rail declining
22
+ * to look.
23
+ */
24
+
25
+ /** The date the pause was asked to end at, verbatim as it was sent and as Paddle echoed it back. */
26
+ export const PADDLE_PAUSE_RESUME_AT = "2026-10-01T00:00:00Z";
27
+
28
+ /**
29
+ * A subscription paused **with** a resume date — `pause({ effective_from: "immediately", resume_at })`.
30
+ *
31
+ * Note `resume_at: null` beside an `effective_at` carrying the date. That inversion is the whole reason
32
+ * `subscriptionResumesAt` reads the action rather than the field named after the answer.
33
+ */
34
+ export const PADDLE_PAUSED_WITH_RESUME_DATE = {
35
+ id: "sub_01kzybh940drg5p1dagqv4xj37",
36
+ status: "paused",
37
+ customer_id: "ctm_01kzybg67tdbn7emh9c2n3zs17",
38
+ paused_at: "2026-08-15T13:43:55.956Z",
39
+ scheduled_change: { action: "resume", effective_at: PADDLE_PAUSE_RESUME_AT, resume_at: null, items: null },
40
+ current_billing_period: { starts_at: "2026-08-13T20:03:06.313818Z", ends_at: "2026-09-13T20:03:06.313818Z" },
41
+ next_billed_at: PADDLE_PAUSE_RESUME_AT,
42
+ items: [{ price: { id: "pri_01kzvyz9e21z9vbhd7xqq3csyh" }, status: "inactive" }],
43
+ created_at: "2026-08-13T20:03:07.008Z",
44
+ };
45
+
46
+ /**
47
+ * The same subscription paused **without** one — `pause({ effective_from: "immediately" })`.
48
+ *
49
+ * `scheduled_change` is null and so is `next_billed_at`: Paddle has nothing to say about when this comes
50
+ * back, because nothing decided it. That is the indefinite pause, and it is a real state rather than a gap.
51
+ */
52
+ export const PADDLE_PAUSED_INDEFINITELY = {
53
+ id: "sub_01kzybh940drg5p1dagqv4xj37",
54
+ status: "paused",
55
+ customer_id: "ctm_01kzybg67tdbn7emh9c2n3zs17",
56
+ paused_at: "2026-08-15T13:44:10.712Z",
57
+ scheduled_change: null,
58
+ current_billing_period: { starts_at: "2026-08-15T13:44:09.279Z", ends_at: "2026-09-15T13:44:09.279Z" },
59
+ next_billed_at: null,
60
+ items: [{ price: { id: "pri_01kzvyz9e21z9vbhd7xqq3csyh" }, status: "inactive" }],
61
+ created_at: "2026-08-13T20:03:07.008Z",
62
+ };