Plan gating that actually works
A short walkthrough of the capability system — how plan checks flow from Stripe webhooks into your server actions without duplicating logic.
Plan gating is the part of every SaaS that silently rots. You ship a feature, guard it behind a capability, then six months later someone bypasses the check because the new page forgot to call it. Tenviq treats this as a type-safe concern, not a convention.
One source of truth
Capabilities and limits are declared once in config/billing.config.ts:
export const capabilities = [
"task.create",
"task.export",
"team.invite",
"team.analytics",
"billing.portal",
"ai.assistant",
] as const;
export const limitKeys = [
"tasksPerMonth",
"teamMembers",
"storageMb",
"aiCredits",
] as const;Each plan declares which capabilities it grants and the numeric limits it allows. Stripe is the authority on which plan an organization is on — the webhook handler syncs plan state into Prisma, and the entitlements loader projects it into an OrganizationEntitlements object.
Guarding a feature
In server actions or route handlers, call assertCapability with the active entitlements:
import { assertCapability } from "@/features/billing/entitlements";
assertCapability(entitlements, "ai.assistant");The capability argument is typed against the capabilities tuple, so typos are compile errors. A missing capability throws UpgradeRequiredError, which the action state layer catches and turns into an upgrade prompt.
For conditional UI on the server, use hasCapability instead:
import { hasCapability } from "@/features/billing/entitlements";
const canUseAI = hasCapability(entitlements, "ai.assistant");Enforcing quotas
Metered features (tasks per month, team members, AI credits) go through the usage service. consumeMonthlyUsage atomically reserves one unit and throws LimitReachedError if the quota is already full:
import { consumeMonthlyUsage } from "@/features/billing/server/usage-service";
await consumeMonthlyUsage({
organizationId,
limitKey: "tasksPerMonth",
entitlements,
});For non-atomic checks (e.g. deciding whether to show an upsell), checkLimit returns { allowed, currentUsage, limit, remaining } without touching the counter.
Why this works
No per-route middleware. No scattered if (plan === "pro") checks. One config file owns the capability list, and every gate — server actions, dashboard widgets, UI flags — reads from the same typed source. When you add a capability, TypeScript tells you every callsite that needs updating.