@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,312 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { type Kysely, sql } from "kysely";
5
+ import type { Migration } from "kysely/migration";
6
+
7
+ /**
8
+ * Payments' six tables: the purchase projection, the materialized entitlement read model, the
9
+ * provider-identity map, the raw webhook log, the reconciliation run log, and the sync cursors.
10
+ *
11
+ * camelCase identifiers; `CamelCasePlugin` snake-cases them in the DDL. `down` is the tested inverse.
12
+ *
13
+ * Two constraints carry the design. `UNIQUE (rail, providerTransactionId)` on purchases is what makes
14
+ * three write paths converge on one row — a replayed webhook violates it and the write becomes a
15
+ * no-op update rather than a second purchase. `UNIQUE (subjectType, subjectId, entitlement)` on
16
+ * entitlements is what makes the read model a read model: one row per subject per entitlement, whichever
17
+ * purchase currently grants it. Correctness lives in the schema, not in a hopeful application-level check
18
+ * a race could skip.
19
+ *
20
+ * The holder of a purchase is a **pair** — a kind and an id — and both columns are in that key, ahead of
21
+ * the entitlement, so the per-subject read is a covering prefix of it. Keying on the id alone would let an
22
+ * organization whose id equalled some user's read that user's grants, and nothing anywhere makes those two
23
+ * namespaces disjoint. The kind is closed at the database with a CHECK on all three subject-bearing
24
+ * tables, for the reason `environment` is: a value the whole schema is keyed on is not something to trust
25
+ * from whatever wrote the row.
26
+ *
27
+ * The indexes come in two families. Three serve the questions the capability asks of itself — a buyer's
28
+ * own purchases, the reconciliation sweep, the pending-delivery queue. Three serve the control-plane
29
+ * reads (#247), which ask a different question: the newest rows across every account. The purchases
30
+ * primary key is a **text UUID**, deliberately, so it is unique but not monotonic and is no use as a
31
+ * sort; without those three, every page of a purchases pane sorts a customer's entire order history to
32
+ * return twenty-five rows, and does it again for the next page. That is not a slow query so much as a
33
+ * defect shipped into other people's production databases, which is why the reads' indexes are part of
34
+ * the schema rather than a note about it.
35
+ *
36
+ * This is the whole payments schema, in one migration — see `CONTRIBUTING.md` §Migrations for why that
37
+ * is the shape while nothing is published, and what changes the day something is.
38
+ */
39
+ export const payments_0001_purchases: Migration = {
40
+ up: async (db: Kysely<unknown>): Promise<void> => {
41
+ await db.schema
42
+ .createTable("pithyPaymentsPurchases")
43
+ // Text UUID: these surface in API responses, and sequential ids would leak order volume.
44
+ .addColumn("id", "text", (c) => c.primaryKey())
45
+ // The owner, as a pair. Adjacent and both not-null, because half a subject identifies nobody.
46
+ .addColumn("subjectType", "text", (c) => c.notNull())
47
+ .addColumn("subjectId", "text", (c) => c.notNull())
48
+ .addColumn("rail", "text", (c) => c.notNull())
49
+ .addColumn("providerTransactionId", "text", (c) => c.notNull())
50
+ .addColumn("productId", "text", (c) => c.notNull())
51
+ .addColumn("providerProductId", "text", (c) => c.notNull())
52
+ .addColumn("type", "text", (c) => c.notNull())
53
+ .addColumn("status", "text", (c) => c.notNull())
54
+ // Defaulted in SQL as well as in the Zod object: three of the four rails never mention it, and a
55
+ // row written by one of them must not depend on the writer remembering to set it.
56
+ .addColumn("role", "text", (c) => c.notNull().defaultTo("charge"))
57
+ .addColumn("environment", "text", (c) => c.notNull())
58
+ .addColumn("purchasedAt", "integer", (c) => c.notNull())
59
+ .addColumn("expiresAt", "integer")
60
+ .addColumn("revokedAt", "integer")
61
+ // When a paused subscription resumes, as the provider stated it. Nullable, because "paused
62
+ // indefinitely" is a real state on every rail that pauses at all — see `data/pause.ts`.
63
+ .addColumn("resumesAt", "integer")
64
+ .addColumn("originalTransactionId", "text")
65
+ .addColumn("amountMinor", "integer")
66
+ .addColumn("currency", "text")
67
+ .addColumn("providerEventAt", "integer", (c) => c.notNull())
68
+ .addColumn("payload", "text", (c) => c.notNull())
69
+ .addColumn("createdAt", "integer", (c) => c.notNull())
70
+ .addColumn("updatedAt", "integer", (c) => c.notNull())
71
+ // The idempotency guard all three write paths rely on. A replay's insert violates it, which routes
72
+ // the write into its `ON CONFLICT` branch, so one provider transaction is one row forever.
73
+ .addUniqueConstraint("pithyPaymentsPurchasesProviderIdx", ["rail", "providerTransactionId"])
74
+ // A sandbox transaction must never grant a production entitlement, so the value is constrained at
75
+ // the database rather than trusted from a provider payload.
76
+ .addCheckConstraint("pithyPaymentsPurchasesEnvironment", sql`environment in ('production', 'sandbox')`)
77
+ // Amounts are integer minor units, never floats, and never negative.
78
+ .addCheckConstraint("pithyPaymentsPurchasesAmount", sql`amount_minor is null or amount_minor >= 0`)
79
+ // A `state` row never fulfills a `grants` clause, so a typo here would credit a ledger for a
80
+ // subscription's standing. Constrained at the database for the same reason `environment` is.
81
+ .addCheckConstraint("pithyPaymentsPurchasesRole", sql`role in ('charge', 'state')`)
82
+ // A resume date only means anything on a paused row. Constrained at the database because it is what
83
+ // makes the column's null readable: on a paused row null is the provider saying "indefinitely", and
84
+ // everywhere else it is "not paused" — two facts a consumer has to tell apart, and a rail that wrote
85
+ // a renewal date or a period end into it would silently collapse them.
86
+ .addCheckConstraint("pithyPaymentsPurchasesResumes", sql`resumes_at is null or status = 'paused'`)
87
+ // The subject kind is closed, and closed here rather than only in Zod: every ownership check in the
88
+ // capability compares both halves, and a third spelling of "organization" would be a holder no gate
89
+ // ever matches. snake_case in the raw fragment — `CamelCasePlugin` does not reach inside one.
90
+ .addCheckConstraint("pithyPaymentsPurchasesSubjectType", sql`subject_type in ('user', 'organization')`)
91
+ .execute();
92
+
93
+ // The owner read: one subject's purchases, newest first. Both halves lead, in the order they are
94
+ // always known in — the kind comes from config, the id from the caller — so the index answers the
95
+ // whole question rather than filtering a kind out afterwards.
96
+ await db.schema
97
+ .createIndex("pithyPaymentsPurchasesOwnerIdx")
98
+ .on("pithyPaymentsPurchases")
99
+ .columns(["subjectType", "subjectId", "purchasedAt"])
100
+ .execute();
101
+
102
+ // The reconciliation read: subscriptions near expiry, oldest verification first.
103
+ await db.schema
104
+ .createIndex("pithyPaymentsPurchasesExpiryIdx")
105
+ .on("pithyPaymentsPurchases")
106
+ .columns(["status", "expiresAt"])
107
+ .execute();
108
+
109
+ // The purchase log, newest first: `GET {base}/admin/purchases`. The keyset resumes on
110
+ // `(purchasedAt, id)`, and `purchasedAt` leading is what makes the page a range scan the LIMIT can
111
+ // genuinely stop.
112
+ await db.schema
113
+ .createIndex("pithyPaymentsPurchasesPurchasedIdx")
114
+ .on("pithyPaymentsPurchases")
115
+ .columns(["purchasedAt", "id"])
116
+ .execute();
117
+
118
+ // The subscriptions listing: `GET {base}/admin/subscriptions`. `type` is an equality and
119
+ // `purchasedAt` is the ordering column, so the filtered page is a range scan of its own rather than a
120
+ // scan of the whole log looking for the rows that renew. A project selling mostly consumables is
121
+ // exactly the one where the difference is large.
122
+ await db.schema
123
+ .createIndex("pithyPaymentsPurchasesTypePurchasedIdx")
124
+ .on("pithyPaymentsPurchases")
125
+ .columns(["type", "purchasedAt", "id"])
126
+ .execute();
127
+
128
+ await db.schema
129
+ .createTable("pithyPaymentsEntitlements")
130
+ .addColumn("id", "text", (c) => c.primaryKey())
131
+ // The holder, as a pair, exactly as the purchase carries it.
132
+ .addColumn("subjectType", "text", (c) => c.notNull())
133
+ .addColumn("subjectId", "text", (c) => c.notNull())
134
+ .addColumn("entitlement", "text", (c) => c.notNull())
135
+ .addColumn("active", "integer", (c) => c.notNull().defaultTo(0))
136
+ .addColumn("expiresAt", "integer")
137
+ .addColumn("sourcePurchaseId", "text")
138
+ // A human's decision, held against the projection. Every other row here is derived from the purchases
139
+ // table on every write that touches its key, which is what keeps the read model honest — and what would
140
+ // otherwise erase a support comp the moment the user's next renewal arrived.
141
+ .addColumn("manual", "integer", (c) => c.notNull().defaultTo(0))
142
+ .addColumn("createdAt", "integer", (c) => c.notNull())
143
+ .addColumn("updatedAt", "integer", (c) => c.notNull())
144
+ // One row per subject per entitlement — the upsert conflict target that makes this a read model.
145
+ // Both subject columns lead the key: a user and an organization may hold the same key at once, and
146
+ // they are two rows, while one subject holding it twice is the thing this refuses.
147
+ .addUniqueConstraint("pithyPaymentsEntitlementsOwnerIdx", ["subjectType", "subjectId", "entitlement"])
148
+ .addCheckConstraint("pithyPaymentsEntitlementsActive", sql`active in (0, 1)`)
149
+ .addCheckConstraint("pithyPaymentsEntitlementsManual", sql`manual in (0, 1)`)
150
+ .addCheckConstraint("pithyPaymentsEntitlementsSubjectType", sql`subject_type in ('user', 'organization')`)
151
+ .execute();
152
+
153
+ // The entitlement listing: `GET {base}/admin/entitlements`. `createdAt` rather than `updatedAt`,
154
+ // because the projection re-derives every affected row on every purchase write — ordering on
155
+ // `updatedAt` would shuffle rows under a reader for reasons that have nothing to do with the grant.
156
+ // The per-holder read needs no index of its own: `UNIQUE (subjectType, subjectId, entitlement)`
157
+ // already serves it, because the subject columns lead.
158
+ await db.schema
159
+ .createIndex("pithyPaymentsEntitlementsCreatedIdx")
160
+ .on("pithyPaymentsEntitlements")
161
+ .columns(["createdAt", "id"])
162
+ .execute();
163
+
164
+ await db.schema
165
+ .createTable("pithyPaymentsProviderAccounts")
166
+ .addColumn("id", "text", (c) => c.primaryKey())
167
+ .addColumn("rail", "text", (c) => c.notNull())
168
+ .addColumn("providerAccountId", "text", (c) => c.notNull())
169
+ // What the identity maps back to: the subject pair, not a user id.
170
+ .addColumn("subjectType", "text", (c) => c.notNull())
171
+ .addColumn("subjectId", "text", (c) => c.notNull())
172
+ .addColumn("createdAt", "integer", (c) => c.notNull())
173
+ // A webhook arrives carrying `cus_123`, and names no holder of ours. This is the only mapping back, so it
174
+ // must be one-to-one per rail.
175
+ //
176
+ // **Deliberately not widened by the subject.** Adding the pair to this key would make a rebind a
177
+ // legal insert, and the second row would collect the first subject's renewals. One provider
178
+ // identity, one holder, until somebody deletes the row on purpose.
179
+ .addUniqueConstraint("pithyPaymentsProviderAccountsIdx", ["rail", "providerAccountId"])
180
+ .addCheckConstraint("pithyPaymentsProviderAccountsSubjectType", sql`subject_type in ('user', 'organization')`)
181
+ .execute();
182
+
183
+ await db.schema
184
+ .createTable("pithyPaymentsWebhookEvents")
185
+ .addColumn("id", "text", (c) => c.primaryKey())
186
+ .addColumn("rail", "text", (c) => c.notNull())
187
+ .addColumn("providerEventId", "text", (c) => c.notNull())
188
+ .addColumn("payload", "text", (c) => c.notNull())
189
+ .addColumn("receivedAt", "integer", (c) => c.notNull())
190
+ // When this delivery was **finished with** — projected, or deliberately nothing to project. The one
191
+ // column the webhook guard short-circuits on, so it says "we have finished this" and never "we have
192
+ // seen this". Three writers once disagreed about that and two of them stopped purchases from ever
193
+ // being projected (#337).
194
+ .addColumn("processedAt", "integer")
195
+ // When a repair pass gave up on this event so its stream could advance.
196
+ //
197
+ // **A second timestamp rather than a status column, and beside `processedAt` rather than replacing
198
+ // it.** Abandoning is the sweep's decision about its own progress; finishing is a fact about the
199
+ // purchase. Collapsing them into one column is what #337 was. A `status` enum would have made the
200
+ // writers exhaustive and left every reader free to spell out its own predicate — which is where the
201
+ // defect actually lived — so the state is derived in `data/webhookEvent.ts` and asked for by name,
202
+ // and the storage stays timestamps: they answer *when*, which is what this table is read for, and
203
+ // they cannot contradict each other the way an enum can contradict the timestamp beside it.
204
+ .addColumn("abandonedAt", "integer")
205
+ .addColumn("error", "text")
206
+ // How many times a repair pass has tried this event and failed.
207
+ //
208
+ // **Here rather than in a table of its own, because the count is a fact about this row's handling.**
209
+ // `processedAt` and `error` are already the other two, and the three are only ever read together:
210
+ // "it arrived, it was tried N times, here is why it still has not gone through". A side table would
211
+ // add a second row per event, a join for the one question anybody asks, and a way for the two to
212
+ // disagree about an event that exists in one and not the other. KV would add a second store with no
213
+ // transactional relationship to the row it counts. The count also has to survive exactly as long as
214
+ // the row does and no longer, which is what a column gets for free and every alternative has to
215
+ // arrange.
216
+ //
217
+ // Defaulted, so the webhook path — which does not retry and does not count — inserts as it always
218
+ // did.
219
+ .addColumn("attempts", "integer", (c) => c.notNull().defaultTo(0))
220
+ .addColumn("createdAt", "integer", (c) => c.notNull())
221
+ // Every provider delivers at-least-once and retries, so a redelivery is expected and must be
222
+ // recognized rather than reprocessed.
223
+ .addUniqueConstraint("pithyPaymentsWebhookEventsIdx", ["rail", "providerEventId"])
224
+ .execute();
225
+
226
+ // The "why didn't this renew" read: everything not yet finished with, oldest first — pending, failed,
227
+ // and abandoned alike, because all three are deliveries whose purchase has not been projected. That
228
+ // an abandoned event appears here is deliberate: it is exactly the row an operator has to be able to
229
+ // find, and `abandonedAt` beside it says which of the three it is.
230
+ await db.schema
231
+ .createIndex("pithyPaymentsWebhookEventsPendingIdx")
232
+ .on("pithyPaymentsWebhookEvents")
233
+ .columns(["processedAt", "receivedAt"])
234
+ .execute();
235
+
236
+ // One row per reconciliation pass. Counts, timestamps and enums — there is deliberately no column a
237
+ // provider payload could be written into, which is a stronger control than never selecting one.
238
+ await db.schema
239
+ .createTable("pithyPaymentsReconcileRuns")
240
+ .addColumn("id", "text", (c) => c.primaryKey())
241
+ .addColumn("startedAt", "integer", (c) => c.notNull())
242
+ .addColumn("finishedAt", "integer", (c) => c.notNull())
243
+ .addColumn("environment", "text", (c) => c.notNull())
244
+ // Null is the scheduled behavior — every enabled rail. A value means somebody narrowed the pass.
245
+ .addColumn("rail", "text")
246
+ .addColumn("pages", "integer", (c) => c.notNull().defaultTo(0))
247
+ .addColumn("scanned", "integer", (c) => c.notNull().defaultTo(0))
248
+ .addColumn("unchanged", "integer", (c) => c.notNull().defaultTo(0))
249
+ .addColumn("drifted", "integer", (c) => c.notNull().defaultTo(0))
250
+ .addColumn("superseded", "integer", (c) => c.notNull().defaultTo(0))
251
+ .addColumn("skipped", "integer", (c) => c.notNull().defaultTo(0))
252
+ .addColumn("failed", "integer", (c) => c.notNull().defaultTo(0))
253
+ .addColumn("truncated", "integer", (c) => c.notNull().defaultTo(0))
254
+ .addColumn("dryRun", "integer", (c) => c.notNull().defaultTo(0))
255
+ .addColumn("createdAt", "integer", (c) => c.notNull())
256
+ // Same rule the purchases table states: a sandbox pass and a production pass are facts about
257
+ // different money, and the database is where that is constrained rather than trusted.
258
+ .addCheckConstraint("pithyPaymentsReconcileRunsEnvironment", sql`environment in ('production', 'sandbox')`)
259
+ .addCheckConstraint("pithyPaymentsReconcileRunsTruncated", sql`truncated in (0, 1)`)
260
+ .addCheckConstraint("pithyPaymentsReconcileRunsDryRun", sql`dry_run in (0, 1)`)
261
+ // A tally cannot be negative, and a run that reported one is a bug in the pass rather than a fact.
262
+ .addCheckConstraint(
263
+ "pithyPaymentsReconcileRunsCounts",
264
+ sql`pages >= 0 and scanned >= 0 and unchanged >= 0 and drifted >= 0 and superseded >= 0 and skipped >= 0 and failed >= 0`,
265
+ )
266
+ .execute();
267
+
268
+ // The runs listing: `GET {base}/admin/reconcile-runs`, newest first, and the retention prune's own
269
+ // range delete. Both read `startedAt` leading; the id is the keyset tiebreak, as everywhere else here.
270
+ await db.schema
271
+ .createIndex("pithyPaymentsReconcileRunsStartedIdx")
272
+ .on("pithyPaymentsReconcileRuns")
273
+ .columns(["startedAt", "id"])
274
+ .execute();
275
+
276
+ // Where a resumable sweep left off. One row per `(rail, name)`, because a rail may sweep more than
277
+ // one stream and a name is what tells them apart.
278
+ //
279
+ // This is **not** a copy of a provider's data and is not a cache of one: it is a single opaque
280
+ // pointer into somebody else's stream, which is the minimum state a resumable read can have. The
281
+ // alternative to storing it is re-reading ninety days of events on every run.
282
+ await db.schema
283
+ .createTable("pithyPaymentsSyncCursors")
284
+ .addColumn("id", "text", (c) => c.primaryKey())
285
+ .addColumn("rail", "text", (c) => c.notNull())
286
+ .addColumn("name", "text", (c) => c.notNull())
287
+ // The provider's own resume token — Paddle's `evt_…`. Nullable, because a cursor that has never
288
+ // advanced is a real state: it means "start at the oldest event this provider still retains".
289
+ .addColumn("cursor", "text")
290
+ .addColumn("updatedAt", "integer", (c) => c.notNull())
291
+ .addColumn("createdAt", "integer", (c) => c.notNull())
292
+ // One cursor per stream. Two rows for one stream would let two runs each believe they were
293
+ // authoritative, and the sweep would silently skip whatever fell between them.
294
+ .addUniqueConstraint("pithyPaymentsSyncCursorsIdx", ["rail", "name"])
295
+ .execute();
296
+ },
297
+ down: async (db: Kysely<unknown>): Promise<void> => {
298
+ await db.schema.dropTable("pithyPaymentsSyncCursors").execute();
299
+ await db.schema.dropIndex("pithyPaymentsReconcileRunsStartedIdx").execute();
300
+ await db.schema.dropTable("pithyPaymentsReconcileRuns").execute();
301
+ await db.schema.dropIndex("pithyPaymentsWebhookEventsPendingIdx").execute();
302
+ await db.schema.dropTable("pithyPaymentsWebhookEvents").execute();
303
+ await db.schema.dropTable("pithyPaymentsProviderAccounts").execute();
304
+ await db.schema.dropIndex("pithyPaymentsEntitlementsCreatedIdx").execute();
305
+ await db.schema.dropTable("pithyPaymentsEntitlements").execute();
306
+ await db.schema.dropIndex("pithyPaymentsPurchasesTypePurchasedIdx").execute();
307
+ await db.schema.dropIndex("pithyPaymentsPurchasesPurchasedIdx").execute();
308
+ await db.schema.dropIndex("pithyPaymentsPurchasesExpiryIdx").execute();
309
+ await db.schema.dropIndex("pithyPaymentsPurchasesOwnerIdx").execute();
310
+ await db.schema.dropTable("pithyPaymentsPurchases").execute();
311
+ },
312
+ };
@@ -0,0 +1,175 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * Where a price quote's location comes from, decided in one place.
6
+ *
7
+ * **A customer is charged from their billing address.** Paddle settles tax on the transaction's address,
8
+ * not on where the browser happened to be — so the browser's IP is a *provisional estimate* and the
9
+ * address on file is the authority. Both are legitimate answers; which one a screen got is a fact the
10
+ * screen has to be able to state, because one of them is what the card will be charged and the other is
11
+ * a guess that resolves at checkout.
12
+ *
13
+ * That is the whole of this module. It takes what a caller knows about a visitor, picks the best source
14
+ * available, and says which one it picked. Nothing here fetches, renders, or formats.
15
+ *
16
+ * **One resolver, not a decision at each call site.** A pricing screen, a paywall and a cart all have to
17
+ * answer "where does this person live", and three answers is how a page quotes from an address the
18
+ * checkout does not charge from. Every site that derives a location for a Paddle price quote calls
19
+ * {@link resolvePriceLocation}, and the honesty label comes off the same object.
20
+ *
21
+ * **No imports, deliberately.** This compiles inside the Worker's program *and* inside an adopter's
22
+ * DOM-typed browser program — `../client/api.ts` explains at length why those are two programs — so it
23
+ * names its own shapes rather than reaching for Zod, for core's codecs, or for Paddle's types. The
24
+ * scaffolded pricing screen hands {@link priceQueryFor}'s answer straight to `usePricePreview`, so
25
+ * TypeScript pins the shape against the real `PaddlePriceQuery` at the one place it matters.
26
+ *
27
+ * **No price, no currency, no country table.** Figures come from Paddle, rendered by Paddle. Nothing in
28
+ * this file knows what anything costs, and nothing in it may learn: a table of amounts here would be
29
+ * wrong in every country it did not list and wrong silently in the ones it did.
30
+ */
31
+
32
+ /**
33
+ * Where a quote's location came from, in order of authority.
34
+ *
35
+ * - `customer` — a Paddle customer id. Paddle prices from the address it holds for them, which is the
36
+ * address the checkout charges. The best answer there is, and the only one that is not a guess.
37
+ * - `address` — a billing address the caller holds but Paddle does not yet. Better than the network,
38
+ * still not proof of what the checkout will settle on, because the buyer may enter another.
39
+ * - `ip` — nobody said. Paddle resolves the country from the browser's own IP. Right for a marketing
40
+ * page a stranger is reading, and an estimate every time.
41
+ */
42
+ export type PriceLocationSource = "customer" | "address" | "ip";
43
+
44
+ /** A billing address, as far as a price quote cares about one. */
45
+ export interface PriceAddress {
46
+ /** ISO 3166-1 alpha-2 — `"US"`, `"GB"`, `"JP"`. Paddle takes it verbatim. */
47
+ countryCode: string;
48
+ /**
49
+ * The postal code, where the caller has one.
50
+ *
51
+ * Load-bearing rather than decorative. United States tax resolves below the country — 15% in Chicago,
52
+ * 8.875% in New York, 0% in Oregon — and Paddle answers a country-only request with 0% rather than
53
+ * with an error, so a quote without one can be short of what the card is charged.
54
+ */
55
+ postalCode?: string;
56
+ }
57
+
58
+ /**
59
+ * What the caller knows about this visitor. Every field optional, because "nothing" is a real answer.
60
+ *
61
+ * A stranger on a marketing page is `null`. A signed-in visitor whose account has never bought anything
62
+ * carries an address and no customer. A returning customer carries the `ctm_…` the webhook path recorded
63
+ * against them, which is the same value `POST /payments/checkout` hands Paddle as `customer_id`.
64
+ */
65
+ export interface PriceVisitor {
66
+ /** The Paddle customer this visitor is, or null. An identifier, not a credential. */
67
+ customerId?: string | null;
68
+ /** A billing address the caller holds, or null. */
69
+ address?: PriceAddress | null;
70
+ }
71
+
72
+ /**
73
+ * The decision: which source was used, and what to send.
74
+ *
75
+ * Both halves matter to a screen. The `customerId`/`address` pair is what goes to Paddle; `source` is
76
+ * what the sentence under the figure has to be true about.
77
+ */
78
+ export interface PriceLocation {
79
+ /** Which source won. */
80
+ source: PriceLocationSource;
81
+ /** The Paddle customer to price as, or null. */
82
+ customerId: string | null;
83
+ /** The address to price at, or null. */
84
+ address: PriceAddress | null;
85
+ /**
86
+ * Whether this location is a guess about where the buyer lives rather than the address they are
87
+ * charged from.
88
+ *
89
+ * True exactly when `source` is `ip`. Kept as a field rather than left to every reader to derive,
90
+ * because "is this provisional" is the question, and re-deriving it from the union is how a fourth
91
+ * source arrives one day and half the readers keep answering for three.
92
+ */
93
+ provisional: boolean;
94
+ }
95
+
96
+ /**
97
+ * Pick the best location available, and say which one it is.
98
+ *
99
+ * Precedence is authority, not convenience: a Paddle customer beats an address the caller holds, because
100
+ * Paddle prices the customer from the address it will actually charge — and if the two disagree, a
101
+ * traveler or a VPN, the billing address wins for what they are charged, so it wins for what they are
102
+ * shown.
103
+ *
104
+ * **Never a silent default.** `null`, `undefined`, an empty customer id and an address with no country
105
+ * all resolve to `ip` *and say so*, so a caller that meant to pass something and passed nothing gets an
106
+ * estimate labeled as one rather than a guess wearing the authority of an address.
107
+ */
108
+ export function resolvePriceLocation(visitor: PriceVisitor | null | undefined): PriceLocation {
109
+ const customerId = visitor?.customerId;
110
+ if (typeof customerId === "string" && customerId.length > 0) {
111
+ return { source: "customer", customerId, address: null, provisional: false };
112
+ }
113
+ const address = visitor?.address;
114
+ if (address && typeof address.countryCode === "string" && address.countryCode.length > 0) {
115
+ // Rebuilt rather than passed through, so an empty postal code — the shape an unfilled form field
116
+ // arrives in — does not reach Paddle as though somebody had typed one.
117
+ const postalCode =
118
+ typeof address.postalCode === "string" && address.postalCode.length > 0 ? address.postalCode : undefined;
119
+ return {
120
+ source: "address",
121
+ customerId: null,
122
+ address:
123
+ postalCode === undefined
124
+ ? { countryCode: address.countryCode }
125
+ : { countryCode: address.countryCode, postalCode },
126
+ provisional: false,
127
+ };
128
+ }
129
+ return { source: "ip", customerId: null, address: null, provisional: true };
130
+ }
131
+
132
+ /** One line of a price request: what to quote, and how many. */
133
+ export interface PriceQuoteItem {
134
+ /** The Paddle price — `pri_…`. */
135
+ priceId: string;
136
+ /** How many units. */
137
+ quantity: number;
138
+ }
139
+
140
+ /**
141
+ * What to ask Paddle, for these items at this location.
142
+ *
143
+ * Structurally a `PaddlePriceQuery`, and deliberately not typed as one — see this module's header. The
144
+ * absent fields are absent rather than `undefined`-valued: `priceQueryKey` serializes the query into an
145
+ * effect dependency, and a key that changed shape between "no address" and "an address of undefined"
146
+ * would re-quote a page that asked the same question twice.
147
+ */
148
+ export function priceQueryFor(
149
+ items: readonly PriceQuoteItem[],
150
+ location: PriceLocation,
151
+ ): { items: readonly PriceQuoteItem[]; customerId?: string; address?: PriceAddress } {
152
+ if (location.customerId !== null) return { items, customerId: location.customerId };
153
+ if (location.address !== null) return { items, address: location.address };
154
+ return { items };
155
+ }
156
+
157
+ /**
158
+ * Whether the figure has to be labeled an estimate.
159
+ *
160
+ * Two independent reasons, and either one is enough:
161
+ *
162
+ * **The location is provisional.** An IP says where a browser connected from, not where a card is
163
+ * registered. The buyer enters a billing address at the card form and the total settles then — which is
164
+ * how commerce works everywhere, and is expected rather than a broken promise, *provided the figure said
165
+ * it was an estimate first*.
166
+ *
167
+ * **The tax is not fully resolved.** `priceSummary().estimated` is true when Paddle resolved no postal
168
+ * code, and United States tax lives below the country. Pass it in; this is where the two facts meet.
169
+ *
170
+ * Deriving the label from the postal code alone made it right by accident — Paddle resolves no postal
171
+ * code from an IP today — and an accident is not a rule. This states the rule.
172
+ */
173
+ export function quoteIsEstimated(location: PriceLocation, taxUnresolved: boolean): boolean {
174
+ return location.provisional || taxUnresolved;
175
+ }
@@ -0,0 +1,82 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { callPayments, type PaymentsClientOptions } from "../client/api";
5
+ import type { PriceVisitor } from "./location";
6
+
7
+ /**
8
+ * Where a signed-in visitor's Paddle customer comes from: `GET {basePath}/pricing`, asked of this
9
+ * project's own Worker.
10
+ *
11
+ * **The same value the checkout charges, from the same row.** That route reads the provider-account map
12
+ * keyed on the subject the seam resolved — `providerAccountForSubject(db, "paddle", subject)` — and so does
13
+ * `POST /payments/checkout`, which hands it to Paddle as `customer_id`. One reader, one row, so the
14
+ * figure quoted and the figure charged cannot resolve location differently. A screen that derived a
15
+ * customer id any other way would move this problem rather than solve it.
16
+ *
17
+ * **`ctm_…` is an identifier, not a credential.** It names a Paddle customer; it authorizes nothing.
18
+ * Paddle's `PricePreview` reads a price with it and the publishable client token, which is the pair
19
+ * Paddle publishes for exactly this. Nothing here widens what reaches a browser: the route is
20
+ * `requireAuth()` and answers only about its own caller, so a visitor learns their own customer id and
21
+ * no one else's.
22
+ *
23
+ * **Hand-written guard, no schema library, no absolute URL.** For the reason `../client/api.ts` gives at
24
+ * length: this compiles into an adopter's browser program, and dragging the Worker's Zod graph in behind
25
+ * it would break their build. The server's half of this response *is* a Zod object — `http/responses.ts`
26
+ * — so the boundary is validated on both sides, each in the vocabulary its own program can carry.
27
+ *
28
+ * **The request itself is not written here.** `callPayments` is the one producer of a same-origin,
29
+ * cookie-bearing call to this Worker, and this module routes through it like every other reader. It used
30
+ * to own a second fetch — its own base-path default, its own `credentials: "include"`, its own three
31
+ * failure directions — which is the drift this kit keeps paying for (#346).
32
+ *
33
+ * **A failure is not an answer, and this one fails honest.** Unreachable, refused, or unreadable all
34
+ * return `null`, which resolves to the IP location and renders the figure labeled `Estimated.` A guess
35
+ * that says it is a guess is the safe direction; the unsafe one would be treating a failed read as
36
+ * proof there is no address on file.
37
+ */
38
+
39
+ /**
40
+ * Where to ask, and what to ask with — the same options every other call on this side of the wire takes.
41
+ *
42
+ * An alias rather than a second declaration. The two shapes agreed by hand until this read started going
43
+ * through `callPayments`; now there is one of them, and a name a scaffolded screen already imports.
44
+ */
45
+ export type PriceVisitorOptions = PaymentsClientOptions;
46
+
47
+ /** Whether a value is a plain record — the first step of the guard. */
48
+ function isRecord(value: unknown): value is Record<string, unknown> {
49
+ return typeof value === "object" && value !== null && !Array.isArray(value);
50
+ }
51
+
52
+ /**
53
+ * The Paddle customer on a `GET /pricing` body, or null.
54
+ *
55
+ * Total, and refusing rather than coercing. `quotedFrom` is absent on an older Worker than this bundle,
56
+ * null for a caller who has never bought anything, and a record naming a rail otherwise — three shapes
57
+ * that all mean "no Paddle customer to price as" unless the third names `paddle` and carries a string.
58
+ */
59
+ export function readPaddleCustomer(body: unknown): string | null {
60
+ if (!isRecord(body)) return null;
61
+ const quotedFrom = body.quotedFrom;
62
+ if (!isRecord(quotedFrom)) return null;
63
+ if (quotedFrom.rail !== "paddle") return null;
64
+ const providerAccountId = quotedFrom.providerAccountId;
65
+ return typeof providerAccountId === "string" && providerAccountId.length > 0 ? providerAccountId : null;
66
+ }
67
+
68
+ /**
69
+ * Ask this project's Worker who Paddle prices the signed-in caller as.
70
+ *
71
+ * Never throws, and never reports a failure: the caller's only sensible response to one is to quote from
72
+ * the IP and say so, which is what `null` already means.
73
+ */
74
+ export async function fetchPriceVisitor(options?: PriceVisitorOptions): Promise<PriceVisitor | null> {
75
+ // `isRecord` is the whole guard, and deliberately so: `readPaddleCustomer` is total over anything, so
76
+ // narrowing harder here would only move the same refusal a line earlier. What matters is that a
77
+ // refusal, an unreachable Worker and an unreadable body all land in the same `!ok` branch.
78
+ const result = await callPayments("/pricing", {}, options, isRecord);
79
+ if (!result.ok) return null;
80
+ const customerId = readPaddleCustomer(result.value);
81
+ return customerId === null ? null : { customerId };
82
+ }