@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,554 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database, D1PreparedStatement } from "@cloudflare/workers-types";
5
+ import { chunkByBoundParameters } from "@pithy-sh/core/src/data/boundParameters";
6
+ import { withD1Retry } from "@pithy-sh/core/src/data/withD1Retry";
7
+ import { InternalError } from "@pithy-sh/core/src/error/pithyError";
8
+ import { type CompiledQuery, type SqlBool, sql } from "kysely";
9
+ import {
10
+ entitlementsForProduct,
11
+ type PaymentsConfig,
12
+ type PaymentsProduct,
13
+ productForProviderSku,
14
+ } from "../config/config";
15
+ import { PaymentsEntitlement } from "../data/entitlement";
16
+ import { PaymentsPurchase, type PaymentsPurchaseRow, type PurchaseEnvironment } from "../data/purchase";
17
+ import { grantingStatuses } from "../data/status";
18
+ import { encodeSubjectReference, type PaymentsSubject, sameSubject } from "../data/subject";
19
+ import {
20
+ PAYMENTS_ENTITLEMENTS_TABLE,
21
+ PAYMENTS_PURCHASES_TABLE,
22
+ type PaymentsDatabase,
23
+ paymentsDatabase,
24
+ } from "../data/tables";
25
+ import {
26
+ PaymentsEnvironmentMismatchError,
27
+ PaymentsProductNotFoundError,
28
+ PaymentsReceiptAlreadyOwnedError,
29
+ } from "../error/errors";
30
+ import { ProviderEvent, type ProviderEventInput } from "./event";
31
+
32
+ /**
33
+ * The projection writer — the one place a purchase is ever written, and the heart of the package.
34
+ *
35
+ * Five triggers converge here, across four modules — `http/routes.ts` twice, and one each from
36
+ * `workflows/reconcile.ts`, `workflows/paddleSweep.ts` and `projection/orphans.ts`:
37
+ *
38
+ * - the buyer's app submitting its receipt to `POST {base}/purchases`;
39
+ * - the provider's webhook, on the route that verified its signature;
40
+ * - the reconciliation Workflow re-verifying what the webhook missed, through each rail's `refresh`;
41
+ * - the Paddle events sweep, which finds a purchase that has **no row** at all, so `refresh` never sees it;
42
+ * - the orphan repair that runs when an account links, projecting the notifications that arrived before
43
+ * anyone could say whose they were (#341).
44
+ *
45
+ * All five produce the identical row, which is what makes a dropped client call cost nothing and a replayed
46
+ * webhook change nothing — and it is why refunds, renewals, and revocations need no handler of their own.
47
+ * They are states, and this projects a state. The count is prose and nothing holds it: the honest check is
48
+ * `grep -rn projectPurchase src/`.
49
+ *
50
+ * Four properties hold, and each is enforced by the database rather than by careful code:
51
+ *
52
+ * - **Idempotent.** `UNIQUE (rail, providerTransactionId)` means one provider transaction is one row
53
+ * forever. A repeat routes into the upsert's `ON CONFLICT` branch instead of inserting a second row.
54
+ * - **Monotonic on the provider's event time.** Providers do not guarantee delivery order, so an `expired`
55
+ * notification can arrive after the `renewed` that superseded it, and last-write-wins would silently
56
+ * revoke a paying subscriber. An event no newer than the row it would update is ignored entirely — the
57
+ * comparison is a SQL predicate on the `ON CONFLICT` branch as well as a pre-read, so two concurrent
58
+ * writers cannot order themselves wrongly.
59
+ * - **Never rebound.** A transaction already projected against another subject raises
60
+ * `payments/receipt_already_owned` (409) rather than moving. Together with the UNIQUE constraint that is
61
+ * what makes a stolen receipt worthless. The `ON CONFLICT` branch carries the owner check too, so the
62
+ * refusal survives a race the pre-read cannot see. The owner is a **pair**, and every comparison of it
63
+ * here is both halves: nothing keeps an organization id from equalling some user's, so an id-only check
64
+ * would let `organization:acme` take `user:acme`'s transaction and the entitlement behind it.
65
+ * - **Environment-isolated.** The deployment's own store environment is an input, and a mismatch is refused
66
+ * outright. A sandbox StoreKit transaction granting a real entitlement is the most common
67
+ * in-app-purchase security defect there is; the safe design is for such a row never to exist.
68
+ *
69
+ * The affected entitlement rows are re-derived **in the same `DB.batch` as the purchase write**, from the
70
+ * purchases table itself rather than from a value this function computed. D1 runs a batch as one
71
+ * transaction, so the read model cannot disagree with the purchases that produced it: there is no window
72
+ * in which a purchase is stored and its entitlement is not. An entitlement is granted when some purchase
73
+ * granting it sits in an access-granting status and has not expired, and the row carries the winning
74
+ * purchase — the one whose access runs longest — as provenance.
75
+ *
76
+ * Which rows those are is a question about the catalog as well as the event, so a write also clears the keys
77
+ * this subject holds that the catalog has stopped granting — see {@link keysToDerive}. A catalog edit
78
+ * produces no event of its own, and nothing else in the package would ever repair such a row.
79
+ *
80
+ * The ledger credit a `grants` clause asks for is deliberately **not** here. This function stays pure D1,
81
+ * so its tests need no optional package resolved; fulfillment acts on what it returns. Nothing is lost —
82
+ * the credit's idempotency rests on the ledger's own `UNIQUE (ref)`, not on call ordering.
83
+ */
84
+
85
+ /** What one projection did, and to what. The caller acts on this — fulfillment reads it, a route renders it. */
86
+ export interface PurchaseProjection {
87
+ /**
88
+ * `created` for a transaction seen for the first time, `updated` when a newer event moved an existing
89
+ * row, `ignored` when the event was no newer than the row it would have updated. `ignored` is a normal,
90
+ * successful outcome: a replay is a 200, not an error.
91
+ */
92
+ outcome: "created" | "updated" | "ignored";
93
+ /** The purchase row as it now stands — the existing one, unchanged, on the `ignored` path. */
94
+ purchase: PaymentsPurchase;
95
+ /** The catalog product this transaction maps to, and the entitlement keys it grants. */
96
+ product: { id: string; entitlements: readonly string[] };
97
+ /** The entitlement rows this projection re-derived, after the write. */
98
+ entitlements: readonly PaymentsEntitlement[];
99
+ }
100
+
101
+ /** What the writer needs beyond the event: the catalog, the deployment's environment, and the clock. */
102
+ export interface ProjectPurchaseOptions {
103
+ /** The resolved catalog. The product, its type, and its entitlement keys all come from here. */
104
+ config: PaymentsConfig;
105
+ /**
106
+ * This deployment's store environment. An input rather than something inferred from the payload, because
107
+ * inferring it is what lets a sandbox purchase grant a production entitlement.
108
+ */
109
+ environment: PurchaseEnvironment;
110
+ /** The clock, for `createdAt`/`updatedAt` and the expiry check. Injected so tests are deterministic. */
111
+ now?: Date;
112
+ /** The id minter. Injected for the same reason. */
113
+ newId?: () => string;
114
+ }
115
+
116
+ /**
117
+ * Project one normalized provider event. Returns what changed; throws only the three refusals above and
118
+ * whatever D1 raises that is not a constraint we expect.
119
+ */
120
+ export async function projectPurchase(
121
+ d1: D1Database,
122
+ input: ProviderEventInput,
123
+ options: ProjectPurchaseOptions,
124
+ ): Promise<PurchaseProjection> {
125
+ const event = ProviderEvent.parse(input);
126
+ // The owner, read off the event once and carried as one object from here down. Named here rather than at
127
+ // each call site because a pair assembled twice is a pair that can be assembled wrongly — the whole point
128
+ // of `PaymentsSubject` is that the two halves never travel apart. See `data/subject.ts`.
129
+ const subject: PaymentsSubject = { subjectType: event.subjectType, subjectId: event.subjectId };
130
+ const { config } = options;
131
+ const now = options.now ?? new Date();
132
+ const newId = options.newId ?? (() => crypto.randomUUID());
133
+
134
+ // Refused before anything is read, let alone written. There is no state in which a sandbox transaction
135
+ // belongs in a production database, so recording-and-ignoring would only leave a row to be mistaken later.
136
+ if (event.environment !== options.environment) {
137
+ throw new PaymentsEnvironmentMismatchError({
138
+ detail: `${event.rail} transaction ${event.providerTransactionId} is ${event.environment}; this deployment is ${options.environment}.`,
139
+ });
140
+ }
141
+
142
+ // The catalog is config, not rows, so an unmapped SKU is a missing resource rather than a bad request.
143
+ const entry = productForProviderSku(config, event.rail, event.providerProductId);
144
+ if (entry === undefined) {
145
+ throw new PaymentsProductNotFoundError({
146
+ detail: `No catalog product maps the ${event.rail} SKU "${event.providerProductId}".`,
147
+ });
148
+ }
149
+
150
+ const db = paymentsDatabase(d1);
151
+ const existing = await readPurchase(db, event);
152
+
153
+ if (existing !== undefined) {
154
+ // The owner comes out of the row as one object and is compared as one object. `sameSubject` is both
155
+ // halves and nothing else — an id-only check here is the cross-holder rebind this refusal exists for.
156
+ if (!sameSubject(existing, subject)) throw alreadyOwned(event, existing);
157
+ // Stale, or an exact replay. Either way the row already says something at least as new, so nothing is
158
+ // written and the caller gets the purchase it asked about.
159
+ if (existing.providerEventAt.getTime() >= event.providerEventAt.getTime()) {
160
+ const keys = affectedKeys(config, entry.product, existing.productId);
161
+ return {
162
+ outcome: "ignored",
163
+ purchase: existing,
164
+ product: { id: existing.productId, entitlements: entitlementsForProduct(config, existing.productId) },
165
+ entitlements: await readEntitlements(db, subject, keys),
166
+ };
167
+ }
168
+ }
169
+
170
+ const keys = await keysToDerive(db, config, entry.product, existing?.productId, subject);
171
+ const row = PaymentsPurchase.encode({
172
+ id: existing?.id ?? newId(),
173
+ // The owner spread from the one object that carries it, rather than two fields written side by side.
174
+ // Two adjacent assignments are two a hand can transpose, and a transposed pair typechecks.
175
+ ...subject,
176
+ rail: event.rail,
177
+ providerTransactionId: event.providerTransactionId,
178
+ productId: entry.id,
179
+ providerProductId: event.providerProductId,
180
+ type: entry.product.type,
181
+ status: event.status,
182
+ role: event.role,
183
+ environment: event.environment,
184
+ purchasedAt: event.purchasedAt,
185
+ expiresAt: event.expiresAt,
186
+ revokedAt: event.revokedAt,
187
+ resumesAt: event.resumesAt,
188
+ originalTransactionId: event.originalTransactionId,
189
+ amountMinor: event.amountMinor,
190
+ currency: event.currency,
191
+ providerEventAt: event.providerEventAt,
192
+ payload: event.payload,
193
+ createdAt: existing?.createdAt ?? now,
194
+ updatedAt: now,
195
+ });
196
+
197
+ const at = now.getTime();
198
+ const statements: D1PreparedStatement[] = [upsertPurchaseStatement(d1, row)];
199
+ for (const key of keys) {
200
+ // Two statements per key, because a derivation cannot update a row that is not there yet. The insert is
201
+ // the row's existence; the update is its value, computed from the purchases table inside this same
202
+ // transaction — so the answer accounts for the purchase written one statement earlier.
203
+ statements.push(compile(d1, ensureEntitlement(db, newId(), subject, key, at)));
204
+ statements.push(compile(d1, deriveEntitlement(db, config, subject, key, at)));
205
+ }
206
+
207
+ // One batch, one D1 transaction: the purchase and every entitlement it implies commit together or not at
208
+ // all. `withD1Retry` covers the transient faults D1 surfaces by message; a retry is safe because every
209
+ // statement here is an upsert or a derivation, so re-running the batch reaches the same state.
210
+ await withD1Retry(() => d1.batch(statements));
211
+
212
+ const projected = await readPurchase(db, event);
213
+ if (projected === undefined) {
214
+ throw new InternalError({
215
+ message: "The purchase could not be recorded.",
216
+ action: "Retry. If it persists, check the app database for the pithy_payments_* tables.",
217
+ detail: `Projected ${event.rail} transaction ${event.providerTransactionId} but could not read it back.`,
218
+ });
219
+ }
220
+ // The race the pre-read cannot see: a concurrent submission for a different subject landed first, and this
221
+ // batch's `ON CONFLICT` guard correctly refused to rebind the row. Report the refusal rather than a
222
+ // success against a row that is not this caller's.
223
+ if (!sameSubject(projected, subject)) throw alreadyOwned(event, projected);
224
+
225
+ return {
226
+ outcome:
227
+ projected.providerEventAt.getTime() !== event.providerEventAt.getTime()
228
+ ? "ignored"
229
+ : existing === undefined
230
+ ? "created"
231
+ : "updated",
232
+ purchase: projected,
233
+ product: { id: entry.id, entitlements: entry.product.entitlements },
234
+ entitlements: await readEntitlements(db, subject, keys),
235
+ };
236
+ }
237
+
238
+ /**
239
+ * The 409, with the throw-site context in `detail` where an operator sees it and a client never does.
240
+ *
241
+ * Both subjects are rendered through `encodeSubjectReference`, so the line names the *kind* as well as the
242
+ * id. The interesting refusal is the one where the two ids read identically and only the kind differs, and
243
+ * a log printing bare ids would render that as a machine refusing `acme` on behalf of `acme`.
244
+ */
245
+ function alreadyOwned(event: ProviderEvent, owner: PaymentsSubject): PaymentsReceiptAlreadyOwnedError {
246
+ return new PaymentsReceiptAlreadyOwnedError({
247
+ detail: `${event.rail} transaction ${event.providerTransactionId} is owned by ${encodeSubjectReference(owner)}; ${encodeSubjectReference(event)} submitted it.`,
248
+ });
249
+ }
250
+
251
+ /**
252
+ * The entitlement keys the catalog says this event touches: the ones its product grants, plus the ones the
253
+ * row's previous product granted when a catalog edit re-mapped the SKU. Sorted, so a batch's statement order
254
+ * is deterministic and two concurrent projections for one subject cannot deadlock on opposite orders.
255
+ */
256
+ function affectedKeys(
257
+ config: PaymentsConfig,
258
+ product: PaymentsProduct,
259
+ previousProductId: string | undefined,
260
+ ): string[] {
261
+ const keys = new Set<string>(product.entitlements);
262
+ if (previousProductId !== undefined) {
263
+ for (const key of entitlementsForProduct(config, previousProductId)) keys.add(key);
264
+ }
265
+ return [...keys].sort();
266
+ }
267
+
268
+ /**
269
+ * Every key a write must re-derive: what {@link affectedKeys} reads out of the catalog, **plus the keys this
270
+ * subject still holds that no current product grants at all**.
271
+ *
272
+ * That second set cannot come from the config, because it is precisely what the config no longer mentions.
273
+ * Drop `beta` from a product's `entitlements` and ship, and every subject holding it holds it forever
274
+ * otherwise: no purchase for that key will ever arrive again, the read path only lapses a row carrying a
275
+ * dated expiry, and the reconciliation pass re-asks about subscriptions. A catalog edit is a deploy, so the
276
+ * repair is one indexed read per projection and lands on this subject's next purchase event, whatever it
277
+ * was about.
278
+ *
279
+ * Held rows are excluded, and the `active` filter keeps the set to rows that still claim something — a
280
+ * support comp of a key the catalog never sold is a human's decision, and an already-cleared row needs no
281
+ * second clearing. Sorted for the same reason as above.
282
+ */
283
+ async function keysToDerive(
284
+ db: PaymentsDatabase,
285
+ config: PaymentsConfig,
286
+ product: PaymentsProduct,
287
+ previousProductId: string | undefined,
288
+ subject: PaymentsSubject,
289
+ ): Promise<string[]> {
290
+ const keys = new Set<string>(affectedKeys(config, product, previousProductId));
291
+ const granted = new Set<string>();
292
+ for (const candidate of Object.values(config.products)) {
293
+ for (const key of candidate.entitlements) granted.add(key);
294
+ }
295
+ const held = await db
296
+ .selectFrom(PAYMENTS_ENTITLEMENTS_TABLE)
297
+ .select("entitlement")
298
+ // Both halves. This read decides what a write repairs, so a predicate matching on the id alone would
299
+ // hand one holder the other's orphaned keys and clear rows nothing asked about.
300
+ .where("subjectType", "=", subject.subjectType)
301
+ .where("subjectId", "=", subject.subjectId)
302
+ .where("active", "=", 1)
303
+ .where("manual", "=", 0)
304
+ .execute();
305
+ for (const row of held) {
306
+ if (!granted.has(row.entitlement)) keys.add(row.entitlement);
307
+ }
308
+ return [...keys].sort();
309
+ }
310
+
311
+ /** The purchase row for this `(rail, providerTransactionId)`, decoded, or undefined. */
312
+ async function readPurchase(
313
+ db: PaymentsDatabase,
314
+ event: Pick<ProviderEvent, "rail" | "providerTransactionId">,
315
+ ): Promise<PaymentsPurchase | undefined> {
316
+ const row = await db
317
+ .selectFrom(PAYMENTS_PURCHASES_TABLE)
318
+ .selectAll()
319
+ .where("rail", "=", event.rail)
320
+ .where("providerTransactionId", "=", event.providerTransactionId)
321
+ .executeTakeFirst();
322
+ return row === undefined ? undefined : PaymentsPurchase.parse(row);
323
+ }
324
+
325
+ /**
326
+ * The entitlement rows for these keys, decoded, in key order. Empty when the product grants none.
327
+ *
328
+ * Chunked, because `keys` is no longer bounded by one product: {@link keysToDerive} adds every key this
329
+ * subject holds that the catalog dropped, and a long-lived account collects them. One statement binds a
330
+ * parameter per key **plus both halves of the subject** — hence `fixed` of 2, where an owner that was one
331
+ * column wanted 1.
332
+ *
333
+ * That number is the whole safety of this loop, and getting it wrong is silent until it is not: left at 1
334
+ * the chunker hands back 100 keys, the statement binds 102, and D1 refuses the statement outright rather
335
+ * than truncating the list. The write has already committed by then, so the failure lands on the read-back
336
+ * of a purchase that was recorded perfectly — and only on the long-lived accounts the chunking exists for.
337
+ */
338
+ async function readEntitlements(
339
+ db: PaymentsDatabase,
340
+ subject: PaymentsSubject,
341
+ keys: readonly string[],
342
+ ): Promise<PaymentsEntitlement[]> {
343
+ const entitlements: PaymentsEntitlement[] = [];
344
+ for (const chunk of chunkByBoundParameters(keys, 2)) {
345
+ const rows = await db
346
+ .selectFrom(PAYMENTS_ENTITLEMENTS_TABLE)
347
+ .selectAll()
348
+ .where("subjectType", "=", subject.subjectType)
349
+ .where("subjectId", "=", subject.subjectId)
350
+ .where("entitlement", "in", chunk)
351
+ .execute();
352
+ for (const row of rows) entitlements.push(PaymentsEntitlement.parse(row));
353
+ }
354
+ // Sorted here rather than per chunk: the caller is handed one list, and its order must not depend on where
355
+ // the chunk boundaries fell.
356
+ return entitlements.sort((left, right) => (left.entitlement < right.entitlement ? -1 : 1));
357
+ }
358
+
359
+ /**
360
+ * The purchase upsert, compiled to the statement that joins the batch. **The only place either commit-time
361
+ * guard is expressed**, and exported for that reason: a test can compile it, let the table move underneath
362
+ * the compiled statement, and execute it — which is the one way to reach the `ON CONFLICT` predicate.
363
+ * Driving `projectPurchase` cannot do it, because its pre-read and post-batch checks serialize around the
364
+ * predicate and cover the same outcomes, so a test at that level passes with the predicate deleted.
365
+ */
366
+ export function upsertPurchaseStatement(d1: D1Database, row: PaymentsPurchaseRow): D1PreparedStatement {
367
+ return compile(d1, upsertPurchase(paymentsDatabase(d1), row));
368
+ }
369
+
370
+ /**
371
+ * The purchase upsert. Both guards live on the `ON CONFLICT` branch rather than only in the pre-read,
372
+ * because a pre-read cannot see a concurrent writer: SQLite evaluates them against the row as it stands at
373
+ * commit, so a stale event and a receipt lifted from another account are both refused by the database.
374
+ *
375
+ * `id`, `subjectType`, `subjectId`, and `createdAt` are absent from the update set on purpose — a
376
+ * transaction's identity, its owner, and when it was first projected are immutable.
377
+ */
378
+ function upsertPurchase(db: PaymentsDatabase, row: PaymentsPurchaseRow) {
379
+ return (
380
+ db
381
+ .insertInto(PAYMENTS_PURCHASES_TABLE)
382
+ // biome-ignore lint/suspicious/noExplicitAny: the row is the schema's z.input side; Kysely's insert type derives from it.
383
+ .values(row as any)
384
+ .onConflict((oc) =>
385
+ oc
386
+ .columns(["rail", "providerTransactionId"])
387
+ .doUpdateSet({
388
+ productId: row.productId,
389
+ providerProductId: row.providerProductId,
390
+ type: row.type,
391
+ status: row.status,
392
+ environment: row.environment,
393
+ purchasedAt: row.purchasedAt,
394
+ expiresAt: row.expiresAt,
395
+ revokedAt: row.revokedAt,
396
+ // Cleared by the same write that ends the pause: a resumed subscription arrives `active` with
397
+ // nothing reported, and a row keeping yesterday's date would say a live subscription is coming
398
+ // back. The column is the current state, exactly as `status` is.
399
+ resumesAt: row.resumesAt,
400
+ originalTransactionId: row.originalTransactionId,
401
+ amountMinor: row.amountMinor,
402
+ currency: row.currency,
403
+ providerEventAt: row.providerEventAt,
404
+ payload: row.payload,
405
+ updatedAt: row.updatedAt,
406
+ // biome-ignore lint/suspicious/noExplicitAny: as above — an encoded row, not the app shape.
407
+ } as any)
408
+ .where((eb) =>
409
+ eb.and([
410
+ // Monotonic: strictly newer, so an exact redelivery is a no-op rather than a rewrite.
411
+ eb(
412
+ eb.ref(`${PAYMENTS_PURCHASES_TABLE}.providerEventAt`),
413
+ "<",
414
+ // biome-ignore lint/suspicious/noExplicitAny: an encoded ms-epoch number from the row.
415
+ row.providerEventAt as any,
416
+ ),
417
+ // Never rebind an owner, whoever wins the race — and the owner is both columns. With only
418
+ // the id compared, `organization:acme` overwrites `user:acme`'s row: the subject columns are
419
+ // absent from the update set, so the theft is silent, and the victim keeps a row now carrying
420
+ // the thief's status, payload, and timestamp.
421
+ // biome-ignore lint/suspicious/noExplicitAny: as above.
422
+ eb(eb.ref(`${PAYMENTS_PURCHASES_TABLE}.subjectType`), "=", row.subjectType as any),
423
+ // biome-ignore lint/suspicious/noExplicitAny: as above.
424
+ eb(eb.ref(`${PAYMENTS_PURCHASES_TABLE}.subjectId`), "=", row.subjectId as any),
425
+ ]),
426
+ ),
427
+ )
428
+ );
429
+ }
430
+
431
+ /**
432
+ * The entitlement row's existence. Cleared, so a project that grants nothing still leaves provenance.
433
+ *
434
+ * **The conflict target is the whole unique, all three columns.** SQLite resolves `ON CONFLICT` against an
435
+ * index rather than against a prefix of one, so a target naming `(subjectId, entitlement)` matches
436
+ * `UNIQUE (subjectType, subjectId, entitlement)` not at all and raises `ON CONFLICT clause does not match
437
+ * any PRIMARY KEY or UNIQUE constraint`. That throw happens inside the batch, which is the whole purchase
438
+ * write — so a target one column short does not cost an entitlement, it costs the sale.
439
+ */
440
+ function ensureEntitlement(db: PaymentsDatabase, id: string, subject: PaymentsSubject, key: string, at: number) {
441
+ const row = {
442
+ id,
443
+ // Spread, not written out: the pair goes into the row the way it came out of the caller.
444
+ ...subject,
445
+ entitlement: key,
446
+ active: 0,
447
+ expiresAt: null,
448
+ sourcePurchaseId: null,
449
+ // A row this function creates is derived, never held. Only `entitlement/manual.ts` sets the hold.
450
+ manual: 0,
451
+ createdAt: at,
452
+ updatedAt: at,
453
+ };
454
+ return (
455
+ db
456
+ .insertInto(PAYMENTS_ENTITLEMENTS_TABLE)
457
+ // biome-ignore lint/suspicious/noExplicitAny: an encoded row; Kysely's insert type derives from z.input.
458
+ .values(row as any)
459
+ .onConflict((oc) => oc.columns(["subjectType", "subjectId", "entitlement"]).doNothing())
460
+ );
461
+ }
462
+
463
+ /**
464
+ * Re-derive one entitlement row from the purchases table. The candidate set is named **once**, as a CTE, and
465
+ * read three times: is anything granting, when does the winner lapse, and which purchase is it.
466
+ *
467
+ * The winner is the purchase whose access runs longest — `NULLS FIRST` on a descending expiry puts a
468
+ * never-expiring purchase ahead of every dated one, which is right: owning `remove_ads` outright is not
469
+ * beaten by a subscription that ends. `providerEventAt` breaks a tie so the choice is deterministic.
470
+ *
471
+ * Written as a derivation rather than as values this function computed, because that is the difference
472
+ * between "the read model agrees with what I read a moment ago" and "the read model agrees with the
473
+ * purchases". Only the second survives a concurrent write.
474
+ *
475
+ * **The two subject predicates are the join, and they are the same pair.** The CTE reads the purchases
476
+ * table and the UPDATE writes the entitlements table, so those `where`s are the only thing tying one
477
+ * holder's money to one holder's row. Key them differently — one half here, both halves there, a type from
478
+ * config beside an id from a row — and the derivation reads one subject's purchases and writes another
479
+ * subject's entitlement. That is a grant nobody bought, and nothing downstream can tell.
480
+ *
481
+ * ## Two shapes here are about D1's bound-parameter cap, not about style
482
+ *
483
+ * D1 refuses a statement carrying more than 100 bound parameters (see `@pithy-sh/core`'s
484
+ * `boundParameters`), and this statement's list of granting products is the one part of it a *catalog* can
485
+ * grow. So the CTE names the candidate set once instead of inlining the subquery per column — three copies
486
+ * tripled every parameter — and the product ids travel as **one** JSON parameter that SQLite's `json_each`
487
+ * expands back into rows, so the count no longer depends on the catalog at all. Twenty-seven products
488
+ * granting one key used to throw `too many SQL variables`, and because the derivation shares the purchase
489
+ * write's batch, the throw meant no purchase touching that key could be recorded — a project with a few
490
+ * grandfathered prices could not sell.
491
+ *
492
+ * An empty list is the honest ordinary case, not a branch: `json_each('[]')` yields no rows, so no purchase
493
+ * qualifies and the row is cleared — which is exactly right when a catalog edit stops granting a key.
494
+ */
495
+ function deriveEntitlement(
496
+ db: PaymentsDatabase,
497
+ config: PaymentsConfig,
498
+ subject: PaymentsSubject,
499
+ key: string,
500
+ at: number,
501
+ ) {
502
+ const grantingProducts = JSON.stringify(
503
+ Object.entries(config.products)
504
+ .filter(([, product]) => product.entitlements.includes(key))
505
+ .map(([id]) => id),
506
+ );
507
+ const statuses = grantingStatuses(config.graceGrantsAccess);
508
+
509
+ return (
510
+ db
511
+ // The purchase currently granting `key`, or no row at all when none does.
512
+ .with("winner", (cb) =>
513
+ cb
514
+ .selectFrom(PAYMENTS_PURCHASES_TABLE)
515
+ .select(["id", "expiresAt"])
516
+ .where("subjectType", "=", subject.subjectType)
517
+ .where("subjectId", "=", subject.subjectId)
518
+ // The catalog as one parameter. `json_each` is SQLite's own, so the ids stay bound values — never
519
+ // interpolated SQL — and a thousand-product catalog binds exactly what a one-product catalog does.
520
+ .where(sql<SqlBool>`${sql.ref("productId")} in (select value from json_each(${grantingProducts}))`)
521
+ .where("status", "in", statuses)
522
+ // The read-time rule, applied at write time too: a status says the purchase still stands,
523
+ // `expiresAt` says whether the period it paid for is over.
524
+ .where((inner) => inner.or([inner("expiresAt", "is", null), inner("expiresAt", ">", at)]))
525
+ .orderBy("expiresAt", (ob) => ob.desc().nullsFirst())
526
+ .orderBy("providerEventAt", "desc")
527
+ .limit(1),
528
+ )
529
+ .updateTable(PAYMENTS_ENTITLEMENTS_TABLE)
530
+ // biome-ignore lint/suspicious/noExplicitAny: three reads of the CTE as column values; Kysely's set type is the row shape.
531
+ .set((eb: any) => ({
532
+ active: eb.exists(eb.selectFrom("winner").select("id")),
533
+ expiresAt: eb.selectFrom("winner").select("expiresAt"),
534
+ sourcePurchaseId: eb.selectFrom("winner").select("id"),
535
+ updatedAt: at,
536
+ }))
537
+ .where("subjectType", "=", subject.subjectType)
538
+ .where("subjectId", "=", subject.subjectId)
539
+ .where("entitlement", "=", key)
540
+ // The hold. Without this predicate a support comp of a key the catalog also sells is erased by the very
541
+ // next purchase event for that key — the derivation would find no purchase behind the comp and clear it.
542
+ //
543
+ // It is a hold on one row, not on one key: a comp written for `user:ada` shields `user:ada`'s row and
544
+ // nothing else, so `organization:acme` buying the same key is derived normally. Anything wider would
545
+ // let a single support decision freeze a key across every holder in the project.
546
+ .where("manual", "=", 0)
547
+ );
548
+ }
549
+
550
+ /** Compile a Kysely query to a D1 prepared statement, so it can join a `DB.batch` transaction. */
551
+ function compile(d1: D1Database, query: { compile(): CompiledQuery }): D1PreparedStatement {
552
+ const compiled = query.compile();
553
+ return d1.prepare(compiled.sql).bind(...(compiled.parameters as unknown[]));
554
+ }
@@ -0,0 +1,91 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { resourceNames } from "@pithy-sh/core/src/naming/resourceNames";
5
+ import { hostWorkflowsFor, resolveWorkflowHost, type WorkflowHostTemplate } from "@pithy-sh/core/src/workflow/host";
6
+ import { masterKeySecretName } from "@pithy-sh/secrets/src/provision/provisionSecrets";
7
+ import type { ManagedEnvironment } from "@pithy-sh/secrets/src/scope";
8
+ import type { PaymentsConfig } from "../config/config";
9
+ import { PAYMENTS_CAPABILITY, paymentsWorkflowRegistry } from "../workflows/specs";
10
+
11
+ /**
12
+ * Resolve the reconcile worker's committed `wrangler.jsonc` template into one environment's standalone config.
13
+ * Every per-environment decision lives here; everything static — the compatibility date, the binding names —
14
+ * stays as the template committed it.
15
+ *
16
+ * Thin over core's {@link resolveWorkflowHost}, which owns the mechanics (clone, fill by binding name, stamp
17
+ * `ENVIRONMENT`). What this file adds is the one thing the generic resolver deliberately does not do: it
18
+ * **rewrites `workflows` and `triggers.crons` from the capability's specs** rather than from the template's own
19
+ * block. The template carries both so it reads as a complete, deployable config, but `workflows/specs.ts` is
20
+ * the single source of the binding name, the class name, and the schedule — so moving the pass off 04:00 is a
21
+ * one-line spec edit rather than a spec edit plus a JSONC edit that nothing checks agree.
22
+ *
23
+ * Pure: the caller parses the template and writes the result.
24
+ */
25
+
26
+ /** The resolved ids and per-env values for one environment's reconcile-worker deploy. */
27
+ export interface PaymentsConfigParams {
28
+ /**
29
+ * The project name — the `<project>` segment the deployed worker and its reconcile Workflow lead
30
+ * with. The root `pithy.config.ts` `name`, resolved by `requireProjectName` and never guessed:
31
+ * Worker script and Workflow names are account-scoped, so a wrong value overwrites another project's
32
+ * running reconcile host rather than colliding with it.
33
+ */
34
+ project: string;
35
+ /** The target environment. */
36
+ env: ManagedEnvironment;
37
+ /** The app database id for this environment — where the `pithy_payments_*` tables live. */
38
+ appDatabaseId: string;
39
+ /** This environment's secrets database id (`<project>-<env>-secrets`) — holds the rails' credentials. */
40
+ secretsDatabaseId: string;
41
+ /** The CF Secrets Store id holding the per-env master key. */
42
+ storeId: string;
43
+ /** The app's resolved payments config — serialized into the worker's `PAYMENTS_CONFIG` var. */
44
+ paymentsConfig: PaymentsConfig;
45
+ }
46
+
47
+ /**
48
+ * The deployed reconcile-worker name for a project's environment — also its resolved config's basename.
49
+ *
50
+ * Through core's naming facade under the **`worker`** namespace, so the name is held to a Worker
51
+ * script's 63 (not the generic composer's one-size 63 that happened to agree) and the environment is
52
+ * validated where it is bound rather than at the deploy that would have used it.
53
+ */
54
+ export function paymentsWorkerName(project: string, env: ManagedEnvironment): string {
55
+ return resourceNames(project).env(env).worker(PAYMENTS_CAPABILITY);
56
+ }
57
+
58
+ /** Fill the template for one environment. */
59
+ export function resolvePaymentsConfig(
60
+ template: WorkflowHostTemplate,
61
+ params: PaymentsConfigParams,
62
+ ): WorkflowHostTemplate {
63
+ const { project, env, appDatabaseId, secretsDatabaseId, storeId, paymentsConfig } = params;
64
+
65
+ // Derived before the resolve rather than assigned after it: `resolveWorkflowHost` refuses to fill a
66
+ // template that declares `workflows` without them, because the only name it could invent unaided is an
67
+ // unscoped one a second project in the same account would overwrite.
68
+ const derived = hostWorkflowsFor(paymentsWorkflowRegistry, { project, capability: PAYMENTS_CAPABILITY, env });
69
+
70
+ const resolved = resolveWorkflowHost(template, {
71
+ project,
72
+ capability: PAYMENTS_CAPABILITY,
73
+ env,
74
+ databaseIds: { DB: appDatabaseId, SECRETS: secretsDatabaseId },
75
+ secretsStoreId: storeId,
76
+ // The master key entry is project- and env-scoped, matching what the secrets manager wrote.
77
+ masterKeySecretName: masterKeySecretName(project, env),
78
+ workflows: derived.workflows,
79
+ // The catalog travels whole: the pass has to map a refreshed store SKU back to a product before it can
80
+ // project it, and the rails' toggles decide which stores it may ask at all. Nothing sensitive is in it —
81
+ // every credential lives in the secrets store, which is why this can be a plain var.
82
+ vars: { PAYMENTS_CONFIG: JSON.stringify(paymentsConfig) },
83
+ });
84
+
85
+ // Only declare a cron block when a spec actually carries one. An empty `crons` array is a declaration
86
+ // wrangler honors, and a worker that advertises a schedule it does not have is a deployment nobody can
87
+ // reason about.
88
+ resolved.triggers = derived.crons.length > 0 ? { crons: derived.crons } : undefined;
89
+
90
+ return resolved;
91
+ }