@pithy-sh/payments 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +157 -0
  3. package/dist/paddle-prices.iife.js +1 -0
  4. package/dist/pithy.manifest.json +68 -0
  5. package/docs/lemon-squeezy.md +167 -0
  6. package/docs/paddle.md +486 -0
  7. package/package.json +73 -0
  8. package/pithy.manifest.json +68 -0
  9. package/src/admin/coverage.ts +107 -0
  10. package/src/admin/read.ts +360 -0
  11. package/src/audit/actions.ts +214 -0
  12. package/src/capability.ts +377 -0
  13. package/src/client/api.ts +734 -0
  14. package/src/client/checkout.ts +252 -0
  15. package/src/client/fixtures/price-preview-de.json +54 -0
  16. package/src/client/fixtures/price-preview-gb.json +54 -0
  17. package/src/client/fixtures/price-preview-jp-yen.json +54 -0
  18. package/src/client/fixtures/price-preview-us-country-only.json +54 -0
  19. package/src/client/fixtures/price-preview-us-new-york.json +54 -0
  20. package/src/client/fixtures/pricePreview.ts +67 -0
  21. package/src/client/hooks.ts +590 -0
  22. package/src/client/paddle.ts +733 -0
  23. package/src/client/paddleCache.ts +265 -0
  24. package/src/client/paddlePrices.iife.ts +43 -0
  25. package/src/client/paddlePrices.ts +143 -0
  26. package/src/client/paddlePricesTag.ts +332 -0
  27. package/src/client/projection.ts +133 -0
  28. package/src/client/test-utils/cacheStore.ts +41 -0
  29. package/src/client/wholeUnits.ts +147 -0
  30. package/src/cloudflare-test.d.ts +16 -0
  31. package/src/config/config.ts +563 -0
  32. package/src/data/discount.ts +238 -0
  33. package/src/data/entitlement.ts +67 -0
  34. package/src/data/money.ts +75 -0
  35. package/src/data/pause.ts +148 -0
  36. package/src/data/providerAccount.ts +47 -0
  37. package/src/data/purchase.ts +131 -0
  38. package/src/data/rail.ts +74 -0
  39. package/src/data/reconcileRun.ts +208 -0
  40. package/src/data/renderMoney.ts +180 -0
  41. package/src/data/status.ts +67 -0
  42. package/src/data/subject.ts +148 -0
  43. package/src/data/subscription.ts +675 -0
  44. package/src/data/syncCursor.ts +44 -0
  45. package/src/data/tables.ts +61 -0
  46. package/src/data/webhookEvent.ts +196 -0
  47. package/src/entitlement/manual.ts +209 -0
  48. package/src/entitlement/resolver.ts +125 -0
  49. package/src/entitlement/subjectSeam.ts +171 -0
  50. package/src/error/errors.ts +421 -0
  51. package/src/grants/apply.ts +207 -0
  52. package/src/grants/clawback.ts +128 -0
  53. package/src/grants/ledgerSeam.ts +141 -0
  54. package/src/http/dispatch.ts +48 -0
  55. package/src/http/guards.ts +58 -0
  56. package/src/http/manifestConfig.ts +56 -0
  57. package/src/http/responses.ts +1147 -0
  58. package/src/http/routes.ts +2911 -0
  59. package/src/http/schemas.ts +577 -0
  60. package/src/http/scopes.ts +260 -0
  61. package/src/http/view.ts +188 -0
  62. package/src/http/webhookGuard.ts +341 -0
  63. package/src/index.ts +231 -0
  64. package/src/migrations/0001_purchases.ts +312 -0
  65. package/src/pricing/location.ts +175 -0
  66. package/src/pricing/visitor.ts +82 -0
  67. package/src/projection/event.ts +101 -0
  68. package/src/projection/orphans.ts +226 -0
  69. package/src/projection/owner.ts +247 -0
  70. package/src/projection/resolve.ts +57 -0
  71. package/src/projection/writer.ts +554 -0
  72. package/src/provision/resolvePaymentsConfig.ts +91 -0
  73. package/src/rails/apple/certs.ts +66 -0
  74. package/src/rails/apple/der.ts +249 -0
  75. package/src/rails/apple/fixtures/chain.ts +412 -0
  76. package/src/rails/apple/fixtures/did-fail-to-renew-grace.json +43 -0
  77. package/src/rails/apple/fixtures/did-renew.json +48 -0
  78. package/src/rails/apple/fixtures/one-time-charge.json +32 -0
  79. package/src/rails/apple/fixtures/refund.json +33 -0
  80. package/src/rails/apple/fixtures/subscribed-initial-buy-sandbox.json +38 -0
  81. package/src/rails/apple/fixtures/test.json +14 -0
  82. package/src/rails/apple/http.ts +128 -0
  83. package/src/rails/apple/jws.ts +123 -0
  84. package/src/rails/apple/notification.ts +357 -0
  85. package/src/rails/apple/rail.ts +114 -0
  86. package/src/rails/apple/serverApi.ts +412 -0
  87. package/src/rails/apple/verify.ts +93 -0
  88. package/src/rails/apple/x509.ts +424 -0
  89. package/src/rails/contract.ts +939 -0
  90. package/src/rails/google/fixtures/play-product-purchased.json +14 -0
  91. package/src/rails/google/fixtures/play-subscription-active.json +23 -0
  92. package/src/rails/google/fixtures/push.ts +152 -0
  93. package/src/rails/google/fixtures/rtdn-one-time-purchased.json +11 -0
  94. package/src/rails/google/fixtures/rtdn-subscription-renewed.json +11 -0
  95. package/src/rails/google/fixtures/rtdn-subscription-revoked.json +11 -0
  96. package/src/rails/google/fixtures/rtdn-test.json +8 -0
  97. package/src/rails/google/fixtures/rtdn-voided-purchase.json +11 -0
  98. package/src/rails/google/http.ts +124 -0
  99. package/src/rails/google/jwt.ts +127 -0
  100. package/src/rails/google/oidc.ts +308 -0
  101. package/src/rails/google/playApi.ts +598 -0
  102. package/src/rails/google/rail.ts +174 -0
  103. package/src/rails/google/rtdn.ts +331 -0
  104. package/src/rails/google/verify.ts +127 -0
  105. package/src/rails/lemonSqueezy/api.ts +192 -0
  106. package/src/rails/lemonSqueezy/checkout.ts +186 -0
  107. package/src/rails/lemonSqueezy/discounts.ts +166 -0
  108. package/src/rails/lemonSqueezy/fixtures/events.ts +180 -0
  109. package/src/rails/lemonSqueezy/objects.ts +511 -0
  110. package/src/rails/lemonSqueezy/portal.ts +82 -0
  111. package/src/rails/lemonSqueezy/pricing.ts +124 -0
  112. package/src/rails/lemonSqueezy/rail.ts +137 -0
  113. package/src/rails/lemonSqueezy/read.ts +72 -0
  114. package/src/rails/lemonSqueezy/refresh.ts +86 -0
  115. package/src/rails/lemonSqueezy/signature.ts +119 -0
  116. package/src/rails/lemonSqueezy/verify.ts +45 -0
  117. package/src/rails/lemonSqueezy/webhook.ts +326 -0
  118. package/src/rails/paddle/adjustments.ts +94 -0
  119. package/src/rails/paddle/api.ts +266 -0
  120. package/src/rails/paddle/checkout.ts +240 -0
  121. package/src/rails/paddle/discounts.ts +176 -0
  122. package/src/rails/paddle/events.ts +285 -0
  123. package/src/rails/paddle/fixtures/browserForged.ts +54 -0
  124. package/src/rails/paddle/fixtures/pausedSubscription.ts +62 -0
  125. package/src/rails/paddle/objects.ts +883 -0
  126. package/src/rails/paddle/portal.ts +130 -0
  127. package/src/rails/paddle/rail.ts +219 -0
  128. package/src/rails/paddle/read.ts +108 -0
  129. package/src/rails/paddle/recorded.ts +83 -0
  130. package/src/rails/paddle/refresh.ts +153 -0
  131. package/src/rails/paddle/refund.ts +297 -0
  132. package/src/rails/paddle/signature.ts +225 -0
  133. package/src/rails/paddle/subscription.ts +793 -0
  134. package/src/rails/paddle/verify.ts +120 -0
  135. package/src/rails/paddle/webhook.ts +405 -0
  136. package/src/rails/providers.ts +168 -0
  137. package/src/rails/stripe/api.ts +253 -0
  138. package/src/rails/stripe/checkout.ts +175 -0
  139. package/src/rails/stripe/discounts.ts +169 -0
  140. package/src/rails/stripe/fixtures/event-charge-refunded.json +23 -0
  141. package/src/rails/stripe/fixtures/event-invoice-paid.json +19 -0
  142. package/src/rails/stripe/fixtures/event-session-completed-payment.json +27 -0
  143. package/src/rails/stripe/fixtures/event-session-completed-subscription.json +27 -0
  144. package/src/rails/stripe/fixtures/event-subscription-canceled.json +43 -0
  145. package/src/rails/stripe/fixtures/event-subscription-created.json +42 -0
  146. package/src/rails/stripe/fixtures/event-subscription-deleted.json +44 -0
  147. package/src/rails/stripe/fixtures/events.ts +111 -0
  148. package/src/rails/stripe/objects.ts +642 -0
  149. package/src/rails/stripe/portal.ts +45 -0
  150. package/src/rails/stripe/pricing.ts +123 -0
  151. package/src/rails/stripe/rail.ts +139 -0
  152. package/src/rails/stripe/refresh.ts +79 -0
  153. package/src/rails/stripe/signature.ts +127 -0
  154. package/src/rails/stripe/verify.ts +105 -0
  155. package/src/rails/stripe/webhook.ts +79 -0
  156. package/src/secret/registry.ts +275 -0
  157. package/src/seeds/example.ts +181 -0
  158. package/src/version.generated.ts +16 -0
  159. package/src/workflows/paddleSweep.ts +717 -0
  160. package/src/workflows/railAccess.ts +87 -0
  161. package/src/workflows/reconcile.ts +648 -0
  162. package/src/workflows/report.ts +64 -0
  163. package/src/workflows/retryPolicy.ts +40 -0
  164. package/src/workflows/specs.ts +143 -0
  165. package/src/workflows/worker.ts +166 -0
  166. package/src/workflows/workerConfig.ts +73 -0
  167. package/src/workflows/wrangler.jsonc +55 -0
@@ -0,0 +1,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
+ }