Skip to content

Subscription Billing and Plans

Aman Kumar Singh8 min read
Part 26 of 40From the Full Stack SaaS Masterclass series
Subscription Billing and Plans — article by Aman Kumar Singh

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.plan.seatLimit,
          apiRateLimit: subscription.plan.apiRateLimit,
          isActive: ['active', 'trialing', 'past_due'].includes(subscription.status),
        }
      : { seatLimit: 1, apiRateLimit: 60, isActive: false };

    await this.redis.set(cacheKey, JSON.stringify(entitlements), 'EX', 300);
    return entitlements;
  }

  async invalidate(organizationId: string): Promise<void> {
    await this.redis.del(`entitlements:${organizationId}`);
  }
}

The five-minute TTL is a deliberate tradeoff: entitlements can be briefly stale after a plan change, which is acceptable, versus hitting Postgres on every gated request, which isn't. Calling invalidate from the webhook handler after a sync closes that window immediately for the common case, so the cache mostly protects you against read load, not correctness.

Including past_due in "active" is a deliberate product decision. It buys a grace period for a failed card without a support ticket, and Stripe's own retry schedule (Smart Retries) will keep attempting the charge for you during that window.

Upgrades, downgrades, and cancellation

Plan changes are where proration lives, and proration is one of those things worth not reinventing. Stripe will compute the credit for unused time on the old plan and the charge for the new one automatically when you update a subscription with proration_behavior: 'create_prorations'. Building that math yourself, across partial months and different billing intervals, is a rabbit hole with very little payoff versus using what the provider already got right.

The decision that's genuinely yours to make is timing. An upgrade should almost always apply immediately: the customer wants the higher tier now and is paying more to get it. A downgrade is more debatable: applying it immediately can feel punitive if they already paid for the current period, so many products apply downgrades at the next renewal instead, using cancel_at_period_end plus a "pending plan change" record rather than an immediate Stripe update. Whichever you choose, make it consistent and visible in the UI, because "why did my price change mid-cycle" is a support ticket you can avoid entirely with a clear policy.

Cancellation deserves the same care. Prefer cancel_at_period_end: true over deleting the subscription outright, since the customer already paid for the current period and revoking access early is the kind of thing that generates chargebacks and bad reviews over a support-cost tradeoff that wasn't worth it.

Key takeaways

  • Treat subscriptions as a state machine (`trialing`, `active`, `past_due`, `canceled`, and so on), not a paid/unpaid boolean, and derive access from that state in one place.
  • Model plans, prices, and subscriptions as real tables; store Stripe's status string as-is instead of remapping it into a custom enum that has to be kept in sync forever.
  • Grant access only from a verified, idempotent webhook, never from a client-side checkout redirect.
  • Separate billing state from entitlements: the rest of the app should ask "what can this org do," not "what does Stripe say."
  • Let the provider handle proration math; keep the upgrade-immediately, downgrade-at-renewal policy as the decision you actually own.
  • Cancel at period end by default; it costs you nothing and avoids a class of avoidable support tickets.

Frequently asked questions

Should I store subscription status locally or always call the Stripe API?

Store it locally, synced by webhooks. Calling Stripe on every request that needs an access check adds latency and a hard dependency on a third-party API's uptime for something as basic as page loads. Keep Stripe as the source of truth, mirror it into Postgres, and cache the derived entitlements briefly in Redis.

What's the safest way to grant access after checkout?

Wait for the `customer.subscription.created` or `invoice.paid` webhook before marking the org as paid. The checkout success redirect is only a UX signal to show a "processing" state. It doesn't prove the payment went through. Most webhooks arrive within seconds, so a short polling or loading state on the frontend covers the gap.

How do I handle a subscription that goes past_due?

Keep access active during the provider's automatic retry window (Stripe's Smart Retries handles this) so a routine expired card doesn't lock someone out immediately. Only downgrade to no access once the subscription actually reaches `canceled` or `unpaid` after retries are exhausted, and email the customer at each retry step.

Should downgrades take effect immediately or at the next billing cycle?

Applying them at the next renewal is the friendlier default: the customer already paid for the current period at the higher tier, so let them keep it until it's used up. Store the pending plan change and apply it when the `customer.subscription.updated` webhook confirms the new period has started.

How do I test Stripe webhooks locally without deploying?

Use the Stripe CLI's `stripe listen --forward-to localhost:3000/webhooks/stripe` command, which forwards real webhook events to your local server and gives you the signing secret for that session. Set it up before writing the handler. Signature verification bugs are much easier to catch against real events than against hand-crafted test payloads.

Do I need a separate table for processed webhook events?

Yes, for anything that isn't naturally idempotent. Stripe's delivery guarantee is at-least-once, so a network hiccup on your side can cause the same event to arrive twice. A table keyed on the event ID, checked before processing and written after, is a small amount of code that prevents double-applying state changes like seat increments or plan swaps.

Related articles

Caching Strategies for a SaaS — Aman Kumar Singh
Scaling a SaaS Beyond One Server — Aman Kumar Singh
WebSockets and Gateways — Aman Kumar Singh
Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.