@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,238 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { JsonDate } from "@pithy-sh/core/src/data/codecs";
5
+ import { z } from "zod";
6
+
7
+ /**
8
+ * A discount, normalized — and every field here is named for **what it means to a customer's bill**, not for
9
+ * what either provider calls it.
10
+ *
11
+ * That is the whole design constraint, and it is not a style preference. Stripe and Lemon Squeezy agree on
12
+ * the concept of a discount and disagree on nearly every field, in ways that are invisible until somebody is
13
+ * charged the wrong amount:
14
+ *
15
+ * - **Duration.** Stripe's `duration_in_months` counts *months*. Lemon Squeezy's equivalent counts *billing
16
+ * periods*. On a monthly plan they coincide. On an annual one, `12` means a year on one rail and twelve
17
+ * years on the other — a decade of a discount nobody offered.
18
+ * - **Expiry.** On one rail the date stops *redemption*: after it the code cannot be claimed, and anyone
19
+ * already holding it keeps their discount. On the other the same-looking field can stop *the discount
20
+ * itself*, changing an existing customer's next invoice.
21
+ * - **Amount.** A fixed amount carries a currency and a percentage does not, and a fixed amount against a
22
+ * subscription in another currency is a provider error at *redemption* — so the failure arrives at the
23
+ * customer rather than at the adopter who created it.
24
+ *
25
+ * So this module models the customer-visible fact and each rail translates into its own vocabulary, with
26
+ * fixtures pinning the *meaning* rather than the field name. Where a translation needs something neither
27
+ * provider's create call carries — the plan's billing interval — it is required here rather than guessed,
28
+ * because guessing is the defect.
29
+ *
30
+ * **Pithy never computes a discounted amount.** The provider is the authority on what is owed; a second
31
+ * calculation here would be a second answer to the one question a customer checks against their statement.
32
+ * Everything in this module describes a discount's *terms*, never its arithmetic.
33
+ */
34
+
35
+ /** The longest a discount code may be. Both providers are more generous; this is a legibility bound. */
36
+ const MAX_CODE_LENGTH = 64;
37
+
38
+ /** A discount code, as a customer types it. Case is preserved — both providers match case-insensitively. */
39
+ export const DiscountCode = z
40
+ .string()
41
+ .min(1)
42
+ .max(MAX_CODE_LENGTH)
43
+ .regex(/^[A-Za-z0-9_-]+$/, "A discount code is letters, digits, underscores and dashes.")
44
+ .describe(
45
+ "The code a customer enters at checkout. Constrained to what both stores accept, so a code this project mints is redeemable on either rail.",
46
+ );
47
+ export type DiscountCode = z.infer<typeof DiscountCode>;
48
+
49
+ /**
50
+ * How much comes off — a percentage, or a fixed amount in a named currency.
51
+ *
52
+ * Discriminated rather than two optional fields, because the currency is meaningful for exactly one of them
53
+ * and meaningless for the other. A percentage with a currency would be a value nothing could act on.
54
+ */
55
+ export const DiscountAmount = z
56
+ .discriminatedUnion("kind", [
57
+ z
58
+ .strictObject({
59
+ kind: z.literal("percent").describe("A percentage off, which carries no currency."),
60
+ percent: z
61
+ .number()
62
+ .positive()
63
+ .max(100)
64
+ .describe("How many percent off, 0 exclusive to 100 inclusive. 100 is a comped period, which is legal."),
65
+ })
66
+ .describe("A percentage off — the portable kind, which works whatever a subscription is priced in."),
67
+ z
68
+ .strictObject({
69
+ kind: z.literal("fixed").describe("A fixed amount off, which is only meaningful in a stated currency."),
70
+ amountMinor: z
71
+ .number()
72
+ .int()
73
+ .positive()
74
+ .describe("How much off, as an integer in the currency's minor unit. Never a float."),
75
+ currency: z
76
+ .string()
77
+ .regex(/^[a-z]{3}$/, "An ISO 4217 currency code is three lowercase letters.")
78
+ .describe(
79
+ "The currency the amount is in. A fixed amount against a subscription priced in another currency is refused before creation, because at the provider it would fail at redemption — in front of the customer.",
80
+ ),
81
+ })
82
+ .describe("A fixed amount off, in one currency and no other."),
83
+ ])
84
+ .describe(
85
+ "How much a discount takes off a bill. Strict on both members: a currency on a percentage is a value nothing can act on, and silently dropping it would leave an adopter believing they had set something.",
86
+ );
87
+ export type DiscountAmount = z.infer<typeof DiscountAmount>;
88
+
89
+ /**
90
+ * How long the discount lasts, in **billing periods** — the unit the customer experiences.
91
+ *
92
+ * Periods rather than months, because a period is what a customer sees on their statement: "the first twelve
93
+ * renewals are cheaper" means the same thing on a monthly plan and an annual one. Months are Stripe's
94
+ * internal unit and mean something different on an annual plan; translating into them is the Stripe rail's
95
+ * job, and {@link DiscountTerms.billingInterval} is what makes that translation possible rather than a guess.
96
+ */
97
+ export const DiscountDuration = z
98
+ .discriminatedUnion("kind", [
99
+ z
100
+ .object({ kind: z.literal("once").describe("One billing period, then list price.") })
101
+ .describe("A single period's discount — a launch offer on the first invoice."),
102
+ z
103
+ .object({ kind: z.literal("forever").describe("Every period, for as long as the subscription runs.") })
104
+ .describe("A permanent rate. Nothing lapses, so nothing surprises anyone later."),
105
+ z
106
+ .object({
107
+ kind: z.literal("repeating").describe("A fixed number of billing periods, then list price."),
108
+ billingPeriods: z
109
+ .number()
110
+ .int()
111
+ .positive()
112
+ .describe(
113
+ "How many billing periods the discount applies to, counted the way a customer counts renewals. Twelve on a monthly plan is a year; twelve on an annual plan is twelve years.",
114
+ ),
115
+ })
116
+ .describe("A rate that lapses — the one whose end date a customer must be told about."),
117
+ ])
118
+ .describe("How long a discount lasts, in billing periods.");
119
+ export type DiscountDuration = z.infer<typeof DiscountDuration>;
120
+
121
+ /** How often the plan this discount is for renews. Required only to translate a repeating duration. */
122
+ export const BillingInterval = z
123
+ .enum(["month", "year"])
124
+ .describe(
125
+ "How often the subscription this discount is meant for renews. Required for a repeating discount, because Stripe counts its duration in months and Lemon Squeezy counts it in periods — so without it, `12` on an annual plan is a year on one rail and twelve years on the other.",
126
+ );
127
+ export type BillingInterval = z.infer<typeof BillingInterval>;
128
+
129
+ /**
130
+ * Everything that decides what a discount does. The shape both rails are created from.
131
+ *
132
+ * Deliberately not a mirror of either provider's create body: the fields it does not have are as considered
133
+ * as the ones it does. There is no "applies to these products" — that is a commercial policy an adopter owns
134
+ * — and no "first-time customers only", which the two stores model incompatibly enough that supporting it
135
+ * would mean promising behavior on one rail we could not deliver on the other.
136
+ */
137
+ export const DiscountTerms = z
138
+ .object({
139
+ code: DiscountCode.optional().describe(
140
+ "The code to mint, or omit it and let the store generate one. Supplying it is what lets an adopter mint one code per applicant against their own record of who was offered it.",
141
+ ),
142
+ amount: DiscountAmount.describe("How much comes off."),
143
+ duration: DiscountDuration.describe("How long it lasts, in billing periods."),
144
+ billingInterval: BillingInterval.optional().describe(
145
+ "How often the plan renews. Required when `duration` is `repeating`, and refused otherwise — a duration that does not lapse has nothing to count.",
146
+ ),
147
+ maxRedemptions: z
148
+ .number()
149
+ .int()
150
+ .positive()
151
+ .optional()
152
+ .describe(
153
+ "How many times the code may be claimed in total, or omit for unlimited. It guards the customer path and only the customer path: the store counts a redemption when a checkout or a transaction uses the code and refuses the next one past the limit, and it holds when a webhook never arrives because the count is the store's rather than ours. It does not guard an administrative application against an existing subscription. Measured against the Paddle sandbox on 2026-08-14: a code with `usage_limit: 1` was applied to two subscriptions with `times_used` still `0`, went to `1` only when a transaction billed under it completed, and refused the next transaction after that. Stripe and Lemon Squeezy are unmeasured, so assume the same of them. So a staff comp applied by updating a subscription is not stopped here, and an adopter's own record of who was offered a code is the only thing that would stop it.",
154
+ ),
155
+ redeemableUntil: JsonDate.optional().describe(
156
+ "After this moment the code can no longer be *claimed*. It does not end a discount already applied: a customer who redeemed it yesterday keeps their rate for its full duration. Named for that meaning because the providers' own fields do not agree on it, and a fixture pins each rail against a code already redeemed.",
157
+ ),
158
+ })
159
+ .describe("The terms of a discount, in customer-visible units, from which either rail can create one.")
160
+ .check((ctx) => {
161
+ const terms = ctx.value;
162
+
163
+ // The one cross-field rule, and the reason it exists is the annual-plan mistranslation above.
164
+ if (terms.duration.kind === "repeating" && terms.billingInterval === undefined) {
165
+ ctx.issues.push({
166
+ code: "custom",
167
+ input: ctx.value,
168
+ path: ["billingInterval"],
169
+ message:
170
+ "A repeating discount must state the plan's `billingInterval`. Stripe counts a duration in months and Lemon Squeezy counts it in billing periods, so without it the same number means a year on one rail and twelve years on the other.",
171
+ });
172
+ }
173
+
174
+ if (terms.duration.kind !== "repeating" && terms.billingInterval !== undefined) {
175
+ ctx.issues.push({
176
+ code: "custom",
177
+ input: ctx.value,
178
+ path: ["billingInterval"],
179
+ message:
180
+ "`billingInterval` is only meaningful for a `repeating` duration. A discount that runs once or forever has no periods to count.",
181
+ });
182
+ }
183
+ });
184
+ export type DiscountTerms = z.output<typeof DiscountTerms>;
185
+ export type DiscountTermsInput = z.input<typeof DiscountTerms>;
186
+
187
+ /** A discount as a store created it: the terms we asked for, plus what the store decided. */
188
+ export const CreatedDiscount = z
189
+ .object({
190
+ code: DiscountCode.describe("The code a customer enters — the one supplied, or the one the store generated."),
191
+ providerDiscountId: z
192
+ .string()
193
+ .min(1)
194
+ .describe("The store's own id for the object, so an adopter can find it in their dashboard."),
195
+ terms: DiscountTerms.describe("The terms it was created with, echoed back as this package models them."),
196
+ })
197
+ .describe("A discount that now exists at a store.");
198
+ export type CreatedDiscount = z.output<typeof CreatedDiscount>;
199
+
200
+ /**
201
+ * What a subscription is paying now, what it becomes, and when — the half that stops a bill changing
202
+ * unannounced.
203
+ *
204
+ * A capability that can apply a discount but not report its end date has shipped the half that creates the
205
+ * surprise: a discount that silently lapses is a customer's bill changing on a Tuesday with nothing having
206
+ * told them, and from where they sit that is indistinguishable from a billing error.
207
+ *
208
+ * Every amount here comes from the provider. Nothing in this package multiplies a price by a percentage.
209
+ */
210
+ export const SubscriptionPricing = z
211
+ .object({
212
+ currency: z
213
+ .string()
214
+ .nullable()
215
+ .describe("The currency both amounts are in, or null when the store did not report one."),
216
+ currentAmountMinor: z
217
+ .number()
218
+ .int()
219
+ .nonnegative()
220
+ .nullable()
221
+ .describe("What the next invoice comes to under any discount in force, as the store computed it."),
222
+ listAmountMinor: z
223
+ .number()
224
+ .int()
225
+ .nonnegative()
226
+ .nullable()
227
+ .describe(
228
+ "What it comes to once the discount ends — the list price. Equal to `currentAmountMinor` when no discount is in force.",
229
+ ),
230
+ discountCode: DiscountCode.nullable().describe(
231
+ "The code in force, or null when the subscription is at list price.",
232
+ ),
233
+ discountEndsAt: JsonDate.nullable().describe(
234
+ "When the current discount stops applying, or null — which means either no discount or one that runs forever. A screen must say which, so read it beside `discountCode`.",
235
+ ),
236
+ })
237
+ .describe("What a subscription pays now, what it will pay, and when that changes.");
238
+ export type SubscriptionPricing = z.output<typeof SubscriptionPricing>;
@@ -0,0 +1,67 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { SQLiteBoolean, SQLiteDate } from "@pithy-sh/core/src/data/codecs";
5
+ import { EntitlementKey } from "@pithy-sh/core/src/entitlement/entitlement";
6
+ import { z } from "zod";
7
+ import { PaymentsSubject } from "./subject";
8
+
9
+ /**
10
+ * One entitlement a subject holds — the row in `pithy_payments_entitlements`, and the materialized read
11
+ * model `requireEntitlement()` hits. `UNIQUE (subjectType, subjectId, entitlement)`: one row per subject
12
+ * per key, whichever purchase currently grants it.
13
+ *
14
+ * **The holder is the pair, never the id alone.** Nothing in the kit keeps an organization id from
15
+ * equalling some user's id, so a key of `(subjectId, entitlement)` would let one holder read the other's
16
+ * grant. Both subject columns lead the unique, ahead of the key, so the per-subject read — every
17
+ * entitlement one holder has — is a covering prefix of the same index rather than an index of its own.
18
+ * The two columns are written together and compared together; see `data/subject.ts` for why.
19
+ *
20
+ * They are `PaymentsSubject`'s own fields rather than a second pair spelled the same way, so a row cannot
21
+ * carry an id the pair itself would refuse.
22
+ *
23
+ * The row **is** stored (issue #79 decision 5) — resolving entitlements per request from a KV cache or a
24
+ * token claim would make a revocation eventually-consistent, and a revocation must be immediate. What it
25
+ * is never stored *independently of* is the purchase state that produced it: only the projection writes
26
+ * this table, and it re-derives every affected row inside the same transaction as the purchase write.
27
+ *
28
+ * `active` and `expiresAt` are both here, and both matter. The flag is an optimization the projection
29
+ * wrote; the timestamp is the truth. A subscription can lapse with no notification arriving at all, so the
30
+ * read path evaluates `expiresAt` itself rather than trusting the flag — and a read never writes, because
31
+ * repairing a stale row is the reconciliation Workflow's job and the hot path stays a pure read.
32
+ */
33
+ export const PaymentsEntitlement = z
34
+ .object({
35
+ id: z
36
+ .string()
37
+ .describe("The row's UUID. Text, like every id payments mints, so nothing about volume is inferable."),
38
+ subjectType: PaymentsSubject.shape.subjectType.describe(
39
+ "Whether `subjectId` names a user or an organization. Half the key — the id alone is ambiguous, so the two columns travel together.",
40
+ ),
41
+ subjectId: PaymentsSubject.shape.subjectId.describe(
42
+ "The subject holding the entitlement — a Pithy user id, or the adopter's organization id.",
43
+ ),
44
+ entitlement: EntitlementKey.describe(
45
+ "The entitlement key — what gating code names. `UNIQUE (subjectType, subjectId, entitlement)` is what makes this a read model rather than a log.",
46
+ ),
47
+ active: SQLiteBoolean.describe(
48
+ "Whether the granting purchase was in an access-granting status when the projection last wrote this row. An optimization, not the truth — `expiresAt` is rechecked on every read.",
49
+ ),
50
+ expiresAt: SQLiteDate.nullable().describe(
51
+ "When the grant lapses, or null for one that never does. The truth: a read applies it even when `active` still says 1.",
52
+ ),
53
+ sourcePurchaseId: z
54
+ .string()
55
+ .nullable()
56
+ .describe(
57
+ "Provenance — the purchase currently granting this entitlement, or null when nothing does. Answers 'why is this subject entitled' without a scan.",
58
+ ),
59
+ manual: SQLiteBoolean.describe(
60
+ "Whether a human wrote this row rather than the projection deriving it. A manual grant is held: the projection skips a row carrying it, so a support comp survives the subject's next renewal instead of being erased by it. A revoke clears the hold, handing the key back to the purchases that support it.",
61
+ ),
62
+ createdAt: SQLiteDate.describe("When this entitlement row first appeared for this subject."),
63
+ updatedAt: SQLiteDate.describe("When the projection last re-derived it."),
64
+ })
65
+ .describe("One entitlement a subject holds — the row in `pithy_payments_entitlements`, the read model gates hit.");
66
+ export type PaymentsEntitlement = z.output<typeof PaymentsEntitlement>;
67
+ export type PaymentsEntitlementRow = z.input<typeof PaymentsEntitlement>;
@@ -0,0 +1,75 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * Money, as the three rails report it and as the purchases table stores it.
6
+ *
7
+ * `amountMinor` is an integer in the currency's minor unit — cents for USD, yen for JPY, fils for KWD.
8
+ * No rail reports that directly. Apple reports thousandths of the currency unit ("milliunits"), Google
9
+ * reports millionths ("micros"), and Stripe reports minor units already. Converting means dividing by the
10
+ * rail's scale and then multiplying by the currency's own exponent, and the exponent is the part that
11
+ * catches people out: dividing Apple's 500000 for ¥500 by ten yields 50000, a hundredfold overstatement,
12
+ * because the yen has no subunit to divide into.
13
+ *
14
+ * So this holds the short list of currencies whose exponent is not two. It is a fixed list — ISO 4217 does
15
+ * not churn — and an unknown code falls back to two rather than throwing, because a webhook must not fail
16
+ * over a reporting detail.
17
+ *
18
+ * **{@link minorUnitDigits} is read from the browser half too**, by `../client/wholeUnits`, which is why
19
+ * this module imports nothing and must keep importing nothing: it is on the browser allowlist in
20
+ * `../client/sameOrigin.test.ts`, so anything reached from here lands in an adopter's bundle. The
21
+ * exponent is the same fact on both sides — how many digits the fraction occupies — and a second table
22
+ * for the client would be the drift `#427` exists to remove.
23
+ */
24
+
25
+ /** Currencies with no subunit — the amount is already in minor units whatever the rail's scale. */
26
+ const ZERO_DECIMAL_CURRENCIES: ReadonlySet<string> = new Set([
27
+ "BIF",
28
+ "CLP",
29
+ "DJF",
30
+ "GNF",
31
+ "ISK",
32
+ "JPY",
33
+ "KMF",
34
+ "KRW",
35
+ "PYG",
36
+ "RWF",
37
+ "UGX",
38
+ "UYI",
39
+ "VND",
40
+ "VUV",
41
+ "XAF",
42
+ "XOF",
43
+ "XPF",
44
+ ]);
45
+
46
+ /** Currencies whose minor unit is a thousandth, not a hundredth. */
47
+ const THREE_DECIMAL_CURRENCIES: ReadonlySet<string> = new Set(["BHD", "IQD", "JOD", "KWD", "LYD", "OMR", "TND"]);
48
+
49
+ /** How many digits the currency's minor unit occupies. Two unless ISO 4217 says otherwise. */
50
+ export function minorUnitDigits(currency: string): number {
51
+ const code = currency.toUpperCase();
52
+ if (ZERO_DECIMAL_CURRENCIES.has(code)) return 0;
53
+ if (THREE_DECIMAL_CURRENCIES.has(code)) return 3;
54
+ return 2;
55
+ }
56
+
57
+ /**
58
+ * Convert a rail-reported amount into minor units, or null when it cannot be trusted.
59
+ *
60
+ * `scale` is what one currency unit is worth in the rail's own integer — 1000 for Apple's milliunits,
61
+ * 1_000_000 for Google's micros, 10 ** `minorUnitDigits` for a rail already reporting minor units.
62
+ *
63
+ * Null is returned rather than an approximation for a missing price, a missing currency, a non-finite
64
+ * number, or a negative amount. The last matters: `pithy_payments_purchases` refuses a negative
65
+ * `amountMinor` by CHECK constraint, so a rail reporting one would abort the whole projection batch and
66
+ * lose the purchase. An absent amount costs a reporting figure; a lost purchase costs a subscriber.
67
+ */
68
+ export function minorUnitsFromScaled(scaled: number | null, scale: number, currency: string | null): number | null {
69
+ if (scaled === null || currency === null) return null;
70
+ if (!Number.isFinite(scaled) || scaled < 0) return null;
71
+ // Multiply before dividing. `(1005 / 1000) * 100` is 100.49999999999999 in binary floating point, which
72
+ // rounds to 100 and quietly loses a cent on every price that lands on a half; `(1005 * 100) / 1000` is
73
+ // exactly 100.5. The operands are small integers, so the product cannot approach the safe-integer limit.
74
+ return Math.round((scaled * 10 ** minorUnitDigits(currency)) / scale);
75
+ }
@@ -0,0 +1,148 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { PaymentsVerificationFailedError } from "../error/errors";
5
+ import type { PaymentsRail } from "./rail";
6
+ import type { PurchaseStatus } from "./status";
7
+
8
+ /**
9
+ * When a paused subscription comes back — the one place every rail answers it.
10
+ *
11
+ * A pause is not an ending, and the single most useful thing anything can say about one is the date it
12
+ * ends. Two rails parsed that date out of a provider response and then dropped it, which is worse than
13
+ * never reading it: the value was provably present and understood, and no caller could reach it (#369).
14
+ * So the answer is stated once here rather than per rail, and the seam is shaped so a sixth rail inherits
15
+ * it instead of repeating the omission.
16
+ *
17
+ * ## Three facts, and they are different facts
18
+ *
19
+ * - **Not paused.** `status` is anything but `paused`, and there is nothing to resume. `resumesAt` is null
20
+ * and {@link pauseResumesAt} will not let it be anything else — a date beside a live subscription is a
21
+ * sentence nobody can write ("active until it resumes on the 14th"), and the purchases table carries a
22
+ * check constraint saying so.
23
+ * - **Paused, with a date.** The provider named it. `resumesAt` is that instant, decoded from the
24
+ * provider's own string and never computed from anything else.
25
+ * - **Paused, indefinitely.** The provider was asked and said none: a Play pause with no `autoResumeTime`,
26
+ * a Paddle pause with no scheduled resume, a Lemon Squeezy pause with `resumes_at: null`. `resumesAt` is
27
+ * null, and "paused until the 14th" and "paused indefinitely" are different sentences a consumer will
28
+ * want to write.
29
+ *
30
+ * **Null on a paused row therefore means indefinite, not "we did not look"** — and that is only true
31
+ * because {@link PAYMENTS_PAUSE_RESUMPTION} is total over the rails and every rail able to report `paused`
32
+ * either reads its provider's field or states why there is no field to read. A rail's `{ none }` reason is
33
+ * a fact about that store, so a consumer that wants to distinguish "this store never dates a pause" from
34
+ * "this pause has no date" reads the table: it is exported for exactly that question.
35
+ *
36
+ * ## Never computed
37
+ *
38
+ * The date is the provider's or it does not exist. Nothing here adds a pause duration to a start date, and
39
+ * nothing falls back to a period end — the fallback available when this was filed was `periodEnd`, which
40
+ * is a period end and not a resumption, and reaching for it would have put a wrong date in a letter to a
41
+ * paying customer. That is worse than an absent one. The only input this function takes is the string the
42
+ * provider sent, which is what makes "never computed" a property of the signature rather than a promise.
43
+ */
44
+
45
+ /** How one rail answers "when does this come back": the provider field it reads, or why there is none. */
46
+ export type PauseResumption =
47
+ | {
48
+ /** The provider field carrying the resume date, in that provider's own spelling. */
49
+ readonly field: string;
50
+ }
51
+ | {
52
+ /** Why this rail has no resume date to read. A fact about the store, not an exemption. */
53
+ readonly none: string;
54
+ };
55
+
56
+ /**
57
+ * Every rail, and where its paused subscriptions state their resume date.
58
+ *
59
+ * `satisfies Record<PaymentsRail, …>` rather than a partial map, exactly as `PAYMENTS_TABLE_DISCLOSURE`
60
+ * is: **a sixth rail does not compile until somebody decides how it answers this.** That is the structural
61
+ * half of the gate; `pause.test.ts` is the other half, and drives each declared field through the rail
62
+ * that reads it.
63
+ */
64
+ export const PAYMENTS_PAUSE_RESUMPTION = {
65
+ /**
66
+ * No pause at all. StoreKit has no paused state — a subscriber either renews, lapses, or is refunded —
67
+ * so `appleStatus` cannot produce `paused` and there is no date to look for.
68
+ */
69
+ apple: { none: "StoreKit has no paused subscription state, so no Apple purchase is ever projected as paused." },
70
+ /** Play states it directly. Absent for an indefinite pause, which is a state Play genuinely has. */
71
+ google: { field: "pausedStateContext.autoResumeTime" },
72
+ /**
73
+ * Stripe's `paused` is not a pause with a date. It is a trial that ended with no payment method while
74
+ * `trial_settings.end_behavior.missing_payment_method` is `pause`, and it resumes when a card arrives
75
+ * rather than on a date — Stripe publishes none for it.
76
+ *
77
+ * `pause_collection.resumes_at` is a **different mechanism**: pausing collection leaves the subscription
78
+ * `active`, so it never produces a row this field could sit on. Modeling that is a separate question
79
+ * about a state this package does not have, and it is on #369's sweep rather than smuggled in here.
80
+ */
81
+ stripe: {
82
+ none: "Stripe's `paused` is a trial ended without a payment method; it resumes when one arrives, and Stripe publishes no date for it. `pause_collection.resumes_at` is a different mechanism that leaves the status `active`.",
83
+ },
84
+ /** Lemon Squeezy states it on the pause object. Null there is an open-ended pause. */
85
+ lemonSqueezy: { field: "pause.resumes_at" },
86
+ /**
87
+ * Paddle states it on the scheduled change — and **not** in the field named after it.
88
+ *
89
+ * Verified against a live sandbox on 2026-08-15. Pausing immediately with a `resume_at` leaves
90
+ * `scheduled_change: { action: "resume", effective_at: "2026-10-01T00:00:00Z", resume_at: null }`: the
91
+ * date moves to `effective_at` and the field literally called `resume_at` is null. `resume_at` carries
92
+ * it only while a *pause* is scheduled and the subscription is still `active`, which is not a paused
93
+ * subscription and not this field. Pausing with no resume date leaves `scheduled_change: null` — an
94
+ * indefinite pause, reported as one.
95
+ *
96
+ * A fix keyed on `resume_at` alone would therefore have shipped null for every paused Paddle
97
+ * subscription while looking correct, so both spellings are read and the one that means resumption wins.
98
+ */
99
+ paddle: { field: "scheduled_change.effective_at (action `resume`), or scheduled_change.resume_at" },
100
+ } as const satisfies Record<PaymentsRail, PauseResumption>;
101
+
102
+ /**
103
+ * A rail that reads a resume date, as a type.
104
+ *
105
+ * Derived from the table rather than restated, so {@link pauseResumesAt} **cannot be called** for a rail
106
+ * declared `{ none }`: a store with no resume date has no way to report one, and the refusal is a compile
107
+ * error rather than a review comment.
108
+ */
109
+ export type ResumingRail = {
110
+ [Rail in PaymentsRail]: (typeof PAYMENTS_PAUSE_RESUMPTION)[Rail] extends { readonly field: string } ? Rail : never;
111
+ }[PaymentsRail];
112
+
113
+ /** What a rail hands over: which rail, what state it is reporting, and the provider's own string. */
114
+ export interface PauseResumptionInput {
115
+ /** The rail reporting. Only a rail declaring a field in {@link PAYMENTS_PAUSE_RESUMPTION} may. */
116
+ rail: ResumingRail;
117
+ /** The normalized status this event carries. Anything but `paused` has nothing to resume. */
118
+ status: PurchaseStatus;
119
+ /**
120
+ * The provider's own resume date, verbatim — an RFC 3339 string. Null, undefined or empty is the
121
+ * provider saying there is none, which is an indefinite pause rather than a missing read.
122
+ */
123
+ reported: string | null | undefined;
124
+ }
125
+
126
+ /**
127
+ * The resume date to project, from what the provider said.
128
+ *
129
+ * Refuses an unreadable string rather than passing it on: `new Date("soon")` is an Invalid Date, and
130
+ * `SQLiteDate` encodes one as `NaN` — a corrupt column value that no read can tell from a real instant.
131
+ * That is the same refusal every other timestamp on these rails makes, and it leaves the delivery recorded
132
+ * for the reconciliation pass rather than silently written.
133
+ */
134
+ export function pauseResumesAt(input: PauseResumptionInput): Date | null {
135
+ // A resume date belongs to a pause. A live subscription's next date is its renewal, and a canceled
136
+ // one's is its period end; neither is a resumption, and neither may be written here.
137
+ if (input.status !== "paused") return null;
138
+ const { reported } = input;
139
+ if (reported === null || reported === undefined || reported === "") return null;
140
+
141
+ const parsed = new Date(reported);
142
+ if (Number.isNaN(parsed.getTime())) {
143
+ throw new PaymentsVerificationFailedError({
144
+ detail: `${input.rail}: the resume date "${reported}" is not a readable timestamp.`,
145
+ });
146
+ }
147
+ return parsed;
148
+ }
@@ -0,0 +1,47 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { SQLiteDate } from "@pithy-sh/core/src/data/codecs";
5
+ import { z } from "zod";
6
+ import { PaymentsRail } from "./rail";
7
+ import { PaymentsSubject } from "./subject";
8
+
9
+ /**
10
+ * A provider identity mapped back to the subject that holds it — the row in
11
+ * `pithy_payments_provider_accounts`, keyed `UNIQUE (rail, providerAccountId)`.
12
+ *
13
+ * A Stripe webhook arrives carrying `cus_123`, and names no holder of ours. This table is the only way back. The
14
+ * link is established at purchase time, and all three rails provide the hook — Apple's `appAccountToken`,
15
+ * Google's `obfuscatedAccountId`, Stripe's `client_reference_id`. All three must be set by the app that
16
+ * starts the purchase, or webhooks arrive orphaned with nobody to project them against.
17
+ *
18
+ * What it maps back to is the **subject pair**, which is what makes the argument stronger rather than
19
+ * weaker: under organization billing the person who started the checkout is not the holder, so a row
20
+ * carrying only their user id would attribute every later renewal to them personally. The two columns are
21
+ * written together and read together — `data/subject.ts`.
22
+ *
23
+ * **The unique stays `(rail, providerAccountId)` and is never widened by the subject.** A provider
24
+ * identity binds to one holder, once, for the life of the link: widening the key would let a second
25
+ * subject claim the same `cus_123` and collect the first one's renewals. A rebind is a support operation
26
+ * with a deliberate delete, not something a purchase flow can do by writing a row.
27
+ */
28
+ export const PaymentsProviderAccount = z
29
+ .object({
30
+ id: z.string().describe("The row's UUID."),
31
+ rail: PaymentsRail.describe("Which store this identity belongs to."),
32
+ providerAccountId: z
33
+ .string()
34
+ .describe(
35
+ "The rail's own account identifier — Stripe's customer id, Apple's appAccountToken, Google's obfuscatedAccountId.",
36
+ ),
37
+ subjectType: PaymentsSubject.shape.subjectType.describe(
38
+ "Whether `subjectId` names a user or an organization. Half the answer — the id alone is ambiguous, so the two columns travel together.",
39
+ ),
40
+ subjectId: PaymentsSubject.shape.subjectId.describe(
41
+ "The subject this provider identity resolves to — a Pithy user id, or the adopter's organization id.",
42
+ ),
43
+ createdAt: SQLiteDate.describe("When the link was established, which is the first purchase that carried it."),
44
+ })
45
+ .describe("A provider identity mapped to the subject that holds it — the row in `pithy_payments_provider_accounts`.");
46
+ export type PaymentsProviderAccount = z.output<typeof PaymentsProviderAccount>;
47
+ export type PaymentsProviderAccountRow = z.input<typeof PaymentsProviderAccount>;