@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.
- package/LICENSE +21 -0
- package/README.md +157 -0
- package/dist/paddle-prices.iife.js +1 -0
- package/dist/pithy.manifest.json +68 -0
- package/docs/lemon-squeezy.md +167 -0
- package/docs/paddle.md +486 -0
- package/package.json +73 -0
- package/pithy.manifest.json +68 -0
- package/src/admin/coverage.ts +107 -0
- package/src/admin/read.ts +360 -0
- package/src/audit/actions.ts +214 -0
- package/src/capability.ts +377 -0
- package/src/client/api.ts +734 -0
- package/src/client/checkout.ts +252 -0
- package/src/client/fixtures/price-preview-de.json +54 -0
- package/src/client/fixtures/price-preview-gb.json +54 -0
- package/src/client/fixtures/price-preview-jp-yen.json +54 -0
- package/src/client/fixtures/price-preview-us-country-only.json +54 -0
- package/src/client/fixtures/price-preview-us-new-york.json +54 -0
- package/src/client/fixtures/pricePreview.ts +67 -0
- package/src/client/hooks.ts +590 -0
- package/src/client/paddle.ts +733 -0
- package/src/client/paddleCache.ts +265 -0
- package/src/client/paddlePrices.iife.ts +43 -0
- package/src/client/paddlePrices.ts +143 -0
- package/src/client/paddlePricesTag.ts +332 -0
- package/src/client/projection.ts +133 -0
- package/src/client/test-utils/cacheStore.ts +41 -0
- package/src/client/wholeUnits.ts +147 -0
- package/src/cloudflare-test.d.ts +16 -0
- package/src/config/config.ts +563 -0
- package/src/data/discount.ts +238 -0
- package/src/data/entitlement.ts +67 -0
- package/src/data/money.ts +75 -0
- package/src/data/pause.ts +148 -0
- package/src/data/providerAccount.ts +47 -0
- package/src/data/purchase.ts +131 -0
- package/src/data/rail.ts +74 -0
- package/src/data/reconcileRun.ts +208 -0
- package/src/data/renderMoney.ts +180 -0
- package/src/data/status.ts +67 -0
- package/src/data/subject.ts +148 -0
- package/src/data/subscription.ts +675 -0
- package/src/data/syncCursor.ts +44 -0
- package/src/data/tables.ts +61 -0
- package/src/data/webhookEvent.ts +196 -0
- package/src/entitlement/manual.ts +209 -0
- package/src/entitlement/resolver.ts +125 -0
- package/src/entitlement/subjectSeam.ts +171 -0
- package/src/error/errors.ts +421 -0
- package/src/grants/apply.ts +207 -0
- package/src/grants/clawback.ts +128 -0
- package/src/grants/ledgerSeam.ts +141 -0
- package/src/http/dispatch.ts +48 -0
- package/src/http/guards.ts +58 -0
- package/src/http/manifestConfig.ts +56 -0
- package/src/http/responses.ts +1147 -0
- package/src/http/routes.ts +2911 -0
- package/src/http/schemas.ts +577 -0
- package/src/http/scopes.ts +260 -0
- package/src/http/view.ts +188 -0
- package/src/http/webhookGuard.ts +341 -0
- package/src/index.ts +231 -0
- package/src/migrations/0001_purchases.ts +312 -0
- package/src/pricing/location.ts +175 -0
- package/src/pricing/visitor.ts +82 -0
- package/src/projection/event.ts +101 -0
- package/src/projection/orphans.ts +226 -0
- package/src/projection/owner.ts +247 -0
- package/src/projection/resolve.ts +57 -0
- package/src/projection/writer.ts +554 -0
- package/src/provision/resolvePaymentsConfig.ts +91 -0
- package/src/rails/apple/certs.ts +66 -0
- package/src/rails/apple/der.ts +249 -0
- package/src/rails/apple/fixtures/chain.ts +412 -0
- package/src/rails/apple/fixtures/did-fail-to-renew-grace.json +43 -0
- package/src/rails/apple/fixtures/did-renew.json +48 -0
- package/src/rails/apple/fixtures/one-time-charge.json +32 -0
- package/src/rails/apple/fixtures/refund.json +33 -0
- package/src/rails/apple/fixtures/subscribed-initial-buy-sandbox.json +38 -0
- package/src/rails/apple/fixtures/test.json +14 -0
- package/src/rails/apple/http.ts +128 -0
- package/src/rails/apple/jws.ts +123 -0
- package/src/rails/apple/notification.ts +357 -0
- package/src/rails/apple/rail.ts +114 -0
- package/src/rails/apple/serverApi.ts +412 -0
- package/src/rails/apple/verify.ts +93 -0
- package/src/rails/apple/x509.ts +424 -0
- package/src/rails/contract.ts +939 -0
- package/src/rails/google/fixtures/play-product-purchased.json +14 -0
- package/src/rails/google/fixtures/play-subscription-active.json +23 -0
- package/src/rails/google/fixtures/push.ts +152 -0
- package/src/rails/google/fixtures/rtdn-one-time-purchased.json +11 -0
- package/src/rails/google/fixtures/rtdn-subscription-renewed.json +11 -0
- package/src/rails/google/fixtures/rtdn-subscription-revoked.json +11 -0
- package/src/rails/google/fixtures/rtdn-test.json +8 -0
- package/src/rails/google/fixtures/rtdn-voided-purchase.json +11 -0
- package/src/rails/google/http.ts +124 -0
- package/src/rails/google/jwt.ts +127 -0
- package/src/rails/google/oidc.ts +308 -0
- package/src/rails/google/playApi.ts +598 -0
- package/src/rails/google/rail.ts +174 -0
- package/src/rails/google/rtdn.ts +331 -0
- package/src/rails/google/verify.ts +127 -0
- package/src/rails/lemonSqueezy/api.ts +192 -0
- package/src/rails/lemonSqueezy/checkout.ts +186 -0
- package/src/rails/lemonSqueezy/discounts.ts +166 -0
- package/src/rails/lemonSqueezy/fixtures/events.ts +180 -0
- package/src/rails/lemonSqueezy/objects.ts +511 -0
- package/src/rails/lemonSqueezy/portal.ts +82 -0
- package/src/rails/lemonSqueezy/pricing.ts +124 -0
- package/src/rails/lemonSqueezy/rail.ts +137 -0
- package/src/rails/lemonSqueezy/read.ts +72 -0
- package/src/rails/lemonSqueezy/refresh.ts +86 -0
- package/src/rails/lemonSqueezy/signature.ts +119 -0
- package/src/rails/lemonSqueezy/verify.ts +45 -0
- package/src/rails/lemonSqueezy/webhook.ts +326 -0
- package/src/rails/paddle/adjustments.ts +94 -0
- package/src/rails/paddle/api.ts +266 -0
- package/src/rails/paddle/checkout.ts +240 -0
- package/src/rails/paddle/discounts.ts +176 -0
- package/src/rails/paddle/events.ts +285 -0
- package/src/rails/paddle/fixtures/browserForged.ts +54 -0
- package/src/rails/paddle/fixtures/pausedSubscription.ts +62 -0
- package/src/rails/paddle/objects.ts +883 -0
- package/src/rails/paddle/portal.ts +130 -0
- package/src/rails/paddle/rail.ts +219 -0
- package/src/rails/paddle/read.ts +108 -0
- package/src/rails/paddle/recorded.ts +83 -0
- package/src/rails/paddle/refresh.ts +153 -0
- package/src/rails/paddle/refund.ts +297 -0
- package/src/rails/paddle/signature.ts +225 -0
- package/src/rails/paddle/subscription.ts +793 -0
- package/src/rails/paddle/verify.ts +120 -0
- package/src/rails/paddle/webhook.ts +405 -0
- package/src/rails/providers.ts +168 -0
- package/src/rails/stripe/api.ts +253 -0
- package/src/rails/stripe/checkout.ts +175 -0
- package/src/rails/stripe/discounts.ts +169 -0
- package/src/rails/stripe/fixtures/event-charge-refunded.json +23 -0
- package/src/rails/stripe/fixtures/event-invoice-paid.json +19 -0
- package/src/rails/stripe/fixtures/event-session-completed-payment.json +27 -0
- package/src/rails/stripe/fixtures/event-session-completed-subscription.json +27 -0
- package/src/rails/stripe/fixtures/event-subscription-canceled.json +43 -0
- package/src/rails/stripe/fixtures/event-subscription-created.json +42 -0
- package/src/rails/stripe/fixtures/event-subscription-deleted.json +44 -0
- package/src/rails/stripe/fixtures/events.ts +111 -0
- package/src/rails/stripe/objects.ts +642 -0
- package/src/rails/stripe/portal.ts +45 -0
- package/src/rails/stripe/pricing.ts +123 -0
- package/src/rails/stripe/rail.ts +139 -0
- package/src/rails/stripe/refresh.ts +79 -0
- package/src/rails/stripe/signature.ts +127 -0
- package/src/rails/stripe/verify.ts +105 -0
- package/src/rails/stripe/webhook.ts +79 -0
- package/src/secret/registry.ts +275 -0
- package/src/seeds/example.ts +181 -0
- package/src/version.generated.ts +16 -0
- package/src/workflows/paddleSweep.ts +717 -0
- package/src/workflows/railAccess.ts +87 -0
- package/src/workflows/reconcile.ts +648 -0
- package/src/workflows/report.ts +64 -0
- package/src/workflows/retryPolicy.ts +40 -0
- package/src/workflows/specs.ts +143 -0
- package/src/workflows/worker.ts +166 -0
- package/src/workflows/workerConfig.ts +73 -0
- 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>;
|