#billing#architecture

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.

Gabriel Nadon

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.