@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,260 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { AdminRoute } from "@pithy-sh/core/src/controlPlane/discovery/adminRoute";
|
|
5
|
+
import type { ControlPlaneScope } from "@pithy-sh/core/src/controlPlane/scope/scope";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Payments' control-plane scopes, and the admin surface a manifest advertises.
|
|
9
|
+
*
|
|
10
|
+
* **Separate from `guards.ts` because a scope name is a client's business** (#315). A management
|
|
11
|
+
* client reads these to render what a connection may do, and `pithy-sh/dashboard`'s scope builder
|
|
12
|
+
* writes the `pithy dashboard connect --scope …` command from exactly these constants — in a browser
|
|
13
|
+
* program, with the DOM lib and no Workers types. While they sat beside the Hono middleware, naming
|
|
14
|
+
* one compiled `PithyHonoEnv`, which reached core's `capability.ts`, which named Worker globals that
|
|
15
|
+
* program has none of. **This module imports types and nothing else, and a gate holds it there**:
|
|
16
|
+
* `tooling/browser-scopes` compiles a DOM-only program against every scope the kit declares.
|
|
17
|
+
*
|
|
18
|
+
* ## Two scopes, not one admin flag
|
|
19
|
+
*
|
|
20
|
+
* The interim gate checked a single `payments:admin` scope on the `AuthContext`. That string is a valid
|
|
21
|
+
* {@link ControlPlaneScope} — it matches the seam's pattern, so nothing forced this change — but it names a
|
|
22
|
+
* *credential holder*, and the seam's scopes name *operations*. Under that model one flag is wrong on the
|
|
23
|
+
* merits: granting and revoking an entitlement are two operations with different blast radii and different
|
|
24
|
+
* holders. **Grant** mints paid product out of nothing, so a compromised comp tool costs revenue and can
|
|
25
|
+
* escalate itself into anything the catalog sells. **Revoke** takes paid access away from a live customer, so
|
|
26
|
+
* a compromised refund tool is an outage for people who paid. A refund tool needs revoke and never grant; a
|
|
27
|
+
* support-comp tool needs grant and never revoke. One flag makes each of them the other, and an adopter who
|
|
28
|
+
* wanted to hand out only the safer half had no way to say so.
|
|
29
|
+
*
|
|
30
|
+
* The split is real rather than cosmetic because `scopeCovers` matches exactly, with no prefix or wildcard
|
|
31
|
+
* rule: `payments:entitlements` confers neither of these, and holding one confers nothing about the other.
|
|
32
|
+
*
|
|
33
|
+
* The names are constants rather than config, for the reason the old comment gave and which has not changed: a
|
|
34
|
+
* configurable scope name is a way to misconfigure a default-denied gate into a differently-named one, and
|
|
35
|
+
* support tooling that read the docs would then quietly hold a scope nothing checks. They are also the join
|
|
36
|
+
* key with what `pithy dashboard connect` offers an adopter to grant, so they must be the same strings in both
|
|
37
|
+
* places.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Minting a discount — an administrative act with a cost attached, and its own scope for that reason.
|
|
42
|
+
*
|
|
43
|
+
* Deliberately not covered by, and not covering, the entitlement grants. Comping somebody an entitlement and
|
|
44
|
+
* creating a code that reduces what everybody who holds it pays are different powers with different blast
|
|
45
|
+
* radii, and a tool that needs one must not acquire the other. `scopeCovers` matches exactly.
|
|
46
|
+
*/
|
|
47
|
+
export const PAYMENTS_DISCOUNT_CREATE_SCOPE: ControlPlaneScope = "payments:discounts:create";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Reading the codes this project has issued.
|
|
51
|
+
*
|
|
52
|
+
* Separate from creating one, and strictly narrower: a pane that lists what was minted does not need the
|
|
53
|
+
* power to mint. The read exists because the write does — a management client that can create a code and
|
|
54
|
+
* never see it leaves a pane computing *absent* rather than blocked, which no grant repairs (#247).
|
|
55
|
+
*/
|
|
56
|
+
export const PAYMENTS_DISCOUNT_READ_SCOPE: ControlPlaneScope = "payments:discounts:read";
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Write an entitlement nobody paid for — a comp, or the repair of a purchase that verified and never
|
|
60
|
+
* projected. The more dangerous of the two: a connection holding this can give any account anything the
|
|
61
|
+
* catalog sells.
|
|
62
|
+
*/
|
|
63
|
+
export const PAYMENTS_ENTITLEMENT_GRANT_SCOPE: ControlPlaneScope = "payments:entitlements:grant";
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Take an entitlement back — a chargeback, an abuse decision, a comp withdrawn. Effective on the account's
|
|
67
|
+
* next request, which is exactly why it is granted separately from the ability to hand one out.
|
|
68
|
+
*/
|
|
69
|
+
export const PAYMENTS_ENTITLEMENT_REVOKE_SCOPE: ControlPlaneScope = "payments:entitlements:revoke";
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Read the purchase log — every verified provider transaction, across every account.
|
|
73
|
+
*
|
|
74
|
+
* Strictly more disclosure than {@link PAYMENTS_SUBSCRIPTIONS_READ_SCOPE}, which is why the two are
|
|
75
|
+
* separate: a renewal monitor needs to know who is still paying and when their period ends, and has no
|
|
76
|
+
* business reading what everybody ever bought. Neither read reaches a stored provider payload.
|
|
77
|
+
*/
|
|
78
|
+
export const PAYMENTS_PURCHASES_READ_SCOPE: ControlPlaneScope = "payments:purchases:read";
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Read the purchases that renew — the same rows as the purchase log, narrowed to `type: subscription`.
|
|
82
|
+
*
|
|
83
|
+
* The narrower of the two grants, and the one a renewal or churn tool should hold on its own. Holding it
|
|
84
|
+
* confers nothing about the rest of the log: `scopeCovers` matches exactly, with no prefix rule.
|
|
85
|
+
*/
|
|
86
|
+
export const PAYMENTS_SUBSCRIPTIONS_READ_SCOPE: ControlPlaneScope = "payments:subscriptions:read";
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Read the entitlement model — what accounts hold, whether it grants right now, and where it came from.
|
|
90
|
+
*
|
|
91
|
+
* **The read that had to exist for grant and revoke to be honest.** Payments shipped both writes with no
|
|
92
|
+
* read beside them, so a management client could comp an entitlement and take one back while never being
|
|
93
|
+
* able to list one; a console that cannot see what it changed is asking an operator to act blind. It is
|
|
94
|
+
* still granted separately, because seeing what every account is entitled to and deciding what they are
|
|
95
|
+
* entitled to are different operations with different blast radii.
|
|
96
|
+
*/
|
|
97
|
+
export const PAYMENTS_ENTITLEMENTS_READ_SCOPE: ControlPlaneScope = "payments:entitlements:read";
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Read what this project **sells** — each product's id, type, display name, and the entitlement keys it
|
|
101
|
+
* grants. Not what anybody bought.
|
|
102
|
+
*
|
|
103
|
+
* **Its own scope, and the reason is that the two disclosures are unrelated.** Every other read here is a
|
|
104
|
+
* page of the adopter's customers: who paid, what they hold, when it lapses. This one names no account and
|
|
105
|
+
* no transaction, and would read identically against a database with no rows in it. A tool that populates a
|
|
106
|
+
* "comp this person an entitlement" list needs exactly this and nothing else, and it should be able to hold
|
|
107
|
+
* exactly this and nothing else — a dropdown is not a reason to hand a client the purchase log.
|
|
108
|
+
*
|
|
109
|
+
* The converse matters as much and is why it is not folded into {@link PAYMENTS_ENTITLEMENTS_READ_SCOPE}: a
|
|
110
|
+
* catalog is a commercial fact — every tier a company sells and every feature it gates — and a connection
|
|
111
|
+
* granted the entitlement model should not acquire it by implication. `scopeCovers` matches exactly, so
|
|
112
|
+
* neither scope confers the other.
|
|
113
|
+
*/
|
|
114
|
+
export const PAYMENTS_CATALOG_READ_SCOPE: ControlPlaneScope = "payments:catalog:read";
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Read what **reconciliation** did — the log of passes, their tallies, and when they last ran.
|
|
118
|
+
*
|
|
119
|
+
* Its own scope because it is operational state rather than anybody's commerce. A run record names no
|
|
120
|
+
* account, no transaction and no amount; it says whether the compensating control for a delivery mechanism
|
|
121
|
+
* that is known to fail has been firing, and how much it had to repair. An adopter should be able to give a
|
|
122
|
+
* health monitor exactly that and nothing else — a "has the cron stopped" alarm has no business holding the
|
|
123
|
+
* purchase log, and `scopeCovers` matches exactly, so neither confers the other.
|
|
124
|
+
*/
|
|
125
|
+
export const PAYMENTS_RECONCILE_READ_SCOPE: ControlPlaneScope = "payments:reconcile:read";
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Start a reconciliation pass now.
|
|
129
|
+
*
|
|
130
|
+
* **Separate from the read, and it is the sharpest split in this list.** Reading the run log says whether the
|
|
131
|
+
* nightly repair has been firing; starting one calls the store, walks the catalog, and *writes entitlements* —
|
|
132
|
+
* granting what a dropped webhook never granted and revoking what a missed cancellation left standing. The
|
|
133
|
+
* health monitor that holds `:read` to alarm on a stopped cron must not be able to move somebody's access, and
|
|
134
|
+
* `scopeCovers` matches exactly, so it cannot.
|
|
135
|
+
*
|
|
136
|
+
* **A trigger exists because the cron is not enough.** `pithy payments reconcile` runs the same pass, and it
|
|
137
|
+
* needs a laptop, a checkout, and a Cloudflare API token for the project — so the answer to "my subscription
|
|
138
|
+
* isn't showing up" at 4pm on a Saturday is either this route or nothing until 04:00 UTC. Adopted from
|
|
139
|
+
* `pithy-sh/dashboard#96`, where the operator with the run log open had no way to act on it.
|
|
140
|
+
*
|
|
141
|
+
* Idempotent by construction, so the scope carries no warning about pressing twice: `reconcilePayments` finds
|
|
142
|
+
* a reconciled catalog already matching and writes nothing.
|
|
143
|
+
*/
|
|
144
|
+
export const PAYMENTS_RECONCILE_RUN_SCOPE: ControlPlaneScope = "payments:reconcile:run";
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Every control-plane scope payments defines — what `pithy dashboard connect` offers for this capability, and
|
|
148
|
+
* the list a manifest or a doc quotes rather than re-typing. Core's `SEAM_SCOPES` is the same idea for the
|
|
149
|
+
* seam's own routes.
|
|
150
|
+
*
|
|
151
|
+
* Reads first, writes after, because that is the order an adopter should read them in: the reads are the
|
|
152
|
+
* grant most connections want and the smallest one that makes a dashboard useful.
|
|
153
|
+
*/
|
|
154
|
+
export const PAYMENTS_CONTROL_PLANE_SCOPES: readonly ControlPlaneScope[] = [
|
|
155
|
+
PAYMENTS_CATALOG_READ_SCOPE,
|
|
156
|
+
PAYMENTS_PURCHASES_READ_SCOPE,
|
|
157
|
+
PAYMENTS_SUBSCRIPTIONS_READ_SCOPE,
|
|
158
|
+
PAYMENTS_ENTITLEMENTS_READ_SCOPE,
|
|
159
|
+
PAYMENTS_DISCOUNT_READ_SCOPE,
|
|
160
|
+
PAYMENTS_RECONCILE_READ_SCOPE,
|
|
161
|
+
PAYMENTS_ENTITLEMENT_GRANT_SCOPE,
|
|
162
|
+
PAYMENTS_ENTITLEMENT_REVOKE_SCOPE,
|
|
163
|
+
PAYMENTS_DISCOUNT_CREATE_SCOPE,
|
|
164
|
+
PAYMENTS_RECONCILE_RUN_SCOPE,
|
|
165
|
+
];
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Payments' management surface, as `GET /control-plane/manifest` reports it.
|
|
169
|
+
*
|
|
170
|
+
* Declared beside the scopes rather than in `routes.ts` so the scope a route demands and the scope a
|
|
171
|
+
* manifest advertises are the same constant, read from one place. `basePath` is a parameter and never a
|
|
172
|
+
* default: an adopter who mounted payments at `/billing` must get a manifest naming
|
|
173
|
+
* `/billing/entitlements/grant`, or a management client composing its calls from it would 404 against
|
|
174
|
+
* exactly the adopters who customized anything.
|
|
175
|
+
*
|
|
176
|
+
* The summaries say what the operation *is for*, not what it does mechanically. A client renders these
|
|
177
|
+
* next to a button somebody is about to press on a paying customer's account.
|
|
178
|
+
*
|
|
179
|
+
* **Everything sits under an `admin/` segment.** The player surface already owns `${basePath}/purchases`
|
|
180
|
+
* and `${basePath}/entitlements`, and the second of those is a `GET` — so a management read mounted at
|
|
181
|
+
* the bare path would either collide outright or sit behind whichever of the two Hono matched first, with
|
|
182
|
+
* a route's gate decided by registration order. The extra segment makes the two sets disjoint by
|
|
183
|
+
* construction rather than by luck. It also keeps the reads at the shape a management client looks for: a
|
|
184
|
+
* `GET`, with a declared scope, no `:` segment, ending in the resource's own noun.
|
|
185
|
+
*/
|
|
186
|
+
export function paymentsAdminRoutes(basePath: string): AdminRoute[] {
|
|
187
|
+
return [
|
|
188
|
+
{
|
|
189
|
+
method: "GET",
|
|
190
|
+
path: `${basePath}/admin/catalog`,
|
|
191
|
+
scope: PAYMENTS_CATALOG_READ_SCOPE,
|
|
192
|
+
summary:
|
|
193
|
+
"What this project sells — each product's id, kind, display name, and the entitlement keys it grants. The list a comp control fills its dropdown from.",
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
method: "GET",
|
|
197
|
+
path: `${basePath}/admin/purchases`,
|
|
198
|
+
scope: PAYMENTS_PURCHASES_READ_SCOPE,
|
|
199
|
+
summary:
|
|
200
|
+
"Page the purchase log — every verified transaction, newest first, filtered by holder (both halves of the subject), store, status or store environment.",
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
method: "GET",
|
|
204
|
+
path: `${basePath}/admin/subscriptions`,
|
|
205
|
+
scope: PAYMENTS_SUBSCRIPTIONS_READ_SCOPE,
|
|
206
|
+
summary: "Page the purchases that renew — who is still paying, and when the period they paid for ends.",
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
method: "GET",
|
|
210
|
+
path: `${basePath}/admin/entitlements`,
|
|
211
|
+
scope: PAYMENTS_ENTITLEMENTS_READ_SCOPE,
|
|
212
|
+
summary: "Page what accounts hold — whether each entitlement grants right now, and which purchase is the reason.",
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
method: "GET",
|
|
216
|
+
path: `${basePath}/admin/entitlements/:subjectType/:subjectId`,
|
|
217
|
+
scope: PAYMENTS_ENTITLEMENTS_READ_SCOPE,
|
|
218
|
+
summary:
|
|
219
|
+
"Everything one subject is entitled to, resolved now — addressed by both halves, `user`/`organization` and the id. The answer to “why can this person not use what they paid for”.",
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
method: "GET",
|
|
223
|
+
path: `${basePath}/admin/discounts`,
|
|
224
|
+
scope: PAYMENTS_DISCOUNT_READ_SCOPE,
|
|
225
|
+
summary: "The discount codes this project has issued, read from the store that holds them.",
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
method: "POST",
|
|
229
|
+
path: `${basePath}/admin/discounts`,
|
|
230
|
+
scope: PAYMENTS_DISCOUNT_CREATE_SCOPE,
|
|
231
|
+
summary: "Mint a discount code at one store, from terms stated in the units a customer experiences.",
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
method: "GET",
|
|
235
|
+
path: `${basePath}/admin/reconcile-runs`,
|
|
236
|
+
scope: PAYMENTS_RECONCILE_READ_SCOPE,
|
|
237
|
+
summary:
|
|
238
|
+
"The reconciliation passes this deployment has run — when each ran, what it compared, and what it had to repair. The answer to whether the nightly repair is still firing.",
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
method: "POST",
|
|
242
|
+
path: `${basePath}/admin/reconcile-runs`,
|
|
243
|
+
scope: PAYMENTS_RECONCILE_RUN_SCOPE,
|
|
244
|
+
summary:
|
|
245
|
+
"Start a reconciliation pass now, rather than waiting for the nightly one. The repair path for a purchase a dropped webhook never projected.",
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
method: "POST",
|
|
249
|
+
path: `${basePath}/entitlements/grant`,
|
|
250
|
+
scope: PAYMENTS_ENTITLEMENT_GRANT_SCOPE,
|
|
251
|
+
summary: "Comp an entitlement, or repair a purchase that verified but never projected.",
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
method: "POST",
|
|
255
|
+
path: `${basePath}/entitlements/revoke`,
|
|
256
|
+
scope: PAYMENTS_ENTITLEMENT_REVOKE_SCOPE,
|
|
257
|
+
summary: "Take an entitlement back, effective immediately.",
|
|
258
|
+
},
|
|
259
|
+
];
|
|
260
|
+
}
|
package/src/http/view.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { PaymentsPurchaseRecord } from "../admin/read";
|
|
5
|
+
import type { PaymentsConfig } from "../config/config";
|
|
6
|
+
import type { PaymentsEntitlement } from "../data/entitlement";
|
|
7
|
+
import type { PaymentsReconcileRun } from "../data/reconcileRun";
|
|
8
|
+
import type { PurchaseProjection } from "../projection/writer";
|
|
9
|
+
import type {
|
|
10
|
+
PaymentsAdminCatalogResponse,
|
|
11
|
+
PaymentsAdminEntitlementView,
|
|
12
|
+
PaymentsAdminPurchaseView,
|
|
13
|
+
PaymentsAdminReconcileRunView,
|
|
14
|
+
PaymentsEntitlementView,
|
|
15
|
+
PaymentsPurchaseView,
|
|
16
|
+
} from "./responses";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* What a client is shown. Nothing in this package ever returns a raw row.
|
|
20
|
+
*
|
|
21
|
+
* ## What payments holds, and therefore what this can leak
|
|
22
|
+
*
|
|
23
|
+
* There is exactly one column across all four tables that is personal data in the ordinary sense, and it
|
|
24
|
+
* is `pithy_payments_purchases.payload` — the verified provider response, retained as received. On Apple
|
|
25
|
+
* and Google it is a bearer artifact. On Stripe it is a document carrying the buyer's email address,
|
|
26
|
+
* their name, and their billing details. So the question `@pithy-sh/email` answers by masking a
|
|
27
|
+
* recipient has a different answer here, and a stronger one: **the payload is not masked, it is not
|
|
28
|
+
* projected, and the management queries do not select it** (`admin/read.ts`). A field that never reaches
|
|
29
|
+
* the Worker's memory cannot reach a response.
|
|
30
|
+
*
|
|
31
|
+
* Everything else payments stores is an identifier or a fact about a transaction. The only identity is the
|
|
32
|
+
* **subject pair**, `(subjectType, subjectId)` — opaque ids the adopter's auth capability and its own
|
|
33
|
+
* membership model issued, and already the address a management client must name to ask about a holder.
|
|
34
|
+
* Turning either into a name or an address needs `auth:users:read`, which is a separate grant against a
|
|
35
|
+
* separate capability.
|
|
36
|
+
*
|
|
37
|
+
* The two halves are projected together or not at all. A view carrying `subjectId` alone would read as a
|
|
38
|
+
* person whenever an adopter's organization ids and user ids met on a value, so the management
|
|
39
|
+
* projections below copy the pair off one row and never assemble one from config and a column.
|
|
40
|
+
*
|
|
41
|
+
* ## The two audiences, and why their projections differ
|
|
42
|
+
*
|
|
43
|
+
* {@link purchaseView} and {@link entitlementView} answer the adopter's own app, over `requireAuth()`,
|
|
44
|
+
* always about the caller's own rows. {@link adminPurchaseView} and {@link adminEntitlementView} answer a
|
|
45
|
+
* management client, over the control-plane seam, about everybody's. The management views are wider by
|
|
46
|
+
* the facts an operator cannot work without — who owns it, what was charged, whether a human granted it,
|
|
47
|
+
* and which purchase is the reason — and narrower by `outcome`, which describes a write and has no
|
|
48
|
+
* meaning on a read. The client views name no subject at all: the caller is the holder, and a body
|
|
49
|
+
* echoing that back would be the protocol offering a field a client could one day fill in.
|
|
50
|
+
*
|
|
51
|
+
* ## The field lists live in `responses.ts`
|
|
52
|
+
*
|
|
53
|
+
* Every return type below is `z.output` of the Zod object there, so there is one declaration of what a
|
|
54
|
+
* client receives rather than an interface here and a hand-written mirror of it in every management
|
|
55
|
+
* client. A field added to one and not the other does not compile.
|
|
56
|
+
*
|
|
57
|
+
* Dates render as ISO-8601 strings. They are ms-epoch integers in SQLite and `Date`s in TypeScript, and
|
|
58
|
+
* a JSON number would leave every client guessing which unit it was in.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A purchase as its own buyer may see it — the projection a write hands back.
|
|
63
|
+
*
|
|
64
|
+
* Deliberately not the row: the stored `payload` is the whole provider response, and a client has no use
|
|
65
|
+
* for its own receipt read back to it.
|
|
66
|
+
*/
|
|
67
|
+
export function purchaseView(projection: PurchaseProjection): PaymentsPurchaseView {
|
|
68
|
+
const { purchase } = projection;
|
|
69
|
+
return {
|
|
70
|
+
id: purchase.id,
|
|
71
|
+
rail: purchase.rail,
|
|
72
|
+
productId: purchase.productId,
|
|
73
|
+
type: purchase.type,
|
|
74
|
+
status: purchase.status,
|
|
75
|
+
environment: purchase.environment,
|
|
76
|
+
purchasedAt: purchase.purchasedAt.toISOString(),
|
|
77
|
+
expiresAt: purchase.expiresAt?.toISOString() ?? null,
|
|
78
|
+
resumesAt: purchase.resumesAt?.toISOString() ?? null,
|
|
79
|
+
outcome: projection.outcome,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** An entitlement as its holder reads it: the key, whether it grants right now, and when it lapses. */
|
|
84
|
+
export function entitlementView(entitlement: {
|
|
85
|
+
key: string;
|
|
86
|
+
active: boolean;
|
|
87
|
+
expiresAt: Date | null;
|
|
88
|
+
}): PaymentsEntitlementView {
|
|
89
|
+
return { key: entitlement.key, granted: entitlement.active, expiresAt: entitlement.expiresAt?.toISOString() ?? null };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Project the catalog for a management client — what this project sells, and nothing about who bought it.
|
|
94
|
+
*
|
|
95
|
+
* The one projection in this file built from **config** rather than from a row, and that is the whole
|
|
96
|
+
* reason it is safe: the value it reads is `pithy.config.ts`, so a credential, a stored payload, and a
|
|
97
|
+
* customer's identity are not merely withheld here, they are not in the input. What *is* in the input and
|
|
98
|
+
* must not cross is the commercial half of the catalog — every rail's SKU, the Stripe price id, and the
|
|
99
|
+
* `grants` block's currency and amount — and the four fields below are the whole of what does.
|
|
100
|
+
*
|
|
101
|
+
* `{ enabled: false }` when the project defines nothing, matching `clientProjection`'s answer for the same
|
|
102
|
+
* state and for the same reason: a client branches on `enabled`, so "composed with nothing to sell" must
|
|
103
|
+
* read as its own state rather than as an empty list that looks like a failed load.
|
|
104
|
+
*/
|
|
105
|
+
export function adminCatalogView(config: PaymentsConfig): PaymentsAdminCatalogResponse {
|
|
106
|
+
const products = Object.entries(config.products).map(([id, product]) => ({
|
|
107
|
+
id,
|
|
108
|
+
type: product.type,
|
|
109
|
+
name: product.name,
|
|
110
|
+
entitlements: [...product.entitlements],
|
|
111
|
+
}));
|
|
112
|
+
const manualEntitlements = [...config.manualEntitlements];
|
|
113
|
+
if (products.length === 0 && manualEntitlements.length === 0) return { enabled: false };
|
|
114
|
+
// Catalog order, not sorted: the order an adopter wrote their products in is the order a list should
|
|
115
|
+
// show them, exactly as the client projection argues.
|
|
116
|
+
return { enabled: true, products, manualEntitlements };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Project one purchase for a management client. The record's columns, verbatim, with dates as strings. */
|
|
120
|
+
export function adminPurchaseView(purchase: PaymentsPurchaseRecord): PaymentsAdminPurchaseView {
|
|
121
|
+
return {
|
|
122
|
+
id: purchase.id,
|
|
123
|
+
subjectType: purchase.subjectType,
|
|
124
|
+
subjectId: purchase.subjectId,
|
|
125
|
+
rail: purchase.rail,
|
|
126
|
+
providerTransactionId: purchase.providerTransactionId,
|
|
127
|
+
originalTransactionId: purchase.originalTransactionId,
|
|
128
|
+
productId: purchase.productId,
|
|
129
|
+
type: purchase.type,
|
|
130
|
+
status: purchase.status,
|
|
131
|
+
environment: purchase.environment,
|
|
132
|
+
amountMinor: purchase.amountMinor,
|
|
133
|
+
currency: purchase.currency,
|
|
134
|
+
purchasedAt: purchase.purchasedAt.toISOString(),
|
|
135
|
+
expiresAt: purchase.expiresAt?.toISOString() ?? null,
|
|
136
|
+
revokedAt: purchase.revokedAt?.toISOString() ?? null,
|
|
137
|
+
resumesAt: purchase.resumesAt?.toISOString() ?? null,
|
|
138
|
+
updatedAt: purchase.updatedAt.toISOString(),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Project one entitlement row for a management client, resolving `granted` against `now`.
|
|
144
|
+
*
|
|
145
|
+
* The stored `active` flag is what the projection last wrote; `expiresAt` is the truth. A subscription
|
|
146
|
+
* can lapse with no notification arriving at all, so a row can say `active` with an expiry in the past —
|
|
147
|
+
* and the gate the adopter's own app calls applies the timestamp on every request. A dashboard that
|
|
148
|
+
* rendered the flag would disagree with that gate, and the customer would believe the dashboard.
|
|
149
|
+
*/
|
|
150
|
+
export function adminEntitlementView(row: PaymentsEntitlement, now: Date): PaymentsAdminEntitlementView {
|
|
151
|
+
const lapsed = row.expiresAt !== null && row.expiresAt.getTime() <= now.getTime();
|
|
152
|
+
return {
|
|
153
|
+
subjectType: row.subjectType,
|
|
154
|
+
subjectId: row.subjectId,
|
|
155
|
+
key: row.entitlement,
|
|
156
|
+
granted: row.active && !lapsed,
|
|
157
|
+
expiresAt: row.expiresAt?.toISOString() ?? null,
|
|
158
|
+
manual: row.manual,
|
|
159
|
+
source: row.sourcePurchaseId,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Project one reconciliation run for a management client.
|
|
165
|
+
*
|
|
166
|
+
* The row's columns verbatim, with the dates as ISO strings and `createdAt` left behind — when the row was
|
|
167
|
+
* *written* is bookkeeping, and `finishedAt` already answers the only version of that question an operator
|
|
168
|
+
* asks. Nothing is withheld beyond it, because there is nothing to withhold: the table has no column a
|
|
169
|
+
* store's response could reach.
|
|
170
|
+
*/
|
|
171
|
+
export function adminReconcileRunView(run: PaymentsReconcileRun): PaymentsAdminReconcileRunView {
|
|
172
|
+
return {
|
|
173
|
+
id: run.id,
|
|
174
|
+
startedAt: run.startedAt.toISOString(),
|
|
175
|
+
finishedAt: run.finishedAt.toISOString(),
|
|
176
|
+
environment: run.environment,
|
|
177
|
+
rail: run.rail,
|
|
178
|
+
pages: run.pages,
|
|
179
|
+
scanned: run.scanned,
|
|
180
|
+
unchanged: run.unchanged,
|
|
181
|
+
drifted: run.drifted,
|
|
182
|
+
superseded: run.superseded,
|
|
183
|
+
skipped: run.skipped,
|
|
184
|
+
failed: run.failed,
|
|
185
|
+
truncated: run.truncated,
|
|
186
|
+
dryRun: run.dryRun,
|
|
187
|
+
};
|
|
188
|
+
}
|