@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,648 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database } from "@cloudflare/workers-types";
5
+ import { type AuditEmit, noopEmit } from "@pithy-sh/core/src/audit/recorder";
6
+ import { SQLiteDate } from "@pithy-sh/core/src/data/codecs";
7
+ import { PithyError } from "@pithy-sh/core/src/error/pithyError";
8
+ import { PaymentsAuditActions } from "../audit/actions";
9
+ import type { PaymentsConfig } from "../config/config";
10
+ import { PaymentsPurchase, type PurchaseEnvironment } from "../data/purchase";
11
+ import { recordReconcileRun } from "../data/reconcileRun";
12
+ import type { PurchaseStatus } from "../data/status";
13
+ import { PAYMENTS_PURCHASES_TABLE, paymentsDatabase } from "../data/tables";
14
+ import { fulfillPurchase } from "../grants/apply";
15
+ import type { PurchaseProjection } from "../projection/writer";
16
+ import { projectPurchase } from "../projection/writer";
17
+ import type { PaddleSweepReport } from "./paddleSweep";
18
+ import type { ReconcileRailAccess } from "./railAccess";
19
+ import { DEFAULT_EXPIRING_WITHIN_SECONDS, DEFAULT_STALE_AFTER_SECONDS, type PaymentsReconcileParams } from "./specs";
20
+
21
+ /**
22
+ * The reconciliation pass: asking each store what it thinks, and repairing what disagrees.
23
+ *
24
+ * **Why this exists at all.** A webhook-only system rots silently. A deploy that drops requests, a Pub/Sub push
25
+ * subscription pointing at a retired URL, a rotated signing key, a store outage — each loses events with
26
+ * nothing surfacing anywhere. And one drift needs no incident to explain it: a subscription can simply lapse,
27
+ * with the store deciding not to renew and telling nobody. The read path already rechecks `expiresAt` so a
28
+ * lapsed row stops granting, but the row itself stays wrong, the renewal that *did* happen stays unheard, and
29
+ * neither is visible until somebody complains.
30
+ *
31
+ * **Why a Workflow rather than a scheduled pass.** The work is hundreds of calls to rate-limited third-party
32
+ * APIs that fail part way. A plain `scheduled()` handler is wall-clock bounded and restarts from nothing; a
33
+ * Workflow step is journalled, so a run that dies on page forty resumes at page forty rather than re-asking
34
+ * Apple about four thousand subscriptions. That is durable execution earning its place, not decorating a cron.
35
+ *
36
+ * ## The shape of a run
37
+ *
38
+ * Pure over injected dependencies — the step runner, the rails, the clock, and the audit emitter all arrive as
39
+ * parameters — so the whole of it, resuming included, is tested against real D1 with fake rails and no
40
+ * network. `workflows/worker.ts` is the durable shell that supplies the real ones.
41
+ *
42
+ * **Pagination is keyset, never offset**, and that is the correctness argument for the file. The selection
43
+ * matches subscriptions that are near expiry *or* have not been verified lately, and a reconciled row stops
44
+ * matching both — so the result set shrinks underneath the scan. With `LIMIT/OFFSET` that is the classic skip
45
+ * bug: page two starts at row 100 of a set that just lost its first 100 members, and half the catalog is never
46
+ * reconciled, silently. Reading `id > cursor` in id order cannot skip and cannot repeat, whatever the
47
+ * predicate does to the rows behind the cursor.
48
+ *
49
+ * **Each page is one `step.do`, named from a page counter.** Step names must be identical across replays or
50
+ * the journal is unusable, so they are derived from a counter and never from a timestamp or a row id.
51
+ *
52
+ * ## Who a repaired purchase belongs to
53
+ *
54
+ * **Off the row, always.** A pass has no request, so there is no `c.var.auth` and no adopter
55
+ * `resolveSubject` — `entitlement/subjectSeam.ts` resolves a subject from a Hono context, and this file
56
+ * never has one. A rail's `refresh` answer carries no subject either, by construction. So every subject the
57
+ * pass touches is the pair already stored on the purchase it read, and the narrowing a support pass takes is
58
+ * the pair a human typed. See `workflows/worker.ts` for why that is structural rather than an omission.
59
+ *
60
+ * ## What is asked, and what is left alone
61
+ *
62
+ * **Subscriptions only.** A consumable or a non-consumable is bought once and does not drift: the single thing
63
+ * that changes one is a refund, which arrives as its own notification on every rail. Asking a store about
64
+ * every one-time purchase forever would be the bulk of the cost and none of the value.
65
+ *
66
+ * **Not the terminal states.** `expired`, `never_paid`, `refunded`, and `revoked` are states we learned *from a
67
+ * store*, and re-asking cannot change them: a resubscription is a new transaction and therefore a new row on all
68
+ * three rails. Excluding them is also what stops a catalog's dead subscriptions being re-fetched every night
69
+ * forever. A row that says `active` with an expiry in the past is **not** terminal — that is precisely the row
70
+ * whose renewal we may have missed, and it is the one this pass exists to ask about.
71
+ *
72
+ * ## What a failure means
73
+ *
74
+ * A store that **cannot be reached** (`payments/provider_unavailable`) fails the step, so the Workflow retries
75
+ * it — the page is idempotent, so a retry re-asks and re-projects to the same place. Every other refusal is
76
+ * about one purchase or one rail and is counted rather than thrown: an unmapped store state, a SKU no longer
77
+ * in the catalog, a rail switched off since the row was written. A pass that aborted on the first of those
78
+ * would leave the rest of the catalog unreconciled to preserve a tally.
79
+ */
80
+
81
+ /** The Workflow step runner, structurally. Injectable, so a test can run a body with no durable execution. */
82
+ export interface ReconcileStep {
83
+ /** Run a named step, or return its journalled result if this instance already completed it. */
84
+ do<T>(name: string, callback: () => Promise<T>): Promise<T>;
85
+ }
86
+
87
+ /** Purchases per page when the caller names none. Bounded so one page fits inside one minted token's life. */
88
+ const DEFAULT_PAGE_SIZE = 100;
89
+
90
+ /** Pages per run when the caller names none. Ten thousand purchases is a long pass and a sane ceiling. */
91
+ const DEFAULT_MAX_PAGES = 100;
92
+
93
+ /**
94
+ * The statuses a pass never re-asks about. Each was reported by a store and cannot become anything else on the
95
+ * same transaction — see the module doc on why `active` with a past expiry is deliberately not among them.
96
+ */
97
+ const TERMINAL_STATUSES: readonly PurchaseStatus[] = ["expired", "never_paid", "refunded", "revoked"];
98
+
99
+ /** What one reconciliation run needs. Every one is a seam. */
100
+ export interface ReconcileDeps {
101
+ /** The app database the `pithy_payments_*` tables live in. */
102
+ d1: D1Database;
103
+ /** The resolved catalog — the writer's product lookup and the rails' enablement come from it. */
104
+ config: PaymentsConfig;
105
+ /** This deployment's store environment. A refreshed sandbox purchase is refused in production, as ever. */
106
+ environment: PurchaseEnvironment;
107
+ /** One page's rail access, built fresh per step so each page mints its own batch tokens. */
108
+ railAccess(now: Date): ReconcileRailAccess;
109
+ /** The clock. */
110
+ now(): Date;
111
+ /** The run id minter, injected so a test is deterministic. Defaults to `crypto.randomUUID`. */
112
+ newId?: () => string;
113
+ /**
114
+ * The audit seam. Drift is emitted through it, because repeated drift on one rail is the signal that the
115
+ * webhook path is broken and a tally in a log line is not something anybody queries. Defaults to core's
116
+ * no-op, which is what a host worker with no audit capability composed gets.
117
+ */
118
+ emit?: AuditEmit;
119
+ /**
120
+ * Fulfillment for a repaired purchase — the ledger credit a `grants` product asks for.
121
+ *
122
+ * A pass that repaired a subscription renewal and did not credit its coins would leave the entitlement right
123
+ * and the balance wrong, and nothing else would ever fix it: the renewal the webhook lost is the same
124
+ * renewal this pass discovered, so whoever discovers a period is the only one who can pay for it.
125
+ *
126
+ * Injectable, and defaults to {@link fulfillPurchase}. Safe to run over an already-credited purchase — the
127
+ * ref is a pure function of the purchase id and the currency, and the ledger's `ref` is UNIQUE, so a credit
128
+ * the webhook already applied is a no-op rather than a double.
129
+ */
130
+ fulfill?: (projection: PurchaseProjection) => Promise<unknown>;
131
+ /**
132
+ * The Paddle events sweep, or undefined to skip it.
133
+ *
134
+ * Injected rather than built here, because it needs Paddle's credentials and its own transport and this
135
+ * module deliberately knows nothing about any single rail — `railAccess` is the seam every other rail is
136
+ * reached through, and the sweep is not a rail method. The Workflow worker supplies it.
137
+ *
138
+ * Undefined on a project that does not sell through Paddle, which is why the step below is skipped
139
+ * entirely rather than run and reported as empty: an empty tally for a rail nobody uses reads as a rail
140
+ * that found nothing, which is a different statement.
141
+ */
142
+ sweepPaddle?: () => Promise<PaddleSweepReport>;
143
+ }
144
+
145
+ /** What one purchase's reconciliation did. */
146
+ type PurchaseOutcome = "unchanged" | "drifted" | "skipped" | "failed" | "superseded";
147
+
148
+ /** What one page did. Returned through the step, so a replay restores it exactly. */
149
+ interface PageResult {
150
+ /** The id to read after on the next page, or the previous cursor when the catalog is exhausted. */
151
+ cursor: string | null;
152
+ /** Rows read. */
153
+ scanned: number;
154
+ /** Rows a store answered for and whose state matched what was stored. */
155
+ unchanged: number;
156
+ /** Rows whose stored state disagreed with the store's. Repaired unless the run is a dry run. */
157
+ drifted: number;
158
+ /** Rows a later period has replaced. Settled once, which makes them terminal and drops them from the scan. */
159
+ superseded: number;
160
+ /** Rows no store could be asked about — a rail switched off, or a purchase it cannot address. */
161
+ skipped: number;
162
+ /** Rows a store refused to answer for. Counted, never thrown: one bad row must not end the pass. */
163
+ failed: number;
164
+ /** Whether this was the last page. */
165
+ done: boolean;
166
+ }
167
+
168
+ /** The tally a run reports. */
169
+ export interface ReconcileReport {
170
+ /**
171
+ * The run's id — the row this pass wrote to `pithy_payments_reconcile_runs`, and the value every repair it
172
+ * audited carries as `runId`. Minted before the first page so the events can name it.
173
+ */
174
+ runId: string;
175
+ /** Pages read — one durable step each. */
176
+ pages: number;
177
+ /** Purchases examined. */
178
+ scanned: number;
179
+ /** Purchases whose stored state already matched the store's. */
180
+ unchanged: number;
181
+ /** Purchases whose stored state disagreed. The number that matters: a rising one means webhooks are lost. */
182
+ drifted: number;
183
+ /**
184
+ * Purchases a later period of the same subscription has replaced, settled on this pass.
185
+ *
186
+ * Counted apart from `drifted` deliberately. A rail answers about the family's **current** transaction
187
+ * whichever row it is asked about, so an old period compared against its own successor always looks like
188
+ * drift — and while these were counted as drift, `drifted` measured how long the catalog had been selling
189
+ * rather than whether webhooks were arriving. Settling makes the old row terminal, so it leaves the scan and
190
+ * this number falls back to zero once a catalog's history has been walked through once.
191
+ */
192
+ superseded: number;
193
+ /** Purchases no store could be asked about. */
194
+ skipped: number;
195
+ /**
196
+ * What the Paddle events sweep found, or undefined when it did not run.
197
+ *
198
+ * Undefined rather than a zeroed tally, and the difference is a statement: a rail nobody sells through
199
+ * did not sweep, where a rail that swept and found nothing is a healthy integration. Collapsing the two
200
+ * would make "the webhooks are fine" indistinguishable from "we never looked".
201
+ */
202
+ swept?: PaddleSweepReport;
203
+ /** Purchases a store refused to answer for. */
204
+ failed: number;
205
+ /** Whether the run stopped at its page cap with more of the catalog unexamined. */
206
+ truncated: boolean;
207
+ /** Whether this run only reported. */
208
+ dryRun: boolean;
209
+ }
210
+
211
+ /** One purchase, before and after, as the drift comparison sees it. */
212
+ interface ProjectedState {
213
+ status: PurchaseStatus;
214
+ expiresAt: number | null;
215
+ }
216
+
217
+ /** The state a drift comparison reads off a row or a refreshed event. */
218
+ function stateOf(value: { status: PurchaseStatus; expiresAt: Date | null }): ProjectedState {
219
+ return { status: value.status, expiresAt: value.expiresAt?.getTime() ?? null };
220
+ }
221
+
222
+ /** Whether the store's answer says something different from what is stored. */
223
+ function drifted(before: ProjectedState, after: ProjectedState): boolean {
224
+ return before.status !== after.status || before.expiresAt !== after.expiresAt;
225
+ }
226
+
227
+ /**
228
+ * One page of purchases to reconcile.
229
+ *
230
+ * The predicate is the pass's whole selection policy: a live subscription that is near or past its expiry, or
231
+ * one nothing has verified lately. `id > cursor` in id order is what makes it resumable — see the module doc.
232
+ */
233
+ async function readPage(
234
+ deps: ReconcileDeps,
235
+ params: PaymentsReconcileParams,
236
+ cursor: string | null,
237
+ cutoffs: { expiring: Date; stale: Date },
238
+ limit: number,
239
+ ): Promise<PaymentsPurchase[]> {
240
+ const db = paymentsDatabase(deps.d1);
241
+ let query = db
242
+ .selectFrom(PAYMENTS_PURCHASES_TABLE)
243
+ .selectAll()
244
+ .where("type", "=", "subscription")
245
+ .where("status", "not in", [...TERMINAL_STATUSES])
246
+ .where((eb) =>
247
+ eb.or([
248
+ // Near or past its expiry: the renewal either just happened or should have.
249
+ eb("expiresAt", "<", SQLiteDate.encode(cutoffs.expiring)),
250
+ // Or nowhere near expiry and unverified for a while: a refund, a pause, a revocation whose
251
+ // notification never arrived changes a subscription mid-period and dates nothing about the expiry.
252
+ eb("updatedAt", "<", SQLiteDate.encode(cutoffs.stale)),
253
+ ]),
254
+ )
255
+ .orderBy("id")
256
+ .limit(limit);
257
+
258
+ if (cursor !== null) query = query.where("id", ">", cursor);
259
+ /**
260
+ * The holder narrowing — **both halves in one guard, or no narrowing at all**.
261
+ *
262
+ * The schema already refuses half a subject, so this is the second line rather than the first. It is
263
+ * written as one condition because the failure it prevents is the tempting one: two independent `if`s
264
+ * would let `subjectId` alone reach the query, and `where subject_id = 'acme'` matches the user *and* the
265
+ * organization called `acme` — nothing keeps those id spaces apart. The support pass would then reconcile
266
+ * a stranger's purchases and report the count as the account it was asked about.
267
+ *
268
+ * Widening when only one half arrives is the safe direction: a pass over the whole catalog is slow and
269
+ * correct, where a pass over the wrong holder is fast and wrong.
270
+ */
271
+ if (params.subjectType !== undefined && params.subjectId !== undefined) {
272
+ query = query.where("subjectType", "=", params.subjectType).where("subjectId", "=", params.subjectId);
273
+ }
274
+ if (params.rail !== undefined) query = query.where("rail", "=", params.rail);
275
+
276
+ return (await query.execute()).map((row) => PaymentsPurchase.parse(row));
277
+ }
278
+
279
+ /**
280
+ * Reconcile one purchase: ask its rail, compare, and project any disagreement through the **same writer** the
281
+ * webhook and the client submission use.
282
+ *
283
+ * Projecting through the writer rather than updating the row is what keeps the whole design's central claim
284
+ * true — one idempotent projection, three triggers, one row. It also means a repair re-derives the entitlement
285
+ * rows in the same D1 transaction, so a subscription found to have lapsed stops granting in the same commit
286
+ * that records the lapse.
287
+ */
288
+ async function reconcileOne(
289
+ deps: ReconcileDeps,
290
+ runId: string,
291
+ access: ReconcileRailAccess,
292
+ purchase: PaymentsPurchase,
293
+ now: Date,
294
+ dryRun: boolean,
295
+ ): Promise<PurchaseOutcome> {
296
+ let refreshed: Awaited<ReturnType<Awaited<ReturnType<ReconcileRailAccess["providerFor"]>>["refresh"]>>;
297
+ try {
298
+ const provider = await access.providerFor(purchase.rail);
299
+ refreshed = await provider.refresh(purchase, { now });
300
+ } catch (cause) {
301
+ // Not reaching a store is the one failure worth retrying, and the step is the unit that retries. Every
302
+ // other refusal — a rail switched off since the row was written, credentials never provisioned, a state
303
+ // the rail does not map — is about this purchase or this rail and would fail identically on every retry.
304
+ if (cause instanceof PithyError && cause.payload.code === "payments/provider_unavailable") throw cause;
305
+ return "failed";
306
+ }
307
+
308
+ // The store cannot address this purchase: a one-time row whose rail offers no lookup for it, or a
309
+ // transaction the store no longer knows. Neither is evidence that anything changed, so the row stands.
310
+ if (refreshed === undefined) return "skipped";
311
+
312
+ /**
313
+ * The store answered about a **different** transaction, which means this row is a period that has ended and
314
+ * the one that replaced it is elsewhere in the table.
315
+ *
316
+ * Every rail's `refresh` looks up the subscription family, not the row it was handed — it has to, because a
317
+ * renewal is a new transaction and the family is the only handle a store offers. So comparing this row
318
+ * against that answer compares two different purchases, and it always disagrees: the old period's expiry is
319
+ * in the past and the new one's is not. That disagreement is not drift, and counting it as drift is what made
320
+ * a year-old subscriber contribute twelve permanent "repairs" every night.
321
+ *
322
+ * Settling the row once is what ends it. Projecting it `expired` at its own expiry puts it in
323
+ * `TERMINAL_STATUSES`, so the next pass does not select it, does not ask the store about it, and does not
324
+ * report it — and the entitlement is unaffected, because the successor grants it and the derivation reads
325
+ * every purchase for the key.
326
+ */
327
+ if (refreshed.providerTransactionId !== purchase.providerTransactionId) {
328
+ if (dryRun) return "superseded";
329
+ try {
330
+ await projectPurchase(
331
+ deps.d1,
332
+ {
333
+ ...purchase,
334
+ // Its own expiry, not the successor's: this row records the period it was paid for, and that period
335
+ // ended when it said it would.
336
+ status: "expired",
337
+ providerEventAt: now,
338
+ payload: { supersededBy: refreshed.providerTransactionId },
339
+ },
340
+ { config: deps.config, environment: deps.environment, now },
341
+ );
342
+ } catch (cause) {
343
+ if (cause instanceof PithyError) return "failed";
344
+ throw cause;
345
+ }
346
+ return "superseded";
347
+ }
348
+
349
+ const before = stateOf(purchase);
350
+ if (dryRun) {
351
+ return drifted(before, stateOf({ status: refreshed.status, expiresAt: refreshed.expiresAt ?? null }))
352
+ ? "drifted"
353
+ : "unchanged";
354
+ }
355
+
356
+ let projection: PurchaseProjection;
357
+ try {
358
+ // The owner comes from the stored row, never from the rail — the same rule every other write path obeys.
359
+ // A refresh cannot rebind a purchase, and the writer's own owner check refuses it if it somehow tried.
360
+ //
361
+ // **Both halves off the same row**, which is the invariant `data/subject.ts` states: a `subjectType`
362
+ // taken from config beside a `subjectId` taken from a row typechecks perfectly and names a holder
363
+ // nobody meant. There is nothing else here it could come from — a Workflow runs with no request and no
364
+ // adopter resolver, so the row is the only source of a subject in this whole file.
365
+ projection = await projectPurchase(
366
+ deps.d1,
367
+ { ...refreshed, subjectType: purchase.subjectType, subjectId: purchase.subjectId },
368
+ { config: deps.config, environment: deps.environment, now },
369
+ );
370
+ } catch (cause) {
371
+ // A SKU dropped from the catalog, or a sandbox row in a production database. Both are real conditions a
372
+ // pass meets in the field, and neither is a reason to stop reconciling everything else.
373
+ if (cause instanceof PithyError) return "failed";
374
+ throw cause;
375
+ }
376
+
377
+ const after = stateOf(projection.purchase);
378
+ if (!drifted(before, after)) return "unchanged";
379
+
380
+ // The audit first, so the repair is on the trail whatever fulfillment does next.
381
+ await emitDrift(deps, runId, purchase, before, after);
382
+
383
+ /**
384
+ * Fulfillment, on drift only.
385
+ *
386
+ * Every rail dates a refresh `now` — it has to, or the monotonic write rule would discard the repair — so
387
+ * the writer reports `updated` for every row a pass touches. Keying fulfillment on that outcome would mean
388
+ * ten thousand ledger calls per pass, each one a no-op against a ref that already exists. Drift is the
389
+ * signal that actually distinguishes a repair from a confirmation, and a repair is the only one that can owe
390
+ * anybody coins.
391
+ */
392
+ const fulfill =
393
+ deps.fulfill ??
394
+ ((repaired: PurchaseProjection) =>
395
+ fulfillPurchase(deps.d1, repaired, { config: deps.config, emit: deps.emit, now: () => now.getTime() }));
396
+ try {
397
+ await fulfill(projection);
398
+ } catch (cause) {
399
+ // The purchase itself is repaired and audited, so this is not a failed reconciliation — but a credit that
400
+ // did not land needs a human, and the tally is where a human looks. The next pass retries it against the
401
+ // same stable ref. A non-`PithyError` is transient by construction and belongs to the step's retry.
402
+ if (cause instanceof PithyError) return "failed";
403
+ throw cause;
404
+ }
405
+ return "drifted";
406
+ }
407
+
408
+ /** Record a repaired disagreement. The one audit event that is about this deployment rather than a caller. */
409
+ async function emitDrift(
410
+ deps: ReconcileDeps,
411
+ runId: string,
412
+ purchase: PaymentsPurchase,
413
+ before: ProjectedState,
414
+ after: ProjectedState,
415
+ ): Promise<void> {
416
+ await (deps.emit ?? noopEmit)({
417
+ action: PaymentsAuditActions.purchaseReconciled,
418
+ outcome: "success",
419
+ // `warning`, not `info`: one of these is a dropped delivery, a pattern of them is a broken webhook path.
420
+ severity: "warning",
421
+ actorType: "service",
422
+ actorId: PaymentsAuditActions.purchaseReconciled,
423
+ resourceType: "purchase",
424
+ resourceId: purchase.id,
425
+ // Identifiers and states only. Never the refreshed payload: the trail is long-lived and queryable, and a
426
+ // store's purchase payload is a bearer artifact.
427
+ metadata: {
428
+ // The pointer, and the reason the run record holds no repairs of its own: a repair is recorded here,
429
+ // once, and the run names the tally over the set of events carrying its id. Copying the repairs into
430
+ // the runs table would be a second audit trail with different access rules.
431
+ runId,
432
+ rail: purchase.rail,
433
+ productId: purchase.productId,
434
+ from: before.status,
435
+ to: after.status,
436
+ expiryChanged: before.expiresAt !== after.expiresAt,
437
+ },
438
+ });
439
+ }
440
+
441
+ /**
442
+ * Run one reconciliation pass. Idempotent by construction — a second run over a reconciled catalog finds every
443
+ * row already matching and writes nothing — so a retried Workflow step, a manual trigger after a cron, and a
444
+ * nervous operator running it twice all reach the same place.
445
+ */
446
+ /** What a sweep that could not run reports. Never written to a report — see `ReconcileReport.swept`. */
447
+ const EMPTY_SWEEP: PaddleSweepReport = {
448
+ read: 0,
449
+ projected: 0,
450
+ ignored: 0,
451
+ orphaned: [],
452
+ duplicate: 0,
453
+ failed: 0,
454
+ quarantined: [],
455
+ cursor: null,
456
+ gap: null,
457
+ };
458
+
459
+ export async function reconcilePayments(
460
+ deps: ReconcileDeps,
461
+ step: ReconcileStep,
462
+ params: PaymentsReconcileParams = {},
463
+ ): Promise<ReconcileReport> {
464
+ const limit = params.pageSize ?? DEFAULT_PAGE_SIZE;
465
+ const maxPages = params.maxPages ?? DEFAULT_MAX_PAGES;
466
+ const dryRun = params.dryRun === true;
467
+
468
+ /**
469
+ * The run's identity and its clock, minted **once, in one journalled step**, and read back from the step's
470
+ * return value.
471
+ *
472
+ * **Why a step.** A Workflow does not resume inside the step it died in; it re-executes this body from the
473
+ * top and serves every completed step from the journal. So anything the body *computes* answers differently
474
+ * on a resume, while everything the journal holds does not — and a run's id and its start instant are both
475
+ * things every page behind the interruption has already been written under.
476
+ *
477
+ * The id was moved here first (#328): a second mint made the run record name an id no repair carried, which
478
+ * broke the runs table's only join. The clock was left in the body one release longer, and it was the same
479
+ * defect one field over (#331) — `startedAt` was recomputed on resume, so a run interrupted at nine and
480
+ * resumed at three claimed to have begun six hours *after* the repairs it names. A pass's start instant is
481
+ * not "when this attempt got going"; it is when the pass got going.
482
+ *
483
+ * It is also the comparison instant every page judges against, and journalling it is right there too: the
484
+ * pages already run were selected and dated against it, and a resume that widened the windows underneath
485
+ * them would make one pass two different queries.
486
+ *
487
+ * Epoch milliseconds rather than a `Date`, because a journal round-trips JSON and a `Date` would come back
488
+ * a string on the resume and an object on the first pass — a difference that shows up as a type error six
489
+ * frames away rather than here.
490
+ */
491
+ const context: { runId: string; nowMs: number } = await step.do("start-run", async () => ({
492
+ runId: (deps.newId ?? (() => crypto.randomUUID()))(),
493
+ nowMs: deps.now().getTime(),
494
+ }));
495
+ const runId = context.runId;
496
+ const now = new Date(context.nowMs);
497
+ const cutoffs = {
498
+ expiring: new Date(now.getTime() + (params.expiringWithinSeconds ?? DEFAULT_EXPIRING_WITHIN_SECONDS) * 1000),
499
+ stale: new Date(now.getTime() - (params.staleAfterSeconds ?? DEFAULT_STALE_AFTER_SECONDS) * 1000),
500
+ };
501
+
502
+ const report: ReconcileReport = {
503
+ runId,
504
+ pages: 0,
505
+ scanned: 0,
506
+ unchanged: 0,
507
+ drifted: 0,
508
+ superseded: 0,
509
+ skipped: 0,
510
+ failed: 0,
511
+ truncated: false,
512
+ dryRun,
513
+ };
514
+
515
+ /**
516
+ * The Paddle events sweep, before the pages — the repair `refresh` cannot make.
517
+ *
518
+ * `refresh` re-reads rows this deployment already holds, so a purchase whose webhook was never delivered
519
+ * and which no client submitted is invisible to it forever. Paddle publishes an account-wide event
520
+ * stream, so it can be found; no other rail here can do this.
521
+ *
522
+ * **First, so what it discovers is then reconciled by the same run.** A subscription the sweep projects
523
+ * is a row the pages below will consider, which is one pass rather than two.
524
+ *
525
+ * Its own `step.do`, so a transient fault retries the sweep rather than losing a whole run's pages. It
526
+ * is skipped entirely when the pass was narrowed to another rail — `--rail stripe` means Stripe.
527
+ */
528
+ if (deps.sweepPaddle && (params.rail === undefined || params.rail === "paddle")) {
529
+ const swept: PaddleSweepReport = await step.do(
530
+ "sweep-paddle",
531
+ async () => (await deps.sweepPaddle?.()) ?? EMPTY_SWEEP,
532
+ );
533
+ report.swept = swept;
534
+ if (swept.gap !== null) {
535
+ // A cursor past Paddle's ninety-day retention. Reported on the trail rather than repaired: restarting
536
+ // from the beginning would re-project three months, and staying silent would leave the gap forever.
537
+ await (deps.emit ?? noopEmit)({
538
+ action: PaymentsAuditActions.purchaseReconciled,
539
+ outcome: "failure",
540
+ severity: "warning",
541
+ actorType: "service",
542
+ actorId: "paddle",
543
+ // The gap's own sentence, which names the window and the cursor. Nothing a sender wrote: this
544
+ // string is composed here, from our own cursor and our own constant.
545
+ metadata: { rail: "paddle", runId, reason: "sweep_gap", detail: swept.gap },
546
+ });
547
+ }
548
+ }
549
+
550
+ let cursor: string | null = null;
551
+ let page = 0;
552
+
553
+ for (;;) {
554
+ page += 1;
555
+ const after = cursor;
556
+ // Zero-padded so the step names sort the way the pages ran — a Workflow's journal is read by humans too.
557
+ const result: PageResult = await step.do(`page-${String(page).padStart(6, "0")}`, async () => {
558
+ const rows = await readPage(deps, params, after, cutoffs, limit);
559
+ if (rows.length === 0) {
560
+ return {
561
+ cursor: after,
562
+ scanned: 0,
563
+ unchanged: 0,
564
+ drifted: 0,
565
+ superseded: 0,
566
+ skipped: 0,
567
+ failed: 0,
568
+ done: true,
569
+ };
570
+ }
571
+
572
+ // Built inside the step, so each page mints its own batch tokens and a retry mints fresh ones rather
573
+ // than replaying an expired pair.
574
+ const access = deps.railAccess(now);
575
+ const tally: Record<PurchaseOutcome, number> = {
576
+ unchanged: 0,
577
+ drifted: 0,
578
+ superseded: 0,
579
+ skipped: 0,
580
+ failed: 0,
581
+ };
582
+ for (const purchase of rows) {
583
+ tally[await reconcileOne(deps, runId, access, purchase, now, dryRun)] += 1;
584
+ }
585
+
586
+ return {
587
+ cursor: (rows[rows.length - 1] as PaymentsPurchase).id,
588
+ scanned: rows.length,
589
+ ...tally,
590
+ // A short page means the catalog is exhausted. A full page might still be the last one; the next step
591
+ // reads zero rows and ends the run, which costs one cheap query and no special case.
592
+ done: rows.length < limit,
593
+ };
594
+ });
595
+
596
+ report.pages = page;
597
+ report.scanned += result.scanned;
598
+ report.unchanged += result.unchanged;
599
+ report.drifted += result.drifted;
600
+ report.superseded += result.superseded;
601
+ report.skipped += result.skipped;
602
+ report.failed += result.failed;
603
+ cursor = result.cursor;
604
+
605
+ if (result.done) break;
606
+ if (page >= maxPages) {
607
+ report.truncated = true;
608
+ break;
609
+ }
610
+ }
611
+
612
+ /**
613
+ * The run, kept.
614
+ *
615
+ * **A run that repaired nothing is written too**, and that is the load-bearing half: *"it ran and found
616
+ * nothing"* is the answer to *"is this integration healthy"*, and a table holding only the exceptional
617
+ * passes makes silence ambiguous with a cron that stopped firing — which is the failure the table exists
618
+ * to make visible.
619
+ *
620
+ * Its own `step.do`, so a D1 hiccup retries the write rather than losing the record of a pass that did
621
+ * real work. The writer is idempotent on the run's id, so a replayed step lands on the row it already
622
+ * wrote instead of counting one pass twice. It is the last step and never the first: a row claiming a
623
+ * finish time before the pass had one would be a lie the health read would then repeat.
624
+ *
625
+ * `finishedAt` is read from the clock again rather than reused from `now`, because `now` is the pass's
626
+ * comparison instant and a run over ten thousand purchases is not instantaneous.
627
+ */
628
+ await step.do("record-run", async () => {
629
+ await recordReconcileRun(
630
+ deps.d1,
631
+ {
632
+ id: runId,
633
+ startedAt: now,
634
+ finishedAt: deps.now(),
635
+ environment: deps.environment,
636
+ // Null is the scheduled behavior — every enabled rail. A value means somebody narrowed the pass.
637
+ rail: params.rail ?? null,
638
+ report,
639
+ },
640
+ { now: deps.now() },
641
+ );
642
+ // Steps journal their return value; this one has nothing worth replaying, and returning the row would
643
+ // put a whole record into the journal for no reader.
644
+ return null;
645
+ });
646
+
647
+ return report;
648
+ }