Last time, in Accepting Payments with Stripe, I covered the part everyone assumes is the hard bit: taking a card and moving money. It isn't, really. Stripe does the hard part. The hard part starts the moment that first payment succeeds and you have to answer a much less glamorous question: what plan is this customer on, what does that plan let them do, and how do you keep that answer correct for months or years without anyone watching it by hand.
This is part of the Full Stack SaaS Masterclass, the series where I build a multi-tenant SaaS from an empty repo forward. Payments got you a charge. Billing is the system that turns a charge into an ongoing, changeable, cancellable relationship, and it's where a surprising number of SaaS products quietly rot: seats that don't reconcile, plans that grant access a card no longer backs, downgrades that never take effect.
None of this is exotic engineering. It's mostly state management done carefully, plus a habit of never trusting the client to tell you what it paid for.
Billing is a state machine
The instinct when you're new to this is to treat a subscription as a boolean: paid or not paid. It doesn't hold up. A real subscription moves through states like trialing, active, past_due, unpaid, canceled, and incomplete, and each one has different implications for what a user should be allowed to do. A past_due customer whose card just expired shouldn't be locked out immediately. That's how you lose someone over a routine card reissue. But they also shouldn't keep provisioning new seats indefinitely while unpaid.
The other thing that trips people up is timing. Stripe (or whichever provider you use) is the source of truth for the subscription, but your application needs its own answer to "is this org entitled to feature X" that doesn't require a network call to a third party on every request. That means you're maintaining a local mirror of billing state, and mirrors drift unless you're deliberate about how they get updated.
The rule that keeps this sane: billing events flow one way, from provider to your database via webhooks, never the reverse assumption that your UI knows the current state. A successful checkout redirect on the frontend is a hint that something probably worked, not proof. Proof arrives as a webhook.
Model plans and subscriptions as first-class data
Resist the urge to hardcode plan names and prices in application code. Plans change, prices change per region, and you'll eventually want a plan that isn't publicly purchasable (a legacy grandfathered plan, an enterprise custom one). Model them as data.
create table plans (
id uuid primary key default gen_random_uuid(),
key text unique not null, -- 'starter', 'pro', 'enterprise'
name text not null,
is_public boolean not null default true,
seat_limit int, -- null = unlimited
api_rate_limit int, -- requests per minute, null = unlimited
created_at timestamptz not null default now()
);
create table plan_prices (
id uuid primary key default gen_random_uuid(),
plan_id uuid not null references plans(id),
stripe_price_id text unique not null,
interval text not null check (interval in ('month', 'year')),
currency text not null default 'usd',
unit_amount int not null -- in cents
);
create table subscriptions (
id uuid primary key default gen_random_uuid(),
organization_id uuid not null references organizations(id),
plan_id uuid not null references plans(id),
stripe_subscription_id text unique not null,
stripe_customer_id text not null,
status text not null, -- mirrors Stripe's subscription.status
current_period_end timestamptz not null,
cancel_at_period_end boolean not null default false,
seats int not null default 1,
updated_at timestamptz not null default now()
);
create index idx_subscriptions_org on subscriptions(organization_id);
create index idx_subscriptions_status on subscriptions(status);
Note that subscriptions.status mirrors the string Stripe gives you rather than being reinterpreted into a custom enum. That's a deliberate choice: translating Stripe's status vocabulary into your own adds a mapping layer that has to be kept in sync every time Stripe adds a status (they have, over the years). Store what the provider tells you, and derive access decisions from it in one place instead of scattering if (status === 'active') checks through the codebase.
One subscription per organization is the common case, and it's fine to start there. If you later need multiple concurrent subscriptions per org (a base plan plus metered add-ons), that's a real reason to revisit the schema, not something to design in speculatively on day one.
Sync subscription state through webhooks
The checkout flow ends with Stripe redirecting the browser back to your app with a success URL. Do not use that redirect to grant access. The redirect fires from the customer's browser, which can be closed, blocked, or manipulated, and it says nothing about whether the payment actually settled. The only thing your backend should trust is a signed webhook event from Stripe.
// src/billing/billing-webhook.controller.ts
import { Controller, Post, Req, Res, HttpStatus } from '@nestjs/common';
import type { Request, Response } from 'express';
import Stripe from 'stripe';
import { BillingService } from './billing.service';
@Controller('webhooks/stripe')
export class BillingWebhookController {
private readonly stripe: Stripe;
constructor(private readonly billingService: BillingService) {
this.stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string);
}
@Post()
async handle(@Req() req: Request, @Res() res: Response) {
let event: Stripe.Event;
try {
event = this.stripe.webhooks.constructEvent(
req.body, // raw Buffer, not JSON-parsed: configure this route to skip body parsing
req.headers['stripe-signature'] as string,
process.env.STRIPE_WEBHOOK_SECRET as string,
);
} catch (err) {
return res.status(HttpStatus.BAD_REQUEST).send(`Webhook signature invalid: ${err.message}`);
}
// Idempotency: Stripe retries on timeout, and can send the same event twice.
const alreadyProcessed = await this.billingService.wasEventProcessed(event.id);
if (alreadyProcessed) {
return res.status(HttpStatus.OK).send({ received: true });
}
switch (event.type) {
case 'customer.subscription.created':
case 'customer.subscription.updated':
await this.billingService.syncSubscription(event.data.object as Stripe.Subscription);
break;
case 'customer.subscription.deleted':
await this.billingService.markCanceled(event.data.object as Stripe.Subscription);
break;
case 'invoice.payment_failed':
await this.billingService.handlePaymentFailed(event.data.object as Stripe.Invoice);
break;
}
await this.billingService.markEventProcessed(event.id);
return res.status(HttpStatus.OK).send({ received: true });
}
}
Two details in there matter more than the happy-path logic. First, the raw request body has to reach constructEvent unparsed, so this route needs to be excluded from your global JSON body parser, otherwise signature verification fails silently and you'll spend an afternoon confused. Second, the processed-events table exists because Stripe's delivery guarantee is "at least once," not "exactly once." Without an idempotency check, a retried webhook can double-apply a state change, and for something like incrementing a seat count, that's a real bug.
syncSubscription should write the whole row from the Stripe object rather than patching individual fields, inside a transaction alongside anything else that needs to change together, like updating a Redis-cached entitlement snapshot for the organization.
Turn subscription state into entitlements the rest of the app can check
Once the subscription row is correct, the rest of the application shouldn't know anything about Stripe. It should ask one question: what is this organization entitled to right now? That's a separate concern from billing state, and collapsing the two leads to feature-gating logic scattered across controllers.
// src/billing/entitlements.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { RedisService } from '../redis/redis.service';
interface Entitlements {
seatLimit: number | null;
apiRateLimit: number | null;
isActive: boolean;
}
@Injectable()
export class EntitlementsService {
constructor(
private readonly prisma: PrismaService,
private readonly redis: RedisService,
) {}
async getEntitlements(organizationId: string): Promise<Entitlements> {
const cacheKey = `entitlements:${organizationId}`;
const cached = await this.redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const subscription = await this.prisma.subscription.findFirst({
where: { organizationId },
include: { plan: true },
});
const entitlements: Entitlements = subscription
? {
seatLimit: subscription.