@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,577 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { JsonDate } from "@pithy-sh/core/src/data/codecs";
5
+ import { MAX_PAGE_SIZE } from "@pithy-sh/core/src/data/cursor";
6
+ import { EntitlementKey } from "@pithy-sh/core/src/entitlement/entitlement";
7
+ import { z } from "zod";
8
+ import { DiscountCode, DiscountTerms } from "../data/discount";
9
+ import { PurchaseEnvironment } from "../data/purchase";
10
+ import { PaymentsHostedRail, PaymentsRail } from "../data/rail";
11
+ import { PurchaseStatus } from "../data/status";
12
+ import { PaymentsSubject } from "../data/subject";
13
+ import { SubscriptionCancelTiming } from "../data/subscription";
14
+
15
+ /**
16
+ * Everything a caller may send to a payments route, declared here and parsed on the route line. Reading a
17
+ * route tells you what it accepts without opening the handler, and `c.req.valid()` is the only way a handler
18
+ * can reach a body at all — the Biome plugin bans the raw accessors under `src/http/**`.
19
+ *
20
+ * ## What is deliberately absent
21
+ *
22
+ * **The subject.** Never a field on a route the adopter's own app calls, and this is the security core of
23
+ * subject billing. A purchase belongs to whoever the project bills, resolved on the server — the
24
+ * authenticated caller under `billingSubject: "user"`, and under `"organization"` whatever the adopter's
25
+ * own resolver answers from its own session, because this package never learns what an organization is. A
26
+ * body that could name a `subjectId` would let any signed-in caller buy, restore, or read against a holder
27
+ * they have no membership of, and the capability has nothing to check that claim against: it has no
28
+ * members table, by design. So the claim is never accepted. The control-plane schemas at the bottom are
29
+ * the deliberate exception, and the exception is the feature: support acting on somebody else's account is
30
+ * what they are for, which is why they sit behind a default-denied scoped credential and are audited on
31
+ * every write.
32
+ *
33
+ * **Half a subject.** Where a subject *is* named, both halves are, because nothing keeps an organization
34
+ * id from equalling some user's id — a filter or a grant carrying the id alone addresses whichever holder
35
+ * happens to share it. The halves inherit their bounds from `PaymentsSubject` rather than restating them,
36
+ * so a request can never carry an id a row would refuse.
37
+ *
38
+ * **The product.** Also never a field, and this one is easy to get wrong. The catalog product is resolved from
39
+ * the *verified* payload's SKU, not from anything the client declared: a client-supplied `productId` would let
40
+ * a caller present a cheap receipt as an expensive product. The client sends the store's artifact and nothing
41
+ * about what it thinks it bought.
42
+ *
43
+ * **The environment.** The deployment's own, from `ENVIRONMENT`. A body naming it would be a sandbox purchase
44
+ * asking to be treated as production.
45
+ *
46
+ * The size bounds are not decoration. A receipt is an unauthenticated string that gets base64-decoded and
47
+ * DER-parsed, so bounding it bounds that work; the limits are generous against real StoreKit and Play
48
+ * artifacts and still refuse a megabyte of nonsense before any parsing starts.
49
+ */
50
+
51
+ /**
52
+ * The longest receipt accepted. A StoreKit 2 signed transaction is a few kilobytes — its certificate chain is
53
+ * most of it — and Google's purchase tokens are shorter. 16 KiB is roomy for both and small enough that a
54
+ * flood of them cannot be a parsing denial of service.
55
+ */
56
+ const MAX_RECEIPT_LENGTH = 16_384;
57
+
58
+ /** The most receipts one restore may carry. A store's entitlement list is short; a thousand is not a restore. */
59
+ const MAX_RESTORE_RECEIPTS = 50;
60
+
61
+ /** The longest a catalog product id may be, matching the SKU bound the catalog itself uses. */
62
+ const MAX_PRODUCT_ID_LENGTH = 200;
63
+
64
+ /** The longest a page cursor may be. Ours are a base64url'd pair; the bound refuses anything that is not. */
65
+ const MAX_CURSOR_LENGTH = 512;
66
+
67
+ export const PurchaseSubmission = z
68
+ .object({
69
+ rail: PaymentsRail.describe("Which store the receipt came from. Decides which verifier runs and nothing else."),
70
+ receipt: z
71
+ .string()
72
+ .min(1)
73
+ .max(MAX_RECEIPT_LENGTH)
74
+ .describe(
75
+ "The store's own artifact, exactly as its SDK returned it — a StoreKit 2 signed transaction, a Play purchase token. Everything about what was bought is read from inside it after it verifies, never from the request.",
76
+ ),
77
+ })
78
+ .describe(
79
+ "A client submitting one purchase for verification. Who holds it is the server's answer, never the request's.",
80
+ );
81
+ export type PurchaseSubmission = z.infer<typeof PurchaseSubmission>;
82
+
83
+ export const RestoreRequest = z
84
+ .object({
85
+ rail: PaymentsRail.describe("Which store the history came from."),
86
+ receipts: z
87
+ .array(
88
+ z.string().min(1).max(MAX_RECEIPT_LENGTH).describe("One store artifact from the caller's purchase history."),
89
+ )
90
+ .min(1)
91
+ .max(MAX_RESTORE_RECEIPTS)
92
+ .describe(
93
+ "The caller's current store entitlements, as the store's own artifacts. Restore is client-driven because only the device can enumerate what its store account owns.",
94
+ ),
95
+ })
96
+ .describe("A client re-submitting its store purchase history, to bind it to the subject the server resolves.");
97
+ export type RestoreRequest = z.infer<typeof RestoreRequest>;
98
+
99
+ /**
100
+ * Apple's webhook body. The Apple rail declares the same one field for itself, because it parses the bytes in
101
+ * order to verify them and must not import across the HTTP seam to do it. Change one, change the other.
102
+ */
103
+ export const AppleWebhookNotification = z
104
+ .object({
105
+ signedPayload: z
106
+ .string()
107
+ .min(1)
108
+ .describe(
109
+ "The App Store Server Notification V2, as a compact JWS. The guard has already verified it against Apple's pinned certificate chain by the time a handler reads this.",
110
+ ),
111
+ })
112
+ .describe("The body Apple POSTs to the notification endpoint. One field, carrying everything.");
113
+ export type AppleWebhookNotification = z.infer<typeof AppleWebhookNotification>;
114
+
115
+ /**
116
+ * What a caller may ask to buy: a catalog product id, and nothing else.
117
+ *
118
+ * No price, no amount, no currency, no return URL. The price comes from the catalog entry the id resolves to,
119
+ * and the return URLs come from config — a client that could name where hosted Checkout returns to could send a
120
+ * paying customer to a page it controls, and a client that could name a price could buy Pro for the price of a
121
+ * coin pack. The purchaser is the subject the server resolves for the caller, as everywhere else.
122
+ */
123
+ export const CheckoutRequest = z
124
+ .object({
125
+ productId: z
126
+ .string()
127
+ .min(1)
128
+ .max(MAX_PRODUCT_ID_LENGTH)
129
+ .describe(
130
+ "The logical catalog product to buy — the key in `products`, never a store SKU. Resolved against config, so an unknown one is a 404 rather than a bad request.",
131
+ ),
132
+ rail: PaymentsHostedRail.optional().describe(
133
+ "Which hosted-checkout rail to buy through. Omit it when the product sells on one, which is the common case. A client may name this because a rail is not a price and not a purchaser — it decides who takes the money, not how much or on whose behalf — so a paywall offering more than one can put a button on the page for each.",
134
+ ),
135
+ discountCode: DiscountCode.optional().describe(
136
+ "A discount code to apply, passed to the store unchanged. A client may send this because the store decides what it is worth and whether it is valid — Pithy never computes a discounted amount, so a code here can only ever ask the provider a question it was going to answer anyway.",
137
+ ),
138
+ })
139
+ .describe("A caller asking to start hosted checkout for one catalog product.");
140
+ export type CheckoutRequest = z.infer<typeof CheckoutRequest>;
141
+
142
+ /**
143
+ * ## The subscription lifecycle requests
144
+ *
145
+ * Four routes, and between them they accept two fields. That is the design rather than an accident of
146
+ * scope, and each absence below is load-bearing.
147
+ *
148
+ * **No subscription.** The route resolves it from the caller's own purchase rows, and there is no field
149
+ * a caller could send that would widen that set. A `subscriptionId` here would be a value a client
150
+ * supplies, and a value a client supplies is one they can point at somebody else's subscription — which
151
+ * this capability could not refuse, because it holds no members table and no ownership graph to check
152
+ * the claim against. The refusal has to be structural, exactly as `SubscriptionChangeInput` states it
153
+ * for the rail below.
154
+ *
155
+ * **No price.** {@link SubscriptionChangeRequest} names the *logical catalog product*, as
156
+ * {@link CheckoutRequest} does and for the same reason: a body-named `pri_…` moves a customer onto a
157
+ * plan this project does not sell, at a price it did not set. The catalog resolves the store's own
158
+ * identifier, server-side, after the product is known to exist.
159
+ *
160
+ * **No rail.** {@link CheckoutRequest} may name one because a rail is not a price and not a purchaser —
161
+ * it decides who takes the money on a purchase that has not happened yet. A subscription that already
162
+ * exists lives at exactly one store, and its own row says which. A named rail here could only ever be
163
+ * the wrong store asked about somebody's subscription.
164
+ *
165
+ * **No proration mode, and no `on_payment_failure`.** The rail picks the mode from the direction of the
166
+ * change and always prevents a change that cannot be paid for; `data/subscription.ts` holds the longer
167
+ * argument. Modeling either would make it a field, a field is a thing a client can set, and the value a
168
+ * client would eventually set is Paddle's `do_not_bill` — a free upgrade. It is unreachable because
169
+ * there is nowhere to write it.
170
+ *
171
+ * **No body at all on `keep`.** Withdrawing a scheduled cancellation takes no parameters: which
172
+ * subscription is the server's answer, and there is nothing to say about it but *do not*. So that route
173
+ * declares no schema and no `zValidator("json", …)`, exactly as `POST {base}/portal` does. A
174
+ * `z.object({})` would read as a refusal of everything and be neither — Zod strips rather than refuses —
175
+ * while making a POST with no body at all a 400 on a route that wants nothing.
176
+ */
177
+
178
+ /**
179
+ * What a caller may move their subscription to: a catalog product id, and nothing else.
180
+ *
181
+ * The same one field {@link CheckoutRequest} leads with, bounded the same way, and meaning the same
182
+ * thing: the key in `products`, never a store SKU and never a price. An unknown one is a 404 on the
183
+ * product rather than a bad request, because whether this project sells something is a config-backed
184
+ * lookup and a schema constrains a string.
185
+ */
186
+ export const SubscriptionChangeRequest = z
187
+ .object({
188
+ productId: z
189
+ .string()
190
+ .min(1)
191
+ .max(MAX_PRODUCT_ID_LENGTH)
192
+ .describe(
193
+ "The logical catalog product to move to — the key in `products`. The route resolves the store's own price from it, so a caller can only ever ask for a plan this project sells.",
194
+ ),
195
+ })
196
+ .describe("A caller asking to move their own subscription onto one catalog product.");
197
+ export type SubscriptionChangeRequest = z.output<typeof SubscriptionChangeRequest>;
198
+
199
+ /**
200
+ * What a caller may ask a quote about — {@link SubscriptionChangeRequest} itself, under the name the
201
+ * preview route line reads with.
202
+ *
203
+ * **The same value, not a copy of it.** A preview is the change with the commit removed: it asks the
204
+ * provider what moving to this product would cost and takes nothing. Two objects that must stay
205
+ * identical are two objects that will not — and the shape of that drift is a preview accepting a field
206
+ * the change refuses, quoting a customer a figure the commit then cannot honor. `schemas.test.ts`
207
+ * asserts the identity, so this stays an alias rather than becoming a second declaration.
208
+ */
209
+ export const SubscriptionPreviewRequest = SubscriptionChangeRequest;
210
+ export type SubscriptionPreviewRequest = SubscriptionChangeRequest;
211
+
212
+ /**
213
+ * When a cancellation takes effect. One field, and it is the customer's word rather than the store's.
214
+ *
215
+ * **Required, with no default.** `at_period_end` is the settled policy, and a default is still the
216
+ * wrong shape for it: the two timings are different things to buy — keep the period already paid for,
217
+ * or lose it today — and a body that omits the field would be choosing one of them by silence. A caller
218
+ * that means `at_period_end` says so, which is also what an audit row then records.
219
+ *
220
+ * The values come from `data/subscription.ts` rather than being spelled again here, so a third timing
221
+ * cannot exist on the wire and not in the rail. Paddle's own `immediately` and `next_billing_period` do
222
+ * not parse: the rail translates, and a request in the store's vocabulary means something upstream
223
+ * stopped translating.
224
+ */
225
+ export const SubscriptionCancelRequest = z
226
+ .object({
227
+ timing: SubscriptionCancelTiming.describe(
228
+ "When the cancellation takes effect — `at_period_end` stops the renewal and lets the paid period run out, `now` ends access today. Stated rather than defaulted: ending somebody's access is not a choice made by omission.",
229
+ ),
230
+ })
231
+ .describe("A caller ending their own subscription, and when they stop.");
232
+ export type SubscriptionCancelRequest = z.output<typeof SubscriptionCancelRequest>;
233
+
234
+ /**
235
+ * The terms of a discount to mint. The one control-plane write that creates an object costing money.
236
+ *
237
+ * `DiscountTerms` carries the whole shape and its own cross-field rules — a repeating duration must state
238
+ * the plan's billing interval, because Stripe counts months and Lemon Squeezy counts periods. The rail is
239
+ * named here rather than inferred: minting is an administrative act against one store's dashboard, and
240
+ * guessing which one would put a code where nobody was looking for it.
241
+ */
242
+ export const AdminDiscountsQuery = z
243
+ .object({
244
+ rail: PaymentsHostedRail.describe(
245
+ "Which store to list from. Required — a discount exists in one store, and the stores do not merge.",
246
+ ),
247
+ })
248
+ .describe("A management client listing the discount codes one store holds.");
249
+ export type AdminDiscountsQuery = z.infer<typeof AdminDiscountsQuery>;
250
+
251
+ export const DiscountCreateRequest = z
252
+ .object({
253
+ rail: PaymentsHostedRail.describe(
254
+ "Which store to mint the discount at. Required — a discount exists in one store's dashboard.",
255
+ ),
256
+ terms: DiscountTerms.describe("The discount's terms, in customer-visible units."),
257
+ })
258
+ .describe("A management client minting one discount code.");
259
+ export type DiscountCreateRequest = z.input<typeof DiscountCreateRequest>;
260
+
261
+ /**
262
+ * Google's webhook body: a Pub/Sub push, not a Play notification.
263
+ *
264
+ * The Google rail declares the same shape for itself, for the reason Apple's does — it decodes the bytes in order
265
+ * to check them and must not import across the HTTP seam to do it. Change one, change the other.
266
+ *
267
+ * **The proof is not in here.** A Pub/Sub push body carries no signature at all: authenticity is the OIDC token
268
+ * in the `Authorization` header, which the guard has already verified by the time a handler reads this. So this
269
+ * validator's job is only to say the body is shaped like a push, which is what keeps a malformed one a 400 rather
270
+ * than something a handler has to defend against.
271
+ */
272
+ export const GoogleWebhookNotification = z
273
+ .object({
274
+ message: z
275
+ .object({
276
+ data: z
277
+ .string()
278
+ .min(1)
279
+ .describe("The Play developer notification, base64-encoded. Decoded and checked by the guard."),
280
+ messageId: z
281
+ .string()
282
+ .min(1)
283
+ .describe("Pub/Sub's own message id — stable across redeliveries, which is what makes it the dedupe key."),
284
+ publishTime: z.string().min(1).optional().describe("When Pub/Sub published the message, RFC 3339."),
285
+ })
286
+ .loose()
287
+ .describe("The Pub/Sub message carrying one Play developer notification."),
288
+ subscription: z
289
+ .string()
290
+ .min(1)
291
+ .optional()
292
+ .describe("The push subscription's resource name. Recorded for diagnosis; nothing is decided from it."),
293
+ })
294
+ .loose()
295
+ .describe("The body Pub/Sub POSTs to the Google notification endpoint.");
296
+ export type GoogleWebhookNotification = z.infer<typeof GoogleWebhookNotification>;
297
+
298
+ /**
299
+ * Stripe's webhook body: an event envelope.
300
+ *
301
+ * Shape-only, like Google's. **The proof is not in here** — it is the HMAC in the `Stripe-Signature` header, which
302
+ * the guard has already checked against the exact received bytes by the time a handler reads this. So this
303
+ * validator's job is to say the body is shaped like an event, which keeps a malformed one a 400 rather than
304
+ * something a handler has to defend against. The Stripe rail declares the same shape for itself, in
305
+ * `rails/stripe/objects.ts`, because it parses the bytes in order to read them and must not import across the
306
+ * HTTP seam. Change one, change the other.
307
+ */
308
+ export const StripeWebhookNotification = z
309
+ .object({
310
+ id: z.string().min(1).describe("The event id — `evt_…`. The dedupe key, stable across Stripe's retries."),
311
+ type: z.string().min(1).describe("What happened — `customer.subscription.updated`."),
312
+ created: z.number().int().describe("Stripe's own timestamp for the event, in seconds since the epoch."),
313
+ data: z
314
+ .object({ object: z.record(z.string(), z.unknown()).describe("The object the event is about.") })
315
+ .loose()
316
+ .describe("The event's payload."),
317
+ })
318
+ .loose()
319
+ .describe("The body Stripe POSTs to the notification endpoint.");
320
+ export type StripeWebhookNotification = z.infer<typeof StripeWebhookNotification>;
321
+
322
+ /**
323
+ * Lemon Squeezy's webhook body: a JSON:API envelope with the event name in `meta`.
324
+ *
325
+ * The rail declares the same shape for itself, for the reason Apple's and Google's do — it reads the bytes in
326
+ * order to check them and must not import across the HTTP seam to do it. Change one, change the other.
327
+ *
328
+ * **The proof is not in here.** Authenticity is the bare HMAC in `X-Signature`, which the guard has verified
329
+ * over the exact received bytes before this validator parses anything. So this validator's job is only to say
330
+ * the body is shaped like a delivery, which keeps a malformed one a 400 rather than something a handler has to
331
+ * defend against.
332
+ */
333
+ export const LemonSqueezyWebhookNotification = z
334
+ .object({
335
+ meta: z
336
+ .object({
337
+ event_name: z.string().min(1).describe("What happened — `subscription_payment_success`, `order_created`."),
338
+ })
339
+ .loose()
340
+ .describe("The delivery's metadata, including anything this deployment asked to have echoed back."),
341
+ data: z
342
+ .object({
343
+ id: z.string().min(1).describe("The object's id. An integer as a string, and unique only within its type."),
344
+ type: z.string().min(1).describe("Which type that id belongs to — `orders`, `subscriptions`."),
345
+ })
346
+ .loose()
347
+ .describe("The object the event is about."),
348
+ })
349
+ .loose()
350
+ .describe("The body Lemon Squeezy POSTs to the notification endpoint.");
351
+ export type LemonSqueezyWebhookNotification = z.infer<typeof LemonSqueezyWebhookNotification>;
352
+
353
+ /**
354
+ * Paddle's webhook body: an event envelope carrying its own id, its type, when it happened, and the entity.
355
+ *
356
+ * The rail declares the same shape for itself, for the reason every other rail's does — it reads the bytes
357
+ * in order to check them and must not import across the HTTP seam to do it. Change one, change the other.
358
+ *
359
+ * **The proof is not in here.** Authenticity is the timestamped HMAC in `Paddle-Signature`, which the guard
360
+ * has verified over the exact received bytes before this validator parses anything. So this validator's job
361
+ * is only to say the body is shaped like a delivery, which keeps a malformed one a 400 rather than something
362
+ * a handler has to defend against.
363
+ */
364
+ export const PaddleWebhookNotification = z
365
+ .object({
366
+ event_id: z.string().min(1).describe("Paddle's own id for the event — `evt_…`. The dedup key."),
367
+ event_type: z.string().min(1).describe("What happened — `transaction.completed`, `subscription.canceled`."),
368
+ occurred_at: z.string().min(1).describe("When it happened. The watermark, off the envelope and never the entity."),
369
+ data: z.record(z.string(), z.unknown()).describe("The entity the event is about."),
370
+ })
371
+ .loose()
372
+ .describe("The body Paddle POSTs to a notification destination.");
373
+ export type PaddleWebhookNotification = z.infer<typeof PaddleWebhookNotification>;
374
+
375
+ /**
376
+ * A control-plane grant: who, which entitlement, and for how long.
377
+ *
378
+ * One of the two places in this package where a subject is a request field, and the only reason it is legal
379
+ * is the gate ahead of it — a manual grant is support acting on somebody else's holding, so naming that
380
+ * holder is the whole point. Every player-facing route resolves the subject on the server precisely because
381
+ * a body that could name one would let any signed-in caller write against another. Here that power *is* the
382
+ * feature, which is why the route requires a default-denied scoped control-plane credential and audits the
383
+ * write.
384
+ *
385
+ * **Both halves, and the type is named rather than assumed.** The row this writes is keyed
386
+ * `(subjectType, subjectId, entitlement)` and is read back by the same pair, so a grant that carried an id
387
+ * alone would land on whichever holder shares it. Whether the named kind is the one this project bills is a
388
+ * config-backed question and stays where the entitlement key's own catalog check is, in the handler: a
389
+ * schema constrains a string, it never replaces a lookup.
390
+ *
391
+ * No product, no rail, no price. A manual grant is not a purchase and must not pretend to be one: it writes
392
+ * the read model directly, with null provenance, and the purchase record stays empty because nothing was
393
+ * bought.
394
+ */
395
+ export const EntitlementGrantRequest = z
396
+ .object({
397
+ subjectType: PaymentsSubject.shape.subjectType.describe(
398
+ "Which kind of holder to grant — the half that makes the id an address. Named, never assumed from the project's `billingSubject`, so what an audit row records is what a management client asked for.",
399
+ ),
400
+ subjectId: PaymentsSubject.shape.subjectId.describe(
401
+ "The subject to grant. Support acts on somebody else's holding, so this is one of the two routes where the subject is a request field rather than the server's own answer.",
402
+ ),
403
+ entitlement: EntitlementKey.describe(
404
+ "The entitlement key to grant, as gating code names it. Not a store SKU. It must be one this project defines — a key some product grants, or one the adopter declared in `manualEntitlements` — and anything else is a 400 naming the key. Comping a key nothing sells is still the durable case; declaring it is how a project says so.",
405
+ ),
406
+ expiresAt: JsonDate.optional().describe(
407
+ "When the grant lapses, as an ISO 8601 timestamp. Omit for a comp that never ends. A past timestamp writes a row that grants nothing, which is a slower way of revoking.",
408
+ ),
409
+ })
410
+ .describe("A control-plane request to grant one entitlement to one subject, with no purchase behind it.");
411
+ export type EntitlementGrantRequest = z.output<typeof EntitlementGrantRequest>;
412
+
413
+ /**
414
+ * A control-plane revoke: who, and which entitlement.
415
+ *
416
+ * No expiry, because a revoke is immediate — the read model is the truth every gate hits, so the subject
417
+ * loses access on the next request rather than at the end of a period. Revoking a key the subject never held
418
+ * is legal and idempotent: the inactive row is itself the record that somebody decided it.
419
+ */
420
+ export const EntitlementRevokeRequest = z
421
+ .object({
422
+ subjectType: PaymentsSubject.shape.subjectType.describe(
423
+ "Which kind of holder to revoke from. Half the address: a revoke aimed at an id alone would clear whichever holder shares it, which is an outage for somebody who paid.",
424
+ ),
425
+ subjectId: PaymentsSubject.shape.subjectId.describe(
426
+ "The subject to revoke. As with the grant, the subject is named because support is acting on somebody else's holding.",
427
+ ),
428
+ entitlement: EntitlementKey.describe("The entitlement key to revoke, as gating code names it."),
429
+ })
430
+ .describe("A control-plane request to revoke one entitlement from one subject, effective immediately.");
431
+ export type EntitlementRevokeRequest = z.output<typeof EntitlementRevokeRequest>;
432
+
433
+ /**
434
+ * ## The management read queries
435
+ *
436
+ * The `Admin*` shapes below belong to the **control-plane reads**, which take no bodies at all: there is
437
+ * nothing for a client to send but filters and a place to resume.
438
+ *
439
+ * **Every filter is a closed enum of payments' own values, not a lookup against the adopter's config**,
440
+ * and that is the deliberate difference from `@pithy-sh/ledger`'s currency filter. A rail, a status and a
441
+ * store environment are the kit's own vocabulary — an unknown one is a *malformed request*, so the
442
+ * validator refuses it with a 400 naming the accepted set. A currency, a product id or an entitlement key
443
+ * is the adopter's, so a value that parses but is not configured is a missing resource and stays the
444
+ * handler's 404. A schema constrains a string; it never replaces a lookup, and it is never built from a
445
+ * configured key set.
446
+ */
447
+
448
+ /** Where a keyset page resumes. Opaque; a malformed one is a first page rather than an error. */
449
+ const Cursor = z
450
+ .string()
451
+ .max(MAX_CURSOR_LENGTH)
452
+ .optional()
453
+ .describe("Where to resume, from the previous page's `nextCursor`. Opaque; a malformed one is a first page.");
454
+
455
+ /** How many rows one page returns. Bounded, because a verified client can still have a bug. */
456
+ const Limit = z.coerce
457
+ .number()
458
+ .int()
459
+ .min(1)
460
+ .max(MAX_PAGE_SIZE)
461
+ .optional()
462
+ .describe(`How many rows to return, from 1 to ${MAX_PAGE_SIZE}. Defaults to a page a dashboard can render.`);
463
+
464
+ /**
465
+ * The owner filter on a management listing: both halves of a subject, or neither.
466
+ *
467
+ * **Two optional fields with a rule, rather than one field.** A query string is flat, so the pair arrives as
468
+ * two values and the rule is what keeps them one fact. An id without a kind is the dangerous half — the
469
+ * listing would narrow on `subject_id` alone and hand back an organization's purchases to a client that
470
+ * asked about a person, whenever an adopter's two id spaces happen to meet on a value. A kind without an id
471
+ * is merely useless, and it is refused with it because "or neither" is a rule somebody can hold in their
472
+ * head and "or neither, unless" is not.
473
+ *
474
+ * Refused rather than ignored. A filter that silently did nothing would render as *this holder bought
475
+ * everything on the page*, which is worse than a 400 naming what to send.
476
+ */
477
+ const SUBJECT_FILTER = {
478
+ subjectType: PaymentsSubject.shape.subjectType
479
+ .optional()
480
+ .describe("Which kind of holder to narrow to. Send it with `subjectId` or send neither."),
481
+ subjectId: PaymentsSubject.shape.subjectId
482
+ .optional()
483
+ .describe(
484
+ "Which holder to narrow to. Send it with `subjectType` or send neither: an id alone names whichever user or organization happens to carry it.",
485
+ ),
486
+ };
487
+
488
+ /** Both halves of the subject filter, or neither. See {@link SUBJECT_FILTER}. */
489
+ function subjectFilterIsWhole(query: { subjectType?: string; subjectId?: string }): boolean {
490
+ return (query.subjectType === undefined) === (query.subjectId === undefined);
491
+ }
492
+
493
+ /** What a caller is told when they send one half. Names the remedy, because the remedy is the other field. */
494
+ const SUBJECT_FILTER_RULE = {
495
+ message: "Send `subjectType` and `subjectId` together, or neither. Half a subject names no holder.",
496
+ } as const;
497
+
498
+ export const AdminPurchasesQuery = z
499
+ .object({
500
+ ...SUBJECT_FILTER,
501
+ rail: PaymentsRail.optional().describe("Restrict the listing to one store."),
502
+ status: PurchaseStatus.optional().describe("Restrict the listing to one normalized status."),
503
+ environment: PurchaseEnvironment.optional().describe(
504
+ "Restrict the listing to one store environment. Unfiltered by default: hiding sandbox transactions by default would hide the thing an operator most needs to notice.",
505
+ ),
506
+ cursor: Cursor,
507
+ limit: Limit,
508
+ })
509
+ .refine(subjectFilterIsWhole, SUBJECT_FILTER_RULE)
510
+ .describe("The purchase-log query: what to narrow it to, and where to resume.");
511
+ export type AdminPurchasesQuery = z.output<typeof AdminPurchasesQuery>;
512
+
513
+ export const AdminSubscriptionsQuery = z
514
+ .object({
515
+ ...SUBJECT_FILTER,
516
+ status: PurchaseStatus.optional().describe(
517
+ "Restrict the listing to one normalized status — `active` for who is paying now, `in_grace` for whose renewal is failing.",
518
+ ),
519
+ cursor: Cursor,
520
+ limit: Limit,
521
+ })
522
+ .refine(subjectFilterIsWhole, SUBJECT_FILTER_RULE)
523
+ .describe(
524
+ "The subscription query: what to narrow it to, and where to resume. No rail — a subscription is read forwards, not by store.",
525
+ );
526
+ export type AdminSubscriptionsQuery = z.output<typeof AdminSubscriptionsQuery>;
527
+
528
+ export const AdminEntitlementsQuery = z
529
+ .object({
530
+ ...SUBJECT_FILTER,
531
+ entitlement: EntitlementKey.optional().describe(
532
+ "Restrict the listing to one entitlement key — the `who holds pro` question. A shape check only: the key set is the adopter's, and one nothing grants is an empty page rather than a refusal.",
533
+ ),
534
+ cursor: Cursor,
535
+ limit: Limit,
536
+ })
537
+ .refine(subjectFilterIsWhole, SUBJECT_FILTER_RULE)
538
+ .describe("The entitlement query: what to narrow it to, and where to resume.");
539
+ export type AdminEntitlementsQuery = z.output<typeof AdminEntitlementsQuery>;
540
+
541
+ export const AdminReconcileRunsQuery = z
542
+ .object({
543
+ rail: PaymentsRail.optional().describe(
544
+ "Restrict to the passes narrowed to one store. A scheduled pass runs against every rail and carries no rail, so this never matches one.",
545
+ ),
546
+ environment: PurchaseEnvironment.optional().describe(
547
+ "Restrict to one store environment — the sandbox host and the production host each keep their own passes.",
548
+ ),
549
+ cursor: Cursor,
550
+ limit: Limit,
551
+ })
552
+ .describe("The reconciliation-run query: what to narrow it to, and where to resume.");
553
+ export type AdminReconcileRunsQuery = z.output<typeof AdminReconcileRunsQuery>;
554
+
555
+ /**
556
+ * The two path segments on the per-subject management read: `…/entitlements/:subjectType/:subjectId`.
557
+ *
558
+ * **Two segments, not one encoded reference.** `encodeSubjectReference` exists for the single-field slots a
559
+ * store gives us — Apple's `appAccountToken`, Stripe's `client_reference_id` — where there is exactly one
560
+ * string to write and it comes back through a webhook. A URL has as many segments as it needs, and the
561
+ * decoder for that wire format answers `undefined` for anything it does not recognize, which on a path
562
+ * would be a 404 that reads like a missing holder rather than the 400 a malformed address deserves. Two
563
+ * validated segments say which half is wrong.
564
+ *
565
+ * Neither is optional, because half an address is not a narrower read: it is a different holder.
566
+ */
567
+ export const AdminSubjectParam = z
568
+ .object({
569
+ subjectType: PaymentsSubject.shape.subjectType.describe(
570
+ "Which kind of holder to resolve — the `:subjectType` path segment. A closed enum, so an unknown kind is a 400 naming the two that exist.",
571
+ ),
572
+ subjectId: PaymentsSubject.shape.subjectId.describe(
573
+ "Whose entitlements to resolve — the `:subjectId` path segment. Opaque to payments: whatever id the adopter's auth capability or its own membership model issued.",
574
+ ),
575
+ })
576
+ .describe("The `:subjectType/:subjectId` path segments on the per-subject management read.");
577
+ export type AdminSubjectParam = z.output<typeof AdminSubjectParam>;