One catalogue, one question, one answer.
Where the numbers live
features/billing/plans.ts is the single source of truth for every limit. A
service never hard-codes a number; it asks:
const max = await getLimit(db, ctx, "projects.max");
await assertWithinLimit(db, ctx, "projects.max", current);
assertWithinLimit throws a typed EntitlementExceeded (HTTP 402) naming the
limit and where to change it. Adding a new limit will not compile until you have
priced it on every plan — the catalogue is typed Record<LimitKey, number> on
purpose.
Subscriptions and purchases
Two different things, deliberately kept apart:
- A subscription renews, has a period end, and lapses on its own when the
period runs out or the status stops entitling.
past_duestill entitles — dunning is not eviction. - A one-time purchase grants a plan permanently. It writes no subscription row, so there is nothing to renew and nothing to cancel, and the billing page does not offer the customer portal for it.
getEntitlement answers both "what plan" and "can this be cancelled" together,
so the two can never disagree. A live subscription wins; a purchase is the floor
it falls back to.
Who is billed
The billing subject is the organization when a session is acting inside one, and the person otherwise. That is why an organization can hold its own subscription separately from the person who bought it.
Trying it without Stripe
With no STRIPE_SECRET_KEY the kit runs an in-memory provider with
deterministic ids, and the whole upgrade path — checkout, webhook, entitlement —
works end to end. It is fake mode, not a broken configuration.