Entitlements
What a plan is allowed to do, decided in code rather than read off a subscription row.
Payments answer "did they pay". Entitlements answer "may they do this", and
the two are deliberately separate. packages/entitlements holds a catalogue in
code, and the rest of the app asks it rather than inspecting the
subscriptions table.
The catalogue works with payments unconfigured. With no Polar credentials
everyone resolves to free, and every gate still behaves correctly. That is
what lets a fresh clone run the whole product without an account anywhere.
Three kinds of limit
| Kind | Declared as | Checked |
|---|---|---|
| Boolean feature | FEATURES | At the action: may this user do this at all |
| Metered feature | METERS | Counted per UTC month against a limit |
| Static cap | caps | At creation time, against a total rather than a rate |
Shipping today: features ai.chat, ai.rag, files.upload,
webhooks.endpoints, api-keys, projects; meters ai.messages and
files.uploads; caps maxWebhookEndpoints and maxApiKeys.
A null limit means unlimited. Rename all of it — the names are this product's
domain, not a framework's.
Using it
import {
requireEntitlement,
requireUsage,
consumeUsage,
UpgradeRequiredError,
} from "@workspace/entitlements"
// Boolean gate. Throws UpgradeRequiredError, which the API maps to a 402.
await requireEntitlement(user.id, "ai.chat")
// Metered gate: check before doing the work…
await requireUsage(user.id, "ai.messages")
const reply = await runTheExpensiveThing()
// …and count after it succeeded.
await consumeUsage(user.id, "ai.messages")The split between requireUsage and consumeUsage is the point. Counting
before the work charges a user for a request that then failed; counting only
after means a burst of concurrent calls can overshoot the limit slightly.
That trade is deliberate, and overshooting a soft monthly quota is the cheaper
side of it. If a meter ever gates something expensive and abusable, reserve
first instead.
getEntitlement and getUsage are the non-throwing versions, for rendering a
UI that shows what a plan includes rather than blocking an action.
Where the plan comes from
plan.ts resolves a user's effective plan from the latest row in
subscriptions, and returns more than an id:
{
plan: "pro",
status: "past_due",
cancelAtPeriodEnd: false,
currentPeriodEnd: Date,
inGracePeriod: true,
}A past_due subscription keeps its entitlements for ENTITLEMENTS_GRACE_DAYS
after the paid period lapses, defaulting to 7. That window exists because
payment providers retry failed charges for days, and locking somebody out on
the first decline turns a bank hiccup into a support ticket and a cancellation.
inGracePeriod is exposed so the UI can say so. A user whose card failed
should see a banner, not a silent countdown to losing access.
Changing plans
Everything is in catalog.ts. Add a plan id, give it a features map, limits
and caps, and map it to a Polar product in the checkout configuration. Access
decisions come from this file, so a plan that exists here works before it
exists in Polar, which is the right order for developing against it.