Skip to main content
Localizia

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: true marks the plan assigned to new users; isFeatured: true highlights 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 keep requireFeature on 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.