Plans, features and quotas
Define your plans, gate a feature to a plan on the server, cap how often it runs, and show an upgrade prompt when a workspace hits the limit.
A new app already has a free and a pro plan in lib/plans.ts, registered by the composition root, and the chat route is gated on the chat feature. By the end of this page a feature of your own is gated, capped and wrapped in an upgrade prompt.
Define your plans
lib/plans.ts exports the plan catalogue, the feature matrix and the per-plan seat and request limits. composeIntelligo() in lib/intelligo.ts hands them to the billing engine with registerProductPlans, registerProductFeatures, registerTeamMemberLimits and registerRateLimits, after setDefaultProductSlug names the product they belong to. Setting INTELLIGO_BILLING_PRODUCT does the same as that call. With neither, billing throws BillingNotConfiguredError.
definePlans makes the catalogue’s slugs a type, and forPlans(PLANS) (the file’s plans) checks every map that names a plan against them: a misspelt slug fails to compile.
export const PLANS = definePlans({
free: {
// ...
limits: { summaries: 5 },
},
pro: {
name: "Pro",
slug: "pro",
description: "For daily use",
priceOneTime: 20,
priceMonthly: 20,
priceYearly: 192,
targetAudience: "Teams",
aiModelLabel: "Advanced",
monthlyAllowance: fromMajor(15, "USD"),
limits: { summaries: -1 },
features: ["Unlimited summaries", "Exports"],
},
});
const plans = forPlans(PLANS);| Field | What it is |
|---|---|
slug, name, description |
Identity and card copy. Keep a plan with the slug free: a workspace with no subscription resolves to it. |
priceOneTime, priceMonthly, priceYearly |
Display prices as plain numbers in the CURRENCY of lib/billing-config.ts. Declaring an interval price shows the pricing page’s toggle. |
monthlyAllowance |
What the plan grants each period, as money with its currency. The credit engine enforces it: see Credits and money. |
limits |
Your own keys, numeric caps. -1 is unlimited; a missing key is 0. |
features |
Bullet copy for the pricing card, rendered verbatim. These are not feature keys. |
targetAudience, aiModelLabel |
Card copy, in your product’s words. |
stripePriceIdMonthly, stripePriceIdYearly |
The Stripe prices checkout charges. See Billing with Stripe. |
Gate a feature on the server
A feature key is a string you choose. The matrix says which plan slugs grant it:
export const FEATURES = plans.features({
assistant: ["free", "pro"],
// POST /api/chat (the `chat` registry item). Every plan, so a clean
// install can chat with no configuration; tighten to ["pro"] to put
// chat behind a paywall.
chat: ["free", "pro"],
});Add exports: ["pro"] and check it wherever the feature runs, after requireWorkspace has resolved the tenant:
"use server";
import { requireWorkspace } from "@intelligo-dev/auth";
import { hasFeature } from "@intelligo-dev/billing";
export async function exportConversations() {
const { workspace } = await requireWorkspace();
if (!(await hasFeature(workspace.id, "exports"))) {
return { success: false as const, error: "upgrade_required" };
}
// ...
}hasFeature(workspaceId, feature) resolves the workspace’s plan (its subscription’s plan while the subscription is active, trialing or past_due; the trial’s planSlug, pro by default, during an active trial; free otherwise) and returns a boolean. requireFeature takes the same arguments and throws a FeatureNotAvailableError instead, which names the feature, the current plan and the plans that grant it. A key that is missing from the matrix is denied on every plan, so a registry item with a featureKey you have not added answers 403.
A row in the feature_flags table overrides the matrix for its feature name, and an inactive row turns the feature off for everyone. Answers are cached in-process for 60 seconds; invalidateFeatureCache(workspaceId) drops them. An answer given while the database could not be read is not cached.
Cap how often a feature runs
consumeFeatureQuota(userId, workspaceId, plan, action) counts one use when the plan’s limit still allows it and refuses it otherwise, in one SQL statement, so requests arriving together never take the counter past the limit. The limit is the key in limits named after the action — summaries above — unless registerActionLimitKeys maps the action to another key.
const { workspace, user } = await requireWorkspace();
const plan = await getWorkspacePlan(workspace.id);
const quota = await consumeFeatureQuota(
user.id,
workspace.id,
plan,
"summaries"
);
if (!quota.allowed) return { success: false as const, quota };
const summary = await summarize(input);checkFeatureQuota(userId, workspaceId, plan, action) only reads the counter, for display, and recordFeatureUsage(userId, workspaceId, action) only adds to it. Checking with one and counting with the other lets requests that run at once each pass the check.
The result is a FeatureQuotaResult: allowed, used, limit, remaining, percentage, nearingLimit from 80 percent, and on refusal the upgradeMessage you registered with registerUpgradeMessages.
Counters are per user per workspace, and nothing resets them. They are lifetime caps until you clear the usage column of user_quotas yourself, for example from a monthly job.
Limit team size
Seats per plan are their own map in lib/plans.ts, registered by the composition root with registerTeamMemberLimits. A seat count includes the owner, -1 is unlimited, and a plan left out of the map has one seat, which refuses every invitation.
export const TEAM_MEMBER_LIMITS = plans.values({
free: 3,
pro: 25,
});That call, like the trial and rate-limit calls below, is imported from @intelligo-dev/billing/plans and lives inside composeIntelligo(). @intelligo-dev/auth never imports billing: the team-settings block binds checkTeamMemberLimit as a port, and the team service calls it before it sends an invitation.
export const team = createTeamService({
checkMemberLimit: checkTeamMemberLimit,
// Invitation emails reach any address from this deployment's domain,
// so they are capped per sender and per workspace.
checkInvitationRate: async ({ userId, workspaceId }) => {
const [byUser, byWorkspace] = await Promise.all([
checkRateLimit(`user:${userId}`, {
limit: INVITATIONS_PER_HOUR.user,
windowMs: HOUR_MS,
endpoint: "invitation",
}),
checkRateLimit(workspaceId, {
limit: INVITATIONS_PER_HOUR.workspace,
windowMs: HOUR_MS,
endpoint: "invitation",
}),
]);
return { allowed: byUser.allowed && byWorkspace.allowed };
},
notifyMemberJoined: ({ workspaceId, memberName, memberEmail, ownerId }) =>
triggerTeamMemberJoinedNotification({
userId: ownerId,
workspaceId,
memberName,
memberEmail,
}),
});Show the upgrade prompt
Install feature-gating. FeatureGate is a server component, so gated content never reaches an unentitled browser:
<FeatureGate workspaceId={workspace.id} feature="exports">
<ExportsPanel />
</FeatureGate>When the gate closes it renders UpgradePrompt (variant="full" or "compact"), or your fallback. InlineUpgradeBanner annotates a disabled control, PaywallBlur previews content, and QuotaWarning and QuotaLimitDialog take the FeatureQuotaResult from the previous section. The gate is not authorization: keep the check in the action.
| File | You set |
|---|---|
lib/feature-catalog.ts |
Per feature key: labelKey, descriptionKey (message keys) and requiredPlan. Unlisted keys get generic copy. |
lib/feature-gating-config.ts |
upgradeHref (default /settings/billing), warnAtPercent (80), urgentAtPercent (95). |
The pricing block reads the same catalogue through getPlans() in lib/billing.ts, so a plan edited in lib/plans.ts changes the cards and the prompts together.
Offer a trial
No template registers a trial, so none is granted. Register one:
registerTrialConfig(PRODUCT_SLUG, {
initialCredits: 100_000,
grant: fromMajor(2, "USD"),
durationDays: 14,
warningThreshold: 0.2,
reminderDaysBeforeExpiry: 3,
});grant funds execution and must be in your billing currency; initialCredits is a display figure. Then grant it in lib/workspace-bootstrap.ts: replace the body of onWorkspaceCreated with await provisionTrialCredits({ workspaceId, email }).
Set rate limits
createChatHandler limits each workspace to its plan’s requests per minute. An unregistered plan gets 10.
registerRateLimits(PRODUCT_SLUG, { free: 10, pro: 60 });