@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,717 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database } from "@cloudflare/workers-types";
5
+ import { withD1Retry } from "@pithy-sh/core/src/data/withD1Retry";
6
+ import { PithyError } from "@pithy-sh/core/src/error/pithyError";
7
+ import type { PaymentsConfig } from "../config/config";
8
+ import { railEnabled } from "../config/config";
9
+ import type { PurchaseEnvironment } from "../data/purchase";
10
+ import { decodeSubjectReference } from "../data/subject";
11
+ import { PADDLE_EVENTS_CURSOR, PaymentsSyncCursor } from "../data/syncCursor";
12
+ import { PAYMENTS_SYNC_CURSORS_TABLE, PAYMENTS_WEBHOOK_EVENTS_TABLE, paymentsDatabase } from "../data/tables";
13
+ import {
14
+ isWebhookEventOutstanding,
15
+ PaymentsWebhookEvent,
16
+ WEBHOOK_EVENT_ORPHANED,
17
+ webhookEventState,
18
+ } from "../data/webhookEvent";
19
+ import { fulfillPurchase } from "../grants/apply";
20
+ import { repairOrphanedEvents } from "../projection/orphans";
21
+ import { linkProviderAccount, resolveNotificationOwner } from "../projection/owner";
22
+ import { projectPurchase } from "../projection/writer";
23
+ import { noteIsRepairable, noteText } from "../rails/contract";
24
+ import { type PaddleEnvironment, type PaddleHttpFetch, paddleHttpFetch } from "../rails/paddle/api";
25
+ import { PADDLE_EVENT_RETENTION_DAYS, sweepPaddleEvents } from "../rails/paddle/events";
26
+ import { PaddleEvent } from "../rails/paddle/objects";
27
+ import { PADDLE_ADJUSTMENTS_INCLUDE, readTransaction } from "../rails/paddle/read";
28
+ import { readPaddleEvent } from "../rails/paddle/webhook";
29
+ import type { PaymentsPaddleCredentials } from "../secret/registry";
30
+
31
+ /**
32
+ * The Paddle events sweep, as a reconciliation step.
33
+ *
34
+ * `refresh` re-reads rows this deployment already holds. A purchase whose webhook was never delivered and
35
+ * which no client submitted has **no row**, so it is invisible to `refresh` forever. Paddle publishes an
36
+ * account-wide event stream, so this rail can find those — and no other rail in this package can.
37
+ *
38
+ * ## Why it writes through the webhook table rather than around it
39
+ *
40
+ * A swept event and its webhook are the *same event*: Paddle's stream carries `event_id` and no
41
+ * `notification_id`, so the id here is the id the delivery would have recorded. Writing through
42
+ * `pithy_payments_webhook_events` with the same `UNIQUE (rail, providerEventId)` is therefore what makes
43
+ * the sweep idempotent against the webhook path *and* against itself — a sweep of an event already
44
+ * delivered inserts nothing and projects nothing.
45
+ *
46
+ * It also keeps one answer to "did we hear about this": an operator reading that table sees every event
47
+ * this deployment acted on, whichever door it came through.
48
+ *
49
+ * ## What a failed event costs, and for how long
50
+ *
51
+ * Two failures are available here and they are mirror images, so neither rule on its own is right.
52
+ *
53
+ * **Always skip.** Advancing past a failure turns a transient D1 fault into a permanently skipped
54
+ * purchase. There is no second pass that would find it — that is the whole reason this sweep exists.
55
+ *
56
+ * **Always halt.** Leaving the cursor in front of the failure and retrying forever is correct for a
57
+ * transient fault and wrong for a permanent one. A malformed event, a deleted transaction, a shape Paddle
58
+ * changed — none of them clears on the next attempt, and the sweep then stands in front of that event for
59
+ * good, so *every later purchase* becomes the one nothing ever finds. That is the same defect with the
60
+ * blame moved.
61
+ *
62
+ * So this halts a **bounded** number of times and then quarantines: see {@link PADDLE_SWEEP_MAX_ATTEMPTS},
63
+ * {@link fail} and {@link quarantine}. Under the bound, both timestamps stay null and the cursor stops in
64
+ * front of the event, so the next run tries it again. At the bound, `abandonedAt` is stamped with an error
65
+ * naming the quarantine and the attempts that earned it, the run's report names the event id, and the
66
+ * cursor moves on. A row nobody can find is the same defect wearing the other hat, so the quarantine is
67
+ * written in words on the row rather than being the absence of one.
68
+ *
69
+ * ## A quarantine bounds a stall; it does not end an event
70
+ *
71
+ * `abandonedAt` and `processedAt` are separate columns for one reason (#337). "This pass has given up" and
72
+ * "this event is finished with" are different claims, and the webhook guard short-circuits on the second.
73
+ * While the quarantine wrote `processedAt`, it also answered Paddle's ordinary retry — and an operator's
74
+ * replay, which reuses the same `event_id` — with `duplicate`, so a quarantined purchase could never be
75
+ * projected even after somebody fixed the cause. A bounded retry that is silently terminal is just the
76
+ * "always skip" rule with extra steps.
77
+ *
78
+ * So an abandoned row is invisible to *this* sweep, which is what stops the attempt count restarting, and
79
+ * fully visible to the webhook path, which is what repairs it. What this sweep repairs is a webhook that
80
+ * was never delivered at all; what repairs a delivery that arrived and failed is the next delivery, and
81
+ * `completeWebhook` no longer writes `processedAt` beside a repairable error.
82
+ *
83
+ * ## An orphan is abandoned, not finished (#339)
84
+ *
85
+ * An orphan is an event whose `custom_data` carries no usable `pithy_user` stamp, for a customer with no
86
+ * `provider_accounts` row. "No usable stamp" now includes a stamp that does not **decode**: the value is an
87
+ * encoded subject reference (`user:ada`, `organization:acme`), and a bare id — the shape every pre-subject
88
+ * checkout wrote — names nobody however well it is proven. This sweep used to call {@link complete} on an
89
+ * orphan — the same `processedAt` that means *finished with*.
90
+ *
91
+ * That was wrong, and it was wrong in the one way that costs a purchase. The webhook path treats its own
92
+ * orphans as outstanding, and both paths write the **same row** under `UNIQUE (rail, providerEventId)`, so
93
+ * whichever ran second decided. When the sweep ran first, the link then arrived and Paddle redelivered, the
94
+ * guard read `processedAt`, answered `duplicate`, and the purchase was never projected. An orphan is the
95
+ * textbook repairable delivery: nothing about the event is wrong, the world is simply missing a row that a
96
+ * checkout, a client submission or an operator will add.
97
+ *
98
+ * The tension is real, though, and it is why `complete` looked reasonable. Leaving an orphan *outstanding*
99
+ * makes {@link record} count it fresh every run, and {@link fail} halts the cursor in front of it — so one
100
+ * customer who never links stalls every event behind them, daily, for as long as the deployment runs.
101
+ *
102
+ * `abandonedAt` is the state that already resolves exactly this, so it is used rather than a fourth one
103
+ * invented for the occasion: abandoned is **invisible to this sweep**, so the cursor advances and nothing
104
+ * stalls, and **not finished to the webhook guard**, so the redelivery that follows the link still repairs
105
+ * it. See {@link abandon}. Unlike a quarantine it costs no attempts, because an orphan is not a failure
106
+ * being retried — no number of sweeps conjures a link, so the first look is as informed as the tenth.
107
+ */
108
+
109
+ /** What the sweep needs. Every seam is explicit, so a test drives it without stubbing a module. */
110
+ export interface PaddleSweepDeps {
111
+ /** The app database the `pithy_payments_*` tables live in. */
112
+ d1: D1Database;
113
+ /** The resolved catalog — the writer's product lookup comes from it. */
114
+ config: PaymentsConfig;
115
+ /** This deployment's store environment. A swept sandbox purchase is refused in production, as ever. */
116
+ environment: PurchaseEnvironment;
117
+ /** Paddle's credentials, resolved through the secrets store by the caller. */
118
+ credentials: PaymentsPaddleCredentials;
119
+ /** Which Paddle account to sweep. */
120
+ paddleEnvironment: PaddleEnvironment;
121
+ /** This deployment's `ENVIRONMENT`, for the shared-sandbox fence and the ownership proof. */
122
+ deployment?: string;
123
+ /** The clock. */
124
+ now(): Date;
125
+ /** The row-id minter, injected so a test is deterministic. */
126
+ newId?: () => string;
127
+ /** The HTTP seam. */
128
+ transport?: PaddleHttpFetch;
129
+ /** Fulfillment for a swept purchase. Defaults to {@link fulfillPurchase}, and is safe to repeat. */
130
+ fulfill?: (d1: D1Database, projection: Parameters<typeof fulfillPurchase>[1]) => Promise<unknown>;
131
+ /** How many pages one sweep may walk. A bound on the work, so a first pass finishes. */
132
+ maxPages?: number;
133
+ }
134
+
135
+ /** What one sweep did. Returned through a `step.do`, so a replay restores it exactly. */
136
+ export interface PaddleSweepReport {
137
+ /** Events read from the stream. */
138
+ read: number;
139
+ /** Events that produced a purchase row. */
140
+ projected: number;
141
+ /** Events that were authentic and projected nothing — a fenced delivery, a type the map ignores. */
142
+ ignored: number;
143
+ /**
144
+ * Events with a purchase in them and nobody to project it against, walked past and left repairable.
145
+ *
146
+ * **Paddle's own `evt_…` ids, and its own field rather than a share of `ignored`.** An ignored event is
147
+ * one this build was never going to act on; an orphan is a real purchase waiting on a link that has not
148
+ * arrived. Counting the second as the first is how {@link complete} came to be called on one — see the
149
+ * module doc — and a number an operator reads as healthy is the wrong place to hide a stuck sale.
150
+ *
151
+ * Each entry is also a row carrying `abandonedAt` and its reason, repairable by any delivery of the same
152
+ * event id once the link exists. **Named here because this sweep will not look again**, so the report is
153
+ * where an operator learns there is a sale to chase — `refresh` cannot find it either, since an orphan
154
+ * has no purchase row to re-read.
155
+ */
156
+ orphaned: string[];
157
+ /** Events already recorded, so the sweep found nothing new. This is the healthy number. */
158
+ duplicate: number;
159
+ /** Events that could not be projected this run. Advancement stops at the first one that is not quarantined. */
160
+ failed: number;
161
+ /**
162
+ * Events given up on this run — tried {@link PADDLE_SWEEP_MAX_ATTEMPTS} times and walked past.
163
+ *
164
+ * **Paddle's own `evt_…` ids, not a count.** A count says an operator has something to read and then
165
+ * makes them go and find it; the id is what they replay, what they grep the table for, and what they
166
+ * paste into Paddle's replay endpoint. `length` is still the count for a log line.
167
+ *
168
+ * Never empty quietly: each entry is also a row carrying `abandonedAt` and its reason. A run reporting
169
+ * quarantines is a run that moved a purchase out of *its own* repair path, which is a decision somebody
170
+ * should see rather than infer.
171
+ */
172
+ quarantined: string[];
173
+ /** Where the cursor now stands, or null when it has never advanced. */
174
+ cursor: string | null;
175
+ /** A gap this run cannot close — a cursor past Paddle's retention — or null. */
176
+ gap: string | null;
177
+ }
178
+
179
+ /** How many pages one sweep walks by default. Two hundred events a page, so this is 2,000 events. */
180
+ const DEFAULT_MAX_PAGES = 10;
181
+
182
+ /**
183
+ * How many sweeps may try one event before it is quarantined and the stream moves on.
184
+ *
185
+ * **The unit is a run, and this sweep runs on a daily cron** — so three attempts is three days. That is
186
+ * long enough for the faults a retry actually fixes: a D1 blip, a Paddle outage, a price somebody has to
187
+ * add to the catalog after being paged. It is short enough that a fault no retry will ever fix costs the
188
+ * stream three days rather than the rest of its life.
189
+ *
190
+ * One would be "always skip" with a counter bolted on. A hundred would be "always halt" with the same.
191
+ */
192
+ export const PADDLE_SWEEP_MAX_ATTEMPTS = 3;
193
+
194
+ /** Read the stored cursor for Paddle's event stream, or null when it has never advanced. */
195
+ async function readCursor(d1: D1Database): Promise<string | null> {
196
+ const row = await paymentsDatabase(d1)
197
+ .selectFrom(PAYMENTS_SYNC_CURSORS_TABLE)
198
+ .select(["cursor"])
199
+ .where("rail", "=", "paddle")
200
+ .where("name", "=", PADDLE_EVENTS_CURSOR)
201
+ .executeTakeFirst();
202
+ return typeof row?.cursor === "string" ? row.cursor : null;
203
+ }
204
+
205
+ /** Write the cursor back, creating the row on the first sweep. */
206
+ async function writeCursor(d1: D1Database, cursor: string, now: Date, newId: () => string): Promise<void> {
207
+ const db = paymentsDatabase(d1);
208
+ const row = PaymentsSyncCursor.encode({
209
+ id: newId(),
210
+ rail: "paddle",
211
+ name: PADDLE_EVENTS_CURSOR,
212
+ cursor,
213
+ updatedAt: now,
214
+ createdAt: now,
215
+ });
216
+ await withD1Retry(() =>
217
+ db
218
+ .insertInto(PAYMENTS_SYNC_CURSORS_TABLE)
219
+ // biome-ignore lint/suspicious/noExplicitAny: an encoded row; Kysely's insert type derives from z.input.
220
+ .values(row as any)
221
+ // One cursor per stream: a second row would let two runs each believe they were authoritative.
222
+ .onConflict((oc) => oc.columns(["rail", "name"]).doUpdateSet({ cursor, updatedAt: now.getTime() } as never))
223
+ .execute(),
224
+ );
225
+ }
226
+
227
+ /**
228
+ * Record one swept event, or report that it was already recorded.
229
+ *
230
+ * The same `UNIQUE (rail, providerEventId)` insert the webhook guard makes, against the same table and the
231
+ * same key. That is what makes a sweep of an already-delivered event a no-op rather than a second write.
232
+ *
233
+ * **`fresh` means "this pass still has work to do here", which is not the same as "not yet seen".** It asks
234
+ * {@link isWebhookEventOutstanding}, so an event this sweep recorded and then failed to project — left
235
+ * `pending` or `failed` by {@link fail} — comes back fresh and is tried again, while one that was
236
+ * projected, fenced out, or is a type that projects nothing is finished and does not.
237
+ *
238
+ * **An orphan is not fresh either, and it is not finished — see {@link orphan}.** A row the *webhook* left
239
+ * outstanding as an orphan still is, so the sweep picks it up, re-resolves the owner against a table that
240
+ * may have gained the link since, and either projects it or abandons it. That re-resolution is the one
241
+ * thing a second look at an orphan is worth, and it happens once.
242
+ *
243
+ * **A quarantined event is not fresh either, and that is a different reason.** It is still outstanding to a
244
+ * webhook delivery, which must be allowed to repair it; it is not outstanding to *this* pass, because the
245
+ * bound exists so one unprojectable event cannot hold the stream up for ever, and picking its own
246
+ * quarantines back up would restart the count and stall again. Two readers, two questions — see
247
+ * `data/webhookEvent.ts`.
248
+ *
249
+ * `attempts` comes back with it, because how many times this event has already been tried is what decides
250
+ * whether the next failure halts the stream or gives up on it.
251
+ */
252
+ async function record(
253
+ d1: D1Database,
254
+ eventId: string,
255
+ payload: Record<string, unknown>,
256
+ now: Date,
257
+ newId: () => string,
258
+ ): Promise<{ id: string; fresh: boolean; attempts: number }> {
259
+ const db = paymentsDatabase(d1);
260
+ const row = PaymentsWebhookEvent.encode({
261
+ id: newId(),
262
+ rail: "paddle",
263
+ providerEventId: eventId,
264
+ payload,
265
+ receivedAt: now,
266
+ processedAt: null,
267
+ abandonedAt: null,
268
+ error: null,
269
+ attempts: 0,
270
+ createdAt: now,
271
+ });
272
+ await withD1Retry(() =>
273
+ db
274
+ .insertInto(PAYMENTS_WEBHOOK_EVENTS_TABLE)
275
+ // biome-ignore lint/suspicious/noExplicitAny: an encoded row; Kysely's insert type derives from z.input.
276
+ .values(row as any)
277
+ // `DO NOTHING`: the first record is the one worth keeping, and overwriting would erase the `error`
278
+ // explaining why the first attempt failed.
279
+ .onConflict((oc) => oc.columns(["rail", "providerEventId"]).doNothing())
280
+ .execute(),
281
+ );
282
+ const stored = await db
283
+ .selectFrom(PAYMENTS_WEBHOOK_EVENTS_TABLE)
284
+ .select(["id", "processedAt", "abandonedAt", "error", "attempts"])
285
+ .where("rail", "=", "paddle")
286
+ .where("providerEventId", "=", eventId)
287
+ .executeTakeFirst();
288
+ return {
289
+ id: stored?.id ?? row.id,
290
+ fresh: stored === undefined || isWebhookEventOutstanding(webhookEventState(stored)),
291
+ // The column is `NOT NULL DEFAULT 0`; the coalesce covers the row this call just inserted being read
292
+ // back before the default is materialised, and a row the webhook path wrote before the column existed.
293
+ attempts: stored?.attempts ?? 0,
294
+ };
295
+ }
296
+
297
+ /**
298
+ * Mark a recorded event **handled**, with the note when it was handled by deciding to do nothing.
299
+ *
300
+ * `processedAt` is the column the webhook guard short-circuits on, so it is set only where the event is
301
+ * genuinely finished with: projected, fenced out, or a type that projects nothing. A failure is not one of
302
+ * those — see {@link fail} — and neither is a quarantine, see {@link quarantine}.
303
+ *
304
+ * **Nor is an orphan, which is the whole of #339.** It reads like one — the sweep can do nothing more with
305
+ * it, so "handled" is tempting — but the guard reads this column, and the event's repair is precisely the
306
+ * redelivery that follows the missing link. See {@link abandon} and the module doc.
307
+ */
308
+ async function complete(d1: D1Database, id: string, now: Date, note?: string): Promise<void> {
309
+ await withD1Retry(() =>
310
+ paymentsDatabase(d1)
311
+ .updateTable(PAYMENTS_WEBHOOK_EVENTS_TABLE)
312
+ // biome-ignore lint/suspicious/noExplicitAny: encoded column values, not the app shape.
313
+ .set({ processedAt: now.getTime(), error: note ?? null } as any)
314
+ .where("id", "=", id)
315
+ .execute(),
316
+ );
317
+ }
318
+
319
+ /**
320
+ * Record why an event could not be projected, count the attempt, and **leave both timestamps null**.
321
+ *
322
+ * The nulls are the whole of it. A `processedAt` beside the error would say "finished", and an
323
+ * `abandonedAt` would say "given up on"; either would stop {@link record} counting the event outstanding,
324
+ * the next sweep would advance the cursor past it, and no pass would ever look at it again — a transient
325
+ * D1 fault turned into a permanently lost purchase. Two nulls with a non-null `error` is the honest
326
+ * triple: it arrived, it was tried, it failed, and it is still outstanding to everyone.
327
+ *
328
+ * `receivedAt` beside a null `processedAt` is already the package's documented drift signal, so this
329
+ * reports through a channel operators read rather than inventing a second one.
330
+ */
331
+ async function fail(d1: D1Database, id: string, attempt: number, error: string): Promise<void> {
332
+ await withD1Retry(() =>
333
+ paymentsDatabase(d1)
334
+ .updateTable(PAYMENTS_WEBHOOK_EVENTS_TABLE)
335
+ // biome-ignore lint/suspicious/noExplicitAny: encoded column values, not the app shape.
336
+ .set({ processedAt: null, error, attempts: attempt } as any)
337
+ .where("id", "=", id)
338
+ .execute(),
339
+ );
340
+ }
341
+
342
+ /** The word an operator greps `error` for. One string, so the writer and a runbook cannot drift apart. */
343
+ const QUARANTINED = "quarantined";
344
+
345
+ /**
346
+ * The word for the other reason this sweep walks away from an event, likewise greppable.
347
+ *
348
+ * Shared with the webhook handler through `data/webhookEvent.ts` rather than spelled here, because the relink
349
+ * repair queries on it: two writers with two sentences for one condition is a set that cannot be selected.
350
+ */
351
+ const ORPHANED = WEBHOOK_EVENT_ORPHANED;
352
+
353
+ /**
354
+ * Stamp `abandonedAt` and say in the row why — the one writer for *this pass has walked away from this*.
355
+ *
356
+ * **The state that is neither finished nor outstanding, and the reason there are two columns.**
357
+ * {@link isWebhookEventOutstanding} reads it false, so this sweep's next run does not pick the event back
358
+ * up and its cursor is free to advance. {@link isWebhookEventFinished} reads it false, so a later delivery
359
+ * of the same event — the provider's retry, or an operator's replay, both reusing the original event id —
360
+ * still runs the handler and can project the purchase.
361
+ *
362
+ * Two callers reach it for two different reasons and both want exactly that pair of answers: a quarantine,
363
+ * which is a bounded retry giving up, and an orphan, which never had anything to retry. `attempts` is
364
+ * written only when a caller counted any, so an orphan's row does not claim a failure it never had.
365
+ */
366
+ async function abandon(d1: D1Database, id: string, now: Date, error: string, attempts?: number): Promise<void> {
367
+ await withD1Retry(() =>
368
+ paymentsDatabase(d1)
369
+ .updateTable(PAYMENTS_WEBHOOK_EVENTS_TABLE)
370
+ .set({
371
+ abandonedAt: now.getTime(),
372
+ error,
373
+ ...(attempts === undefined ? {} : { attempts }),
374
+ // biome-ignore lint/suspicious/noExplicitAny: encoded column values, not the app shape.
375
+ } as any)
376
+ .where("id", "=", id)
377
+ .execute(),
378
+ );
379
+ }
380
+
381
+ /**
382
+ * Walk past an event with a purchase in it and nobody to project it against, and leave it repairable.
383
+ *
384
+ * **Not {@link complete}, and that reversal is #339.** Writing `processedAt` here told the webhook guard the
385
+ * event was finished, and the two paths share one row — so a sweep that reached an orphan first made the
386
+ * redelivery that arrives *after* the account links answer `duplicate`, and the purchase was never
387
+ * projected. The one repair path for an orphan is the very delivery that write silenced.
388
+ *
389
+ * Not {@link fail} either, and that is the other half. A failure halts the cursor so the next run retries,
390
+ * which is right for a fault that might clear; an orphan clears when a *link* appears, and no amount of
391
+ * re-reading this event produces one. Halting would stall every event behind one customer, daily, for good.
392
+ *
393
+ * So: abandoned. Invisible to this sweep, still repairable by a delivery. The row says which of the two
394
+ * reasons put it there, and names the command for an operator who would rather not wait for a redelivery.
395
+ */
396
+ async function orphan(d1: D1Database, id: string, now: Date): Promise<void> {
397
+ await abandon(
398
+ d1,
399
+ id,
400
+ now,
401
+ `${ORPHANED} no subject could be resolved for this swept event, so the sweep has moved past it. It is not finished — the account linking re-examines it (see \`projection/orphans.ts\`), and any later delivery of this event id projects it too, including a replay from Paddle.`,
402
+ );
403
+ }
404
+
405
+ /**
406
+ * Give up on an event: stamp `abandonedAt` so this pass moves past it, and say in the row why.
407
+ *
408
+ * **`abandonedAt`, not `processedAt`, and the two columns are the whole fix for #337.** What this sweep
409
+ * needs is for its own next run to stop counting the event fresh and restarting the attempt count — that is
410
+ * a fact about *this pass*, and {@link record} reads it through {@link isWebhookEventOutstanding}. What it
411
+ * emphatically must not do is tell the webhook guard the event is finished: writing `processedAt` here made
412
+ * a quarantine answer Paddle's ordinary retry, and an operator's replay, with `duplicate` — so a
413
+ * quarantined purchase could never be projected even once its cause was fixed. A quarantine bounds a
414
+ * stall; it was never meant to be terminal.
415
+ *
416
+ * The row still says so in words. `error` names {@link QUARANTINED}, the attempt count, and the last
417
+ * failure that earned it, so an operator reading the table has the reason and not only a timestamp.
418
+ */
419
+ async function quarantine(d1: D1Database, id: string, attempt: number, now: Date, error: string): Promise<void> {
420
+ await abandon(
421
+ d1,
422
+ id,
423
+ now,
424
+ `${QUARANTINED}: after ${attempt} attempts; the sweep has moved past it. Last failure: ${error}`,
425
+ attempt,
426
+ );
427
+ }
428
+
429
+ /**
430
+ * Count one failed attempt against an event, and say whether the sweep may walk past it.
431
+ *
432
+ * `true` means quarantined — the caller advances its cursor. `false` means the bound has not been reached,
433
+ * so the caller halts in front of the event and the next run tries again.
434
+ */
435
+ async function attemptFailed(
436
+ d1: D1Database,
437
+ id: string,
438
+ attempts: number,
439
+ now: Date,
440
+ reason: string,
441
+ ): Promise<boolean> {
442
+ const attempt = attempts + 1;
443
+ if (attempt < PADDLE_SWEEP_MAX_ATTEMPTS) {
444
+ await fail(d1, id, attempt, reason);
445
+ return false;
446
+ }
447
+ await quarantine(d1, id, attempt, now, reason);
448
+ return true;
449
+ }
450
+
451
+ /**
452
+ * Project the orphans this link just made resolvable, and never let that fail the sweep.
453
+ *
454
+ * The events are gone from this pass's point of view — the cursor moved past them the run they were
455
+ * abandoned — so an account linking is the only signal left that anything changed. The repair replays each
456
+ * row's own recorded payload through the same map this sweep already uses, resolves the owner against the
457
+ * table the link just landed in, and finishes only what it actually projected. See `projection/orphans.ts`.
458
+ *
459
+ * Swallowing here rather than in the repair: a sweep is a Workflow step, and a step that throws is retried
460
+ * from the top — re-reading a page of Paddle events to repair a row that was never this page's business.
461
+ */
462
+ async function repairOrphans(deps: PaddleSweepDeps, at: Date): Promise<void> {
463
+ const base = {
464
+ credentials: deps.credentials,
465
+ environment: deps.paddleEnvironment,
466
+ transport: deps.transport ?? paddleHttpFetch,
467
+ };
468
+ await repairOrphanedEvents(deps.d1, "paddle", {
469
+ config: deps.config,
470
+ environment: deps.environment,
471
+ now: at,
472
+ replay: async (payload) => {
473
+ const parsed = PaddleEvent.safeParse(payload);
474
+ if (!parsed.success) return undefined;
475
+ return await readPaddleEvent(parsed.data, {
476
+ credentials: deps.credentials,
477
+ environment: deps.paddleEnvironment,
478
+ now: at,
479
+ deployment: deps.deployment,
480
+ readTransaction: (id) => readTransaction(id, base, PADDLE_ADJUSTMENTS_INCLUDE),
481
+ });
482
+ },
483
+ fulfill: async (projection) => {
484
+ const fulfill =
485
+ deps.fulfill ?? ((d1, value) => fulfillPurchase(d1, value, { config: deps.config, now: () => at.getTime() }));
486
+ await fulfill(deps.d1, projection);
487
+ },
488
+ });
489
+ }
490
+
491
+ /** What to write in `error` for a thrown cause. A `PithyError`'s `detail` is throw-site context, not client text. */
492
+ function reasonFor(cause: unknown): string {
493
+ if (cause instanceof PithyError) return `${cause.payload.code}: ${cause.payload.detail ?? cause.payload.message}`;
494
+ return "projection failed";
495
+ }
496
+
497
+ /**
498
+ * Sweep Paddle's event stream and project what it finds.
499
+ *
500
+ * Returns a report rather than throwing on a projection failure: one unprojectable event must not lose the
501
+ * run's whole progress, and the cursor stopping in front of it is what makes it repairable. A store that
502
+ * cannot be *reached* still throws, so the step fails and retries rather than recording a sweep that never
503
+ * happened.
504
+ */
505
+ export async function sweepPaddle(deps: PaddleSweepDeps): Promise<PaddleSweepReport> {
506
+ const newId = deps.newId ?? (() => crypto.randomUUID());
507
+ const report: PaddleSweepReport = {
508
+ read: 0,
509
+ projected: 0,
510
+ ignored: 0,
511
+ orphaned: [],
512
+ duplicate: 0,
513
+ failed: 0,
514
+ quarantined: [],
515
+ cursor: null,
516
+ gap: null,
517
+ };
518
+
519
+ if (!railEnabled(deps.config, "paddle")) return report;
520
+
521
+ let cursor = await readCursor(deps.d1);
522
+ report.cursor = cursor;
523
+ const maxPages = deps.maxPages ?? DEFAULT_MAX_PAGES;
524
+
525
+ for (let page = 0; page < maxPages; page += 1) {
526
+ const now = deps.now();
527
+ const swept = await sweepPaddleEvents({
528
+ credentials: deps.credentials,
529
+ environment: deps.paddleEnvironment,
530
+ now,
531
+ deployment: deps.deployment,
532
+ cursor: cursor ?? undefined,
533
+ transport: deps.transport,
534
+ });
535
+
536
+ if (swept.gap !== null) {
537
+ // A cursor Paddle no longer knows: the retention cliff. Reported, and the cursor is left exactly
538
+ // where it stands — restarting would re-project ninety days, and silence would lose the gap.
539
+ report.gap = swept.gap;
540
+ return report;
541
+ }
542
+
543
+ if (swept.events.length === 0) return report;
544
+
545
+ for (const event of swept.events) {
546
+ report.read += 1;
547
+ const at = deps.now();
548
+
549
+ // **Before the allowlist check, because both leave `notification` null and they mean opposite
550
+ // things.** A withheld type is walked past on purpose; an unreadable one is a failure that must be
551
+ // recorded and counted. Reading them in the other order files every unreadable event as "ignored"
552
+ // and advances the cursor over it — losing exactly the purchase this sweep exists to find.
553
+ if (event.failure !== null) {
554
+ // The event read cleanly out of the stream and then its *second* read — the transaction an
555
+ // adjustment names — failed. Recorded so it has a row, a reason and an attempt count, then handled
556
+ // by exactly the same bound as a projection failure: there is no reason one kind of unrepairable
557
+ // event should hold the stream up forever and the other should not.
558
+ const { id, fresh, attempts } = await record(deps.d1, event.eventId, event.failure.payload, at, newId);
559
+ if (!fresh) {
560
+ report.duplicate += 1;
561
+ cursor = event.eventId;
562
+ continue;
563
+ }
564
+ report.failed += 1;
565
+ if (await attemptFailed(deps.d1, id, attempts, at, reasonFor(event.failure.cause))) {
566
+ report.quarantined.push(event.eventId);
567
+ cursor = event.eventId;
568
+ continue;
569
+ }
570
+ report.cursor = cursor;
571
+ if (cursor !== null) await writeCursor(deps.d1, cursor, at, newId);
572
+ return report;
573
+ }
574
+
575
+ if (event.notification === null) {
576
+ // A type the query asked Paddle to withhold and Paddle returned anyway. Not recorded — that is the
577
+ // point of the allowlist, and `client_token.created` carries a live token. The cursor still
578
+ // advances, because an event nothing here will ever act on is behind us the moment it is read.
579
+ report.ignored += 1;
580
+ cursor = event.eventId;
581
+ continue;
582
+ }
583
+
584
+ const { notification } = event;
585
+ const { id, fresh, attempts } = await record(deps.d1, event.eventId, notification.payload, at, newId);
586
+ if (!fresh) {
587
+ // Already recorded and already handled — the healthy case, and the one that makes two consecutive
588
+ // sweeps idempotent. The cursor still advances past it: it is behind us either way.
589
+ report.duplicate += 1;
590
+ cursor = event.eventId;
591
+ continue;
592
+ }
593
+
594
+ /**
595
+ * The pairing is worth keeping even for an event that projects nothing, and it is proven — the rail
596
+ * returns a reference only when a MAC this deployment's secret produced sits beside it.
597
+ *
598
+ * **Decoded, never split by hand, and never read as a bare id.** `accountReference` is the one
599
+ * single-field slot Paddle gives us, so it carries the subject as one string, and
600
+ * `decodeSubjectReference` is the only reader of that format. It answers `undefined` for anything
601
+ * that is not exactly the encoding — including `ada`, the shape every pre-subject checkout stamped —
602
+ * and `undefined` here means no link is written at all. That is the safe direction: a permanent
603
+ * binding is what `linkProviderAccount` writes and it never rebinds, so a guessed holder is a
604
+ * customer's renewals delivered to a stranger for as long as the deployment lives.
605
+ */
606
+ const referenced = notification.accountReference
607
+ ? decodeSubjectReference(notification.accountReference)
608
+ : undefined;
609
+ if (notification.providerAccountId && referenced !== undefined) {
610
+ await linkProviderAccount(deps.d1, "paddle", notification.providerAccountId, referenced, { now: at });
611
+ await repairOrphans(deps, at);
612
+ // The link is what an orphan was waiting for, and this sweep will not look at those events again —
613
+ // its cursor is past them. So the repair runs on the signal rather than on the next pass. #341.
614
+ }
615
+
616
+ if (notification.event === null) {
617
+ // **A note a provider read produced is not a completion, and that is #341 on this path.** The rail
618
+ // reads a transaction to tell a full refund from a partial one, and a read answering "no such
619
+ // transaction" is exactly as repairable as one that threw — a rotated key, a shared sandbox, an
620
+ // adjustment swept ahead of its own transaction. So it goes through the same bound the thrown case
621
+ // above uses, rather than stamping `processedAt` and making Paddle's redelivery a duplicate.
622
+ if (noteIsRepairable(notification.note)) {
623
+ report.failed += 1;
624
+ if (await attemptFailed(deps.d1, id, attempts, at, noteText(notification.note) ?? "unreadable")) {
625
+ report.quarantined.push(event.eventId);
626
+ cursor = event.eventId;
627
+ continue;
628
+ }
629
+ report.cursor = cursor;
630
+ if (cursor !== null) await writeCursor(deps.d1, cursor, at, newId);
631
+ return report;
632
+ }
633
+ report.ignored += 1;
634
+ await complete(deps.d1, id, at, noteText(notification.note));
635
+ cursor = event.eventId;
636
+ continue;
637
+ }
638
+
639
+ /**
640
+ * Who holds it — **resolved from rows, because a Workflow has nothing else**.
641
+ *
642
+ * There is no request here, so no `c.var.auth` and no adopter `resolveSubject`; see
643
+ * `workflows/worker.ts`. Every source `resolveNotificationOwner` consults is a row this server wrote
644
+ * or a reference it stamped and Paddle returned, and it answers with the **pair** read off one row.
645
+ */
646
+ const subject = await resolveNotificationOwner(paymentsDatabase(deps.d1), "paddle", {
647
+ providerAccountId: notification.providerAccountId,
648
+ providerTransactionId: notification.event.providerTransactionId,
649
+ originalTransactionId: notification.event.originalTransactionId,
650
+ });
651
+ if (subject === undefined) {
652
+ // Orphaned. No number of sweeps will conjure a link, so the cursor advances — retrying it forever
653
+ // would stall every event behind one customer. But the event is **not finished**: it is a real
654
+ // purchase waiting on a link, and the delivery that follows that link is what projects it. So
655
+ // `abandonedAt`, which advances this pass and leaves the guard's short-circuit shut. See #339.
656
+ report.orphaned.push(event.eventId);
657
+ await orphan(deps.d1, id, at);
658
+ cursor = event.eventId;
659
+ continue;
660
+ }
661
+
662
+ try {
663
+ // Both halves, spread from the one object they were read into. Two events from one notification bind
664
+ // the identical subject for the same reason a pair is never assembled twice: a charge row and a
665
+ // standing row that disagreed about their holder would be one purchase owned by two accounts.
666
+ const projection = await projectPurchase(
667
+ deps.d1,
668
+ { ...notification.event, ...subject },
669
+ { config: deps.config, environment: deps.environment, now: at },
670
+ );
671
+ if (notification.stateEvent) {
672
+ await projectPurchase(
673
+ deps.d1,
674
+ { ...notification.stateEvent, ...subject },
675
+ { config: deps.config, environment: deps.environment, now: at },
676
+ );
677
+ }
678
+ // The same fulfillment the webhook path performs. A sweep that repaired a renewal and did not
679
+ // credit its coins would leave the entitlement right and the balance wrong, and nothing else would
680
+ // ever fix it — the renewal this sweep discovered is the one the webhook lost.
681
+ const fulfill =
682
+ deps.fulfill ?? ((d1, value) => fulfillPurchase(d1, value, { config: deps.config, now: () => at.getTime() }));
683
+ await fulfill(deps.d1, projection);
684
+ await complete(deps.d1, id, at);
685
+ report.projected += 1;
686
+ cursor = event.eventId;
687
+ } catch (cause) {
688
+ // **Advancement stops here for a bounded number of runs, and the row stays unprocessed.** Every
689
+ // event behind this one is unswept, which is correct while there is any prospect of the fault
690
+ // clearing: skipping straight past would turn a transient fault into a purchase nothing ever
691
+ // finds. So the row is left with a null `processedAt` and the next sweep sees it fresh.
692
+ //
693
+ // Once the attempt count reaches {@link PADDLE_SWEEP_MAX_ATTEMPTS}, the prospect is gone and
694
+ // holding the stream is the more expensive answer — so the event is quarantined in words on its
695
+ // own row and the cursor moves on. See the module doc: neither rule alone is right.
696
+ report.failed += 1;
697
+ if (await attemptFailed(deps.d1, id, attempts, at, reasonFor(cause))) {
698
+ report.quarantined.push(event.eventId);
699
+ cursor = event.eventId;
700
+ continue;
701
+ }
702
+ report.cursor = cursor;
703
+ if (cursor !== null) await writeCursor(deps.d1, cursor, at, newId);
704
+ return report;
705
+ }
706
+ }
707
+
708
+ report.cursor = cursor;
709
+ if (cursor !== null) await writeCursor(deps.d1, cursor, deps.now(), newId);
710
+ if (!swept.hasMore) return report;
711
+ }
712
+
713
+ return report;
714
+ }
715
+
716
+ /** How long Paddle keeps an event, re-exported so a caller reporting a gap need not import two modules. */
717
+ export { PADDLE_EVENT_RETENTION_DAYS };