@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,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>;
|