@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,207 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
5
|
+
import { type AuditEmit, noopEmit } from "@pithy-sh/core/src/audit/recorder";
|
|
6
|
+
import { PaymentsAuditActions } from "../audit/actions";
|
|
7
|
+
import type { PaymentsConfig, PaymentsLedgerGrant } from "../config/config";
|
|
8
|
+
import type { PaymentsPurchase } from "../data/purchase";
|
|
9
|
+
import type { PurchaseStatus } from "../data/status";
|
|
10
|
+
import type { PurchaseProjection } from "../projection/writer";
|
|
11
|
+
import { type ClawbackOutcome, clawbackGrants } from "./clawback";
|
|
12
|
+
import { ledgerAccountId, openPaymentsLedger, type PaymentsLedger } from "./ledgerSeam";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Fulfillment: turning a projected purchase into the balance a `grants` clause promised.
|
|
16
|
+
*
|
|
17
|
+
* This is deliberately **not** in the projection writer. The writer stays pure D1 so its tests resolve no
|
|
18
|
+
* optional package, and the credit is a separate step its caller runs on what it returned — a route, or any
|
|
19
|
+
* other caller holding a projection. Nothing is lost by the separation, because the credit's idempotency has
|
|
20
|
+
* never rested on call ordering: it rests on the ledger's `UNIQUE (ref)`.
|
|
21
|
+
*
|
|
22
|
+
* ## The ref, which is the whole design
|
|
23
|
+
*
|
|
24
|
+
* `payments:grant:<purchaseId>:<currency>`. Three properties, each load-bearing:
|
|
25
|
+
*
|
|
26
|
+
* - **Per credit, never per purchase.** A duplicate `ref` aborts the ledger's batch, and the ledger reports
|
|
27
|
+
* that abort as *success* with the current balance — a replay must be a no-op, so it cannot distinguish
|
|
28
|
+
* itself from one. So a purchase crediting two currencies under one ref would credit the first and silently
|
|
29
|
+
* drop the second, with nothing anywhere to read. The currency is in the ref for that reason alone.
|
|
30
|
+
* - **A pure function of durable identifiers.** Never `crypto.randomUUID()`, never `Date.now()`. `purchaseId`
|
|
31
|
+
* is minted once, at first projection, and every later write keeps it — so a retry three days later derives
|
|
32
|
+
* the identical ref and the ledger recognizes the replay. A ref that varied would double-credit.
|
|
33
|
+
* - **Per billing period on a subscription, for free.** Each renewal is a distinct provider transaction on all
|
|
34
|
+
* three rails, so it is a distinct purchase row with a distinct id, so it is a distinct ref. The same guard
|
|
35
|
+
* that makes a redelivery a no-op lets next month's renewal through, with no period arithmetic anywhere.
|
|
36
|
+
*
|
|
37
|
+
* ## When a credit fires
|
|
38
|
+
*
|
|
39
|
+
* Whenever the charge stands — regardless of the projection's `outcome`. Applying a grant for an `ignored`
|
|
40
|
+
* replay is not waste, it is the repair: if a previous delivery projected the purchase and then failed before
|
|
41
|
+
* the ledger write, the redelivery is the only thing that will ever fix it, and the stable ref makes trying
|
|
42
|
+
* free. The one thing checked is whether the money is actually with us; see {@link UNPAID_STATUSES}.
|
|
43
|
+
*
|
|
44
|
+
* A failure that is not the ledger's own refusal propagates. `openLedger` classifies aborts by regexing the
|
|
45
|
+
* error message, so a transient D1 fault arrives unwrapped and un-typed — treating it as "credited nothing"
|
|
46
|
+
* would drop a purchase's currency permanently, where letting it raise leaves the retry to settle it.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The statuses in which this charge's money is not with us: a payment still outstanding, one that never
|
|
51
|
+
* arrived at all, or one that was taken back. Everything else — `active`, `canceled`, `expired`, `paused` —
|
|
52
|
+
* describes a period that was paid for, whatever has since become of the subscription.
|
|
53
|
+
*
|
|
54
|
+
* `expired` is the member most easily got wrong in the other direction. It says a period ended, not that its
|
|
55
|
+
* invoice failed; refusing to credit on it would lose a coin pack whose only delivery arrived late. Which is
|
|
56
|
+
* precisely why a termination that took no money needs its own status: **`never_paid`** is here, and reading
|
|
57
|
+
* a never-paid ending as `expired` credited a 100-coin pack for a bank debit that bounced — with no clawback
|
|
58
|
+
* ever to follow it, because nothing was ever charged to reverse. See `data/status.ts`.
|
|
59
|
+
*/
|
|
60
|
+
const UNPAID_STATUSES: ReadonlySet<PurchaseStatus> = new Set<PurchaseStatus>([
|
|
61
|
+
"in_grace",
|
|
62
|
+
"on_hold",
|
|
63
|
+
"never_paid",
|
|
64
|
+
"refunded",
|
|
65
|
+
"revoked",
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
/** One balance credit a purchase produced, with the ref that makes it happen exactly once. */
|
|
69
|
+
export interface AppliedGrant {
|
|
70
|
+
/** The ledger currency credited. */
|
|
71
|
+
currency: string;
|
|
72
|
+
/** How much, as an integer in the currency's minor unit. */
|
|
73
|
+
amount: number;
|
|
74
|
+
/** The idempotency key the ledger recorded it under. */
|
|
75
|
+
ref: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** What one fulfillment did in both directions. Empty on both sides for a catalog with no `grants` clause. */
|
|
79
|
+
export interface FulfillmentReport {
|
|
80
|
+
/** The credits applied, in catalog order. */
|
|
81
|
+
granted: readonly AppliedGrant[];
|
|
82
|
+
/** The refund reversals attempted, whether or not the balance covered them. */
|
|
83
|
+
clawedBack: readonly ClawbackOutcome[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** What the grant modules need beyond the projection: the catalog the `grants` clause lives in. */
|
|
87
|
+
export interface GrantOptions {
|
|
88
|
+
/** The resolved catalog. */
|
|
89
|
+
config: PaymentsConfig;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** What {@link fulfillPurchase} needs: the catalog, the audit seam, and optionally a ledger to use. */
|
|
93
|
+
export interface FulfillPurchaseOptions extends GrantOptions {
|
|
94
|
+
/** The audit seam. Defaults to core's no-op, so a caller with no recorder composed needs no branch. */
|
|
95
|
+
emit?: AuditEmit;
|
|
96
|
+
/** An already-open ledger. Absent in production — the guarded import supplies one only when it is needed. */
|
|
97
|
+
ledger?: PaymentsLedger;
|
|
98
|
+
/** The clock the ledger stamps its rows with. Injected so a fulfillment is deterministic under test. */
|
|
99
|
+
now?: () => number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The idempotency key for one credit. A pure function of two durable identifiers, and the only place the
|
|
104
|
+
* shape is written — a caller reconciling a ledger row back to a purchase derives it the same way.
|
|
105
|
+
*/
|
|
106
|
+
export function grantRef(purchaseId: string, currency: string): string {
|
|
107
|
+
return `payments:grant:${purchaseId}:${currency}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The ledger grant this purchase's catalog product declares, or undefined for the products that declare none. */
|
|
111
|
+
export function ledgerGrantFor(config: PaymentsConfig, productId: string): PaymentsLedgerGrant | undefined {
|
|
112
|
+
return config.products[productId]?.grants?.ledger;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Whether this purchase's charge stands, and so whether what it bought should be credited.
|
|
117
|
+
*
|
|
118
|
+
* **A `state` row is never paid, whatever its status says**, and the check is on `role` before it is on
|
|
119
|
+
* `status` because the two answer different questions. `status` asks what became of a charge; a
|
|
120
|
+
* subscription's standing is not a charge, so the question does not apply to it and an honest `active`
|
|
121
|
+
* would otherwise read as money that arrived. Lemon Squeezy is the only rail that writes such a row —
|
|
122
|
+
* its subscription webhooks carry no charge at all — and without this line every LS subscriber would be
|
|
123
|
+
* credited once for the subscription on top of once per invoice. See {@link PurchaseRole}.
|
|
124
|
+
*/
|
|
125
|
+
export function purchaseIsPaid(purchase: Pick<PaymentsPurchase, "status" | "role">): boolean {
|
|
126
|
+
if (purchase.role === "state") return false;
|
|
127
|
+
return !UNPAID_STATUSES.has(purchase.status);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Credit what a fulfilled purchase's `grants` clause promised. Returns what was asked of the ledger, which is
|
|
132
|
+
* not the same as what changed: a replay asks for the identical credit and the ledger no-ops it.
|
|
133
|
+
*/
|
|
134
|
+
export async function applyGrants(
|
|
135
|
+
ledger: PaymentsLedger,
|
|
136
|
+
projection: PurchaseProjection,
|
|
137
|
+
options: GrantOptions,
|
|
138
|
+
): Promise<AppliedGrant[]> {
|
|
139
|
+
const { purchase } = projection;
|
|
140
|
+
const grant = ledgerGrantFor(options.config, purchase.productId);
|
|
141
|
+
if (grant === undefined || !purchaseIsPaid(purchase)) return [];
|
|
142
|
+
|
|
143
|
+
const ref = grantRef(purchase.id, grant.currency);
|
|
144
|
+
// The account is derived from the purchase's subject pair, never from its id alone — `ledgerAccountId`
|
|
145
|
+
// says why, and the clawback derives it the same way so a reversal cannot miss.
|
|
146
|
+
//
|
|
147
|
+
// Identifiers only in the memo. It lands in a queryable ledger row beside a player's balance, so it names
|
|
148
|
+
// what was bought and on which store, and never the artifact that proved it.
|
|
149
|
+
await ledger.credit(ledgerAccountId(purchase), grant.currency, grant.amount, ref, {
|
|
150
|
+
memo: `payments ${purchase.rail} ${purchase.productId}`,
|
|
151
|
+
});
|
|
152
|
+
return [{ currency: grant.currency, amount: grant.amount, ref }];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Fulfill one projected purchase in both directions: credit what it bought, and reverse what a refund took
|
|
157
|
+
* back where the catalog asks for it.
|
|
158
|
+
*
|
|
159
|
+
* The ledger is opened **lazily, and only when the catalog names a currency**. That laziness is the whole
|
|
160
|
+
* reason the optional peer stays optional: a project selling nothing but features never resolves the import,
|
|
161
|
+
* so it neither installs `@pithy-sh/ledger` nor carries it in a Worker bundle.
|
|
162
|
+
*
|
|
163
|
+
* A refused clawback is recorded here rather than thrown. It is the one fulfillment fact no other table
|
|
164
|
+
* holds — a successful credit or debit is already a ledger row, keyed by a ref that names the purchase — so
|
|
165
|
+
* the audit trail is where a shortfall becomes queryable and alertable.
|
|
166
|
+
*/
|
|
167
|
+
export async function fulfillPurchase(
|
|
168
|
+
d1: D1Database,
|
|
169
|
+
projection: PurchaseProjection,
|
|
170
|
+
options: FulfillPurchaseOptions,
|
|
171
|
+
): Promise<FulfillmentReport> {
|
|
172
|
+
const { purchase } = projection;
|
|
173
|
+
if (ledgerGrantFor(options.config, purchase.productId) === undefined) return { granted: [], clawedBack: [] };
|
|
174
|
+
|
|
175
|
+
const ledger = options.ledger ?? (await openPaymentsLedger(d1, { now: options.now }));
|
|
176
|
+
const granted = await applyGrants(ledger, projection, options);
|
|
177
|
+
const clawedBack = await clawbackGrants(ledger, projection, options);
|
|
178
|
+
|
|
179
|
+
const emit = options.emit ?? noopEmit;
|
|
180
|
+
for (const attempt of clawedBack) {
|
|
181
|
+
if (attempt.outcome !== "refused") continue;
|
|
182
|
+
await emit({
|
|
183
|
+
action: PaymentsAuditActions.clawbackFailed,
|
|
184
|
+
outcome: "failure",
|
|
185
|
+
// Alert-worthy: the refund stands, the balance does not cover it, and only a human can decide what to
|
|
186
|
+
// do about the difference. A run of these on one product is a catalog that should not claw back at all.
|
|
187
|
+
severity: "critical",
|
|
188
|
+
actorType: "system",
|
|
189
|
+
resourceType: "purchase",
|
|
190
|
+
resourceId: purchase.id,
|
|
191
|
+
metadata: {
|
|
192
|
+
rail: purchase.rail,
|
|
193
|
+
productId: purchase.productId,
|
|
194
|
+
// Both halves, as every record of an owner carries them. An id alone would leave whoever answers the
|
|
195
|
+
// alert unable to say whether the shortfall is a person's balance or a company's.
|
|
196
|
+
subjectType: purchase.subjectType,
|
|
197
|
+
subjectId: purchase.subjectId,
|
|
198
|
+
currency: attempt.currency,
|
|
199
|
+
amount: attempt.amount,
|
|
200
|
+
ref: attempt.ref,
|
|
201
|
+
reason: attempt.error.payload.code,
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return { granted, clawedBack };
|
|
207
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { PithyError } from "@pithy-sh/core/src/error/pithyError";
|
|
5
|
+
import type { PurchaseStatus } from "../data/status";
|
|
6
|
+
import { PaymentsClawbackFailedError } from "../error/errors";
|
|
7
|
+
import type { PurchaseProjection } from "../projection/writer";
|
|
8
|
+
import type { GrantOptions } from "./apply";
|
|
9
|
+
import { ledgerGrantFor } from "./apply";
|
|
10
|
+
import { ledgerAccountId, type PaymentsLedger } from "./ledgerSeam";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The refund clawback: taking back what a `grants` clause credited, where — and only where — the catalog says
|
|
14
|
+
* to.
|
|
15
|
+
*
|
|
16
|
+
* **A clawback can fail, and that failure is correct behavior.** The ledger's `CHECK (balance >= 0)` refuses
|
|
17
|
+
* a debit the balance cannot cover, and a player who spent their coins before the refund arrived is exactly
|
|
18
|
+
* that case. There are only three things to do about it and two are wrong: allow a negative balance, which
|
|
19
|
+
* breaks the invariant the ledger exists to hold; write the difference off silently, which leaves nothing
|
|
20
|
+
* anywhere to read; or accept the refusal and record it. This module does the third.
|
|
21
|
+
*
|
|
22
|
+
* So the shape is: the refund is projected and audited **unconditionally** by the caller, the clawback is
|
|
23
|
+
* attempted **only** where `clawback: true`, and a refusal is returned as an outcome rather than thrown.
|
|
24
|
+
* `fulfillPurchase` turns that outcome into a `payments/clawback_failed` audit event at `critical` — a
|
|
25
|
+
* recorded, queryable, alertable state, which is what issue #79 asks for and what an exception could not be.
|
|
26
|
+
*
|
|
27
|
+
* **Off by default**, and that default is the honest one. Clawing back is the exception: the user may well
|
|
28
|
+
* have spent the currency legitimately, and a store's refund decision is not automatically a fraud finding. A
|
|
29
|
+
* project that sells hard currency and has decided otherwise says so per product, in one word.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** The statuses that mean this charge came back — the two a clawback answers. */
|
|
33
|
+
const REVERSED_STATUSES: ReadonlySet<PurchaseStatus> = new Set<PurchaseStatus>(["refunded", "revoked"]);
|
|
34
|
+
|
|
35
|
+
/** The ledger's own code for the refusal a clawback is expected to meet. Anything else is unknown, not short. */
|
|
36
|
+
const INSUFFICIENT_FUNDS_CODE = "ledger/insufficient_funds";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* One attempted reversal. `refused` carries its payload rather than a boolean, because the shortfall has to
|
|
40
|
+
* survive as far as the audit trail with its code and its context intact.
|
|
41
|
+
*/
|
|
42
|
+
export type ClawbackOutcome =
|
|
43
|
+
| {
|
|
44
|
+
/** The debit applied, or was recognized as one already applied. */
|
|
45
|
+
outcome: "reversed";
|
|
46
|
+
/** The ledger currency debited. */
|
|
47
|
+
currency: string;
|
|
48
|
+
/** How much, as an integer in the currency's minor unit. */
|
|
49
|
+
amount: number;
|
|
50
|
+
/** The idempotency key the ledger recorded it under. */
|
|
51
|
+
ref: string;
|
|
52
|
+
}
|
|
53
|
+
| {
|
|
54
|
+
/** The balance could not cover it. The refund stands; the difference is a fact for a human. */
|
|
55
|
+
outcome: "refused";
|
|
56
|
+
/** The ledger currency the debit was attempted in. */
|
|
57
|
+
currency: string;
|
|
58
|
+
/** How much the reversal would have taken. */
|
|
59
|
+
amount: number;
|
|
60
|
+
/** The idempotency key the debit was attempted under. */
|
|
61
|
+
ref: string;
|
|
62
|
+
/** The refusal, as the payload the audit trail records. */
|
|
63
|
+
error: PaymentsClawbackFailedError;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The idempotency key for one reversal. Distinct from the grant's by its verb: sharing the grant's ref would
|
|
68
|
+
* make the debit look like a replayed credit, which the ledger swallows as success — so the reversal would
|
|
69
|
+
* report success and move nothing.
|
|
70
|
+
*/
|
|
71
|
+
export function clawbackRef(purchaseId: string, currency: string): string {
|
|
72
|
+
return `payments:clawback:${purchaseId}:${currency}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Reverse what a refunded purchase credited, if the catalog opted in.
|
|
77
|
+
*
|
|
78
|
+
* Runs on every delivery of the refund, not only the first: the debit is idempotent on its ref, so a
|
|
79
|
+
* redelivery is a no-op and a delivery that arrived after an earlier attempt failed is the repair.
|
|
80
|
+
*/
|
|
81
|
+
export async function clawbackGrants(
|
|
82
|
+
ledger: PaymentsLedger,
|
|
83
|
+
projection: PurchaseProjection,
|
|
84
|
+
options: GrantOptions,
|
|
85
|
+
): Promise<ClawbackOutcome[]> {
|
|
86
|
+
const { purchase } = projection;
|
|
87
|
+
const product = options.config.products[purchase.productId];
|
|
88
|
+
const grant = ledgerGrantFor(options.config, purchase.productId);
|
|
89
|
+
// `role` first, and for the mirror of the reason `purchaseIsPaid` checks it first: a `state` row records a
|
|
90
|
+
// subscription's standing rather than a charge, so it never credited and there is nothing to take back.
|
|
91
|
+
// Debiting one would take currency the buyer was never given — the same row's `revoked` status is what a
|
|
92
|
+
// refund correctly writes there, so without this line every clawback-enabled Lemon Squeezy product would
|
|
93
|
+
// debit twice for one refund.
|
|
94
|
+
if (purchase.role === "state") return [];
|
|
95
|
+
if (grant === undefined || product?.clawback !== true || !REVERSED_STATUSES.has(purchase.status)) return [];
|
|
96
|
+
|
|
97
|
+
const ref = clawbackRef(purchase.id, grant.currency);
|
|
98
|
+
// The same derivation the credit used, from the same helper. This is the invariant the helper exists for:
|
|
99
|
+
// a reversal that composed the account any other way would debit an account nothing was ever credited to,
|
|
100
|
+
// and the ledger would answer that with `insufficient_funds` — a refusal indistinguishable from a spent
|
|
101
|
+
// balance, so the shortfall would be recorded and nobody would learn the reversal had missed.
|
|
102
|
+
const accountId = ledgerAccountId(purchase);
|
|
103
|
+
try {
|
|
104
|
+
await ledger.debit(accountId, grant.currency, grant.amount, ref, {
|
|
105
|
+
memo: `payments refund ${purchase.rail} ${purchase.productId}`,
|
|
106
|
+
});
|
|
107
|
+
} catch (cause) {
|
|
108
|
+
// Only the ledger's own solvency refusal is an outcome. A transient D1 fault reaches here unwrapped —
|
|
109
|
+
// `openLedger` classifies aborts by regexing the message — and recording that as "the balance was short"
|
|
110
|
+
// would assert something we do not know. It propagates, and the stable ref settles it on the retry.
|
|
111
|
+
if (!(cause instanceof PithyError) || cause.payload.code !== INSUFFICIENT_FUNDS_CODE) throw cause;
|
|
112
|
+
return [
|
|
113
|
+
{
|
|
114
|
+
outcome: "refused",
|
|
115
|
+
currency: grant.currency,
|
|
116
|
+
amount: grant.amount,
|
|
117
|
+
ref,
|
|
118
|
+
error: new PaymentsClawbackFailedError(
|
|
119
|
+
{
|
|
120
|
+
detail: `Reversing ${grant.amount} ${grant.currency} for ${accountId} after the refund of ${purchase.rail} transaction ${purchase.providerTransactionId} exceeds their available balance.`,
|
|
121
|
+
},
|
|
122
|
+
{ cause },
|
|
123
|
+
),
|
|
124
|
+
},
|
|
125
|
+
];
|
|
126
|
+
}
|
|
127
|
+
return [{ outcome: "reversed", currency: grant.currency, amount: grant.amount, ref }];
|
|
128
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
5
|
+
import { InternalError } from "@pithy-sh/core/src/error/pithyError";
|
|
6
|
+
import { encodeSubjectReference, type PaymentsSubject } from "../data/subject";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The single point of contact between `@pithy-sh/payments` and `@pithy-sh/ledger`.
|
|
10
|
+
*
|
|
11
|
+
* The ledger is an **optional peer**: most catalogs sell features, not currency, and a project that never
|
|
12
|
+
* declares a `grants` clause must neither install the package nor pay for it in its Worker bundle. So the
|
|
13
|
+
* import is dynamic and it lives in exactly one file — grep `@pithy-sh/ledger` in this package and you find
|
|
14
|
+
* this line and nothing else. Everything downstream depends on {@link PaymentsLedger}, a structural
|
|
15
|
+
* two-method view, which is also what keeps the grant modules testable without resolving an optional package.
|
|
16
|
+
*
|
|
17
|
+
* **An absent ledger is an error, never a skip.** A catalog with a `grants` clause has told us a purchase
|
|
18
|
+
* credits a balance; quietly not crediting it is a support ticket that arrives weeks later as "I paid and got
|
|
19
|
+
* no coins". The normal place this is caught is far earlier — `payments()`'s `compose` hook refuses at
|
|
20
|
+
* assembly when a `grants` product is composed without the ledger, so a deploy fails rather than a purchase.
|
|
21
|
+
* This throw is the backstop for a caller that reached fulfillment some other way, and it names the fix.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The ledger operations payments performs: one to fulfill a purchase, one to reverse it.
|
|
26
|
+
*
|
|
27
|
+
* A structural subset rather than `@pithy-sh/ledger`'s own `Ledger`, because a type imported from an optional
|
|
28
|
+
* package would put that package on the typecheck path of every module that touches fulfillment. Holds,
|
|
29
|
+
* captures, and transfers are absent because a purchase is not a wager — money arrives from a store and, on a
|
|
30
|
+
* refund, leaves again.
|
|
31
|
+
*
|
|
32
|
+
* The leading parameter is `@pithy-sh/ledger`'s own `userId`, named `accountId` here because payments never
|
|
33
|
+
* hands it one — see {@link ledgerAccountId}.
|
|
34
|
+
*/
|
|
35
|
+
export interface PaymentsLedger {
|
|
36
|
+
/** Add funds. Idempotent on `ref`, which is `UNIQUE` across the whole ledger. */
|
|
37
|
+
credit(
|
|
38
|
+
accountId: string,
|
|
39
|
+
currency: string,
|
|
40
|
+
amount: number,
|
|
41
|
+
ref: string,
|
|
42
|
+
options?: { memo?: string },
|
|
43
|
+
): Promise<unknown>;
|
|
44
|
+
/** Remove funds. Refuses with `ledger/insufficient_funds` rather than letting a balance go negative. */
|
|
45
|
+
debit(
|
|
46
|
+
accountId: string,
|
|
47
|
+
currency: string,
|
|
48
|
+
amount: number,
|
|
49
|
+
ref: string,
|
|
50
|
+
options?: { memo?: string },
|
|
51
|
+
): Promise<unknown>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The ledger account a subject's balance lives in — **the pair, encoded, and the only derivation of it**.
|
|
56
|
+
*
|
|
57
|
+
* `@pithy-sh/ledger` keys an account on `(userId, currency)`: one flat id namespace, with no column saying
|
|
58
|
+
* what kind of thing the id names. Nothing in the kit keeps a user id and an organization id disjoint — they
|
|
59
|
+
* are minted by different systems, and under `billingSubject: "organization"` the id belongs to a membership
|
|
60
|
+
* model this package knows nothing about. So handing the ledger a bare `subjectId` would let an organization
|
|
61
|
+
* called `acme` and a user called `acme` share one balance: coins the company bought spendable by the person,
|
|
62
|
+
* and a refund of either debiting whatever the other had left.
|
|
63
|
+
*
|
|
64
|
+
* **`@pithy-sh/ledger` is a per-user model, deliberately**, and this function's whole job is to respect that.
|
|
65
|
+
* It keys an account `(userId, currency)`, and every route it serves addresses a user: the authenticated
|
|
66
|
+
* balance read, the `:userId` segment on its management routes, its own seeds. So a user's ledger account id
|
|
67
|
+
* **is** their user id — the identity on `subjectId`, nothing composed.
|
|
68
|
+
*
|
|
69
|
+
* It briefly encoded both halves, and that was a bug rather than a stylistic choice: a grant credited to
|
|
70
|
+
* `user:ada` landed in an account nothing reads. The player's balance stayed empty, the grant was invisible
|
|
71
|
+
* from both sides, and no test in either package could see it, because each package was internally
|
|
72
|
+
* consistent while the two disagreed.
|
|
73
|
+
*
|
|
74
|
+
* **An organization never reaches here.** `checkLedgerGrants` in `capability.ts` refuses a catalog carrying a
|
|
75
|
+
* `grants.ledger` clause under `billingSubject: "organization"`, at assembly — there is no account in a
|
|
76
|
+
* per-user ledger for a company's credit to land in, and inventing one would write a non-user into a column
|
|
77
|
+
* that means user. The throw below is the backstop for a path that composition already closed.
|
|
78
|
+
*
|
|
79
|
+
* **One derivation, called by `apply.ts` and `clawback.ts` alike.** A credit and its clawback must address the
|
|
80
|
+
* identical account or the reversal misses, silently, leaving a refunded buyer holding the currency. Keeping
|
|
81
|
+
* that in one function makes it a property of the code rather than of two edits staying in step.
|
|
82
|
+
*/
|
|
83
|
+
export function ledgerAccountId(subject: PaymentsSubject): string {
|
|
84
|
+
if (subject.subjectType !== "user") {
|
|
85
|
+
// Unreachable through composition: `checkLedgerGrants` refuses a catalog with a `grants.ledger` clause
|
|
86
|
+
// under organization billing, at assembly. Thrown rather than encoded anyway, because the alternative is
|
|
87
|
+
// a row in `pithy_ledger_accounts` whose `userId` is not a user — invisible to every route the ledger
|
|
88
|
+
// serves, and impossible to tell from a balance nobody funded.
|
|
89
|
+
throw new InternalError({
|
|
90
|
+
message: "A balance cannot be credited to an organization.",
|
|
91
|
+
action: 'Set `billingSubject: "user"`, or drop the `grants` clause from the products that credit one.',
|
|
92
|
+
detail: `@pithy-sh/ledger keys every account on a user id. Refused an account for ${encodeSubjectReference(subject)}.`,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return subject.subjectId;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** How the ledger module is reached. Injectable so the absent-package path is a test rather than a hope. */
|
|
99
|
+
export type PaymentsLedgerLoader = () => Promise<{
|
|
100
|
+
openLedger: (d1: D1Database, now?: () => number) => PaymentsLedger;
|
|
101
|
+
}>;
|
|
102
|
+
|
|
103
|
+
/** The real loader — the only `@pithy-sh/ledger` import in the package. */
|
|
104
|
+
const loadLedgerModule: PaymentsLedgerLoader = () => import("@pithy-sh/ledger/src/ledger");
|
|
105
|
+
|
|
106
|
+
/** What {@link openPaymentsLedger} accepts beyond the binding. */
|
|
107
|
+
export interface OpenPaymentsLedgerOptions {
|
|
108
|
+
/** Override the module loader. Tests only; production always resolves the real package. */
|
|
109
|
+
load?: PaymentsLedgerLoader;
|
|
110
|
+
/** The clock the ledger stamps its rows with. Injected so a fulfillment is deterministic under test. */
|
|
111
|
+
now?: () => number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Open the ledger against the app database, or explain what is missing.
|
|
116
|
+
*
|
|
117
|
+
* The ledger's tables live in the same D1 as payments' own — both capabilities bind `DB` — so there is no
|
|
118
|
+
* second database to reach and no configuration to resolve. That is also why the two write independently and
|
|
119
|
+
* a credit is idempotent on its `ref` rather than on being in the purchase's transaction: D1 has no
|
|
120
|
+
* cross-statement transaction a caller can hold open across two packages.
|
|
121
|
+
*/
|
|
122
|
+
export async function openPaymentsLedger(
|
|
123
|
+
d1: D1Database,
|
|
124
|
+
options: OpenPaymentsLedgerOptions = {},
|
|
125
|
+
): Promise<PaymentsLedger> {
|
|
126
|
+
const load = options.load ?? loadLedgerModule;
|
|
127
|
+
let module: { openLedger: (d1: D1Database, now?: () => number) => PaymentsLedger };
|
|
128
|
+
try {
|
|
129
|
+
module = await load();
|
|
130
|
+
} catch (cause) {
|
|
131
|
+
throw new InternalError(
|
|
132
|
+
{
|
|
133
|
+
message: "This purchase credits a balance, and the ledger is not installed.",
|
|
134
|
+
action: "Install @pithy-sh/ledger and compose ledger(...), or drop the `grants` clause from the product.",
|
|
135
|
+
detail: `Loading @pithy-sh/ledger failed: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
136
|
+
},
|
|
137
|
+
{ cause },
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
return module.openLedger(d1, options.now);
|
|
141
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { Logger } from "@pithy-sh/core/src/logger/logger";
|
|
5
|
+
import { triggerWorkflow } from "@pithy-sh/core/src/workflow/dispatch";
|
|
6
|
+
import { workflowKey } from "@pithy-sh/core/src/workflow/naming";
|
|
7
|
+
import { PAYMENTS_CAPABILITY, type PaymentsReconcileParams, paymentsWorkflowRegistry } from "../workflows/specs";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Starting a reconciliation pass from inside a Worker.
|
|
11
|
+
*
|
|
12
|
+
* **One dispatch path, three callers.** The cron in the host worker, an on-demand pass for a single holder,
|
|
13
|
+
* and anything a later surface adds all route through {@link triggerWorkflow} — so a scheduled run and a
|
|
14
|
+
* manual one validate their parameters identically, resolve the binding identically, and log an absent
|
|
15
|
+
* binding identically. Calling `env.PAYMENTS_RECONCILE.create()` directly would skip all three: a mistyped
|
|
16
|
+
* payload would surface as a failed step inside a running instance rather than at the call site, and an
|
|
17
|
+
* unprovisioned project would silently do nothing.
|
|
18
|
+
*
|
|
19
|
+
* **What is handed over is a payload, and that is why a holder is the pair.** `triggerWorkflow` parses
|
|
20
|
+
* `params` through `PaymentsReconcileParams` before it touches the binding, so a narrowing missing half its
|
|
21
|
+
* subject is refused here — where the caller is still on the stack — rather than inside an instance that
|
|
22
|
+
* would otherwise start, scan the wrong rows, and report them as the account somebody asked about. Nothing
|
|
23
|
+
* callable crosses: the instance runs with no subject seam at all, which `workflows/worker.ts` explains.
|
|
24
|
+
*
|
|
25
|
+
* **An absent binding is a logged skip, not a failure.** The job is declared `optional`, because the Workflow
|
|
26
|
+
* lives in the prebuilt reconcile worker and that worker exists only once `pithy payments provision` has run.
|
|
27
|
+
* A support request that cannot start a pass yet must not become a 500 on a route that otherwise works.
|
|
28
|
+
*
|
|
29
|
+
* It lives under `http/` because the on-demand caller is a request handler — support reconciling one holder.
|
|
30
|
+
* The host worker's cron uses the identical function, which is the point: there is one way to start this job.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** The dispatch key for the reconciliation pass. Built from core's key builder, never spelled out. */
|
|
34
|
+
export const PAYMENTS_RECONCILE_KEY = workflowKey(PAYMENTS_CAPABILITY, "reconcile");
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Start a reconciliation pass.
|
|
38
|
+
*
|
|
39
|
+
* `env` is the raw Worker env rather than a typed binding, because that is what `triggerWorkflow` reads the
|
|
40
|
+
* binding off — and taking the binding itself would let a caller pass one the registry never validated.
|
|
41
|
+
*/
|
|
42
|
+
export async function triggerPaymentsReconcile(
|
|
43
|
+
env: Record<string, unknown>,
|
|
44
|
+
params: PaymentsReconcileParams = {},
|
|
45
|
+
log?: Logger,
|
|
46
|
+
): Promise<void> {
|
|
47
|
+
await triggerWorkflow(env, paymentsWorkflowRegistry, PAYMENTS_RECONCILE_KEY, params, log);
|
|
48
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
|
|
5
|
+
import { UnauthorizedError } from "@pithy-sh/core/src/error/pithyError";
|
|
6
|
+
import type { MiddlewareHandler } from "hono";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The payments routes' identity gates: the one middleware payments owns.
|
|
10
|
+
*
|
|
11
|
+
* **The scope constants moved to `./scopes`** (#315). A management client reads them to render what a
|
|
12
|
+
* connection may do, and it reads them in a browser — so they cannot live in a module that imports
|
|
13
|
+
* Hono middleware and `PithyHonoEnv`. The gates stayed here; the names they demand are next door, and
|
|
14
|
+
* the routes import both.
|
|
15
|
+
*
|
|
16
|
+
* ## `requireAuth` is copied, not imported
|
|
17
|
+
*
|
|
18
|
+
* These three lines are the same three `@pithy-sh/storage`, `@pithy-sh/media`, and `@pithy-sh/ledger` each
|
|
19
|
+
* carry, and the duplication is deliberate. Importing the gate from `@pithy-sh/auth` would make auth a hard
|
|
20
|
+
* dependency, and a package that *imports its authorization from another package fails open when that package
|
|
21
|
+
* is absent*. Depending on the core `AuthContext` seam instead means `c.var.auth` is simply `null` with no
|
|
22
|
+
* auth capability composed, and every route denies. Failing closed is not a side effect of the copy; it is the
|
|
23
|
+
* reason for it. So `dependsOn` stays free of auth and the manifest lists it under `optionalCapabilities`.
|
|
24
|
+
*
|
|
25
|
+
* ## The control-plane gate is core's, and payments contributes only the scope names
|
|
26
|
+
*
|
|
27
|
+
* `requireControlPlane` lives in `@pithy-sh/core/src/controlPlane/http/guard` and the two admin routes wear it
|
|
28
|
+
* directly. Payments verifies nothing itself: a management call arrives as an EdDSA-signed compact JWS on the
|
|
29
|
+
* `pithy-control-plane` header, and the seam checks the signature against a public key the **adopter**
|
|
30
|
+
* registered, the connection it addresses, that connection's environment, the token's lifetime, a digest of
|
|
31
|
+
* the body, and the token's single use — none of which a capability could re-implement per package without
|
|
32
|
+
* five subtly different answers to the same question.
|
|
33
|
+
*
|
|
34
|
+
* **This is not the opposite of the rule above; it is the same rule.** The rule is never to import
|
|
35
|
+
* authorization from a package that might be absent. `@pithy-sh/auth` is optional, so its gate is copied.
|
|
36
|
+
* `@pithy-sh/core` is a hard dependency of every capability there is, so importing its gate cannot leave a
|
|
37
|
+
* deployment without one — and when the *seam* is not composed the imported gate raises
|
|
38
|
+
* `controlplane/not_connected` rather than passing. Both halves fail closed; only the mechanism differs.
|
|
39
|
+
*
|
|
40
|
+
* **`requireAuth()` must never sit on those two routes.** A management client is not a user of the adopter's
|
|
41
|
+
* app: it holds no session, owns no user row, and the seam deliberately leaves `c.var.auth` null so that a
|
|
42
|
+
* control-plane credential cannot satisfy an ordinary `requireAuth()` anywhere in the tree. An auth gate on an
|
|
43
|
+
* admin route would therefore deny every legitimate management call, permanently, and no credential could fix
|
|
44
|
+
* it. The gate that replaced the interim one is the whole verification strategy, not half of it.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/** Require an authenticated caller (the core AuthContext seam). */
|
|
48
|
+
export function requireAuth(): MiddlewareHandler<PithyHonoEnv> {
|
|
49
|
+
return async (c, next) => {
|
|
50
|
+
if (!c.var.auth) {
|
|
51
|
+
throw new UnauthorizedError({
|
|
52
|
+
message: "Authentication required.",
|
|
53
|
+
action: "Sign in and retry with a valid session or bearer token.",
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
await next();
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { CapabilityManifestConfig } from "@pithy-sh/core/src/controlPlane/discovery/configuration";
|
|
5
|
+
import { defineManifestConfig } from "@pithy-sh/core/src/controlPlane/discovery/configuration";
|
|
6
|
+
import { PaymentsSubjectType } from "../data/subject";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The configured fact payments states into `GET /control-plane/manifest` (#422).
|
|
10
|
+
*
|
|
11
|
+
* **A management client cannot write a grant without it.** `POST {base}/entitlements/grant` names the
|
|
12
|
+
* holder and never assumes it, which is right — the two kinds are different rows and different gates.
|
|
13
|
+
* But `PaymentsConfig.billingSubject` is required with no default, so there is nothing a client may
|
|
14
|
+
* safely assume either: guessing `user` against an organization-billed project writes a row nothing
|
|
15
|
+
* reads, the call succeeds, and the person still cannot use what somebody just gave them.
|
|
16
|
+
*
|
|
17
|
+
* Nothing else on the wire answers it. `GET {base}/admin/catalog` returns products.
|
|
18
|
+
* `PaymentsClientProjection` leaves it out deliberately and is a browser bundle besides. And
|
|
19
|
+
* `GET {base}/admin/entitlements` carries a `subjectType` per *row* — evidence about who happens to hold
|
|
20
|
+
* something, not a statement about what the project bills. An empty table says nothing, and a project
|
|
21
|
+
* that migrated has both kinds in it.
|
|
22
|
+
*
|
|
23
|
+
* **Its own module rather than a block in `scopes.ts`.** That module declares nine `ControlPlaneScope`
|
|
24
|
+
* constants and `tooling/browser-scopes/src/coverage.test.ts` holds every such module to type-only
|
|
25
|
+
* imports, so a scope name compiles in a browser program with no Workers types. This needs
|
|
26
|
+
* `defineManifestConfig` and `PaymentsSubjectType` as values, which is exactly what that gate refuses —
|
|
27
|
+
* so the two live apart rather than the gate being weakened.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** The key the fact appears under. The join key a management client reads it back by. */
|
|
31
|
+
export const PAYMENTS_BILLING_SUBJECT = "billingSubject";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What this project bills, stated for a client that has to name a holder.
|
|
35
|
+
*
|
|
36
|
+
* **Takes the resolved value rather than reading config itself**, for the same reason
|
|
37
|
+
* `paymentsAdminRoutes` takes the resolved `basePath`: a declaration built from defaults describes a
|
|
38
|
+
* Worker other than this one, and nothing downstream compares the two — a manifest that drifts from the
|
|
39
|
+
* capability is worse than no manifest, because a client believes it.
|
|
40
|
+
*
|
|
41
|
+
* `choices` is read off {@link PaymentsSubjectType} rather than retyped. A third kind of holder is a
|
|
42
|
+
* change to that enum, and reading it here is what makes the manifest learn about it in the same commit.
|
|
43
|
+
*/
|
|
44
|
+
export function paymentsManifestConfig(billingSubject: PaymentsSubjectType): CapabilityManifestConfig {
|
|
45
|
+
return defineManifestConfig({
|
|
46
|
+
keys: [
|
|
47
|
+
{
|
|
48
|
+
key: PAYMENTS_BILLING_SUBJECT,
|
|
49
|
+
choices: [...PaymentsSubjectType.options],
|
|
50
|
+
summary:
|
|
51
|
+
"What kind of thing holds a purchase in this project — one person, or one organization. Every entitlement this project grants names a subject of this kind, so a client must state it rather than guess it.",
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
values: { [PAYMENTS_BILLING_SUBJECT]: billingSubject },
|
|
55
|
+
});
|
|
56
|
+
}
|