@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,16 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /// <reference types="@cloudflare/vitest-plugin/types" />
5
+
6
+ // Bindings the Workers-runtime test project provides to `*.workers.test.ts`, matching the Miniflare config
7
+ // in `vitest.workers.config.ts`: the app `DB` database the `pithy_payments_*` tables live in, and the
8
+ // dedicated `SECRETS` database plus master key the provider credential bundle is read through.
9
+ declare namespace Cloudflare {
10
+ interface Env {
11
+ DB: D1Database;
12
+ SECRETS: D1Database;
13
+ /** The master-key config as a string (the `.dev.vars` shape), set in `vitest.workers.config.ts`. */
14
+ SECRETS_ENCRYPTION_KEYS: string;
15
+ }
16
+ }
@@ -0,0 +1,563 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { EntitlementKey } from "@pithy-sh/core/src/entitlement/entitlement";
5
+ import { z } from "zod";
6
+ import { PAYMENTS_RAILS, type PaymentsRail } from "../data/rail";
7
+ import { PaymentsSubjectType } from "../data/subject";
8
+
9
+ /**
10
+ * The payments capability's config — the catalog, and the thin surface an adopter owns in
11
+ * `pithy.config.ts`. Every field is `.describe()`d: the descriptions feed the self-documenting CLI
12
+ * (CLAUDE.md §Config).
13
+ *
14
+ * **The catalog lives here, not in D1.** A product's entitlement mapping is policy: it should be
15
+ * diffable in git and it should not be mutable at runtime. The cost is that a new SKU requires a
16
+ * deploy, which is the correct trade — a table an attacker or a mis-click can edit decides who is
17
+ * entitled to what.
18
+ *
19
+ * The load-bearing distinction the shape encodes is that **a product is not an entitlement**.
20
+ * `pro_monthly` and `pro_annual` are two products, in three rails' catalogs, granting one entitlement —
21
+ * `pro`. Gating code names the entitlement; only this file ever names a SKU.
22
+ *
23
+ * A product declares rails selectively. `remove_ads` shipping on mobile only is expressed by omitting
24
+ * the `stripe` block, not by a flag — so the config reads as the catalog it is.
25
+ */
26
+
27
+ /** The longest a display name may be. Long enough for a real product title, short enough to render. */
28
+ const MAX_NAME_LENGTH = 120;
29
+
30
+ /** The longest a provider SKU or price id may be. Apple's reverse-DNS ids are the long end of this. */
31
+ const MAX_SKU_LENGTH = 200;
32
+
33
+ /** A ledger currency code, matching `@pithy-sh/ledger`'s own: lowercase, digits, and dashes. */
34
+ const CURRENCY_CODE_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
35
+
36
+ /** The longest a return URL may be. Generous against a long path with a template token in the query. */
37
+ const MAX_URL_LENGTH = 2048;
38
+
39
+ /** A page in the adopter's own app that a hosted Stripe flow returns a browser to. */
40
+ const ReturnUrl = z.url({ protocol: /^https?$/ }).max(MAX_URL_LENGTH);
41
+
42
+ export const PaymentsRailToggles = z
43
+ .object({
44
+ apple: z
45
+ .boolean()
46
+ .default(false)
47
+ .describe("Whether App Store purchases are accepted. Credentials come from the secrets store, never config."),
48
+ google: z
49
+ .boolean()
50
+ .default(false)
51
+ .describe("Whether Google Play purchases are accepted. Credentials come from the secrets store, never config."),
52
+ stripe: z
53
+ .boolean()
54
+ .default(false)
55
+ .describe(
56
+ "Whether Stripe purchases are accepted, through hosted Checkout and the Billing Portal. Pithy never owns payment UI, SCA, tax, or proration.",
57
+ ),
58
+ lemonSqueezy: z
59
+ .boolean()
60
+ .default(false)
61
+ .describe(
62
+ "Whether Lemon Squeezy purchases are accepted, through hosted checkout and the customer portal. Lemon Squeezy is the merchant of record: it handles global sales tax, EU VAT, invoicing and dunning, and it issues refunds on its own. Credentials come from the secrets store, never config.",
63
+ ),
64
+ paddle: z
65
+ .boolean()
66
+ .default(false)
67
+ .describe(
68
+ "Whether Paddle purchases are accepted, through an overlay, an inline frame, or Paddle's hosted page. Paddle is the merchant of record: it handles global sales tax, EU VAT, invoicing and dunning, and it issues refunds on its own. The API key and the webhook secret come from the secrets store; the publishable client token is config, because it is designed to reach a browser.",
69
+ ),
70
+ })
71
+ .describe("Which payment rails this project accepts. A rail that is off refuses its routes and its webhook.");
72
+ export type PaymentsRailToggles = z.infer<typeof PaymentsRailToggles>;
73
+
74
+ export const PaymentsProductType = z
75
+ .enum(["consumable", "non_consumable", "subscription"])
76
+ .describe(
77
+ "What kind of thing the product is: a consumable spent after purchase, a non-consumable owned forever and restorable, or a subscription that renews and can lapse.",
78
+ );
79
+ export type PaymentsProductType = z.infer<typeof PaymentsProductType>;
80
+
81
+ export const PaymentsAppleProduct = z
82
+ .object({
83
+ productId: z
84
+ .string()
85
+ .min(1)
86
+ .max(MAX_SKU_LENGTH)
87
+ .describe("The App Store Connect product identifier, usually reverse-DNS — `com.acme.pro.monthly`."),
88
+ })
89
+ .describe("How this product is listed on the App Store. Omit the block to ship without the Apple rail.");
90
+ export type PaymentsAppleProduct = z.infer<typeof PaymentsAppleProduct>;
91
+
92
+ export const PaymentsGoogleProduct = z
93
+ .object({
94
+ productId: z
95
+ .string()
96
+ .min(1)
97
+ .max(MAX_SKU_LENGTH)
98
+ .describe("The Play Console product or base-plan id — `pro_monthly`."),
99
+ })
100
+ .describe("How this product is listed on Google Play. Omit the block to ship without the Google rail.");
101
+ export type PaymentsGoogleProduct = z.infer<typeof PaymentsGoogleProduct>;
102
+
103
+ export const PaymentsStripeProduct = z
104
+ .object({
105
+ priceId: z
106
+ .string()
107
+ .min(1)
108
+ .max(MAX_SKU_LENGTH)
109
+ .describe(
110
+ "The Stripe Price id — `price_1Abc`. Publishable by design: it is what a Checkout Session names, so it may reach a browser.",
111
+ ),
112
+ })
113
+ .describe("How this product is priced in Stripe. Omit the block to ship without the Stripe rail.");
114
+ export type PaymentsStripeProduct = z.infer<typeof PaymentsStripeProduct>;
115
+
116
+ export const PaymentsLemonSqueezyProduct = z
117
+ .object({
118
+ variantId: z
119
+ .string()
120
+ .min(1)
121
+ .max(MAX_SKU_LENGTH)
122
+ .describe(
123
+ "The Lemon Squeezy variant id — `123456`. A variant is Lemon Squeezy's price-equivalent, so this sits exactly where Stripe's `priceId` does. Publishable by design: it is what a hosted checkout names.",
124
+ ),
125
+ })
126
+ .describe("How this product is sold through Lemon Squeezy. Omit the block to ship without that rail.");
127
+ export type PaymentsLemonSqueezyProduct = z.infer<typeof PaymentsLemonSqueezyProduct>;
128
+
129
+ export const PaymentsLemonSqueezySettings = z
130
+ .object({
131
+ successUrl: ReturnUrl.describe(
132
+ "Where hosted checkout returns a buyer who paid. Unlike Stripe there is no session token to substitute — a Lemon Squeezy purchase is only ever heard about through its webhook, so this page shows a pending state rather than posting a receipt.",
133
+ ),
134
+ storeCurrency: z
135
+ .string()
136
+ .regex(CURRENCY_CODE_PATTERN, "A currency code is lowercase, digits, and dashes.")
137
+ .optional()
138
+ .describe(
139
+ "The currency this Lemon Squeezy store sells in, lowercase ISO 4217 — `usd`. Optional, and the only thing it does is let a fixed-amount discount in another currency be refused when it is created rather than when a customer redeems it. Lemon Squeezy accepts the mismatched object and fails at redemption, so without this the error arrives at the buyer instead of at you.",
140
+ ),
141
+ })
142
+ .describe(
143
+ "Where Lemon Squeezy's hosted checkout sends a browser back to. Config, not request input: a client that could name a return URL could send a paying customer to a page it controls. Build it on `PUBLIC_ORIGIN` and never on a literal. One URL, where Stripe takes three, and both absences are the store's rather than an omission here: Lemon Squeezy's checkout has no cancel destination — a buyer who backs out closes the tab or uses the back button — and its customer portal is a signed, expiring link with nowhere to return to.",
144
+ );
145
+ export type PaymentsLemonSqueezySettings = z.infer<typeof PaymentsLemonSqueezySettings>;
146
+
147
+ export const PaymentsPaddleProduct = z
148
+ .object({
149
+ priceId: z
150
+ .string()
151
+ .min(1)
152
+ .max(MAX_SKU_LENGTH)
153
+ .describe(
154
+ "The Paddle Price id — `pri_01hv8w…`. Publishable by design: it is what a transaction and `Paddle.Checkout.open` both name, so it may reach a browser.",
155
+ ),
156
+ })
157
+ .describe("How this product is priced in Paddle. Omit the block to ship without the Paddle rail.");
158
+ export type PaymentsPaddleProduct = z.infer<typeof PaymentsPaddleProduct>;
159
+
160
+ export const PaymentsPaddleSettings = z
161
+ .strictObject({
162
+ checkout: z
163
+ .enum(["overlay", "inline", "hosted"])
164
+ .default("overlay")
165
+ .describe(
166
+ "How checkout is presented. `overlay` opens Paddle.js over your own page; `inline` renders it in a container the screen provides; `hosted` redirects to Paddle's own page and **requires a default payment link set in the Paddle dashboard** — without one Paddle refuses to create the transaction at all, account-wide, so `pithy doctor` asks before a buyer finds out.",
167
+ ),
168
+ clientToken: z
169
+ .string()
170
+ .min(1)
171
+ .describe(
172
+ "Paddle's publishable client token — `live_…` or `test_…`. Config and not a secret, the same call `stripe.priceId` gets and for the same reason: it is designed to reach a browser, and putting it behind the secrets store would suggest verification depended on its secrecy. The API key and the webhook signing secret are secrets and are not here.",
173
+ ),
174
+ environment: z
175
+ .enum(["sandbox", "production"])
176
+ .describe(
177
+ "Which Paddle account this project sells through. Paddle Billing partitions sandbox from live by account — separate host, separate key, separate notification destinations — so this decides both the API host and every purchase's recorded environment. There is no `mode` field on a Paddle payload that could contradict it.",
178
+ ),
179
+ successUrl: ReturnUrl.describe(
180
+ "Where a buyer lands after paying. Used as `settings.successUrl` for Paddle.js and as the redirect target in `hosted` mode. Build it on `PUBLIC_ORIGIN`, never on a literal.",
181
+ ),
182
+ cancelUrl: ReturnUrl.optional().describe(
183
+ "Where a buyer who backs out lands, in `hosted` mode. Optional, because an overlay a buyer closes leaves them exactly where they were.",
184
+ ),
185
+ webhookFreshnessSeconds: z
186
+ .number()
187
+ .int()
188
+ .positive()
189
+ .optional()
190
+ .describe(
191
+ "How many seconds either side of now a delivery's `ts` may be dated. Omitted uses 300, deliberately not the 5 Paddle's own SDKs use: replay protection here is the webhook table's `UNIQUE (rail, providerEventId)`, which is absolute, and a five-second window adds nothing to that while turning ordinary clock skew into a dropped renewal.",
192
+ ),
193
+ storeCurrency: z
194
+ .string()
195
+ .regex(CURRENCY_CODE_PATTERN, "A currency code is lowercase, digits, and dashes.")
196
+ .optional()
197
+ .describe(
198
+ "The currency this Paddle catalog prices in, lowercase ISO 4217 — `usd`. Optional, and the only thing it does is let a fixed-amount discount in another currency be refused when it is created rather than when a customer redeems it. Paddle accepts the mismatched object and fails at redemption, so without this the error arrives at the buyer instead of at you.",
199
+ ),
200
+ })
201
+ .describe(
202
+ "How this project sells through Paddle. `strictObject`, and that is what refuses `portalReturnUrl`: Paddle's customer portal takes no return parameter, so accepting one and dropping it would leave a URL an adopter wrote and believed in that nothing ever reads — a lie in a file they trust.",
203
+ );
204
+ export type PaymentsPaddleSettings = z.infer<typeof PaymentsPaddleSettings>;
205
+ export type PaymentsPaddleSettingsInput = z.input<typeof PaymentsPaddleSettings>;
206
+
207
+ export const PaymentsStripeSettings = z
208
+ .object({
209
+ successUrl: ReturnUrl.describe(
210
+ "Where hosted Checkout returns a buyer who paid. Put `{CHECKOUT_SESSION_ID}` in the query and Stripe fills it in, so the page can post it to /payments/purchases and show the entitlement at once instead of waiting for the webhook.",
211
+ ),
212
+ cancelUrl: ReturnUrl.describe("Where hosted Checkout returns a buyer who backed out. Usually the paywall."),
213
+ portalReturnUrl: ReturnUrl.describe("Where the Billing Portal returns a subscriber who is done managing."),
214
+ })
215
+ .describe(
216
+ "Where Stripe's hosted pages send a browser back to. Config, not request input: a client that could name a return URL could send a paying customer to a page it controls. Build all three on `PUBLIC_ORIGIN` — the constant the scaffolded pithy.config.ts derives from `domains` — and never on a literal: an origin written down is production's origin written into staging, which lands a staging payer in production on an account that has bought nothing (#256).",
217
+ );
218
+ export type PaymentsStripeSettings = z.infer<typeof PaymentsStripeSettings>;
219
+
220
+ export const PaymentsLedgerGrant = z
221
+ .object({
222
+ currency: z
223
+ .string()
224
+ .regex(CURRENCY_CODE_PATTERN, "A currency code is lowercase, digits, and dashes.")
225
+ .describe("The `@pithy-sh/ledger` currency to credit — must be one that capability's config declares."),
226
+ amount: z
227
+ .number()
228
+ .int()
229
+ .positive()
230
+ .describe("How much to credit, as an integer in the currency's minor unit. Never a float, never zero."),
231
+ })
232
+ .describe("A balance credit performed on fulfillment, once per provider transaction.");
233
+ export type PaymentsLedgerGrant = z.infer<typeof PaymentsLedgerGrant>;
234
+
235
+ export const PaymentsGrants = z
236
+ .object({
237
+ ledger: PaymentsLedgerGrant.optional().describe(
238
+ "Credit a `@pithy-sh/ledger` balance when this product is fulfilled. The only point of contact between the two capabilities, and opt-in — most products never touch a balance.",
239
+ ),
240
+ })
241
+ .describe("What a purchase fulfills beyond its entitlements. Absent for the products that only unlock a feature.");
242
+ export type PaymentsGrants = z.infer<typeof PaymentsGrants>;
243
+
244
+ export const PaymentsProduct = z
245
+ .object({
246
+ type: PaymentsProductType.describe("What kind of product this is. Decides how a renewal and a restore behave."),
247
+ name: z
248
+ .string()
249
+ .min(1)
250
+ .max(MAX_NAME_LENGTH)
251
+ .describe(
252
+ "The display name a paywall renders — `Pro`, `Remove ads`. Required: it is the one product field a browser is given, and a paywall with no copy renders nothing.",
253
+ ),
254
+ entitlements: z
255
+ .array(EntitlementKey)
256
+ .default([])
257
+ .describe(
258
+ "The entitlement keys this product grants. Many products across many rails may grant one key, which is the whole point — gating code names the key, never this product. Empty for a product that only credits a balance.",
259
+ ),
260
+ apple: PaymentsAppleProduct.optional().describe("The Apple listing, if this product ships on the App Store."),
261
+ google: PaymentsGoogleProduct.optional().describe("The Google listing, if this product ships on Google Play."),
262
+ stripe: PaymentsStripeProduct.optional().describe("The Stripe price, if this product is sold through Stripe."),
263
+ lemonSqueezy: PaymentsLemonSqueezyProduct.optional().describe(
264
+ "The Lemon Squeezy variant, if this product is sold through Lemon Squeezy.",
265
+ ),
266
+ paddle: PaymentsPaddleProduct.optional().describe("The Paddle price, if this product is sold through Paddle."),
267
+ grants: PaymentsGrants.optional().describe(
268
+ "What this purchase fulfills beyond its entitlements. Opt-in per product; a subscription's grant fires once per billing period, since each renewal is a distinct provider transaction.",
269
+ ),
270
+ clawback: z
271
+ .boolean()
272
+ .default(false)
273
+ .describe(
274
+ "Whether a refund attempts to debit back what `grants` credited. Off by default: the user may already have spent it, and a clawback that would overdraw is refused by the ledger rather than routed around. A failed clawback is recorded and queryable, not an exception.",
275
+ ),
276
+ })
277
+ .describe("One logical catalog product — what it is, what it grants, and how each store lists it.");
278
+ export type PaymentsProduct = z.infer<typeof PaymentsProduct>;
279
+
280
+ export const PaymentsConfig = z
281
+ .object({
282
+ billingSubject: PaymentsSubjectType.describe(
283
+ "Who a purchase belongs to, and who its entitlements reach. `user` is one person buying for themselves: they pay, they are entitled, nobody else is. `organization` is a company buying for its people: the company is invoiced, and everybody the adopter counts as a member holds what it bought — so a colleague who joined this morning is entitled and one who left this afternoon is not, with no row rewritten either time. Required, and decided once for the project: a codebase that could grant to a user on one route and an organization on the next eventually disagrees with itself about who is entitled, and the disagreement surfaces as somebody being refused what they paid for. Changing it later is a migration of every entitlement, purchase, and store link, so it is worth the minute now.",
284
+ ),
285
+ rails: PaymentsRailToggles.default({
286
+ apple: false,
287
+ google: false,
288
+ stripe: false,
289
+ lemonSqueezy: false,
290
+ paddle: false,
291
+ }).describe("Which stores this project sells through. Every rail is off until named."),
292
+ products: z
293
+ .record(z.string(), PaymentsProduct)
294
+ .default({})
295
+ .describe(
296
+ "The catalog, keyed by logical product id — the id that lands in every purchase row. Keys are yours and outlive any store's SKU, so renaming a SKU in a console never rewrites history.",
297
+ ),
298
+ manualEntitlements: z
299
+ .array(EntitlementKey)
300
+ .default([])
301
+ .describe(
302
+ "Entitlement keys the control plane may grant that no product sells — a beta flag, an internal tier, a key that exists only to be comped. Declared, because the alternative to declaring is not checking: with this empty, a grant of any key outside the catalog is refused, which is what turns `pr` for `pro` into a 400 instead of a row nobody notices. Only grants are constrained; a revoke of a key the catalog has since dropped stays legal, or a catalog edit would be irreversible for everyone still holding it.",
303
+ ),
304
+ stripe: PaymentsStripeSettings.optional().describe(
305
+ "Where Stripe's hosted Checkout and Billing Portal return the browser. Required when the Stripe rail is on — the two routes cannot create a session without them.",
306
+ ),
307
+ lemonSqueezy: PaymentsLemonSqueezySettings.optional().describe(
308
+ "Where Lemon Squeezy's hosted checkout returns the browser. Required when that rail is on — the checkout route cannot create a session without it.",
309
+ ),
310
+ paddle: PaymentsPaddleSettings.optional().describe(
311
+ "How this project sells through Paddle: the account, the publishable client token, the checkout mode, and where a buyer lands. Required when the Paddle rail is on — the rail cannot initialize Paddle.js or create a transaction without it.",
312
+ ),
313
+ basePath: z
314
+ .string()
315
+ .default("/payments")
316
+ .describe("Where the payments routes mount, webhooks included. Register the webhook URLs to match."),
317
+ graceGrantsAccess: z
318
+ .boolean()
319
+ .default(true)
320
+ .describe(
321
+ "Whether a subscription in its billing-retry grace period still grants its entitlements. True, because that is the point of grace — a failed card should not lock a paying subscriber out mid-period. Once grace is exhausted the purchase moves to on_hold, which never grants.",
322
+ ),
323
+ })
324
+ .describe("Configuration for the payments capability — the cross-rail product catalog and where it mounts.")
325
+ .check((ctx) => {
326
+ const config = ctx.value;
327
+ const entries = Object.entries(config.products);
328
+
329
+ // Stripe's hosted flows cannot be created without somewhere to return to, and a deploy is the moment to
330
+ // find that out. The alternative is a project that ships, sells nothing through Stripe, and reports it as
331
+ // a 404 on the first checkout — which reads as a bug rather than as a missing three lines of config.
332
+ if (config.rails.stripe && config.stripe === undefined) {
333
+ ctx.issues.push({
334
+ code: "custom",
335
+ input: ctx.value,
336
+ path: ["stripe"],
337
+ message:
338
+ "The Stripe rail is on, so `stripe` must declare `successUrl`, `cancelUrl`, and `portalReturnUrl`. Hosted Checkout and the Billing Portal have nowhere to return a browser without them.",
339
+ });
340
+ }
341
+
342
+ // The mirror of the per-product rule: return URLs for a rail that is off describe a flow nothing can reach.
343
+ if (!config.rails.stripe && config.stripe !== undefined) {
344
+ ctx.issues.push({
345
+ code: "custom",
346
+ input: ctx.value,
347
+ path: ["stripe"],
348
+ message: "`stripe` declares return URLs, but `rails.stripe` is off. Enable the rail, or drop the block.",
349
+ });
350
+ }
351
+
352
+ // The same pair for Lemon Squeezy, and for the same reason. Only two URLs, not three: that rail's
353
+ // customer portal is a signed expiring link with nowhere to return to.
354
+ if (config.rails.lemonSqueezy && config.lemonSqueezy === undefined) {
355
+ ctx.issues.push({
356
+ code: "custom",
357
+ input: ctx.value,
358
+ path: ["lemonSqueezy"],
359
+ message:
360
+ "The Lemon Squeezy rail is on, so `lemonSqueezy` must declare a `successUrl`. Hosted checkout has nowhere to return a browser without it.",
361
+ });
362
+ }
363
+
364
+ if (!config.rails.lemonSqueezy && config.lemonSqueezy !== undefined) {
365
+ ctx.issues.push({
366
+ code: "custom",
367
+ input: ctx.value,
368
+ path: ["lemonSqueezy"],
369
+ message:
370
+ "`lemonSqueezy` declares a return URL, but `rails.lemonSqueezy` is off. Enable the rail, or drop the block.",
371
+ });
372
+ }
373
+
374
+ // The same pair for Paddle. The client token and the account are not return URLs — they are what a
375
+ // browser needs to open a checkout at all — so the rail cannot start one without this block either.
376
+ if (config.rails.paddle && config.paddle === undefined) {
377
+ ctx.issues.push({
378
+ code: "custom",
379
+ input: ctx.value,
380
+ path: ["paddle"],
381
+ message:
382
+ "The Paddle rail is on, so `paddle` must declare `clientToken`, `environment`, and `successUrl`. Paddle.js cannot initialize and no transaction can be created without them.",
383
+ });
384
+ }
385
+
386
+ if (!config.rails.paddle && config.paddle !== undefined) {
387
+ ctx.issues.push({
388
+ code: "custom",
389
+ input: ctx.value,
390
+ path: ["paddle"],
391
+ message: "`paddle` declares settings, but `rails.paddle` is off. Enable the rail, or drop the block.",
392
+ });
393
+ }
394
+
395
+ for (const [id, product] of entries) {
396
+ // A SKU for a rail the project turned off can never be bought, and the webhook that would carry it
397
+ // is refused. Silently ignoring the block would leave a catalog that reads as selling something it
398
+ // does not.
399
+ for (const rail of PAYMENTS_RAILS) {
400
+ if (product[rail] !== undefined && !config.rails[rail]) {
401
+ ctx.issues.push({
402
+ code: "custom",
403
+ input: ctx.value,
404
+ path: ["products", id, rail],
405
+ message: `Product "${id}" declares a ${rail} SKU, but \`rails.${rail}\` is off. Enable the rail, or drop the block — a product ships without a rail by omission.`,
406
+ });
407
+ }
408
+ }
409
+
410
+ // A product on no rail is unreachable: a webhook resolves a product by its rail SKU, and there is
411
+ // none to match.
412
+ if (!PAYMENTS_RAILS.some((rail) => product[rail] !== undefined)) {
413
+ ctx.issues.push({
414
+ code: "custom",
415
+ input: ctx.value,
416
+ path: ["products", id],
417
+ message: `Product "${id}" declares no rail. Give it an \`apple\`, \`google\`, \`stripe\`, \`lemonSqueezy\`, or \`paddle\` block — nothing could buy it otherwise.`,
418
+ });
419
+ }
420
+
421
+ // A purchase that neither unlocks a feature nor credits a balance does nothing at all. That is a
422
+ // catalog mistake worth catching on deploy rather than as a support ticket after the first sale.
423
+ if (product.entitlements.length === 0 && product.grants?.ledger === undefined) {
424
+ ctx.issues.push({
425
+ code: "custom",
426
+ input: ctx.value,
427
+ path: ["products", id],
428
+ message: `Product "${id}" grants nothing. Give it \`entitlements\`, a \`grants\` clause, or both.`,
429
+ });
430
+ }
431
+
432
+ // A `grants` clause credits once per provider transaction. A non-consumable has exactly one, and is
433
+ // restorable forever — so the credit would either fire again on every restore (minting currency) or
434
+ // fire once and make the restore a lie. A subscription is fine: each renewal is its own transaction,
435
+ // so the grant fires per period. A one-time currency purchase is a `consumable`.
436
+ if (product.type === "non_consumable" && product.grants !== undefined) {
437
+ ctx.issues.push({
438
+ code: "custom",
439
+ input: ctx.value,
440
+ path: ["products", id, "grants"],
441
+ message: `Product "${id}" is a non_consumable with a \`grants\` clause. A non-consumable is bought once and restored forever, so a balance credit would fire again on every restore. Make it a consumable, or drop the grant.`,
442
+ });
443
+ }
444
+ }
445
+
446
+ // A webhook carries a rail and a SKU, and nothing else that identifies what was bought. Two products
447
+ // claiming one SKU on one rail would make that resolution ambiguous, so it is refused at assembly.
448
+ // The same string on two different rails is fine — the rails are separate namespaces.
449
+ for (const rail of PAYMENTS_RAILS) {
450
+ const owners = new Map<string, string[]>();
451
+ for (const [id, product] of entries) {
452
+ const sku = providerProductId(product, rail);
453
+ if (sku === undefined) continue;
454
+ owners.set(sku, [...(owners.get(sku) ?? []), id]);
455
+ }
456
+ for (const [sku, claimants] of owners) {
457
+ if (claimants.length < 2) continue;
458
+ ctx.issues.push({
459
+ code: "custom",
460
+ input: ctx.value,
461
+ path: ["products"],
462
+ message: `Two products claim the ${rail} SKU "${sku}": ${claimants.sort().join(", ")}. A webhook resolves a product by its rail SKU, so a duplicate makes that resolution ambiguous.`,
463
+ });
464
+ }
465
+ }
466
+ });
467
+ export type PaymentsConfig = z.output<typeof PaymentsConfig>;
468
+ export type PaymentsConfigInput = z.input<typeof PaymentsConfig>;
469
+
470
+ /**
471
+ * The catalog's pieces as *written*, beside the whole config as written.
472
+ *
473
+ * A catalog assembled in TypeScript — products built from a map of price ids, which is what makes the
474
+ * ids swappable per environment — needs a name for each piece it builds. The output types are the
475
+ * wrong ones: they are what `PaymentsConfig.parse()` returns, with every default already filled in, so
476
+ * typing an unparsed product with `PaymentsProduct` demands `entitlements` and `clawback` back from the
477
+ * author. The alternative was leaving adopters to write `z.input<typeof PaymentsProduct>` themselves,
478
+ * which works and makes the schema's internals part of the surface anyway.
479
+ *
480
+ * `PaymentsStripeSettings` defaults nothing today, so its input and output coincide. It is exported all
481
+ * the same: the three pieces a catalog is assembled from should be nameable as a set, and a default
482
+ * added later then shifts nothing.
483
+ */
484
+ export type PaymentsProductInput = z.input<typeof PaymentsProduct>;
485
+ export type PaymentsRailTogglesInput = z.input<typeof PaymentsRailToggles>;
486
+ export type PaymentsStripeSettingsInput = z.input<typeof PaymentsStripeSettings>;
487
+
488
+ /**
489
+ * A catalog product and the logical id it is keyed by. Both halves travel together because every write
490
+ * needs the id — a purchase row stores `productId`, not the product — and every read needs the product.
491
+ */
492
+ export interface PaymentsCatalogEntry {
493
+ /** The logical product id — the key in `products`, and what lands in a purchase row. */
494
+ id: string;
495
+ /** The catalog entry itself. */
496
+ product: PaymentsProduct;
497
+ }
498
+
499
+ /** The provider SKU this product is listed under on `rail`, or undefined when it does not ship there. */
500
+ export function providerProductId(product: PaymentsProduct, rail: PaymentsRail): string | undefined {
501
+ switch (rail) {
502
+ case "stripe":
503
+ return product.stripe?.priceId;
504
+ case "lemonSqueezy":
505
+ return product.lemonSqueezy?.variantId;
506
+ case "paddle":
507
+ return product.paddle?.priceId;
508
+ case "apple":
509
+ return product.apple?.productId;
510
+ case "google":
511
+ return product.google?.productId;
512
+ }
513
+ }
514
+
515
+ /** The product with this logical id, or undefined. Ids come from config, so an unknown one is a 404. */
516
+ export function resolveProduct(config: PaymentsConfig, id: string): PaymentsCatalogEntry | undefined {
517
+ const product = config.products[id];
518
+ return product === undefined ? undefined : { id, product };
519
+ }
520
+
521
+ /**
522
+ * The product a rail's own SKU maps to — the lookup every provider event needs, since a webhook carries
523
+ * the store's identifier and never a Pithy product id. Scoped to one rail: the same string can be a
524
+ * different product on a different store, and `PaymentsConfig`'s duplicate check is what guarantees the
525
+ * answer is unique within a rail.
526
+ */
527
+ export function productForProviderSku(
528
+ config: PaymentsConfig,
529
+ rail: PaymentsRail,
530
+ sku: string,
531
+ ): PaymentsCatalogEntry | undefined {
532
+ for (const [id, product] of Object.entries(config.products)) {
533
+ if (providerProductId(product, rail) === sku) return { id, product };
534
+ }
535
+ return undefined;
536
+ }
537
+
538
+ /**
539
+ * Every entitlement key this project defines — what the catalog's products grant, plus what the adopter
540
+ * declared grantable with no sale behind it.
541
+ *
542
+ * The set a manual grant is checked against, and the set the control-plane catalog read publishes the first
543
+ * half of. Computed once per composition: the catalog is config, so it cannot change under a running Worker.
544
+ *
545
+ * **Empty is a real answer.** A project composing payments with nothing to sell and nothing declared defines
546
+ * no keys, and every grant against it is refused — which is correct, because there is no vocabulary to grant
547
+ * in. It is the same statement `clientProjection` makes as `{ enabled: false }`.
548
+ */
549
+ export function grantableEntitlements(config: PaymentsConfig): ReadonlySet<string> {
550
+ const keys = new Set<string>(config.manualEntitlements);
551
+ for (const product of Object.values(config.products)) for (const key of product.entitlements) keys.add(key);
552
+ return keys;
553
+ }
554
+
555
+ /** The entitlement keys a product grants, or an empty list for an unknown product or one that grants none. */
556
+ export function entitlementsForProduct(config: PaymentsConfig, id: string): readonly string[] {
557
+ return config.products[id]?.entitlements ?? [];
558
+ }
559
+
560
+ /** Whether a rail is enabled for this project. A disabled rail refuses its routes and its webhook. */
561
+ export function railEnabled(config: PaymentsConfig, rail: PaymentsRail): boolean {
562
+ return config.rails[rail];
563
+ }