@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,1147 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { PaymentsProductType } from "../config/config";
6
+ import { PurchaseEnvironment } from "../data/purchase";
7
+ import { PaymentsHostedRail, PaymentsRail } from "../data/rail";
8
+ import { PurchaseStatus } from "../data/status";
9
+ import { PaymentsSubject } from "../data/subject";
10
+ import { RefundRequestStatus, ScheduledSubscriptionChangeAction } from "../data/subscription";
11
+
12
+ /**
13
+ * What the payments routes return, as Zod objects a client can validate against.
14
+ *
15
+ * `schemas.ts` bounds what a caller may send; this file states what it gets back. Both halves are
16
+ * runtime values for the same reason: a management client reading a customer's Worker is crossing a
17
+ * trust boundary and must validate what comes back, and a TypeScript interface is erased before it
18
+ * can help — so every client that had only an interface hand-wrote a mirror, and the mirror drifted
19
+ * the first time a field landed here.
20
+ *
21
+ * **No codecs, and no transform anywhere in this file.** These describe JSON on the wire, so parsing
22
+ * one hands back exactly what went in — which is what lets `responses.test.ts` compare a parsed value
23
+ * with the projection's output and fail on a field either side forgot.
24
+ *
25
+ * **Nothing here is a receipt.** `PaymentsPurchase.payload` is the whole verified provider response,
26
+ * and a bearer artifact; the purchase view is the normalized projection of it, and there is no field
27
+ * that carries the original. The webhook responses are deliberately absent from this file too — they
28
+ * are acknowledgements addressed to Apple, Google and Stripe, not a contract offered to any client.
29
+ *
30
+ * **A client's own views name no subject; the management views name both halves of one.** A player reads
31
+ * its own rows, and who holds them is the answer the request already carried — echoing it back teaches a
32
+ * client that the holder is a value in the protocol, which is the first step towards one sending it. A
33
+ * management client reads everybody's, so every row it sees has to say whose it is, and says it as the
34
+ * pair: nothing keeps an organization id from equalling some user's id, so a view carrying the id alone
35
+ * would render one holder's subscription under the other's name.
36
+ *
37
+ * **A field added here later is `.optional()`, not merely `.nullable()`.** This module is read across a
38
+ * version boundary — a management client validates a response with this schema against a customer's
39
+ * Worker at whatever kit version it is on — so an additive required key fails `safeParse` for everyone
40
+ * below that release and takes the whole pane with it (#450). Absent then means *this Worker cannot
41
+ * say*, which is a different fact from `null`.
42
+ */
43
+
44
+ /** One entitlement as a client reads it. */
45
+ export const PaymentsEntitlementView = z
46
+ .object({
47
+ key: z.string().describe("The entitlement key, as the catalog spells it."),
48
+ granted: z.boolean().describe("Whether it grants right now. Re-checked against `expiresAt` on every read."),
49
+ expiresAt: z.iso.datetime().nullable().describe("When it lapses, ISO-8601; null when it does not."),
50
+ })
51
+ .describe("One entitlement: the key, whether it grants right now, and when it lapses.");
52
+ export type PaymentsEntitlementView = z.output<typeof PaymentsEntitlementView>;
53
+
54
+ /** One purchase as a client may see it — the normalized projection, never the stored provider payload. */
55
+ export const PaymentsPurchaseView = z
56
+ .object({
57
+ id: z.string().describe("The purchase's UUID."),
58
+ rail: PaymentsRail.describe("Which store this transaction came from."),
59
+ productId: z.string().describe("The catalog product the verified SKU resolved to."),
60
+ type: PaymentsProductType.describe("What kind of product it is."),
61
+ status: PurchaseStatus.describe("The normalized status. Nothing here is ever a rail-specific state."),
62
+ environment: PurchaseEnvironment.describe(
63
+ "The store environment it happened in. A sandbox purchase never grants in production.",
64
+ ),
65
+ purchasedAt: z.iso.datetime().describe("When the store recorded the purchase, ISO-8601."),
66
+ expiresAt: z.iso.datetime().nullable().describe("When the entitlement it bought lapses, ISO-8601; null when none."),
67
+ resumesAt: z.iso
68
+ .datetime()
69
+ .nullable()
70
+ .describe(
71
+ "When a paused subscription resumes, ISO-8601, as the store stated it. Null with `status` `paused` is a pause the store put no end on; null with any other status means it is not paused.",
72
+ ),
73
+ outcome: z
74
+ .enum(["created", "updated", "ignored"])
75
+ .describe(
76
+ "What the write actually did. `ignored` is a success — a replay of a receipt already projected, or an event staler than the row it would have overwritten.",
77
+ ),
78
+ })
79
+ .describe("One purchase as the projection left it. Never the receipt, which is a bearer artifact.");
80
+ export type PaymentsPurchaseView = z.output<typeof PaymentsPurchaseView>;
81
+
82
+ /** `POST {base}/purchases`. */
83
+ export const PaymentsPurchaseResponse = z
84
+ .object({
85
+ purchase: PaymentsPurchaseView.describe("The purchase as this submission left it."),
86
+ entitlements: z.array(PaymentsEntitlementView).describe("What that purchase grants, resolved now."),
87
+ })
88
+ .describe("A verified purchase and the entitlements it produced.");
89
+ export type PaymentsPurchaseResponse = z.output<typeof PaymentsPurchaseResponse>;
90
+
91
+ /** `GET {base}/entitlements` — always the caller's own. */
92
+ export const PaymentsEntitlementsResponse = z
93
+ .object({ entitlements: z.array(PaymentsEntitlementView).describe("The caller's entitlements, resolved now.") })
94
+ .describe("Every entitlement the caller holds.");
95
+ export type PaymentsEntitlementsResponse = z.output<typeof PaymentsEntitlementsResponse>;
96
+
97
+ /** `POST {base}/restore`. */
98
+ export const PaymentsRestoreResponse = z
99
+ .object({
100
+ purchases: z.array(PaymentsPurchaseView).describe("Every receipt in the batch, as the projection left it."),
101
+ entitlements: z.array(PaymentsEntitlementView).describe("The caller's entitlements after the restore."),
102
+ })
103
+ .describe("What a Restore Purchases run projected, and what the caller now holds.");
104
+ export type PaymentsRestoreResponse = z.output<typeof PaymentsRestoreResponse>;
105
+
106
+ /**
107
+ * `POST {base}/checkout` — how the browser reaches the store's payment page.
108
+ *
109
+ * A discriminated union rather than `{ url }`, because one rail has no URL to give. Stripe and Lemon
110
+ * Squeezy mint a hosted page and answer with its address; Paddle's overlay and inline modes never leave
111
+ * the adopter's page, so the server answers with the transaction the browser opens with Paddle.js and the
112
+ * publishable token it initializes against. A `url` filled with an empty string would be a field a screen
113
+ * navigates to.
114
+ *
115
+ * **Nothing secret crosses.** The client token is publishable exactly as a Stripe price id is. The API
116
+ * key, the webhook signing secret and the resolved discount id are all server-side and none of them is
117
+ * expressible here.
118
+ */
119
+ export const PaymentsCheckoutHandoffResponse = z
120
+ .discriminatedUnion("kind", [
121
+ z
122
+ .object({
123
+ kind: z.literal("redirect").describe("A hosted page the browser is sent to."),
124
+ url: z
125
+ .string()
126
+ .describe("Where to send the browser. The store's own page; it expires on the store's schedule."),
127
+ })
128
+ .describe("A redirect handoff — Stripe's hosted Checkout, Lemon Squeezy's hosted checkout."),
129
+ z
130
+ .object({
131
+ kind: z.literal("paddle").describe("A transaction the browser opens over this page with Paddle.js."),
132
+ transactionId: z.string().describe("The transaction the server created — `txn_…`."),
133
+ clientToken: z.string().describe("Paddle's publishable client token, which is designed to reach a browser."),
134
+ environment: z
135
+ .enum(["sandbox", "production"])
136
+ .describe("Which Paddle environment the token belongs to. `Paddle.Environment.set` takes it verbatim."),
137
+ displayMode: z
138
+ .enum(["overlay", "inline"])
139
+ .describe("Whether the checkout opens over the page or inside a container the screen provides."),
140
+ successUrl: z
141
+ .string()
142
+ .describe(
143
+ "Where a buyer who paid is sent, from `config.paddle.successUrl`. Paddle.js takes it as `settings.successUrl` when the checkout opens, which is why it crosses here rather than staying on the server — and why it comes from config rather than from the request that asked for the checkout.",
144
+ ),
145
+ })
146
+ .describe("A Paddle.js handoff — nothing to navigate to, because the checkout opens in place."),
147
+ ])
148
+ .describe("How the buyer reaches checkout: a page to go to, or a transaction to open in place.");
149
+ export type PaymentsCheckoutHandoffResponse = z.output<typeof PaymentsCheckoutHandoffResponse>;
150
+
151
+ /**
152
+ * `POST {base}/portal` — the caller's own billing portal.
153
+ *
154
+ * `subscriptions` is present only for a store that mints per-subscription deep links, which is Paddle
155
+ * alone. Every URL in this response is a bearer credential for that customer's billing — Paddle's is good
156
+ * for 24 hours — so nothing here is cached, persisted, or logged.
157
+ */
158
+ export const PaymentsPortalHandoffResponse = z
159
+ .object({
160
+ url: z.string().describe("The portal's overview page for this customer."),
161
+ subscriptions: z
162
+ .array(
163
+ z
164
+ .object({
165
+ subscriptionId: z.string().describe("The store's own subscription id."),
166
+ cancel: z.string().describe("Where this subscription is canceled."),
167
+ updatePaymentMethod: z.string().describe("Where this subscription's payment method is changed."),
168
+ })
169
+ .describe("One subscription's deep links. Every URL here is a bearer credential for that billing."),
170
+ )
171
+ .optional()
172
+ .describe("Per-subscription deep links, for the store that offers them. Absent on the rails that do not."),
173
+ })
174
+ .describe("Where the caller manages their own billing, and the per-subscription actions the store offers.");
175
+ export type PaymentsPortalHandoffResponse = z.output<typeof PaymentsPortalHandoffResponse>;
176
+
177
+ /**
178
+ * `POST {base}/entitlements/grant` and `POST {base}/entitlements/revoke` — the two control-plane routes.
179
+ *
180
+ * One shape for both, because both are the same act read two ways: the entitlement as it now stands.
181
+ * A revoke returns `granted: false` rather than nothing, so a management client renders the state it
182
+ * produced instead of assuming it.
183
+ */
184
+ export const PaymentsEntitlementResponse = z
185
+ .object({ entitlement: PaymentsEntitlementView.describe("The entitlement as the write left it.") })
186
+ .describe("The single entitlement a management client granted or revoked, as it now stands.");
187
+ export type PaymentsEntitlementResponse = z.output<typeof PaymentsEntitlementResponse>;
188
+
189
+ /**
190
+ * What the caller's own subscription pays now, what it becomes, and when.
191
+ *
192
+ * A **bearer** response, not a management one: this is a customer reading their own bill. Dates cross as
193
+ * ISO-8601 strings like everything else here, and every amount is the store's own figure — nothing in this
194
+ * package multiplies a price by a percentage.
195
+ */
196
+ export const PaymentsPricingResponse = z
197
+ .object({
198
+ currency: z.string().nullable().describe("The currency both amounts are in, or null."),
199
+ currentAmountMinor: z.number().int().nullable().describe("What the next invoice comes to under any discount."),
200
+ listAmountMinor: z.number().int().nullable().describe("What it comes to once the discount ends."),
201
+ discountCode: z.string().nullable().describe("The code in force, or null at list price."),
202
+ discountEndsAt: z
203
+ .string()
204
+ .nullable()
205
+ .describe(
206
+ "When the rate changes, ISO-8601, or null — which is either no discount or one that runs forever. Read it beside `discountCode` to tell which.",
207
+ ),
208
+ })
209
+ .describe("What this subscriber pays, what they will pay, and when that changes.");
210
+ export type PaymentsPricingResponse = z.output<typeof PaymentsPricingResponse>;
211
+
212
+ /**
213
+ * Who a store prices this caller as — the identity a quote and a charge must both resolve from.
214
+ *
215
+ * **The point of it is agreement, not disclosure.** `POST /payments/checkout` hands this exact value to
216
+ * the rail as the customer being charged, read from the provider-account map keyed on the subject the
217
+ * authenticated caller acts for. A browser quoting a price without it reads an IP-derived estimate and is then charged from a
218
+ * billing address, and the two can differ by up to 15% in the United States alone. So the same row is
219
+ * published here, and the screen asks Paddle about the customer rather than about the network.
220
+ *
221
+ * **An identifier, not a credential.** `ctm_…` names a Paddle customer and authorizes nothing; Paddle's
222
+ * `PricePreview` reads a price with it and the publishable client token, which is the pair Paddle
223
+ * publishes for browsers. The route is `requireAuth()` and answers only about its own caller, so nobody
224
+ * learns anybody else's.
225
+ */
226
+ export const PaymentsQuotedFrom = z
227
+ .object({
228
+ rail: z.literal("paddle").describe("Which store holds this identity. Paddle is the rail that quotes in a browser."),
229
+ providerAccountId: z
230
+ .string()
231
+ .min(1)
232
+ .describe("The store's own customer id — `ctm_…`. The same value this caller's checkout is charged against."),
233
+ })
234
+ .describe("Who a store prices this caller as, so a quote and a charge resolve location from one row.");
235
+ export type PaymentsQuotedFrom = z.output<typeof PaymentsQuotedFrom>;
236
+
237
+ /**
238
+ * What `GET /payments/pricing` answers with.
239
+ *
240
+ * Two independent facts about one caller, and each is null on its own terms. `pricing` is null when no
241
+ * rail can price a subscription they hold — including when they hold none. `quotedFrom` is null when no
242
+ * store holds a customer for them yet, which is the ordinary state of somebody who has not bought
243
+ * anything. A caller can have either without the other, which is why they are siblings rather than one
244
+ * nested in the other.
245
+ */
246
+ export const PaymentsPricingEnvelope = z
247
+ .object({
248
+ pricing: PaymentsPricingResponse.nullable().describe("What this caller's subscription pays, or null."),
249
+ quotedFrom: PaymentsQuotedFrom.nullable().describe("Who a store prices this caller as, or null."),
250
+ })
251
+ .describe("What this caller pays, and who the store prices them as.");
252
+ export type PaymentsPricingEnvelope = z.output<typeof PaymentsPricingEnvelope>;
253
+
254
+ /**
255
+ * ## The subscription lifecycle responses
256
+ *
257
+ * What `GET {base}/subscription` reads and what the three writes beside it answer with. **Bearer
258
+ * shapes, not management ones**: this is a customer reading and changing their own bill.
259
+ *
260
+ * **These are wire mirrors of `data/subscription.ts`, and the duplication is forced.** Those shapes
261
+ * carry `JsonDate` codecs so the rail hands the rest of the package real `Date`s; this file declares no
262
+ * codec and no transform anywhere, because these describe the JSON a browser receives — parsing one has
263
+ * to hand back exactly what went in. Re-exporting the data shapes here would give a client `Date`
264
+ * objects it cannot have and would make `responses.test.ts`'s equality check unwritable. So `.encode()`
265
+ * is the bridge, and `responses.test.ts` builds every fixture below by encoding a real
266
+ * `SubscriptionStanding` and a real `SubscriptionChangeQuote` and comparing key sets — which is what
267
+ * keeps two objects that must agree from drifting into a date a screen cannot read.
268
+ *
269
+ * **No store identifier crosses.** No `sub_…`, no `ctm_…`, no `txn_…`, and no price id. A customer's
270
+ * screen addresses their subscription by *being that customer*; the route resolves the row. An
271
+ * identifier published here is a field a request grows next, and the request that grows it is the one
272
+ * that names somebody else's subscription.
273
+ *
274
+ * **Required rather than `.optional()`, unlike a field added to a shape that already shipped.** The
275
+ * module note above is about widening a response an older Worker already answers; these arrive with the
276
+ * routes that answer them, so a Worker too old to have the field is a Worker that 404s the route and
277
+ * never returns a body for a client to validate.
278
+ */
279
+
280
+ /**
281
+ * An amount a provider quoted, on the wire: an integer in the currency's minor unit, and the currency.
282
+ *
283
+ * **Signed, because the provider's own figures are.** A credit comes back negative from Paddle and a
284
+ * downgrade's totals are negative throughout; a `.nonnegative()` here would refuse the recorded response
285
+ * of every plan change these routes exist to make. **And never a float** — 6582 is $65.82, so a `65.82`
286
+ * arriving here is somebody reading a rendered figure back in, in a currency whose minor unit may not be
287
+ * a hundredth.
288
+ *
289
+ * `currency` is a plain string, as it is on {@link PaymentsPricingResponse}, and deliberately not the
290
+ * lowercase-only rule `data/subscription.ts` enforces. That rule is where a rail that stopped lowering
291
+ * its provider's casing fails — at the boundary that reads the store. Re-refusing it here would take a
292
+ * customer's whole subscription pane down over a casing difference, which is the failure mode #450
293
+ * names, arrived at from the other direction.
294
+ *
295
+ * **`rendered` is what a screen puts in front of the customer, and it is why this response is usable at
296
+ * all** (#465). Minor units alone cross as bare digits, and a client cannot scale them without carrying
297
+ * the currency exponents itself — which is the table this Worker already has and the client does not. So
298
+ * the figure is rendered here, in the locale the request resolved, and crosses beside the integer rather
299
+ * than instead of it: a client comparing two amounts still reads `amountMinor`.
300
+ *
301
+ * **`.min(1)` on it, unlike a `.string()` elsewhere in this file.** An empty string is a confirmation
302
+ * screen with a blank where the price goes, and a blank is the one thing worse than a figure in the wrong
303
+ * language.
304
+ */
305
+ export const PaymentsQuotedMoney = z
306
+ .object({
307
+ amountMinor: z
308
+ .number()
309
+ .int()
310
+ .describe(
311
+ "How much, as an integer in the currency's minor unit. Signed: a credit is negative on the wire. 6582 is $65.82.",
312
+ ),
313
+ currency: z.string().describe("The ISO currency the amount is in, as this Worker stores it — lowercase."),
314
+ rendered: z
315
+ .string()
316
+ .min(1)
317
+ .describe(
318
+ "The same amount, rendered for the reader this response was built for — `$65.82`, `65,82 US$`, `¥6,582`. What a screen displays; `amountMinor` is what it compares. Never a second answer to how much: the store's integer decides the amount and only its spelling is decided here.",
319
+ ),
320
+ })
321
+ .describe("An amount a store quoted: minor units, one currency, and the figure as this reader reads it.");
322
+ export type PaymentsQuotedMoney = z.output<typeof PaymentsQuotedMoney>;
323
+
324
+ /**
325
+ * The three settlement members, declared once because two unions are built from them — the same sharing
326
+ * `data/subscription.ts` does, for the same reason. Two hand-written lists differing by one member are
327
+ * two lists that will differ by two the next time an outcome is added.
328
+ */
329
+ /**
330
+ * The two halves a settled amount reconciles: what the plan being moved to costs for the rest of the period,
331
+ * and what the plan being left gives back over the same unused time.
332
+ *
333
+ * **This exists because the net alone is a figure the customer cannot check.** Recorded 2026-08-31 (#96),
334
+ * previewing Solo → Team mid-period: `charge` 5116, `credit` -233, net 4883. The screen said "$48.83 to pay"
335
+ * beside two prices of $18 and $110, and the only honest reading available to somebody looking at it was
336
+ * that the store had got it wrong.
337
+ *
338
+ * **The credit is negative, and stays negative on the wire.** Flipping the sign here would put the direction
339
+ * in the field name for one half and in the number for the other. It is money coming off; it reads that way.
340
+ *
341
+ * Nothing is derived: `charge + credit` is the store's arithmetic, and the net is stated separately because
342
+ * it is separately stated. A screen that summed these to check would have a second answer to what a change
343
+ * costs, and the one the customer believes is on their statement.
344
+ */
345
+ const SettlementParts = z
346
+ .object({
347
+ charge: PaymentsQuotedMoney.describe(
348
+ "What the plan being moved to costs for the remainder of the period already paid for, before the credit comes off.",
349
+ ),
350
+ credit: PaymentsQuotedMoney.describe(
351
+ "What the plan being left is worth back over that same unused time. **Negative** — it is money coming off.",
352
+ ),
353
+ })
354
+ .describe("The charge and the credit a settlement reconciles, both as the store stated them.");
355
+
356
+ /** Why it is nullable, said once for both members that carry it. */
357
+ const MADE_UP_OF =
358
+ "The charge and the credit this amount reconciles, or null when the store did not state both in full. A screen shows the breakdown when there is one; the amount stands on its own when there is not.";
359
+
360
+ const SettlesByCharge = z
361
+ .object({
362
+ outcome: z.literal("charge").describe("The customer is billed."),
363
+ amount: PaymentsQuotedMoney.describe("How much is taken, as a positive magnitude. The direction is `outcome`."),
364
+ madeUpOf: SettlementParts.nullable().describe(MADE_UP_OF),
365
+ })
366
+ .describe("Money leaves the customer — the upgrade case, prorated immediately.");
367
+
368
+ const SettlesByCredit = z
369
+ .object({
370
+ outcome: z.literal("credit").describe("The customer is owed, and it lands as credit rather than as cash."),
371
+ amount: PaymentsQuotedMoney.describe(
372
+ "How much the customer is owed, as a positive magnitude. The same number rendered without `outcome` is a charge.",
373
+ ),
374
+ madeUpOf: SettlementParts.nullable().describe(MADE_UP_OF),
375
+ })
376
+ .describe("The customer is owed. It reaches their balance, not their card.");
377
+
378
+ const SettlesNothing = z
379
+ .object({ outcome: z.literal("nothing").describe("Nothing is billed or credited. There is no amount to state.") })
380
+ .describe("Nothing settles: no transaction at all, with the difference carried to the next invoice.");
381
+
382
+ /**
383
+ * What settles at one moment — the thing a confirmation screen states.
384
+ *
385
+ * **A discriminated union rather than a signed number**, because the direction must be unreadable
386
+ * without being read: a 6581 credit and a 6581 charge are the same characters and the opposite meaning,
387
+ * and nothing in a type system would object to the swap. Here `amount` cannot be reached without
388
+ * matching `outcome` first.
389
+ *
390
+ * **`nothing` is a member, not a zero**, and it carries no amount — one smuggled in does not survive the
391
+ * parse. "Nothing to pay today" and "a charge of $0.00" are different sentences and only one of them
392
+ * describes what is happening.
393
+ */
394
+ export const PaymentsSubscriptionSettlement = z
395
+ .discriminatedUnion("outcome", [SettlesByCharge, SettlesByCredit, SettlesNothing])
396
+ .describe("What settles at one moment — a charge, a credit, or nothing, with the direction as the discriminant.");
397
+ export type PaymentsSubscriptionSettlement = z.output<typeof PaymentsSubscriptionSettlement>;
398
+
399
+ /**
400
+ * The same settlement minus `nothing` — what lands on an invoice that is not today's.
401
+ *
402
+ * The block holding it is nullable, and null already says nothing lands later. Two spellings of one fact
403
+ * is how a screen checks the block for presence, finds it, and renders "$— credit on 15 Sep": a row
404
+ * about no money, dated.
405
+ */
406
+ export const PaymentsDeferredSubscriptionSettlement = z
407
+ .discriminatedUnion("outcome", [SettlesByCharge, SettlesByCredit])
408
+ .describe("What lands on a later invoice: a charge or a credit. Never nothing — a null block says that.");
409
+ export type PaymentsDeferredSubscriptionSettlement = z.output<typeof PaymentsDeferredSubscriptionSettlement>;
410
+
411
+ /**
412
+ * What a change costs, as the customer sees it before confirming — the store's own preview, normalized.
413
+ *
414
+ * **Three facts, because a deferred downgrade has three**: what happens today, what happens on the next
415
+ * invoice, and what the subscription pays from then on. The recorded downgrade settles *nothing* today
416
+ * and still owes the customer 6558 — so a shape with two parts is one that either says money moved on a
417
+ * day it did not, or drops 65.58 dollars out of a quote a customer is being asked to agree to.
418
+ *
419
+ * Nothing here is derived from anything else here. The store is the authority on what is owed, and a
420
+ * second answer is a second number for a customer to hold against their statement.
421
+ */
422
+ /**
423
+ * What a recurring price is made of: the plan's own rate, and the tax on it.
424
+ *
425
+ * **The plans table said $110 and the quote beside it said $119.76** — both that plan's price, one before
426
+ * tax and one after, with nothing on the page saying so. The store states the split whole (`subtotal`,
427
+ * `tax`), so it crosses to the browser rather than being reconstructed there.
428
+ *
429
+ * The tax *rate* is deliberately absent. A percentage beside two amounts invites a reader to check
430
+ * arithmetic, and one rounding decision separates a rate that reproduces the figure from one that does not.
431
+ */
432
+ const RecurringParts = z
433
+ .object({
434
+ beforeTax: PaymentsQuotedMoney.describe(
435
+ "What the plan itself costs each period, before tax and after any discount.",
436
+ ),
437
+ tax: PaymentsQuotedMoney.describe("The tax on that base, as the store assessed it for this customer."),
438
+ })
439
+ .describe("The two figures a recurring price is the sum of, both as the store stated them.");
440
+
441
+ export const PaymentsSubscriptionQuote = z
442
+ .object({
443
+ settlesToday: PaymentsSubscriptionSettlement.describe(
444
+ "What is taken or given **today, and only today** — charged, credited, or nothing at all.",
445
+ ),
446
+ nextInvoice: z
447
+ .object({
448
+ settlement: PaymentsDeferredSubscriptionSettlement.describe(
449
+ "What lands on that invoice, direction first — the recorded deferred downgrade is a credit of 6558.",
450
+ ),
451
+ at: z.iso.datetime().describe("The day that invoice falls, ISO-8601."),
452
+ })
453
+ .nullable()
454
+ .describe(
455
+ "The part of the change that settles on the **next** invoice rather than now, and the day it does. Null means nothing from this change lands later, which is every immediate proration. The amount is what *this change* is worth, never that invoice's own total with the new rate already netted off it.",
456
+ ),
457
+ recurring: z
458
+ .object({
459
+ amount: PaymentsQuotedMoney.describe("What each period costs once the change has taken effect, tax included."),
460
+ startsAt: z.iso.datetime().describe("When that amount first bills, ISO-8601 — the end of the period prorated."),
461
+ madeUpOf: RecurringParts.nullable().describe(
462
+ "The base and the tax `amount` is the sum of, or null when the store did not state both in full. The same name a settlement uses for the same idea, so a screen has one word for what a figure is made of.",
463
+ ),
464
+ })
465
+ .nullable()
466
+ .describe(
467
+ "What the subscription pays each period afterwards, and from when. Null means nothing renews after this change — the subscription is ending, which is a sentence a screen writes rather than a figure it invents.",
468
+ ),
469
+ })
470
+ .describe(
471
+ "A store's preview of a subscription change: what settles today, what settles on the next invoice, and what it pays afterwards. Rendered, confirmed, discarded — never stored.",
472
+ );
473
+ export type PaymentsSubscriptionQuote = z.output<typeof PaymentsSubscriptionQuote>;
474
+
475
+ /**
476
+ * A change the store will apply later — the object that makes an `active` subscription's future
477
+ * different from its present.
478
+ *
479
+ * **This is what distinguishes "renews on the 15th" from "ends on the 15th".** With a cancellation
480
+ * scheduled, Paddle reports `status: "active"`, no cancellation date, and a blank next billing date: two
481
+ * of those say the subscription is fine and the third says nothing. Only this object says what is
482
+ * coming, which is why it crosses to the customer rather than being read into the status server-side.
483
+ *
484
+ * The action enum is imported from `data/subscription.ts` rather than respelled — it is a closed set
485
+ * with no codec in it, so it crosses this seam intact, and a fourth schedulable action cannot then exist
486
+ * on one side only.
487
+ */
488
+ export const PaymentsSubscriptionScheduledChange = z
489
+ .object({
490
+ action: ScheduledSubscriptionChangeAction.describe("What will happen: the subscription ends, pauses, or resumes."),
491
+ effectiveAt: z.iso
492
+ .datetime()
493
+ .describe(
494
+ "When it happens, ISO-8601. On a scheduled cancellation this is the date the customer is owed — it is where 'until' comes from once the next billing date has gone blank.",
495
+ ),
496
+ resumesAt: z.iso
497
+ .datetime()
498
+ .nullable()
499
+ .describe(
500
+ "When a paused subscription comes back, ISO-8601, when the store named a day. Null on a pause means indefinitely; null on a cancel or a resume means the field does not apply.",
501
+ ),
502
+ })
503
+ .describe("A change the store will apply at a stated future moment, on a subscription that is fine until then.");
504
+ export type PaymentsSubscriptionScheduledChange = z.output<typeof PaymentsSubscriptionScheduledChange>;
505
+
506
+ /**
507
+ * What happens to this subscription next, and when — the reading of the standing, published rather than
508
+ * left for a screen to derive.
509
+ *
510
+ * **The precedence is the whole point, and it is not obvious**: a scheduled change wins over the next
511
+ * billing date, because Paddle *blanks* that date the moment a cancellation is scheduled. A screen
512
+ * reading the status says the subscription renews; a screen reading the billing date says nothing at
513
+ * all; the date the customer is owed exists only on the scheduled change. Every client would have to
514
+ * rediscover that, and the ones that got it wrong would tell somebody who canceled that they will be
515
+ * billed again.
516
+ *
517
+ * So this is a derived field on purpose, in the register {@link PaymentsAdminEntitlementView}'s
518
+ * `granted` already sets: the rule lives once, on the server, and the answer crosses. It is the answer
519
+ * `nextSubscriptionEvent` gives in `data/subscription.ts`, encoded — a client cannot call that function,
520
+ * because it takes `Date`s and this is JSON.
521
+ *
522
+ * **A union of two members, so `at` is null on exactly one kind.** A caller that has narrowed to any
523
+ * other has a date without checking for one, and `unknown` cannot carry a day a screen would print for
524
+ * an event nobody said was happening.
525
+ */
526
+ export const PaymentsSubscriptionNextEvent = z
527
+ .discriminatedUnion("kind", [
528
+ z
529
+ .object({
530
+ kind: z
531
+ .enum(["renews", "ends", "pauses", "resumes"])
532
+ .describe("What happens next — a renewal falling due, or the scheduled change landing."),
533
+ at: z.iso.datetime().describe("When it happens, ISO-8601."),
534
+ })
535
+ .describe("Something is going to happen, and the store said when."),
536
+ z
537
+ .object({
538
+ kind: z
539
+ .literal("unknown")
540
+ .describe(
541
+ "Nothing is scheduled and nothing is due. An expired subscription and one whose store went quiet both land here, and neither of them renews.",
542
+ ),
543
+ at: z.null().describe("There is no date, because there is no event."),
544
+ })
545
+ .describe("Nothing is known to be coming. A screen says so rather than printing a date it was not given."),
546
+ ])
547
+ .describe("The next thing that happens to this subscription, and when — the scheduled change first.");
548
+ export type PaymentsSubscriptionNextEvent = z.output<typeof PaymentsSubscriptionNextEvent>;
549
+
550
+ /**
551
+ * One subscription as its own holder reads it: which plan, where it stands, and what happens next.
552
+ *
553
+ * The standing's own five fields cross verbatim, and two are added.
554
+ *
555
+ * **`productId`, because a screen has to name the plan.** "Team, renews 15 Sep" is unwritable without
556
+ * it, and it is the one fact here a client cannot already know: the route resolved the subscription from
557
+ * this caller's own purchase rows, so which plan they are on is the server's answer. The display name is
558
+ * not copied beside it — that is the catalog's, read once by whatever screen renders a paywall, and a
559
+ * second copy traveling on every standing is a name that goes stale on the customer's screen the day an
560
+ * adopter renames a product.
561
+ *
562
+ * **`nextEvent`, because the precedence rule must not be client-side.** See
563
+ * {@link PaymentsSubscriptionNextEvent}.
564
+ *
565
+ * **Nothing about money.** What the subscription costs is `GET {base}/pricing`'s answer, which already
566
+ * states the discount in force and when it lapses; a second price here would be two figures to keep in
567
+ * step. `currency` crosses so a screen can format that price without a second lookup, which is exactly
568
+ * why it is on the standing in the first place.
569
+ */
570
+ export const PaymentsSubscriptionView = z
571
+ .object({
572
+ productId: z
573
+ .string()
574
+ .describe(
575
+ "The catalog product this subscription is for — the key in `products`, resolved from the caller's own purchase row. What a screen looks a display name up by.",
576
+ ),
577
+ status: PurchaseStatus.describe(
578
+ "The normalized status, never a store's own. **It does not say whether the subscription is ending** — a scheduled cancellation leaves it `active`. Read `nextEvent`.",
579
+ ),
580
+ currency: z
581
+ .string()
582
+ .nullable()
583
+ .describe(
584
+ "The currency this subscription bills in, or null when the store did not state one. Here to format the price `GET {base}/pricing` carries, not to carry a price.",
585
+ ),
586
+ currentPeriodEndsAt: z.iso
587
+ .datetime()
588
+ .nullable()
589
+ .describe(
590
+ "When the period already paid for runs out, ISO-8601 — the day access lapses if nothing renews it. Null while trialing or paused, which are the states with no billing period.",
591
+ ),
592
+ nextBilledAt: z.iso
593
+ .datetime()
594
+ .nullable()
595
+ .describe(
596
+ "When the next charge falls due, ISO-8601, or null when none is going to. **Null is not canceled and not broken:** the store blanks it the moment a cancellation is scheduled and leaves the status `active`. Render `nextEvent` instead of putting this beside the word 'renews'.",
597
+ ),
598
+ scheduledChange: PaymentsSubscriptionScheduledChange.nullable().describe(
599
+ "The change waiting to land, or null when nothing is. The only field that separates a subscription ending this period from one renewing.",
600
+ ),
601
+ nextEvent: PaymentsSubscriptionNextEvent.describe(
602
+ "What happens next and when, already resolved — the scheduled change ahead of the billing date. The sentence a screen prints.",
603
+ ),
604
+ })
605
+ .describe("One subscription as the person paying for it reads it: which plan, where it stands, and what is next.");
606
+ export type PaymentsSubscriptionView = z.output<typeof PaymentsSubscriptionView>;
607
+
608
+ /**
609
+ * `GET {base}/subscription` — the caller's own subscription, or that they have none.
610
+ *
611
+ * **The read ships before the writes**, and this is it. A capability that can cancel a subscription and
612
+ * cannot report the cancellation has shipped the half that creates the support ticket; #247 is the
613
+ * larger version of the same mistake, where writes went out with no read beside them and a dashboard's
614
+ * panes dropped out of the rail entirely.
615
+ *
616
+ * `null` is a real answer and a common one: somebody who has never bought anything. It is not an error
617
+ * and not a 404 — a 404 would make this route an existence oracle and would read, to a screen, exactly
618
+ * like a Worker that could not be reached.
619
+ */
620
+ export const PaymentsSubscriptionResponse = z
621
+ .object({
622
+ subscription: PaymentsSubscriptionView.nullable().describe(
623
+ "The caller's own subscription, or null when they hold none.",
624
+ ),
625
+ })
626
+ .describe("Where the caller's own subscription stands, or that there is not one.");
627
+ export type PaymentsSubscriptionResponse = z.output<typeof PaymentsSubscriptionResponse>;
628
+
629
+ /**
630
+ * What the three writes answer with — `change`, `cancel` and `keep`.
631
+ *
632
+ * **The store's own answer to where the subscription now stands, not a prediction of it.** The screen
633
+ * that just wrote renders what it wrote, from the state the store reported after applying it. A
634
+ * prediction is how a customer sees a plan they are not on — and it is why nothing in this package
635
+ * writes a purchase row on these routes: the webhook owns that row, and a second producer of it disagrees
636
+ * with the first the moment a webhook is late.
637
+ *
638
+ * **Never null, which is the one way this differs from {@link PaymentsSubscriptionResponse}.** Each of
639
+ * the three resolved a subscription before it ran, so a null here is a case every screen would branch on
640
+ * and none could reach.
641
+ *
642
+ * A no-op answers exactly this shape too, and answers 200: a change to the plan already held, or a
643
+ * cancellation already scheduled for the timing asked for. The subscription is how the caller wanted it,
644
+ * so there is nothing to refuse — and these verbs sit behind a network, where a retried intent must not
645
+ * become a second proration.
646
+ */
647
+ export const PaymentsSubscriptionStandingResponse = z
648
+ .object({
649
+ subscription: PaymentsSubscriptionView.describe("Where the subscription stands now, as the store reports it."),
650
+ })
651
+ .describe("Where the caller's subscription stands after a change, a cancellation, or a withdrawal of one.");
652
+ export type PaymentsSubscriptionStandingResponse = z.output<typeof PaymentsSubscriptionStandingResponse>;
653
+
654
+ /**
655
+ * `POST {base}/subscription/preview` — what the change would cost, before anything is committed.
656
+ *
657
+ * The quote alone: it echoes nothing back about what was asked, because the request named the product
658
+ * and the client therefore already holds it. Contrast {@link PaymentsAdminSubjectEntitlementsResponse},
659
+ * which echoes the subject precisely because half of what it renders came from the server.
660
+ */
661
+ export const PaymentsSubscriptionQuoteResponse = z
662
+ .object({ quote: PaymentsSubscriptionQuote.describe("What the change would cost, as the store previews it.") })
663
+ .describe("A preview of one subscription change. Nothing has been committed and nothing has been stored.");
664
+ export type PaymentsSubscriptionQuoteResponse = z.output<typeof PaymentsSubscriptionQuoteResponse>;
665
+
666
+ /**
667
+ * ## The refund report
668
+ *
669
+ * What `POST {base}/subscription/refund` answers with. **Nothing in it says the money moved**, because at
670
+ * the moment it is produced nobody has decided that: a refund is a request, most live ones sit at the store
671
+ * awaiting a person, and the settlement arrives later as a webhook.
672
+ *
673
+ * **No amount, anywhere.** A figure here would be read as what the customer is getting back, which is the
674
+ * one thing this response cannot know. What a screen renders is *how many* payments were asked about and
675
+ * where each stands — and the customer's own payment history, which they already have, is where the sums
676
+ * are.
677
+ *
678
+ * **No identifier either — not the store's, and not ours.** No `txn_…` and no adjustment id: the module note
679
+ * above bans store identifiers from every bearer response, and an adjustment id published to a browser is a
680
+ * field a request grows next. The purchase id is withheld on the same principle rather than on a rule, since
681
+ * a screen that has just asked to refund a whole subscription needs a count and a state, not a join key. The
682
+ * ids are in the audit trail, where an operator is the reader.
683
+ */
684
+
685
+ /** Where one refund request stands. The values come from `data/subscription.ts`, so the wire cannot hold a state the rail cannot produce. */
686
+ export const PaymentsRefundRequestStatus = RefundRequestStatus.describe(
687
+ "Where a refund request stands at the store. **None of these means the money has arrived** — `approved` is a decision, not a settlement.",
688
+ );
689
+ export type PaymentsRefundRequestStatus = z.output<typeof PaymentsRefundRequestStatus>;
690
+
691
+ /** A refund this request raised: it exists at the store, and it is waiting. */
692
+ const RefundRaised = z
693
+ .object({
694
+ outcome: z.literal("requested").describe("A refund was raised for this payment. It is a request, not a payout."),
695
+ status: PaymentsRefundRequestStatus.describe("Where it stands at the store."),
696
+ })
697
+ .describe("A refund request this call raised.");
698
+
699
+ /** A refund that was already there, so nothing new was asked for. The per-payment no-op. */
700
+ const RefundAlreadyStanding = z
701
+ .object({
702
+ outcome: z
703
+ .literal("already_requested")
704
+ .describe("A refund was already standing against this payment, so nothing was sent."),
705
+ status: PaymentsRefundRequestStatus.describe("Where that standing refund is."),
706
+ })
707
+ .describe(
708
+ "A payment that already had a refund against it. Success, not a refusal: it is the state that was asked for.",
709
+ );
710
+
711
+ /**
712
+ * A payment the store would not refund, in a request where others were.
713
+ *
714
+ * **It carries no reason**, and that is the security boundary rather than an omission. The store's own
715
+ * sentence is throw-site context — it names transactions, statuses and account facts — so it rides in the
716
+ * audit trail and in a refusal's `detail`, both of which the codec keeps off the wire. A screen is told
717
+ * which payments did not go through and asks the customer to get in touch, which is the only action
718
+ * available to them either way.
719
+ */
720
+ const RefundNotRaised = z
721
+ .object({
722
+ outcome: z
723
+ .literal("failed")
724
+ .describe("The store would not refund this payment. Others in the same request may have gone through."),
725
+ })
726
+ .describe("One payment that was not refunded, reported so a partial cannot pass as a success.");
727
+
728
+ /** What became of one payment. Three outcomes, and the discriminant is what stops a report reading as a success. */
729
+ export const PaymentsRefundOutcome = z
730
+ .discriminatedUnion("outcome", [RefundRaised, RefundAlreadyStanding, RefundNotRaised])
731
+ .describe("What became of one payment: a refund was raised, one was already standing, or the store refused it.");
732
+ export type PaymentsRefundOutcome = z.output<typeof PaymentsRefundOutcome>;
733
+
734
+ /**
735
+ * What came of asking for a subscription's payments back.
736
+ *
737
+ * **One entry per payment, always** — the report is total over what was asked about, which is what makes a
738
+ * partial impossible to miss. A caller counting entries and a caller counting their own payments get the
739
+ * same number, whatever happened in between.
740
+ *
741
+ * A refund attaches to a *transaction*, and a subscription is a family of them. A customer who joined on one
742
+ * plan, upgraded mid-period and canceled has paid twice; a policy that owes them their money owes both, so
743
+ * this is a list rather than a single outcome even in the common case.
744
+ */
745
+ export const PaymentsRefundRequest = z
746
+ .object({
747
+ outcomes: z
748
+ .array(PaymentsRefundOutcome)
749
+ .describe(
750
+ "One entry per payment asked about, in the order the server resolved them — never a subset. A shorter list is what a silent partial looks like.",
751
+ ),
752
+ })
753
+ .describe("What came of a refund request: one outcome per payment, none of which says the money has arrived.");
754
+ export type PaymentsRefundRequest = z.output<typeof PaymentsRefundRequest>;
755
+
756
+ /** The envelope `POST {base}/subscription/refund` answers with. */
757
+ export const PaymentsRefundResponse = z
758
+ .object({ refund: PaymentsRefundRequest.describe("What became of each payment on the subscription.") })
759
+ .describe("What a refund request produced. Never a claim that anybody has been paid.");
760
+ export type PaymentsRefundResponse = z.output<typeof PaymentsRefundResponse>;
761
+
762
+ /**
763
+ * The discount codes one store holds.
764
+ *
765
+ * A management shape, never a client one: what an adopter has issued is a commercial fact, and the client
766
+ * projection draws the same line here it draws for SKUs and the `grants` block.
767
+ */
768
+ export const PaymentsAdminDiscountsResponse = z
769
+ .object({
770
+ discounts: z
771
+ .array(
772
+ z
773
+ .object({
774
+ code: z.string().min(1).describe("The code a customer enters."),
775
+ providerDiscountId: z.string().min(1).describe("The store's own id, for finding it in the dashboard."),
776
+ amount: z.string().describe("How much comes off, rendered for a person — the store's own figures."),
777
+ redemptions: z
778
+ .number()
779
+ .int()
780
+ .nullable()
781
+ .describe("How many times it has been claimed, when the store says."),
782
+ })
783
+ .describe("One discount code, as the store holds it."),
784
+ )
785
+ .describe("The codes, as the store lists them."),
786
+ })
787
+ .describe("Every discount code one store holds for this project.");
788
+ export type PaymentsAdminDiscountsResponse = z.output<typeof PaymentsAdminDiscountsResponse>;
789
+
790
+ /**
791
+ * A discount as the store minted it.
792
+ *
793
+ * The **code** is the point — an adopter mints one per applicant and has to be told what it is — and the
794
+ * provider id is what finds it in that store's dashboard afterwards. The terms are echoed back as this
795
+ * package models them rather than as the store recorded them, so a client can show what it asked for
796
+ * without learning either provider's vocabulary.
797
+ *
798
+ * Nothing here is a list. A management client learns the code it just created and no other, because the set
799
+ * of codes an adopter has issued is a commercial fact and not one this route publishes.
800
+ */
801
+ export const PaymentsDiscountResponse = z
802
+ .object({
803
+ code: z.string().min(1).describe("The code a customer enters, whether supplied or store-generated."),
804
+ providerDiscountId: z.string().min(1).describe("The store's own id, for finding it in the dashboard."),
805
+ rail: PaymentsHostedRail.describe("Which store now holds it."),
806
+ })
807
+ .describe("One discount code, as the store minted it.");
808
+ export type PaymentsDiscountResponse = z.output<typeof PaymentsDiscountResponse>;
809
+
810
+ /**
811
+ * ## The management read surface
812
+ *
813
+ * Everything below answers a **control-plane** route, so it is read by a dashboard across a trust
814
+ * boundary rather than by the adopter's own app. The `Admin` prefix is what keeps the two apart: a
815
+ * client's view of its own purchase and a management client's view of everybody's are different
816
+ * projections with different rules, and one schema doing both would be one edit away from serving the
817
+ * wider shape to the narrower caller.
818
+ *
819
+ * **The provider payload appears nowhere in this file and is not even selected by the queries behind
820
+ * it** — see `admin/read.ts`. That is where an email address would otherwise reach a purchases list,
821
+ * since payments stores no address of its own.
822
+ */
823
+
824
+ /**
825
+ * One catalog product, as a management client sees it.
826
+ *
827
+ * **Strictly less than the client projection already ships, and the reasoning is that file's.**
828
+ * `clientProjection` argues that a product's Apple and Google SKUs stay server-side because a browser has
829
+ * no use for them, and that the `grants` block stays there because a currency code and an amount describe
830
+ * the economy. A management client needs less again: it is filling a list of *things that can be comped*,
831
+ * and a comp names an entitlement key. So there is no Stripe price id here either — publishable in a
832
+ * paywall, where it is the thing a Checkout Session names, and simply not this surface's business.
833
+ *
834
+ * What `controlPlane.workers.test.ts` asserts is the invariant first: every leaf in the response is one of
835
+ * these four facts about some product. A field added here carrying anything else fails it whatever the
836
+ * field is called, which is the point — a projection somebody must remember not to widen is not a control.
837
+ * Beside it sits a hand-written list of the seven keys that may cross, because the value half alone cannot
838
+ * police a boolean or a null: `true` and `null` are in every JSON document's vocabulary. That list is
839
+ * deliberately **not** read off this schema. It was, and a field added here and to the view together
840
+ * widened the gate by the same edit (#308).
841
+ */
842
+ export const PaymentsAdminCatalogProduct = z
843
+ .object({
844
+ id: z.string().describe("The logical product id — the key in `products`, and what lands in every purchase row."),
845
+ type: PaymentsProductType.describe("What kind of product it is. Decides how a renewal and a restore behave."),
846
+ name: z.string().describe("The display name the adopter wrote — `Pro`, `Remove ads`. What a list renders."),
847
+ entitlements: z
848
+ .array(z.string())
849
+ .describe(
850
+ "The entitlement keys this product grants. The whole reason this read exists: a comp names a key, so this is the list a grant control offers instead of a text box.",
851
+ ),
852
+ })
853
+ .describe("One catalog product as a management client sees it: what it is, and what it grants.");
854
+ export type PaymentsAdminCatalogProduct = z.output<typeof PaymentsAdminCatalogProduct>;
855
+
856
+ /**
857
+ * `GET {base}/admin/catalog` — what this project sells.
858
+ *
859
+ * **`enabled` is the same modeled answer `clientProjection` gives, and deliberately the same shape.** A
860
+ * catalog with nothing in it answers `{ enabled: false }` rather than an empty list, so "composed with
861
+ * nothing to sell" is a state a client renders as *there is nothing to comp here* instead of as a dropdown
862
+ * that came back broken. A catalog that failed to load is not this: it is a non-200, or a body that does
863
+ * not parse, and a client that branches on `enabled` never confuses the two.
864
+ *
865
+ * A discriminated union rather than an optional `products`, because the two states are genuinely different
866
+ * answers and an optional array makes "empty" and "absent" indistinguishable at the exact moment a caller
867
+ * needs them apart.
868
+ */
869
+ export const PaymentsAdminCatalogResponse = z
870
+ .discriminatedUnion("enabled", [
871
+ z
872
+ .object({
873
+ enabled: z
874
+ .literal(false)
875
+ .describe(
876
+ "This project defines nothing — no product is configured and no key was declared grantable, so there is no entitlement a comp control could offer and no grant that would succeed.",
877
+ ),
878
+ })
879
+ .describe("A project composing payments with an empty catalog. The same answer as not composing it at all."),
880
+ z
881
+ .object({
882
+ enabled: z.literal(true).describe("This project sells something."),
883
+ products: z
884
+ .array(PaymentsAdminCatalogProduct)
885
+ .describe("The catalog, in the order the adopter wrote it — which is the order a list should show."),
886
+ manualEntitlements: z
887
+ .array(z.string())
888
+ .describe(
889
+ "Entitlement keys the adopter declared grantable with no product behind them. Offered beside the products because a comp control that omitted them would refuse the grants it then submitted.",
890
+ ),
891
+ })
892
+ .describe("The catalog, as a management client reads it."),
893
+ ])
894
+ .describe("What this project sells, or that it sells nothing. Never a price, a SKU, or a rail's identifier.");
895
+ export type PaymentsAdminCatalogResponse = z.output<typeof PaymentsAdminCatalogResponse>;
896
+
897
+ /** Where a page resumes, or the end of the list. */
898
+ const NextCursor = z
899
+ .string()
900
+ .nullable()
901
+ .describe("Where the next page resumes. Null at the end of the list. Opaque — pass it back verbatim.");
902
+
903
+ /**
904
+ * One purchase, as a management client sees it.
905
+ *
906
+ * Wider than {@link PaymentsPurchaseView} in the two ways an operator needs and a buyer does not: it
907
+ * names the **owner** — as the pair, always — and it names the **money**. Narrower in one: there is no `outcome`, because
908
+ * `outcome` says what a *write* did — projected, replayed, ignored — and a read of the log has no write
909
+ * to report.
910
+ *
911
+ * The provider identifiers are kept and the provider *payload* is not, and the line between them is
912
+ * whether the value is a bearer artifact. A transaction id is the join key an operator pastes into App
913
+ * Store Connect or the Stripe dashboard to settle a dispute; a receipt is the thing that could be
914
+ * replayed. The first is the whole point of the pane and the second never leaves the Worker.
915
+ */
916
+ export const PaymentsAdminPurchaseView = z
917
+ .object({
918
+ id: z.string().describe("The purchase's UUID — its stable identifier on this Worker."),
919
+ subjectType: PaymentsSubject.shape.subjectType.describe(
920
+ "Whether `subjectId` names a user or an organization. Half the owner: read the two together or a row is attributed to whoever else holds that id.",
921
+ ),
922
+ subjectId: PaymentsSubject.shape.subjectId.describe(
923
+ "The subject that bought it — the opaque id the adopter's auth capability issued, or the one its own membership model did. The only identity payments stores, and the join key to `auth:users:read`, which is granted separately.",
924
+ ),
925
+ rail: PaymentsRail.describe("Which store this transaction came from."),
926
+ providerTransactionId: z
927
+ .string()
928
+ .describe(
929
+ "The store's own transaction id — what an operator pastes into App Store Connect, Play Console or the Stripe dashboard. An identifier, never a credential: the receipt that would be one is not projected.",
930
+ ),
931
+ originalTransactionId: z
932
+ .string()
933
+ .nullable()
934
+ .describe(
935
+ "The transaction that started this subscription, chaining renewals back to it. Null for a one-time purchase.",
936
+ ),
937
+ productId: z
938
+ .string()
939
+ .describe(
940
+ "The catalog product the verified SKU resolved to, copied at projection time so a later config edit cannot rewrite history.",
941
+ ),
942
+ type: PaymentsProductType.describe("What kind of product it is."),
943
+ status: PurchaseStatus.describe("The normalized status. Nothing here is ever a rail-specific state."),
944
+ environment: PurchaseEnvironment.describe(
945
+ "The store environment it happened in. Rendered rather than filtered on by default: a sandbox transaction read as a production one is the oldest defect in in-app purchasing.",
946
+ ),
947
+ amountMinor: z
948
+ .number()
949
+ .int()
950
+ .nullable()
951
+ .describe(
952
+ "What was charged, in the currency's minor unit — an integer, never a float. Null where the rail reported no amount, which is common on a renewal.",
953
+ ),
954
+ currency: z.string().nullable().describe("The ISO currency the amount is in, or null when none was reported."),
955
+ purchasedAt: z.iso.datetime().describe("When the store recorded the purchase, ISO-8601."),
956
+ expiresAt: z.iso.datetime().nullable().describe("When access lapses, ISO-8601; null for one that never does."),
957
+ revokedAt: z.iso.datetime().nullable().describe("When it was refunded or revoked, ISO-8601; null when it was not."),
958
+ resumesAt: z.iso
959
+ .datetime()
960
+ .nullable()
961
+ .describe(
962
+ "When a paused subscription resumes, ISO-8601, as the store stated it — never computed here. Null with `status` `paused` is an indefinite pause; null with any other status means it is not paused. A store that never dates a pause says so in the kit's `PAYMENTS_PAUSE_RESUMPTION` table rather than by leaving this ambiguous.",
963
+ ),
964
+ updatedAt: z.iso
965
+ .datetime()
966
+ .describe("When this row was last projected, ISO-8601 — how an operator tells a live row from a stale one."),
967
+ })
968
+ .describe("One purchase as a management client sees it. Never the stored provider payload.");
969
+ export type PaymentsAdminPurchaseView = z.output<typeof PaymentsAdminPurchaseView>;
970
+
971
+ /**
972
+ * One entitlement, as a management client sees it.
973
+ *
974
+ * Wider than {@link PaymentsEntitlementView} by the two facts a buyer has no use for and an operator
975
+ * cannot work without: **whose** it is, and **why** they have it. `manual` is the difference between an
976
+ * entitlement somebody paid for and one somebody decided; `source` is the purchase currently granting
977
+ * it, which answers "why is this subject entitled" without a scan.
978
+ *
979
+ * Whose it is crosses as the pair the row is keyed on, `UNIQUE (subjectType, subjectId, entitlement)`. A
980
+ * dashboard that read only the id would show an organization's `pro` beside a person's name the moment
981
+ * an adopter's two id spaces met on a value.
982
+ */
983
+ export const PaymentsAdminEntitlementView = z
984
+ .object({
985
+ subjectType: PaymentsSubject.shape.subjectType.describe("Whether `subjectId` names a user or an organization."),
986
+ subjectId: PaymentsSubject.shape.subjectId.describe(
987
+ "The subject holding it — the opaque id the adopter's auth capability issued, or its own organization id.",
988
+ ),
989
+ key: z.string().describe("The entitlement key the adopter's gating code names — `pro`, `beta`."),
990
+ granted: z
991
+ .boolean()
992
+ .describe(
993
+ "Whether it grants access right now, resolved against `expiresAt` on this read exactly as the hot path resolves it. The stored flag is an optimization; this is the answer.",
994
+ ),
995
+ expiresAt: z.iso.datetime().nullable().describe("When it lapses, ISO-8601; null for one that does not."),
996
+ manual: z
997
+ .boolean()
998
+ .describe(
999
+ "Whether a human wrote this row through the control plane rather than a purchase producing it. A manual grant is held against the projection, so it survives the subject's next renewal.",
1000
+ ),
1001
+ source: z
1002
+ .string()
1003
+ .nullable()
1004
+ .describe(
1005
+ "The purchase currently granting this entitlement, or null when nothing does — a comp, or a grant whose purchase has lapsed.",
1006
+ ),
1007
+ })
1008
+ .describe("One entitlement as a management client sees it: whose it is, whether it grants, and why.");
1009
+ export type PaymentsAdminEntitlementView = z.output<typeof PaymentsAdminEntitlementView>;
1010
+
1011
+ /**
1012
+ * One reconciliation pass, as a management client sees it.
1013
+ *
1014
+ * **The positive invariant this response is held to: every field is a count, a timestamp, an enum, or this
1015
+ * run's own id.** Not "no payload field" — a list of forbidden names is a list somebody has to keep, and the
1016
+ * field that leaks is the one nobody thought to forbid. There is no field here whose value comes from a
1017
+ * store, because the run record has no column one could be written into.
1018
+ *
1019
+ * It names no account and no transaction either, which is why it sits behind its own scope: reading whether
1020
+ * the nightly repair is firing is not reading anybody's commerce.
1021
+ */
1022
+ export const PaymentsAdminReconcileRunView = z
1023
+ .object({
1024
+ id: z.string().describe("The run's id. The same value every repair this pass audited carries as `runId`."),
1025
+ startedAt: z.iso.datetime().describe("When the pass began, ISO-8601."),
1026
+ finishedAt: z.iso.datetime().describe("When it finished, ISO-8601."),
1027
+ environment: PurchaseEnvironment.describe("The store environment the host was deployed to."),
1028
+ rail: PaymentsRail.nullable().describe(
1029
+ "The store this pass was narrowed to, or null for every enabled rail. Null is the scheduled behavior.",
1030
+ ),
1031
+ pages: z.number().int().describe("Durable steps read — one page of purchases each."),
1032
+ scanned: z.number().int().describe("Purchases examined."),
1033
+ unchanged: z.number().int().describe("Purchases whose stored state already matched the store's."),
1034
+ drifted: z
1035
+ .number()
1036
+ .int()
1037
+ .describe(
1038
+ "Purchases whose stored state disagreed. The number an operator watches: a rising one means webhooks are being lost.",
1039
+ ),
1040
+ superseded: z.number().int().describe("Old periods a later transaction replaced, settled on this pass."),
1041
+ skipped: z.number().int().describe("Purchases no store could be asked about."),
1042
+ failed: z.number().int().describe("Purchases a store refused to answer for."),
1043
+ truncated: z
1044
+ .boolean()
1045
+ .describe("Whether the pass stopped at its page cap. True means the tally is a floor rather than a total."),
1046
+ dryRun: z.boolean().describe("Whether the pass only reported. A dry run's `drifted` is a finding, not a fix."),
1047
+ })
1048
+ .describe("One reconciliation pass: when it ran, what it was narrowed to, and its tally. Counts and times only.");
1049
+ export type PaymentsAdminReconcileRunView = z.output<typeof PaymentsAdminReconcileRunView>;
1050
+
1051
+ /**
1052
+ * `GET {base}/admin/reconcile-runs`.
1053
+ *
1054
+ * An empty page is a real answer and a loud one: reconciliation has never run here, which for a project that
1055
+ * has provisioned the Workflow is the failure the read exists to surface.
1056
+ */
1057
+ export const PaymentsAdminReconcileRunsResponse = z
1058
+ .object({
1059
+ runs: z.array(PaymentsAdminReconcileRunView).describe("The page, most recently started first."),
1060
+ nextCursor: NextCursor,
1061
+ })
1062
+ .describe("A page of the reconciliation run log.");
1063
+ export type PaymentsAdminReconcileRunsResponse = z.output<typeof PaymentsAdminReconcileRunsResponse>;
1064
+
1065
+ /**
1066
+ * `POST {base}/admin/reconcile-runs` — a pass was started, and nothing about how it went.
1067
+ *
1068
+ * **It reports a start, never an outcome.** The pass is a Workflow: it is durable, it retries, and it outlives
1069
+ * the request that began it by minutes. A response claiming what was repaired would have to either block on a
1070
+ * job designed not to be blocked on, or invent the answer — and the answer is the one thing an operator pressed
1071
+ * the button to find out. So this says *started, here is its id*, and the run log is where the tally lands.
1072
+ *
1073
+ * **`started: false` is a real answer rather than an error.** The pass is idempotent and the trigger is too: a
1074
+ * second press while a run is in flight starts nothing and says so, which is a true sentence about somebody's
1075
+ * catalog. Turning that into a 409 would make a nervous operator's second click look like a fault.
1076
+ *
1077
+ * No count, no duration, no "queued position". Every one of those is a number a screen would print and nobody
1078
+ * could stand behind.
1079
+ */
1080
+ export const PaymentsAdminReconcileRunStarted = z
1081
+ .object({
1082
+ started: z
1083
+ .boolean()
1084
+ .describe("Whether this request began a pass. False when one was already running, which is not a failure."),
1085
+ runId: z
1086
+ .string()
1087
+ .nullable()
1088
+ .describe(
1089
+ "The Workflow instance that was started, so a caller can find this pass in the run log once it finishes. Null when `started` is false — there is no instance of *this* request to name, and naming the one already running would attribute somebody else's pass to this press.",
1090
+ ),
1091
+ })
1092
+ .describe("What a press on the reconciliation trigger produced: whether a pass began, and which one.");
1093
+ export type PaymentsAdminReconcileRunStarted = z.output<typeof PaymentsAdminReconcileRunStarted>;
1094
+
1095
+ /** `GET {base}/admin/purchases`. */
1096
+ export const PaymentsAdminPurchasesResponse = z
1097
+ .object({
1098
+ purchases: z.array(PaymentsAdminPurchaseView).describe("The page, most recently purchased first."),
1099
+ nextCursor: NextCursor,
1100
+ })
1101
+ .describe("A page of the purchase log.");
1102
+ export type PaymentsAdminPurchasesResponse = z.output<typeof PaymentsAdminPurchasesResponse>;
1103
+
1104
+ /** `GET {base}/admin/subscriptions` — the same rows, narrowed to the ones that renew. */
1105
+ export const PaymentsAdminSubscriptionsResponse = z
1106
+ .object({
1107
+ subscriptions: z
1108
+ .array(PaymentsAdminPurchaseView)
1109
+ .describe("The page, most recently purchased first. Every row has `type: subscription`."),
1110
+ nextCursor: NextCursor,
1111
+ })
1112
+ .describe("A page of the purchases that renew.");
1113
+ export type PaymentsAdminSubscriptionsResponse = z.output<typeof PaymentsAdminSubscriptionsResponse>;
1114
+
1115
+ /** `GET {base}/admin/entitlements`. */
1116
+ export const PaymentsAdminEntitlementsResponse = z
1117
+ .object({
1118
+ entitlements: z.array(PaymentsAdminEntitlementView).describe("The page, most recently first granted first."),
1119
+ nextCursor: NextCursor,
1120
+ })
1121
+ .describe("A page of the entitlement model, across every subject.");
1122
+ export type PaymentsAdminEntitlementsResponse = z.output<typeof PaymentsAdminEntitlementsResponse>;
1123
+
1124
+ /**
1125
+ * `GET {base}/admin/entitlements/:subjectType/:subjectId`.
1126
+ *
1127
+ * No cursor, because there is no page: the table is keyed `UNIQUE (subjectType, subjectId, entitlement)`,
1128
+ * so this is at most one row per key. A subject holding nothing answers an empty list rather than a 404 —
1129
+ * an entitlement row appears with the first purchase that grants one, so its absence is not a missing
1130
+ * holder, and a 404 would make this an existence oracle for ids.
1131
+ *
1132
+ * Both halves are echoed, and that is the reason this response can stand on its own: a body carrying one
1133
+ * id and a list is a body a client has to remember it asked about an organization. The pair it asked with
1134
+ * comes back verbatim, so what it renders is what it requested.
1135
+ */
1136
+ export const PaymentsAdminSubjectEntitlementsResponse = z
1137
+ .object({
1138
+ subjectType: PaymentsSubject.shape.subjectType.describe(
1139
+ "Which kind of holder was asked after, echoed back. Half of the address — the id alone named nothing.",
1140
+ ),
1141
+ subjectId: PaymentsSubject.shape.subjectId.describe("The id asked after, echoed so a response stands on its own."),
1142
+ entitlements: z
1143
+ .array(PaymentsAdminEntitlementView)
1144
+ .describe("Every entitlement this subject holds, by key. Empty when it holds none."),
1145
+ })
1146
+ .describe("One subject's entitlements, resolved now.");
1147
+ export type PaymentsAdminSubjectEntitlementsResponse = z.output<typeof PaymentsAdminSubjectEntitlementsResponse>;