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:
| Plan | Seats | Records | Key features |
|---|---|---|---|
| Starter | Limited | Limited | Core features |
| Pro | More seats | More records | All Starter features + extras |
| Enterprise | Unlimited | Unlimited | All 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
- Workspace OWNER clicks Upgrade on the billing page
createCheckoutSession()server action creates a Stripe Checkout session- User is redirected to Stripe's hosted checkout page
- After payment, Stripe sends a
checkout.session.completedwebhook - The webhook handler at
/api/stripe/webhookcreates or updates thesubscriptionsrow - User is redirected back to the billing page with a success message
How downgrading/canceling works
- OWNER clicks Manage Subscription on the billing page
createBillingPortalSession()server action creates a Stripe Customer Portal session- User is redirected to Stripe's portal where they can:
- Change their plan
- Update payment methods
- Cancel their subscription
- View invoices
- 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.
| Column | Type | Description |
|---|---|---|
id | uuid PK | |
workspace_id | uuid (unique) | One subscription per workspace |
stripe_customer_id | text | For Customer Portal sessions |
stripe_subscription_id | text | For webhook matching |
plan | text | starter, pro, or enterprise |
status | subscription_status | active, trialing, past_due, canceled, incomplete |
current_period_end | timestamptz | Renewal date |
cancel_at_period_end | boolean | Will cancel at end of period |
created_at | timestamptz |
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):
- Add the limit to your plan definitions in
src/config/pricing.ts - Update
checkLimit()to count the relevant resource - Call
checkLimit()in the appropriate server actions
Webhook handler
The Stripe webhook at /api/stripe/webhook handles three events:
| Event | Action |
|---|---|
checkout.session.completed | Creates or updates the subscription row |
customer.subscription.updated | Updates plan, status, and renewal date |
customer.subscription.deleted | Marks 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
- Add a
credits_balancecolumn to theworkspacestable (or a separate table) - 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 - 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.