Gating on an entitlement
app.get("/reports", requireAuth(), requireEntitlement("pro"), handler);Name the key, never a SKU. That is the whole integration for most applications, and gating on an entitlement is the walkthrough.
The gate lives in core rather than in this package, so a route can say what it requires without importing the capability that provides it. With no provider composed, the seam denies — a gate with no provider fails closed.
Reading entitlements from a client
Every status the read can return is in the reference; what moves a purchase between them is refunds, lapses and the rest of the after-sale.
const { entitlements } = await (await fetch("/payments/entitlements")).json();Lapsed rows come back with their flag false rather than filtered out, so a paywall can say your Pro ended on the 4th rather than you are not subscribed.
A subject is entitled while some purchase granting that key is active, in_grace, or canceled with time left — because turning off auto-renew forfeits the next period rather than the one already paid for.
Starting a checkout
const { url } = await (await fetch("/payments/checkout", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ product: "pro_monthly" }),
})).json();
location.assign(url);It names a product, not a price. The catalog maps it.
Opening the billing portal
One route, for whatever subject the caller is acting for.
Under organization billing, that is worth thinking about. There is no per-person card to lean on — the payment method belongs to the company’s billing account — so any caller your resolver maps to that organization can open the portal and cancel the subscription or change the card. Checkout is the same shape in the other direction: a member can start a subscription the company is billed for.
If your membership model has roles, gate it yourself
defineCapability({
name: "app",
middleware: [
(app) => {
for (const path of ["/payments/portal", "/payments/checkout"]) {
app.use(path, async (c, next) => {
if (c.var.auth && !(await mayManageBilling(c))) {
throw new ForbiddenError({ message: "An owner or an admin manages billing." });
}
await next();
});
}
},
],
});Signed out passes through — the route’s own gate answers 401. A 403 there would tell somebody who was never signed in that they are forbidden.
Do not enforce the role in the subject resolver
It is the obvious shortcut and it breaks the read.
Unanswered is unentitled, so a plain member would stop seeing the Pro features their employer is paying for.
A member should hold the plan and be refused the cancel button, and only two separate seams say that. The resolver stays truthful; the middleware restricts.
Submitting a receipt
Optional, and worth doing:
await fetch("/payments/purchases", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ rail: "stripe", receipt: sessionId }),
});Nothing about correctness rests on it. The webhook produces the identical row through the same idempotent writer.
What it buys is the buyer seeing their entitlement on the thank-you page rather than a second or two later.
Restore Purchases
The store knows what somebody bought; your database knows who they are. Restore rebinds one to the other.
Call it when the user asks, and on a fresh install before showing a paywall. It matters for a new device, a reinstall, and somebody who bought before they made an account.
One writer, three triggers
Every write converges on a single idempotent projection keyed on the rail and the store’s own transaction id.
| Trigger | Why |
|---|---|
| Client submission | So the purchaser sees their entitlement immediately |
| Provider webhook | Authoritative. Produces the identical row |
| Reconciliation | Repairs drift from missed deliveries |
Because all three share a writer, replays are free. A dropped client call costs nothing, a replayed webhook changes nothing, and a replayed transaction is a success carrying the existing purchase rather than an error.
Refunds, renewals and revocations need no handling of their own — they are states, and this projects a state.
The rule that stops a stale event revoking a subscriber
The projection is monotonic on the provider’s own event time. An event no newer than the row it would update is ignored entirely.
Providers do not guarantee delivery order. An expiry notification can arrive after the renewal that superseded it, and last-write-wins would then silently revoke a paying subscriber — a defect that produces no error anywhere, and one the subscriber reports rather than your monitoring.
It is a database predicate as well as a pre-read, because two concurrent writers cannot order themselves correctly on their own.
And a client submission is not dated by our clock when what it read is a snapshot
A completed checkout session is immutable — its payment status reads paid forever, refund or no refund.
Dated now, re-posting that session id from the success URL would outrank the refund already projected against the same payment — and the session names its own purchaser, so every ownership check passes. The purchase would be re-granted permanently.
So a one-time session is dated by the store’s own clock, the lookup expands the charge so a refund is visible at all, and only a status that grants nothing may be dated now.
A submission that read live state is genuinely the freshest fact anyone holds, and the clock is the honest date for it.
Repairing one person
pithy payments reconcile --env prod --subject user:usr_a1b2c3The same steps the cron runs, narrowed. This is the answer to my subscription isn’t showing up.