@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,214 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The audit action codes `@pithy-sh/payments` emits, as `domain/reason` strings under the `payments` domain.
|
|
6
|
+
*
|
|
7
|
+
* Payments emits through core's seam (`c.var.emit`) and never imports `@pithy-sh/audit` — audit is optional
|
|
8
|
+
* (principle 4: depend on core seams, not on other capabilities). With audit absent the seam is a no-op; with
|
|
9
|
+
* it composed these land in `pithy_audit_events`. The taxonomy is federated, so this list is owned here and
|
|
10
|
+
* core holds no union of every action.
|
|
11
|
+
*
|
|
12
|
+
* **What is audited, and why these.** Money moving is not itself interesting — the purchases table already
|
|
13
|
+
* records every transaction, in more detail than an audit event could. What an audit trail adds is the
|
|
14
|
+
* *attempts*: a receipt that was refused, a notification that failed its signature check, an entitlement that
|
|
15
|
+
* appeared without a payment. Those are the security-relevant events, and they are the ones a trail is read
|
|
16
|
+
* for after the fact.
|
|
17
|
+
*
|
|
18
|
+
* **Never put a credential, a receipt, a purchase token, or a webhook signature in an event's metadata.** The
|
|
19
|
+
* trail is queryable and long-lived, and a receipt is a bearer artifact — the same rule as `detail` on a
|
|
20
|
+
* `PithyError`, for the same reason.
|
|
21
|
+
*/
|
|
22
|
+
export const PaymentsAuditActions = {
|
|
23
|
+
/** A client-submitted receipt was verified and projected. `denied` when it belonged to another account. */
|
|
24
|
+
purchaseVerified: "payments/purchase_verified",
|
|
25
|
+
/** A restore re-submitted a store's transaction history against the authenticated caller. */
|
|
26
|
+
purchaseRestored: "payments/purchase_restored",
|
|
27
|
+
/** An authentic provider notification was received. `failure` when it arrived with nobody to project against. */
|
|
28
|
+
webhookReceived: "payments/webhook_received",
|
|
29
|
+
/** A notification failed its authenticity check. Always `denied`, and the reason a forgery is visible at all. */
|
|
30
|
+
webhookUnverified: "payments/webhook_unverified",
|
|
31
|
+
/**
|
|
32
|
+
* A caller submitted a purchase whose store-account identifier is already bound to a different subject.
|
|
33
|
+
*
|
|
34
|
+
* On Apple and Google that identifier is a value the *app* chose, so one store account legitimately
|
|
35
|
+
* reaching two subjects is possible — a shared device, a reinstall against a new account, one person
|
|
36
|
+
* buying for two organizations. It is also exactly what squatting on another holder's identifier looks
|
|
37
|
+
* like, and the two are indistinguishable from one request.
|
|
38
|
+
* So the binding is left alone (the first one stands) and the attempt is recorded at `warning`: one is
|
|
39
|
+
* noise, a pattern of them against different identifiers is somebody enumerating.
|
|
40
|
+
*/
|
|
41
|
+
providerAccountContested: "payments/provider_account_contested",
|
|
42
|
+
/**
|
|
43
|
+
* A reconciliation pass found a purchase whose stored state disagreed with the store's, and repaired it.
|
|
44
|
+
*
|
|
45
|
+
* Audited at `warning` rather than `info`, and this is the one audit event that is about **us** rather than
|
|
46
|
+
* about a caller. One drift is a dropped delivery. Repeated drift on the same rail means the webhook path is
|
|
47
|
+
* broken — a rotated signing key, a Pub/Sub subscription pointing at a retired deployment — and the only
|
|
48
|
+
* place that pattern is visible is a trail of these. A pass that finds nothing emits nothing.
|
|
49
|
+
*/
|
|
50
|
+
purchaseReconciled: "payments/purchase_reconciled",
|
|
51
|
+
/**
|
|
52
|
+
* A hosted checkout session was created for a caller. Money has not moved — that is the webhook's event — but
|
|
53
|
+
* this is the record of who was sent to pay for what, which is what a disputed charge is reconstructed from.
|
|
54
|
+
*/
|
|
55
|
+
checkoutStarted: "payments/checkout_started",
|
|
56
|
+
/**
|
|
57
|
+
* A billing-portal session was opened. Audited because the portal shows and can cancel a customer's whole
|
|
58
|
+
* billing history, so which caller opened one against which store account is a security-relevant fact.
|
|
59
|
+
*/
|
|
60
|
+
portalOpened: "payments/portal_opened",
|
|
61
|
+
/**
|
|
62
|
+
* A refund's clawback debit was refused because the balance no longer covered it. Always `failure`, always
|
|
63
|
+
* `critical`.
|
|
64
|
+
*
|
|
65
|
+
* The only fulfillment event that is audited, and the reason is the rule at the top of this file: a
|
|
66
|
+
* successful credit or debit is already a ledger row keyed by a ref that names the purchase, so auditing it
|
|
67
|
+
* would duplicate a better record. A refusal is a row that does **not** exist anywhere — the refund stands,
|
|
68
|
+
* the balance is short, and the difference is a decision only a human can make. This is where it becomes
|
|
69
|
+
* queryable.
|
|
70
|
+
*/
|
|
71
|
+
clawbackFailed: "payments/clawback_failed",
|
|
72
|
+
/** A discount code was minted at a store. An administrative act with a cost attached, so it is recorded. */
|
|
73
|
+
discountCreated: "payments/discount_created",
|
|
74
|
+
/**
|
|
75
|
+
* A management client listed the discount codes a store holds.
|
|
76
|
+
*
|
|
77
|
+
* Its own action rather than a flag on the mint event. A read-scoped connection polling a pane would
|
|
78
|
+
* otherwise write thousands of mint-shaped rows into the one record of who decided a customer should pay
|
|
79
|
+
* less — and `payments:discounts:read` is granted precisely to connections that cannot mint anything.
|
|
80
|
+
*/
|
|
81
|
+
discountsRead: "payments/discounts_read",
|
|
82
|
+
/**
|
|
83
|
+
* An entitlement was granted by hand through the control plane — a comp, or the repair of a purchase that
|
|
84
|
+
* never projected. Audited because it is one of exactly two ways an entitlement appears without money
|
|
85
|
+
* moving, and because the actor is support tooling rather than the user who benefits.
|
|
86
|
+
*/
|
|
87
|
+
entitlementGranted: "payments/entitlement_granted",
|
|
88
|
+
/** An entitlement was revoked by hand through the control plane. The other of the two, for the same reason. */
|
|
89
|
+
entitlementRevoked: "payments/entitlement_revoked",
|
|
90
|
+
/**
|
|
91
|
+
* A management client read the purchase log.
|
|
92
|
+
*
|
|
93
|
+
* **Reads are audited here, and only on this surface.** The rule at the top of this file still holds for
|
|
94
|
+
* the adopter's own users: a buyer reading their own purchases is not a security-relevant event, and
|
|
95
|
+
* auditing it would drown the trail. A *management* read is the opposite case — a credential paging every
|
|
96
|
+
* account's commerce leaves no other trace anywhere, and the whole point of the control plane is that a
|
|
97
|
+
* customer can reconstruct what a dashboard did with the access they granted it.
|
|
98
|
+
*
|
|
99
|
+
* Counts and filters only. Never a row, never an amount, never a provider identifier: copying the
|
|
100
|
+
* purchase log into the audit trail would make a second purchase log with weaker access rules than the
|
|
101
|
+
* first.
|
|
102
|
+
*/
|
|
103
|
+
purchasesRead: "payments/purchases_read",
|
|
104
|
+
/**
|
|
105
|
+
* A subscription's plan was changed — a move up or down the ladder.
|
|
106
|
+
*
|
|
107
|
+
* **By the subscriber, on a bearer route** — not through the control plane, unlike the two grant
|
|
108
|
+
* actions above. That distinction is the reason the row matters: an operator acting through the
|
|
109
|
+
* control plane is already accountable to it, while a customer changing their own bill leaves this
|
|
110
|
+
* row and the store's own record and nothing else.
|
|
111
|
+
*
|
|
112
|
+
* Audited even though a purchase row may follow it, and the exception to the rule at the top of this
|
|
113
|
+
* file is the direction of the move. An upgrade settles immediately and does leave a transaction; a
|
|
114
|
+
* downgrade is booked against the next billing period and writes **no** transaction at all (#465), so
|
|
115
|
+
* the purchases table holds nothing until the renewal, and the act that made next month's invoice
|
|
116
|
+
* smaller has no record anywhere else. Auditing only one of the two directions would make the trail
|
|
117
|
+
* agree with the ledger precisely where the ledger is already sufficient, and go silent where it is not.
|
|
118
|
+
*
|
|
119
|
+
* The proration mode is chosen by the rail from the direction of the change and is never a caller's to
|
|
120
|
+
* set. It would belong in the event for exactly that reason — a decision this package made on a
|
|
121
|
+
* customer's behalf — but **the route cannot record it**: the rail returns a `SubscriptionStanding`
|
|
122
|
+
* and no flag saying which mode it picked, so the row carries both plans and both price ids and lets
|
|
123
|
+
* the direction imply the mode. Stated rather than left as a silent omission, because a reader who
|
|
124
|
+
* expects the mode here would otherwise conclude the trail had lost it.
|
|
125
|
+
*
|
|
126
|
+
* Price ids and the direction. Never a quote's totals — a preview is not what was charged, and copying
|
|
127
|
+
* money into the trail would make a second, weaker ledger.
|
|
128
|
+
*/
|
|
129
|
+
subscriptionPlanChanged: "payments/subscription_plan_changed",
|
|
130
|
+
/**
|
|
131
|
+
* A subscription was canceled by its subscriber on a bearer route, effective at the end of the
|
|
132
|
+
* period already paid for.
|
|
133
|
+
*
|
|
134
|
+
* The write whose *absence* is the incident. A scheduled cancellation leaves `status` at `active` and
|
|
135
|
+
* blanks `next_billed_at` (recorded against the sandbox, #465), so a subscription that has quietly
|
|
136
|
+
* stopped renewing is indistinguishable, in any projection built from status, from one that has not.
|
|
137
|
+
* When a customer says they never asked to cancel, this row is the whole answer: which actor asked,
|
|
138
|
+
* for which subject, and when.
|
|
139
|
+
*/
|
|
140
|
+
subscriptionCanceled: "payments/subscription_canceled",
|
|
141
|
+
/**
|
|
142
|
+
* A scheduled cancellation was withdrawn, and the subscription will renew after all.
|
|
143
|
+
*
|
|
144
|
+
* Its own action rather than an outcome on the cancellation event, because the two are separate acts by
|
|
145
|
+
* possibly separate actors, and the pair is what a dispute is reconstructed from. Folded together, the
|
|
146
|
+
* trail asserts a cancellation and holds nothing that says it was taken back — which reads, to whoever
|
|
147
|
+
* audits later, as a subscription that kept billing after it was canceled.
|
|
148
|
+
*/
|
|
149
|
+
subscriptionCancelWithdrawn: "payments/subscription_cancel_withdrawn",
|
|
150
|
+
/**
|
|
151
|
+
* A subscriber asked for a subscription's payments back, on a bearer route.
|
|
152
|
+
*
|
|
153
|
+
* **The one write in this capability that leaves no other record at all.** A refund is a *request*:
|
|
154
|
+
* Paddle holds most live ones awaiting a human, no transaction is created, the purchase rows are
|
|
155
|
+
* untouched, and the entitlement stands. Nothing enters `pithy_payments_purchases` until — and unless —
|
|
156
|
+
* the store approves it weeks later and the webhook arrives. Between the ask and that webhook there is
|
|
157
|
+
* one place recording that somebody asked for money back, for which subject, and which actor asked. This
|
|
158
|
+
* is it, and a rejected refund means that gap is permanent.
|
|
159
|
+
*
|
|
160
|
+
* Its own action rather than an outcome on the cancellation, for the reason the withdrawal has its own:
|
|
161
|
+
* canceling and asking for a refund are separate acts, often minutes apart and sometimes by different
|
|
162
|
+
* actors, and the pair is what a dispute is reconstructed from.
|
|
163
|
+
*
|
|
164
|
+
* **Counts and adjustment ids. Never an amount.** How much a customer is getting back is not decided at
|
|
165
|
+
* this moment by anybody — that is the store's later decision — so a figure here would be a number
|
|
166
|
+
* asserting something nobody has agreed to, in the one table nothing corrects. The adjustment ids are
|
|
167
|
+
* carried because they are the only handle an operator has on money in flight, and they are neither a
|
|
168
|
+
* credential nor a bearer artifact.
|
|
169
|
+
*
|
|
170
|
+
* Emitted **only when an adjustment was actually raised.** A repeat of a request already standing is the
|
|
171
|
+
* no-op, the same as a retried cancel, and a trail claiming two refunds where one was asked for is worse
|
|
172
|
+
* than one claiming none.
|
|
173
|
+
*/
|
|
174
|
+
subscriptionRefundRequested: "payments/subscription_refund_requested",
|
|
175
|
+
/** A management client read the subscriptions. The narrower half of the purchase log, recorded separately. */
|
|
176
|
+
subscriptionsRead: "payments/subscriptions_read",
|
|
177
|
+
/**
|
|
178
|
+
* A management client read the entitlement model — the page, or one account's.
|
|
179
|
+
*
|
|
180
|
+
* The read that pairs with `entitlement_granted` and `entitlement_revoked`, so a trail shows what a
|
|
181
|
+
* console looked at as well as what it changed.
|
|
182
|
+
*/
|
|
183
|
+
entitlementsRead: "payments/entitlements_read",
|
|
184
|
+
/**
|
|
185
|
+
* A management client read the catalog — what this project sells.
|
|
186
|
+
*
|
|
187
|
+
* Audited like the others, and the reason is the same one: the customer's ability to reconstruct what a
|
|
188
|
+
* dashboard did with the access they granted does not have an exception for the reads that touch no
|
|
189
|
+
* account. This one discloses no person and no transaction, so the event carries a count and nothing
|
|
190
|
+
* else — which is also the only fact about it worth asking after.
|
|
191
|
+
*/
|
|
192
|
+
catalogRead: "payments/catalog_read",
|
|
193
|
+
/**
|
|
194
|
+
* A management client read the reconciliation run log.
|
|
195
|
+
*
|
|
196
|
+
* Audited like every other management read, and the rule has no exception for the one that touches no
|
|
197
|
+
* account: the customer's ability to reconstruct what a dashboard did with the access they granted is the
|
|
198
|
+
* point, not the sensitivity of the rows. Counts and filters only — the runs themselves are already a
|
|
199
|
+
* queryable table, and copying them into the trail would make a second one.
|
|
200
|
+
*/
|
|
201
|
+
reconcileRunsRead: "payments/reconcile_runs_read",
|
|
202
|
+
/**
|
|
203
|
+
* A management client started a reconciliation pass.
|
|
204
|
+
*
|
|
205
|
+
* **A write, and audited as one** — this is the trail entry that says a human, on a date, caused every repair
|
|
206
|
+
* the run then made. The repairs carry the run's id, so the trail joins: this row names who pressed, and the
|
|
207
|
+
* grant and revoke rows behind it name what moved. Without it a pass triggered from a browser is
|
|
208
|
+
* indistinguishable from the 04:00 cron, which is exactly the question somebody reading the trail is asking.
|
|
209
|
+
*/
|
|
210
|
+
reconcileRunStarted: "payments/reconcile_run_started",
|
|
211
|
+
} as const;
|
|
212
|
+
|
|
213
|
+
/** One of the payments audit action codes. */
|
|
214
|
+
export type PaymentsAuditAction = (typeof PaymentsAuditActions)[keyof typeof PaymentsAuditActions];
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { BindingSpecInput } from "@pithy-sh/core/src/capability/bindings";
|
|
5
|
+
import {
|
|
6
|
+
type Capability,
|
|
7
|
+
type CapabilityComposeContext,
|
|
8
|
+
defineCapability,
|
|
9
|
+
} from "@pithy-sh/core/src/capability/capability";
|
|
10
|
+
import type { DatabaseSpecMap } from "@pithy-sh/core/src/data/databases";
|
|
11
|
+
import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
12
|
+
import type { KvNamespaceSpecMap } from "@pithy-sh/core/src/kv/namespaces";
|
|
13
|
+
import { workflowBindings } from "@pithy-sh/core/src/workflow/bindings";
|
|
14
|
+
import type { Migration } from "kysely/migration";
|
|
15
|
+
import type { PaymentsClientProduct, PaymentsClientProjection } from "./client/projection";
|
|
16
|
+
import { PaymentsConfig, type PaymentsConfigInput } from "./config/config";
|
|
17
|
+
import { paymentsTables } from "./data/tables";
|
|
18
|
+
import { installEntitlementResolver } from "./entitlement/resolver";
|
|
19
|
+
import type { PaymentsSubjectResolver, PaymentsSubjectSeam } from "./entitlement/subjectSeam";
|
|
20
|
+
import { paymentsManifestConfig } from "./http/manifestConfig";
|
|
21
|
+
import { registerPaymentsRoutes } from "./http/routes";
|
|
22
|
+
import { paymentsAdminRoutes } from "./http/scopes";
|
|
23
|
+
import { payments_0001_purchases } from "./migrations/0001_purchases";
|
|
24
|
+
import { paymentsSecretsRegistry } from "./secret/registry";
|
|
25
|
+
import { paymentsExampleSeed } from "./seeds/example";
|
|
26
|
+
import { PACKAGE_VERSION } from "./version.generated";
|
|
27
|
+
import { paymentsWorkflows } from "./workflows/specs";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Where payments' migrations sort in the app database. Unique per database; the registry composes keys
|
|
31
|
+
* like `1000_payments_0001_purchases`. Sits after vector (900), and was `NEXT_FREE_ORDER` when it was
|
|
32
|
+
* taken — see `packages/cli/src/migrations/orders.test.ts`, which is the only place an order is allocated.
|
|
33
|
+
*/
|
|
34
|
+
export const PAYMENTS_MIGRATION_ORDER = 1000;
|
|
35
|
+
|
|
36
|
+
/** The database name every capability sharing the app D1 coordinates on. `DB` is the binding. */
|
|
37
|
+
const PAYMENTS_DATABASE_NAME = "app" as const;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The options `payments()` takes: the catalog, plus the one thing that cannot live in it.
|
|
41
|
+
*
|
|
42
|
+
* **`resolveSubject` rides here rather than in `PaymentsConfig`, and the split is forced rather than
|
|
43
|
+
* stylistic.** The config is serializable by contract: `provision/resolvePaymentsConfig.ts`
|
|
44
|
+
* `JSON.stringify`s it into the reconcile worker's `PAYMENTS_CONFIG` var, and `workflows/worker.ts` parses
|
|
45
|
+
* it back with `PaymentsConfig.parse` at boot. A function does not survive that round trip — `JSON.stringify`
|
|
46
|
+
* drops it, and the field is simply gone in the one deployment that has no human to ask. So the decision is
|
|
47
|
+
* split along the line the round trip draws: the **serializable** half (`billingSubject`, a two-value enum)
|
|
48
|
+
* is config, diffable in git and carried into the var, and the **callable** half is a composition-time
|
|
49
|
+
* option, handed to the factory alongside it.
|
|
50
|
+
*
|
|
51
|
+
* The consequence is worth stating rather than leaving to be discovered: **the reconcile and paddleSweep
|
|
52
|
+
* Workflows always run with no adopter resolver.** They have no request to hand one, so anything in a
|
|
53
|
+
* Workflow that needs a subject reads it from the rows it is already reconciling — never from the seam.
|
|
54
|
+
*
|
|
55
|
+
* An intersection rather than a field on the config schema for the same reason a Zod object cannot hold it:
|
|
56
|
+
* everything in `PaymentsConfig` is data, and this is not.
|
|
57
|
+
*/
|
|
58
|
+
export type PaymentsOptions = PaymentsConfigInput & {
|
|
59
|
+
/**
|
|
60
|
+
* Which subject this caller is acting for. Required in practice under `billingSubject: "organization"`,
|
|
61
|
+
* where the capability has no way to answer on its own — see `entitlement/subjectSeam.ts`. Under `"user"`
|
|
62
|
+
* it is optional and the authenticated caller is the answer.
|
|
63
|
+
*/
|
|
64
|
+
resolveSubject?: PaymentsSubjectResolver;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The slice of `@pithy-sh/ledger`'s capability the grants check reads — its declared currency codes.
|
|
69
|
+
*
|
|
70
|
+
* Structural, and recognized by a copied three-line predicate rather than by importing the ledger's own
|
|
71
|
+
* `isLedgerCapability`. Two reasons, and both are binding. `compose` is **synchronous**, so the guarded
|
|
72
|
+
* dynamic import that keeps the ledger optional everywhere else is not available here. And a static import
|
|
73
|
+
* would make an optional peer a hard one for every project, including the great majority whose catalogs sell
|
|
74
|
+
* features and never name a currency. This is the same trade `guards.ts` documents for `requireAuth`.
|
|
75
|
+
*/
|
|
76
|
+
interface LedgerPeer extends Capability {
|
|
77
|
+
ledgerConfig: { currencies: readonly { code: string }[] };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Whether a composed capability is the ledger, carrying its parsed currency set. */
|
|
81
|
+
function isLedgerPeer(capability: Capability): capability is LedgerPeer {
|
|
82
|
+
return capability.name === "ledger" && "ledgerConfig" in capability;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Validate every `grants.currency` against the composed ledger, at assembly.
|
|
87
|
+
*
|
|
88
|
+
* `openLedger` validates nothing — it is a primitive, and `credit()` on an unconfigured currency happily
|
|
89
|
+
* opens a real account row. Nobody can reach that row: the ledger's own routes resolve a currency from config
|
|
90
|
+
* and 404 an unknown code, so the balance exists, is correct, and is invisible. The player is told their
|
|
91
|
+
* purchase worked. A typo in a currency code is therefore a defect that produces no error anywhere at
|
|
92
|
+
* runtime, which makes the deploy the only place left to catch it.
|
|
93
|
+
*
|
|
94
|
+
* Every mismatch is reported in one message rather than the first, because an operator fixing one typo per
|
|
95
|
+
* deploy is a bad afternoon.
|
|
96
|
+
*/
|
|
97
|
+
function checkLedgerGrants({ capabilities }: CapabilityComposeContext, config: PaymentsConfig): void {
|
|
98
|
+
const granting = Object.entries(config.products).flatMap(([id, product]) =>
|
|
99
|
+
product.grants?.ledger === undefined ? [] : [{ id, currency: product.grants.ledger.currency }],
|
|
100
|
+
);
|
|
101
|
+
if (granting.length === 0) return;
|
|
102
|
+
|
|
103
|
+
// **A per-user ledger and an organization-billed catalog do not compose**, and the refusal belongs here
|
|
104
|
+
// rather than at the first purchase. `@pithy-sh/ledger` is a per-user model by design: it keys an account
|
|
105
|
+
// `(userId, currency)` and every read it owns addresses a user — the authenticated balance route, the
|
|
106
|
+
// `:userId` management segment, its seeds. There is no honest address for an organization in it. Writing
|
|
107
|
+
// one anyway would put a non-user in a column that means user, and the balance would be invisible to every
|
|
108
|
+
// route the ledger ships; refusing at the purchase instead would take the customer's money first.
|
|
109
|
+
//
|
|
110
|
+
// So this is a catalog that has to change, not a runtime state to handle: either the project bills people,
|
|
111
|
+
// or the products that credit a balance are not the ones it sells to companies.
|
|
112
|
+
if (config.billingSubject !== "user") {
|
|
113
|
+
throw new ValidationError({
|
|
114
|
+
message: "This catalog credits a balance, and this project bills organizations.",
|
|
115
|
+
action: 'Set `billingSubject: "user"`, or drop the `grants` clause from the products that credit a balance.',
|
|
116
|
+
detail: `@pithy-sh/ledger keys every account on a user id and every route it serves reads one, so there is no account an organization's credit could land in that anything would read. Products with a \`grants.ledger\` clause: ${granting.map((entry) => entry.id).join(", ")}.`,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const peer = capabilities.find(isLedgerPeer);
|
|
121
|
+
if (!peer) {
|
|
122
|
+
throw new ValidationError({
|
|
123
|
+
message: "This catalog credits a balance, and no ledger is composed.",
|
|
124
|
+
action: "Add `ledger(...)` to this Worker's capabilities, or drop the `grants` clause from the product.",
|
|
125
|
+
detail: `Products with a \`grants.ledger\` clause: ${granting.map((entry) => entry.id).join(", ")}. Crediting needs @pithy-sh/ledger composed in the same Worker.`,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const declared = new Set(peer.ledgerConfig.currencies.map((currency) => currency.code));
|
|
130
|
+
const unknown = granting.filter((entry) => !declared.has(entry.currency));
|
|
131
|
+
if (unknown.length === 0) return;
|
|
132
|
+
throw new ValidationError({
|
|
133
|
+
message: "A product grants a currency the ledger does not have.",
|
|
134
|
+
action: "Add the currency to the ledger's `currencies`, or correct the product's `grants.ledger.currency`.",
|
|
135
|
+
detail: `${unknown
|
|
136
|
+
.map((entry) => `product "${entry.id}" grants "${entry.currency}"`)
|
|
137
|
+
.join("; ")}. The ledger declares: ${[...declared].sort().join(", ") || "nothing"}.`,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Refuse the one composition that can never entitle anybody: organization billing with no resolver.
|
|
143
|
+
*
|
|
144
|
+
* **A throw at assembly, not a `pithy doctor` finding**, and the difference from the entitlement-gap check
|
|
145
|
+
* is where the evidence lives. That check must read the adopter's *route code* to know whether anything
|
|
146
|
+
* gates at all, which is a question no capability can ask about itself — so it belongs to a tool that reads
|
|
147
|
+
* the project. This one needs nothing but the two arguments already in hand: `billingSubject` from the
|
|
148
|
+
* config being parsed on the line above, and `resolveSubject` from the same call. Where a capability can
|
|
149
|
+
* see the whole fault by itself, it says so at the deploy, which is what `checkLedgerGrants` does two
|
|
150
|
+
* functions down and for the same reason.
|
|
151
|
+
*
|
|
152
|
+
* And the fault is total. Under `"organization"` there is no default resolver — deliberately, because a
|
|
153
|
+
* capability that guessed would key a company's plan to whoever logged in first — so with none supplied
|
|
154
|
+
* `resolvePaymentsSubject` answers nobody for every request. Every entitlement gate denies, every write
|
|
155
|
+
* raises `payments/subject_unresolved`, and both look exactly like a customer who has not paid. There is no
|
|
156
|
+
* request that ever works, so there is nothing to lose by refusing to boot, and a Worker that boots into
|
|
157
|
+
* that state is one whose first symptom is a support ticket from a paying company.
|
|
158
|
+
*/
|
|
159
|
+
function requireResolvableSubject(config: PaymentsConfig, resolveSubject: PaymentsSubjectResolver | undefined): void {
|
|
160
|
+
if (config.billingSubject !== "organization" || resolveSubject !== undefined) return;
|
|
161
|
+
throw new ValidationError({
|
|
162
|
+
message: "This project bills organizations, and nothing can say which one a caller is acting for.",
|
|
163
|
+
action:
|
|
164
|
+
'Pass `resolveSubject` to `payments(...)` in this Worker\'s pithy.config.ts, or set `billingSubject: "user"`.',
|
|
165
|
+
detail:
|
|
166
|
+
'billingSubject is "organization" and no subject resolver was supplied. Payments has no members table and never guesses a holder, so every entitlement gate would deny and every write would raise payments/subject_unresolved, for every caller, forever.',
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* What a browser may know about this project's payments — the `virtual:pithy/payments` module.
|
|
172
|
+
*
|
|
173
|
+
* The list is short and each entry earns its place. **Enabled rails**, so a paywall can show an Apple-only
|
|
174
|
+
* product as owned-elsewhere rather than offering a buy button nothing on the web can honor. **The base
|
|
175
|
+
* path**, because the client calls these routes. And **per product** its id, type, entitlement keys,
|
|
176
|
+
* display name, and its SKU on each web rail — a price id is publishable by design, since it is the thing
|
|
177
|
+
* a checkout names and the store's own page shows back to the buyer. Plus, for Paddle, the three facts
|
|
178
|
+
* Paddle.js needs to initialize: the publishable client token, the account, and the display mode.
|
|
179
|
+
*
|
|
180
|
+
* **Nothing else can cross, and not merely by discipline.** Apple's issuer id and key, Google's
|
|
181
|
+
* service-account credentials, and Stripe's secret and webhook signing keys live in the secrets store
|
|
182
|
+
* behind `paymentsSecretsRegistry` — they are not in the config this closure can see, so there is no
|
|
183
|
+
* expression here that would reach them. What discipline does cover is the rest of the catalog: a
|
|
184
|
+
* product's Apple and Google SKUs stay server-side because a browser has no use for them, and the
|
|
185
|
+
* `grants` block stays server-side because a currency code and an amount describe the economy.
|
|
186
|
+
* `capability.test.ts` locks the key set and sweeps the serialized result for every credential shape.
|
|
187
|
+
*
|
|
188
|
+
* `?? null`, never `undefined`: the projection is inlined into a bundle with `JSON.stringify`, which
|
|
189
|
+
* drops an undefined value and leaves the screen reading a key that is simply absent.
|
|
190
|
+
*
|
|
191
|
+
* An empty catalog projects `{ enabled: false }`. A screen branches on `enabled` rather than guarding, so
|
|
192
|
+
* "composed with nothing to sell" must read the same as "not composed" — both are a paywall with nothing
|
|
193
|
+
* on it.
|
|
194
|
+
*
|
|
195
|
+
* The return type is {@link PaymentsClientProjection}, and the catalog's element type is
|
|
196
|
+
* {@link PaymentsClientProduct} — **declared, not inferred**. `ClientProjection` is `{ enabled: boolean }`
|
|
197
|
+
* plus a JSON catchall, which accepts anything this function could return. The declared types are what
|
|
198
|
+
* make a dropped field a compile error here rather than a browser's problem, and what make a fourth
|
|
199
|
+
* product kind or a fourth Paddle checkout mode in `PaymentsConfig` a red line here rather than a silent
|
|
200
|
+
* widening of what every adopter's bundle carries. The element type is annotated
|
|
201
|
+
* separately because the `.map` is where inference is weakest: it would take the shape of whatever the
|
|
202
|
+
* first product happened to be.
|
|
203
|
+
*/
|
|
204
|
+
function clientProjection(config: PaymentsConfig, environment: string): PaymentsClientProjection {
|
|
205
|
+
const products = Object.entries(config.products).map(
|
|
206
|
+
([id, product]): PaymentsClientProduct => ({
|
|
207
|
+
id,
|
|
208
|
+
type: product.type,
|
|
209
|
+
entitlements: [...product.entitlements],
|
|
210
|
+
name: product.name,
|
|
211
|
+
// **Keyed by rail, not one field per rail.** Two flat fields were already one too many, and a third
|
|
212
|
+
// would have made `purchasable()` a growing chain of `&&`s that a fourth rail silently falls out of.
|
|
213
|
+
// A screen now asks `skus[rail]`, which is the same question it was already asking and cannot go
|
|
214
|
+
// stale when a rail is added. Every id here is publishable by design: each is what a checkout names,
|
|
215
|
+
// so each may reach a browser. The API keys and the signing secrets never do.
|
|
216
|
+
skus: {
|
|
217
|
+
stripe: product.stripe?.priceId ?? null,
|
|
218
|
+
lemonSqueezy: product.lemonSqueezy?.variantId ?? null,
|
|
219
|
+
paddle: product.paddle?.priceId ?? null,
|
|
220
|
+
},
|
|
221
|
+
}),
|
|
222
|
+
);
|
|
223
|
+
if (products.length === 0) return { enabled: false };
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
enabled: true,
|
|
227
|
+
environment,
|
|
228
|
+
rails: {
|
|
229
|
+
apple: config.rails.apple,
|
|
230
|
+
google: config.rails.google,
|
|
231
|
+
stripe: config.rails.stripe,
|
|
232
|
+
lemonSqueezy: config.rails.lemonSqueezy,
|
|
233
|
+
paddle: config.rails.paddle,
|
|
234
|
+
},
|
|
235
|
+
basePath: config.basePath,
|
|
236
|
+
// The three facts Paddle.js needs to initialize, and no more. The client token is publishable — it is
|
|
237
|
+
// designed to reach a browser — and the checkout mode decides whether a screen renders a container.
|
|
238
|
+
// Null when the rail is off, so a screen branches on one value rather than on three absences.
|
|
239
|
+
paddle:
|
|
240
|
+
config.paddle === undefined
|
|
241
|
+
? null
|
|
242
|
+
: {
|
|
243
|
+
clientToken: config.paddle.clientToken,
|
|
244
|
+
environment: config.paddle.environment,
|
|
245
|
+
checkout: config.paddle.checkout,
|
|
246
|
+
},
|
|
247
|
+
// Catalog order, not sorted: the order an adopter wrote their products in is the order a paywall
|
|
248
|
+
// should list them, and it is stable for a given config either way.
|
|
249
|
+
products,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The payments capability, with its resolved catalog attached for other tooling to read. */
|
|
254
|
+
export interface PaymentsCapability
|
|
255
|
+
extends Capability<DatabaseSpecMap, KvNamespaceSpecMap, "payments", typeof paymentsWorkflows> {
|
|
256
|
+
/** The resolved catalog. */
|
|
257
|
+
paymentsConfig: PaymentsConfig;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Three rails — Apple, Google, Stripe — projecting onto one cross-rail entitlement, in the adopter's own
|
|
262
|
+
* D1. The catalog is config, not rows: a product's entitlement mapping is policy, so it is diffable in
|
|
263
|
+
* git and not mutable at runtime.
|
|
264
|
+
*
|
|
265
|
+
* `providesEntitlements` is what makes payments the seam's provider, and it is read in two places. At
|
|
266
|
+
* runtime the middleware below replaces core's fail-closed `noEntitlementProvider` with the D1-backed
|
|
267
|
+
* resolver. At development time `pithy doctor` and `pithy dev` read the flag to answer a question no
|
|
268
|
+
* amount of runtime care can: a Worker whose routes call `requireEntitlement()` while composing no
|
|
269
|
+
* provider is not broken, it is silently paywalled shut, and the seam's own fail-closed default makes
|
|
270
|
+
* that indistinguishable from a user who has not bought anything.
|
|
271
|
+
*
|
|
272
|
+
* `dependsOn: ["secrets"]` is real. Every rail's credentials are read through the aggregated secret
|
|
273
|
+
* registry, so a project composing payments without `@pithy-sh/secrets` must fail at assembly rather
|
|
274
|
+
* than at the first receipt. Auth is *not* listed — it is a seam: under `billingSubject: "user"` a purchase
|
|
275
|
+
* scopes to the authenticated caller, so with no auth capability composed every route denies, which is the
|
|
276
|
+
* right failure and needs no dependency edge. `@pithy-sh/ledger` is likewise a seam, reached through one
|
|
277
|
+
* guarded dynamic import and only for products whose catalog entry declares `grants`. Both belong in the
|
|
278
|
+
* manifest's `optionalCapabilities`.
|
|
279
|
+
*
|
|
280
|
+
* Under `billingSubject: "organization"` the holder is not the caller and cannot be derived from them, so
|
|
281
|
+
* the adopter supplies `resolveSubject` and a composition without one is refused outright — see
|
|
282
|
+
* {@link requireResolvableSubject}.
|
|
283
|
+
*/
|
|
284
|
+
export function payments(options: PaymentsOptions): PaymentsCapability {
|
|
285
|
+
// Split before parsing rather than letting Zod strip it. `PaymentsConfig` would drop `resolveSubject`
|
|
286
|
+
// silently — it is an unknown key on a stripping object — and silence is the wrong outcome for the field
|
|
287
|
+
// whose whole hazard is being serialized by accident. Separated here, the config that reaches
|
|
288
|
+
// `JSON.stringify` in `provision/resolvePaymentsConfig.ts` provably holds no function, because no function
|
|
289
|
+
// was ever in it.
|
|
290
|
+
const { resolveSubject, ...catalog } = options;
|
|
291
|
+
|
|
292
|
+
// Parse the catalog at assembly — a product naming a disabled rail, a duplicate provider SKU, or a
|
|
293
|
+
// ledger grant on a non-consumable fails on deploy, not on the first webhook. `billingSubject` is required
|
|
294
|
+
// and has no default: what a project bills is a decision, and a default would make it an accident.
|
|
295
|
+
const resolved = PaymentsConfig.parse(catalog);
|
|
296
|
+
requireResolvableSubject(resolved, resolveSubject);
|
|
297
|
+
|
|
298
|
+
// The one seam, built once. Both halves come from different places by construction (see
|
|
299
|
+
// {@link PaymentsOptions}), and every asker — the entitlement gate through the middleware, the routes
|
|
300
|
+
// through their own factory — is handed this object rather than reassembling it. A second construction is
|
|
301
|
+
// a second policy, and the two disagree the day one of them is edited.
|
|
302
|
+
//
|
|
303
|
+
// Spread rather than writing the key outright, so an absent resolver leaves an absent key rather than one
|
|
304
|
+
// holding `undefined`. Nothing reads it by presence today — `resolvePaymentsSubject` coalesces and
|
|
305
|
+
// `requirePaymentsSubject` tests truthiness — but a seam that has to be inspected (a doctor check, a log
|
|
306
|
+
// line saying whether one is wired) then reads the fact rather than a placeholder for it.
|
|
307
|
+
const subject: PaymentsSubjectSeam = {
|
|
308
|
+
billingSubject: resolved.billingSubject,
|
|
309
|
+
...(resolveSubject ? { resolveSubject } : {}),
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const migrations: Record<string, Migration> = {
|
|
313
|
+
"0001_purchases": payments_0001_purchases,
|
|
314
|
+
};
|
|
315
|
+
const requiredBindings: BindingSpecInput[] = [
|
|
316
|
+
// The app database — all four pithy_payments_* tables live here.
|
|
317
|
+
{ type: "d1", name: "DB" },
|
|
318
|
+
// The reconciliation Workflow's binding, derived from the spec rather than written again — one
|
|
319
|
+
// declaration, so a binding rename cannot leave the two disagreeing. `optional` because the binding
|
|
320
|
+
// exists only once `pithy payments provision` has deployed the host, and an unprovisioned project must
|
|
321
|
+
// still verify receipts, accept webhooks, and resolve entitlements.
|
|
322
|
+
...workflowBindings(paymentsWorkflows),
|
|
323
|
+
];
|
|
324
|
+
|
|
325
|
+
const capability = defineCapability({
|
|
326
|
+
name: "payments",
|
|
327
|
+
// The package version this capability ships at, stamped by `scripts/stampVersions.ts` — a Worker
|
|
328
|
+
// cannot read its own package.json. Reported per capability by the control-plane manifest.
|
|
329
|
+
version: PACKAGE_VERSION,
|
|
330
|
+
dependsOn: ["secrets"],
|
|
331
|
+
providesEntitlements: true,
|
|
332
|
+
// One secret holds every rail's credentials. Declaring the slice here is what lets `sharedSecretsStore`
|
|
333
|
+
// resolve it — a read of a name no composed capability declared throws.
|
|
334
|
+
secretRegistry: paymentsSecretsRegistry,
|
|
335
|
+
// The one durable job: the nightly reconciliation pass. Declared here so `createBackend` derives its
|
|
336
|
+
// binding and types `c.var.workflows.trigger("payments/reconcile", …)` precisely.
|
|
337
|
+
workflows: paymentsWorkflows,
|
|
338
|
+
// The one thing this capability cannot check on its own: whether the ledger it credits agrees the
|
|
339
|
+
// currency exists. See `checkLedgerGrants` for why the failure is otherwise invisible.
|
|
340
|
+
compose: (context) => checkLedgerGrants(context, resolved),
|
|
341
|
+
// What a browser may know. See `clientProjection` for why the list is what it is.
|
|
342
|
+
client: ({ environment }): PaymentsClientProjection => clientProjection(resolved, environment),
|
|
343
|
+
requiredBindings,
|
|
344
|
+
config: PaymentsConfig,
|
|
345
|
+
databases: {
|
|
346
|
+
[PAYMENTS_DATABASE_NAME]: {
|
|
347
|
+
binding: "DB",
|
|
348
|
+
tables: paymentsTables(),
|
|
349
|
+
migrationOrder: PAYMENTS_MIGRATION_ORDER,
|
|
350
|
+
migrations,
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
// Replaces core's fail-closed `noEntitlementProvider` with the D1-backed resolver, over the seam that
|
|
354
|
+
// says who to resolve for.
|
|
355
|
+
middleware: [installEntitlementResolver(PAYMENTS_DATABASE_NAME, subject)],
|
|
356
|
+
// The management surface, described for `GET /control-plane/manifest`. Built from the resolved
|
|
357
|
+
// `basePath` rather than the default, so an adopter who mounted payments at `/billing` gets a
|
|
358
|
+
// manifest naming `/billing/entitlements/grant` — the whole point of describing rather than
|
|
359
|
+
// assuming. `routeContract.test.ts` checks these against the routes actually registered.
|
|
360
|
+
adminRoutes: paymentsAdminRoutes(resolved.basePath),
|
|
361
|
+
// The one decision a management client cannot infer: what this project bills (#422). Built from the
|
|
362
|
+
// resolved config for the same reason the routes are, and it is the only check there is — nothing
|
|
363
|
+
// downstream compares a stated fact against the seam that acts on it.
|
|
364
|
+
manifestConfig: paymentsManifestConfig(resolved.billingSubject),
|
|
365
|
+
// The routes ask the same question the gate does — a checkout, a restore and a portal all need a holder
|
|
366
|
+
// to key a row to — so they are handed the same seam rather than building one from the config alone.
|
|
367
|
+
routes: registerPaymentsRoutes({ config: resolved, subject }),
|
|
368
|
+
seeds: [paymentsExampleSeed],
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
return Object.assign(capability, { paymentsConfig: resolved });
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Whether a capability is the payments capability — carries its resolved catalog. */
|
|
375
|
+
export function isPaymentsCapability(capability: Capability): capability is PaymentsCapability {
|
|
376
|
+
return capability.name === "payments" && "paymentsConfig" in capability;
|
|
377
|
+
}
|