Use case · Stripe billing

The whole subscription product, not a Checkout button

Plans in code, Customer Portal embed, webhook router that survives retries, dunning emails wired to your transactional provider, tax handled by Stripe Tax, reconciliation against the database every night. The billing layer your SaaS needs, scoped and shipped in two to four weeks.

The problem

Most "Stripe integrations" stop at the Checkout button

The hosted Checkout page works in an hour. Then the founder discovers that the real billing layer needs subscriptions with prorating, a Customer Portal for cancellations, a webhook handler that does not lose events when Stripe retries, dunning emails when a card fails, tax configuration per region, invoice templates that carry the brand, and a reconciliation job that catches the day Stripe and the database disagree. Six months later the team has a half-built billing stack that one engineer secretly hates. We deliver the whole thing in two to four weeks, scoped, with the runbook to keep it healthy.

Our approach

The six steps of a Stripe billing integration

Each step closes one source of risk. The integration ships when the team can run a fresh signup, a plan switch, a card update, a cancellation and a refund without opening Stripe's dashboard.

  1. 01

    Define the pricing model in code

    Plans, currencies, billing intervals, trial behaviour and add-ons get defined in a typed PLANS config under source control. The same definitions feed Checkout, the Customer Portal, the admin dashboard and the marketing pricing page. One source of truth, no manual sync.

  2. 02

    Configure the Stripe account

    Tax settings, billing thresholds, Customer Portal configuration, branded invoice templates, dunning policy and Smart Retries get configured in Stripe and committed to the runbook. Nothing important lives only in the Stripe dashboard.

  3. 03

    Model the subscription state in Postgres

    A subscriptions table mirrors the Stripe subscription canonically (status, current_period_end, cancel_at_period_end, price_id, quantity). The application reads from Postgres; the webhook keeps Postgres aligned with Stripe.

  4. 04

    Wire Checkout and Customer Portal

    A typed Server Action creates a Checkout Session with the right price, currency and trial. The Customer Portal opens via a signed session URL with a return path. Both flows live on the application domain.

  5. 05

    Build the webhook event router

    One Node-runtime route receives every Stripe event, verifies the signature, dedupes by event ID against an idempotency table, routes by event type via a discriminated union, and writes back to Postgres in a transaction. Retries are safe by construction.

  6. 06

    Add reconciliation and dunning

    A nightly job compares Stripe events to the database and surfaces drift. Dunning emails wire into the transactional provider (Resend by default) with a sequence that mirrors Stripe's Smart Retries. Recovered MRR shows up in the admin dashboard.

What we deliver

Typed PLANS config

A single TypeScript file defining every plan, currency, interval and feature flag. Used by Checkout, Portal, admin and pricing page.

Stripe Checkout integration

Hosted Checkout for fast launch, or embedded Elements when the buyer needs a fully branded checkout. We pick on conversion evidence.

Customer Portal embed

Self-service plan management, cancellations, payment methods and invoice history. Configured to match the brand and the product surface.

Webhook event router

Idempotent handler with signature verification, event-ID dedupe table, type-safe routing by event type, transactional database writes.

Postgres mirror of Stripe state

A subscriptions table that holds the canonical local view. Application code reads from Postgres; the webhook keeps it aligned.

Trial management

Trial periods configured per plan, trial_will_end handlers wired to notification, trial expiration that gracefully degrades when no payment method exists.

Prorated upgrades and downgrades

Plan switches via `stripe.subscriptions.update` with `proration_behavior: create_prorations`. Mid-cycle, end-of-cycle, and immediate options exposed per UX requirement.

Dunning and Smart Retries

Failed payment recovery, smart retry windows, dunning email sequence wired into your transactional provider, in-app banners when a payment method needs attention.

Stripe Tax wiring

Automatic VAT, GST and sales tax calculation per region. Tax IDs collected at checkout, invoice formatting that complies with the buyer's jurisdiction.

Branded invoice templates

Invoice and receipt PDFs customised with the brand, footer copy, payment terms and contact details. Multi-currency formatting handled correctly.

Reconciliation job

A nightly job compares Stripe events with the database, surfaces drift, alerts on inconsistencies before finance does.

Migration from existing billing

Existing subscriptions migrated from PayPal, Paddle, Lemon Squeezy or another Stripe account, billing cycle preserved, no double-charging.

Pricing config, Checkout, Portal and the post-checkout state sync

Four files that compose a complete billing entry-point. The PLANS config is the source of truth; Checkout reads it; the webhook handler writes the resulting subscription row; the Customer Portal opens via a signed session URL.

Most Stripe integration write-ups stop at the Checkout button. Production billing is the entry-point below: a typed PLANS config that drives every surface, a Checkout Server Action that knows about currencies and trials, a Customer Portal helper that returns to the right place, and a webhook handler that writes the resulting subscription row when Checkout completes.

1. The pricing config

Plans live in code. The same file is the source of truth for Checkout, the Customer Portal, the admin dashboard and the marketing pricing page. Updating a price is a typed edit, not a Stripe dashboard click.

// src/lib/billing/plans.ts
import { z } from 'zod'

export const PLAN_IDS = ['starter', 'growth', 'enterprise'] as const
export type PlanId = (typeof PLAN_IDS)[number]

export const CURRENCY_IDS = ['eur', 'usd', 'gbp'] as const
export type CurrencyId = (typeof CURRENCY_IDS)[number]

export interface PlanPrice {
  stripePriceId: string
  monthlyCents: number
  yearlyDiscount: number
}

interface PlanDefinition {
  trialDays: number
  features: string[]
  prices: Record<CurrencyId, PlanPrice>
}

export const PLANS: Record<PlanId, PlanDefinition> = {
  starter: {
    trialDays: 14,
    features: ['5 seats', 'core integrations', 'email support'],
    prices: {
      eur: {
        stripePriceId: process.env.STRIPE_PRICE_STARTER_EUR!,
        monthlyCents: 2900,
        yearlyDiscount: 0.15,
      },
      usd: {
        stripePriceId: process.env.STRIPE_PRICE_STARTER_USD!,
        monthlyCents: 3200,
        yearlyDiscount: 0.15,
      },
      gbp: {
        stripePriceId: process.env.STRIPE_PRICE_STARTER_GBP!,
        monthlyCents: 2500,
        yearlyDiscount: 0.15,
      },
    },
  },
  growth: {
    trialDays: 14,
    features: ['20 seats', 'all integrations', 'priority support', 'SSO'],
    prices: {
      eur: {
        stripePriceId: process.env.STRIPE_PRICE_GROWTH_EUR!,
        monthlyCents: 9900,
        yearlyDiscount: 0.20,
      },
      usd: {
        stripePriceId: process.env.STRIPE_PRICE_GROWTH_USD!,
        monthlyCents: 10900,
        yearlyDiscount: 0.20,
      },
      gbp: {
        stripePriceId: process.env.STRIPE_PRICE_GROWTH_GBP!,
        monthlyCents: 8500,
        yearlyDiscount: 0.20,
      },
    },
  },
  enterprise: {
    trialDays: 0,
    features: ['unlimited seats', 'custom integrations', 'dedicated CSM', 'SLA'],
    prices: {
      eur: {
        stripePriceId: process.env.STRIPE_PRICE_ENTERPRISE_EUR!,
        monthlyCents: 49900,
        yearlyDiscount: 0.25,
      },
      usd: {
        stripePriceId: process.env.STRIPE_PRICE_ENTERPRISE_USD!,
        monthlyCents: 54900,
        yearlyDiscount: 0.25,
      },
      gbp: {
        stripePriceId: process.env.STRIPE_PRICE_ENTERPRISE_GBP!,
        monthlyCents: 42900,
        yearlyDiscount: 0.25,
      },
    },
  },
}

export const PlanIdSchema = z.enum(PLAN_IDS)
export const CurrencyIdSchema = z.enum(CURRENCY_IDS)

2. The Checkout Server Action

A typed Server Action creates a Checkout Session. The action reads the PLANS config, picks the price by plan and currency, applies the trial, and returns the URL Stripe expects the browser to navigate to.

// app/[lang]/(public)/pricing/actions.ts
'use server'

import { z } from 'zod'
import { redirect } from 'next/navigation'
import { stripe } from '@/lib/stripe/server'
import { PLANS, PlanIdSchema, CurrencyIdSchema } from '@/lib/billing/plans'
import { createDraftTenant } from '@/lib/tenants/server'

const Input = z.object({
  email: z.string().email(),
  planId: PlanIdSchema,
  currency: CurrencyIdSchema,
  interval: z.enum(['monthly', 'yearly']),
})

export async function startCheckout(raw: unknown): Promise<void> {
  const parsed = Input.parse(raw)
  const plan = PLANS[parsed.planId]
  const price = plan.prices[parsed.currency]

  const tenant = await createDraftTenant(parsed.email)

  const session = await stripe.checkout.sessions.create({
    mode: 'subscription',
    customer_email: parsed.email,
    client_reference_id: tenant.id,
    line_items: [
      {
        price: price.stripePriceId,
        quantity: 1,
      },
    ],
    subscription_data:
      plan.trialDays > 0
        ? { trial_period_days: plan.trialDays }
        : undefined,
    automatic_tax: { enabled: true },
    billing_address_collection: 'required',
    success_url: `${process.env.SITE_URL}/welcome?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.SITE_URL}/pricing`,
    metadata: {
      tenant_id: tenant.id,
      plan_id: parsed.planId,
      currency: parsed.currency,
      interval: parsed.interval,
    },
  })

  redirect(session.url!)
}

3. The post-Checkout webhook handler

When Checkout completes, Stripe fires checkout.session.completed. The webhook handler is the one that writes the canonical subscription row, not the success page. The browser may never reach /welcome (network error, navigation away); the webhook always does.

// src/lib/stripe/handlers/checkout-completed.ts
import type Stripe from 'stripe'
import { adminClient } from '@/lib/supabase/admin'

export async function handleCheckoutCompleted(
  session: Stripe.Checkout.Session,
): Promise<void> {
  if (session.mode !== 'subscription') return
  if (!session.subscription || !session.client_reference_id) {
    throw new Error('checkout session missing required fields')
  }

  const subscriptionId =
    typeof session.subscription === 'string'
      ? session.subscription
      : session.subscription.id

  const customerId =
    typeof session.customer === 'string'
      ? session.customer
      : session.customer?.id

  if (!customerId) throw new Error('checkout session missing customer')

  // Promote the draft tenant: link the user, store the Stripe IDs.
  const { error: tenantError } = await adminClient
    .from('tenants')
    .update({
      stripe_customer_id: customerId,
      status: 'active',
    })
    .eq('id', session.client_reference_id)
  if (tenantError) throw tenantError

  // Write the canonical subscription row. The full subscription state will
  // arrive in customer.subscription.updated; this seeds the row.
  const { error: subError } = await adminClient.from('subscriptions').insert({
    id: subscriptionId,
    tenant_id: session.client_reference_id,
    stripe_customer_id: customerId,
    plan_id: session.metadata?.plan_id ?? 'unknown',
    status: 'trialing',
    created_at: new Date().toISOString(),
  })
  if (subError) throw subError
}

4. The Customer Portal helper

When the customer wants to manage their subscription, the application opens a Stripe-hosted portal. We never recreate Stripe's UI; we open a signed session and redirect.

// app/[lang]/(app)/account/billing/actions.ts
'use server'

import { redirect } from 'next/navigation'
import { stripe } from '@/lib/stripe/server'
import { createServerClient } from '@/lib/supabase/server'
import { getServerSession } from '@/lib/auth/server'

export async function openBillingPortal(): Promise<void> {
  const session = await getServerSession()
  if (!session) throw new Error('unauthorised')

  const supabase = await createServerClient()
  const { data: tenant, error } = await supabase
    .from('tenants')
    .select('stripe_customer_id')
    .eq('id', session.tenantId)
    .single()
  if (error || !tenant?.stripe_customer_id) {
    throw new Error('no billing customer found')
  }

  const portal = await stripe.billingPortal.sessions.create({
    customer: tenant.stripe_customer_id,
    return_url: `${process.env.SITE_URL}/account`,
  })

  redirect(portal.url)
}

5. What this composes

The PLANS config drives every billing surface. Checkout reads it and creates a session. The webhook writes the resulting subscription row. The Customer Portal opens for self-service. Each piece is one focused file; the application code does not need to know about Stripe's API surface beyond these four entry-points.

Production billing is not a Checkout button. It is the composition of these four files, plus the webhook router from the Stripe stack page, plus the nightly reconciliation job. Three thousand lines of work, two to four weeks if you let the SDK do its job, an unfixable mess if you treat Stripe as something to wrap.

Frequently asked questions

How fast can you ship the billing layer?

Two to four weeks for a typical SaaS with three plans, two currencies and standard trial behaviour. Faster for a contained scope (a single plan, single currency, no portal). Longer when the pricing model is unusual (usage-based, complex tiered, marketplace via Connect). The scoping phase produces a concrete number.

Checkout or Elements?

Checkout for the vast majority of SaaS — faster to ship, smaller PCI scope (SAQ A), fewer conversion regressions because Stripe's flow is optimised. Elements when the buyer needs a fully branded in-app checkout (often premium consumer or marketplace). We pick based on conversion data, not preference.

How do you handle multi-currency?

One Stripe account, multiple presentment currencies. Prices live in the PLANS config with a per-currency entry. The buyer's currency is detected from locale or selected at checkout. The reporting dashboard always shows the canonical currency for reconciliation.

What about webhook reliability under load?

Every event is idempotent by Stripe event ID. The handler persists the event ID in a unique-constraint table before doing the work; a retry hits the constraint and exits early. The database write is transactional; a failure rolls back and Stripe retries. The audit table records every event we ever saw.

Can you migrate from PayPal, Paddle, Lemon Squeezy?

Yes. Active subscriptions migrate with their existing billing cycle preserved. Payment methods import via the provider's migration tools where supported (Stripe has direct importers for some). The cutover happens on a single day with a freeze window.

How do you handle EU VAT and tax compliance?

Stripe Tax handles automatic VAT, GST and sales tax calculation for the regions your buyer cares about. Tax IDs are collected at checkout, invoices format correctly per jurisdiction, the compliance posture is documented in the technical handover. We do not invent tax law.

Who owns the billing infrastructure after handover?

You. The Stripe account is yours, the code is in your repository, the runbook lives next to it. We can stay on as the partner for ongoing pricing experiments, dunning tuning and tax changes, or we hand it over to your team. Either way the operational layer is portable.

Tell us about your pricing model

A scoping call, a concrete number in the first reply, no agency theater. The complete billing layer in two to four weeks.