Billing & plans
Overview
Billing is built on Stripe, but the rest of the app never imports the
Stripe SDK directly — everything goes through the payment abstraction in
src/lib/payment/. This is what makes the provider swappable. Plans are defined
in src/config/plans.config.ts.
Defining plans
Each plan is a typed object. The kit ships Free / Pro / Enterprise as examples — edit them to match your Stripe products:
{
id: "pro",
name: "Pro",
priceMonthly: 2900, // display only — Stripe is authoritative
stripePriceId: env.STRIPE_PLAN_PRO_PRICE_ID,
stripeProductId: env.STRIPE_PLAN_PRO_PRODUCT_ID,
trial: { days: 14 },
priceModel: "per_seat",
features: { seats: 5, storage: "20 GB", monthlyTokens: 200_000 },
}
Notes:
- Display prices are for the UI only. Stripe is the source of truth for what the customer is actually charged.
- A plan with no
stripePriceId(like Free) has no Stripe backing and is never billed. isDefault: truemarks the plan assigned to new users;isFeatured: truehighlights one in the pricing UI.
Flat vs per-seat pricing
The priceModel field controls billing shape:
flat— one fixed price per cycle, regardless of members.per_seat— the Stripe subscription quantity tracks the active member count. Checkout and seat-sync keep Stripe aligned as members join or leave.
Per-seat pricing requires shared organizations (TENANCY_ORGS_ENABLED=true) —
a personal workspace always has exactly one member, so the kit fails fast if you
configure a per-seat plan without orgs.
Trials
Set trial: { days: N } on a plan to grant a trial. A TrialWillEnd email
reminds the customer before it expires.
Subscription state & enforcement
The kit enforces subscription status: a lapsed or unpaid subscription triggers a
lockout, and when a plan's seat limit is exceeded, overflow members are
suspended (owners are always counted; suspended managers get scoped
recovery access). Guards for this live in src/lib/billing/.
Webhooks
Stripe events are handled at src/app/api/webhooks/stripe/route.ts. This is
what keeps your database in sync with Stripe (subscription changes, payment
failures, disputes, credit-pack purchases). Relevant events trigger localized
emails — PaymentFailed, SubscriptionCanceled, DisputeCreated.
To receive events locally, forward them with the Stripe CLI:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
Redirect safety
Checkout and portal redirect URLs (successUrl, cancelUrl, returnUrl) must
be app-relative paths — they're validated server-side and prefixed with
APP_URL. The client can never supply an absolute URL for a redirect.
Feature gating by plan
Each plan declares boolean capability flags in its features (from the
PLAN_FEATURES catalog in plans.config.ts), alongside quantitative limits
like seats. Gate on them without re-reading the subscription everywhere:
// Server — throws FORBIDDEN if the active plan lacks the capability
import { requireFeature, planHasFeature } from "@/lib/billing"
requireFeature(subscription, "apiAccess")
// Client — hide UI / show an upgrade prompt
import { Gate } from "@/components/billing/FeatureGate"
<Gate feature="apiAccess" fallback={<UpgradePrompt />}>
<ApiKeysSection />
</Gate>
Key points:
- Gating only applies when billing is enabled. With no Stripe configured the app is single-tier — every feature resolves as available, so a kit without billing is never restricted.
- The client reads a server-resolved map via
billing.features;<Gate>is UI only — always keeprequireFeatureon the server as the real check. - Add a capability by adding its key to
PLAN_FEATURES, marking which plans include it, and gating with the two helpers above.
The shipped example: API keys are gated behind the apiAccess capability
(free plan excluded; Pro and Enterprise included).
One-time purchases
Beyond subscriptions, the kit supports one-time credit packs. See Credits.