Billing & Subscriptions

Stripe powers plans, cards, invoices, and the customer portal; the billing UI in-app is wired to the same webhook-driven tables you will extend for usage limits later.

Plans

The template ships with three plan tiers:

PlanSeatsRecordsKey features
StarterLimitedLimitedCore features
ProMore seatsMore recordsAll Starter features + extras
EnterpriseUnlimitedUnlimitedAll features + SSO, IP allowlist

Where to customize

Plan names, limits, and features are configured in:

  • Pricing config: src/config/pricing.ts -- plan definitions, feature lists, seat/record limits
  • Stripe price IDs: Environment variables (see Stripe Setup)
  • Pricing page: src/app/(marketing)/pricing/page.tsx -- the public pricing page
  • Billing UI: src/app/(app)/w/[workspaceSlug]/billing/ -- the in-app billing page

Changing plan limits

To adjust how many seats or records each plan allows, update the limits in src/config/pricing.ts. The checkLimit() function in src/lib/billing/check-limit.ts reads these limits and enforces them in server actions.

Subscription flow

How upgrading works

  1. Workspace OWNER clicks Upgrade on the billing page
  2. createCheckoutSession() server action creates a Stripe Checkout session
  3. User is redirected to Stripe's hosted checkout page
  4. After payment, Stripe sends a checkout.session.completed webhook
  5. The webhook handler at /api/stripe/webhook creates or updates the subscriptions row
  6. User is redirected back to the billing page with a success message

How downgrading/canceling works

  1. OWNER clicks Manage Subscription on the billing page
  2. createBillingPortalSession() server action creates a Stripe Customer Portal session
  3. User is redirected to Stripe's portal where they can:
    • Change their plan
    • Update payment methods
    • Cancel their subscription
    • View invoices
  4. Changes are synced back via webhooks

Billing page

The billing page at /w/[slug]/billing is only accessible to workspace OWNERs and shows:

  • Current plan name and status (active, trialing, canceled, etc.)
  • Renewal date (from current_period_end)
  • Plan comparison cards with upgrade/downgrade CTAs
  • Billing history with past invoices (fetched from Stripe)

Where to customize

  • Billing page: src/app/(app)/w/[workspaceSlug]/billing/page.tsx
  • Billing actions: src/app/(app)/w/[workspaceSlug]/billing/billing-actions.ts
  • Billing client: src/app/(app)/w/[workspaceSlug]/billing/billing-client.tsx
  • Plan comparison: src/app/(app)/w/[workspaceSlug]/billing/plan-comparison.tsx
  • Billing history: src/app/(app)/w/[workspaceSlug]/billing/billing-history.tsx

Subscriptions table

One row per workspace, managed entirely by the Stripe webhook handler.

ColumnTypeDescription
iduuid PK
workspace_iduuid (unique)One subscription per workspace
stripe_customer_idtextFor Customer Portal sessions
stripe_subscription_idtextFor webhook matching
plantextstarter, pro, or enterprise
statussubscription_statusactive, trialing, past_due, canceled, incomplete
current_period_endtimestamptzRenewal date
cancel_at_period_endbooleanWill cancel at end of period
created_attimestamptz

Important

Never update the subscriptions table directly from your application code. The Stripe webhook handler is the single source of truth. This prevents state drift between Stripe and your database.

Plan limit enforcement

Plan limits are checked before creating resources or inviting members.

How limits work

import { checkLimit } from "@/lib/billing/check-limit";

// Before creating a product
const limitCheck = await checkLimit(workspaceId, "records");
if (!limitCheck.allowed) {
  return { error: `Plan limit reached. Upgrade to create more.` };
}

// Before inviting a member
const seatCheck = await checkLimit(workspaceId, "seats");
if (!seatCheck.allowed) {
  return { error: `Seat limit reached. Upgrade for more seats.` };
}

Where to customize

  • Limit checking: src/lib/billing/check-limit.ts
  • Plan limits: src/config/pricing.ts

To add new limit types (e.g., storage, API calls):

  1. Add the limit to your plan definitions in src/config/pricing.ts
  2. Update checkLimit() to count the relevant resource
  3. Call checkLimit() in the appropriate server actions

Webhook handler

The Stripe webhook at /api/stripe/webhook handles three events:

EventAction
checkout.session.completedCreates or updates the subscription row
customer.subscription.updatedUpdates plan, status, and renewal date
customer.subscription.deletedMarks the subscription as canceled

Where to customize

  • Webhook route: src/app/api/stripe/webhook/route.ts
  • Event handling: Add additional Stripe events (e.g., invoice.payment_failed) by extending the switch statement in the webhook handler

Credits (usage-based billing)

SaaSForge Core ships with subscription billing, but the architecture supports usage-based credits too.

How to add credits

  1. Add a credits_balance column to the workspaces table (or a separate table)
  2. Wrap metered features in a server-side guard:
    const balance = await getCreditsBalance(workspaceId);
    if (balance < cost) return { error: "Insufficient credits." };
    await deductCredits(workspaceId, cost); // Use a DB transaction
    
  3. Optionally sync usage to Stripe via metered billing or usage records

See Credits for more details.

Setup

For full Stripe configuration instructions, see Stripe Setup.