arch-atlas
SaaS Atlas·Pillar 03 / 12

Billing

Stripe · Webhooks · Customer Portal · Stripe Tax

Billing is the highest-stakes pillar that's easiest to get wrong: every bug is either a refund or a lawyer. Lean on Stripe for the hard parts (PCI scope, dunning, tax, customer portal) and own the entitlement model in your own DB — a `subscriptions` table mirrored from webhooks that your code joins on for feature gates. Treat the webhook handler as a state machine, not a script.

8Nodes0Vendors1Decisions9Checklist3Flows
01
DomainFree · Pro · Team · Enterprise

Plan Catalog

The tiers and what each one unlocks. Defined in code (TypeScript) so feature gates are typed, mirrored to Stripe Products + Prices for billing. Single source of truth lives in the repo; deploy → sync script reconciles to Stripe.

How it flows

step by step
  1. 01

    Engineer edits lib/billing/plans.ts — adds a feature flag entitlement.

    step
  2. 02

    PR review: someone confirms pricing matches Notion/spreadsheet truth.

    step
  3. 03

    Deploy → `pnpm sync:stripe-plans` reconciles to Stripe Products / Prices, never destructive.

    step
  4. 04

    App reads from plans.ts (typed); Stripe sees the same prices on Checkout.

    step

Cases & edge cases

watch for these

Yearly + monthly are TWO Prices per Product. Map them by lookup_key, not internal ID.

Grandfathered pricing: never delete a Price, archive it. Old subs keep referencing it.

Per-seat plans: enforce seat counts on assignment, not on next renewal — customers HATE surprise overages.

Checklist

ship readiness
  • Plan catalog in code, typed

    must
  • Yearly + monthly variants per paid plan

    must
  • Per-feature entitlements map (not just plan name)

    must

Where it lives

code references
References
  • lib/billing/plans.ts // typed catalog
  • scripts/sync-stripe-plans.ts

Notes

margin

If your plan structure can't fit in one TS file, it's too complex. Start with 3 tiers max.

02
DomainMirror of Stripe state

Subscription

Your `subscriptions` table mirrored from Stripe. Holds status, current_period_end, price_id, quantity, cancel_at_period_end. Every entitlement check joins on this row — not a Stripe API call.

How it flows

step by step
  1. 01

    Stripe Checkout completes → customer.subscription.created webhook fires.

    step
  2. 02

    Handler upserts (id, org_id, status, price_id, quantity, current_period_end).

    step
  3. 03

    On customer.subscription.updated → reconciles same row.

    step
  4. 04

    On customer.subscription.deleted → status = 'canceled' (don't delete the row, you'll lose history).

    step

Cases & edge cases

watch for these

Webhook arrives BEFORE the redirect from Checkout → the success page must tolerate 'sub not yet visible' for a few seconds.

Status values: trialing, active, past_due, canceled, unpaid, paused, incomplete. Each gates differently.

Quantity > 1 (per-seat) — your seat-assignment UI MUST not exceed the subscription quantity.

Where it lives

code references
References
  • drizzle/schema/subscriptions.ts
  • app/api/webhooks/stripe/route.ts:80 // subscription handlers
  • lib/billing/entitlements.ts // joins sub → plan → features

Notes

margin

If you find yourself calling stripe.subscriptions.retrieve() on the hot path, your mirror is incomplete. Add the field and replay.

03
IntegrationHosted or embedded

Stripe Checkout

Stripe's hosted (or embedded) payment page. You server-action a Checkout Session, redirect, Stripe handles cards/Apple Pay/Google Pay/SEPA/etc., and you get a customer + subscription on the other side via webhook.

How it flows

step by step
  1. 01

    User clicks 'Upgrade to Pro' → server action createCheckoutSession({ priceId, orgId }).

    step
  2. 02

    Action calls stripe.checkout.sessions.create with success_url, cancel_url, client_reference_id=orgId.

    step
  3. 03

    Redirect to session.url (hosted) or render <EmbeddedCheckout sessionId=...>.

    step
  4. 04

    On success → checkout.session.completed webhook fires (use this for fulfillment, not the redirect).

    step
  5. 05

    Webhook upserts customer (if new) + subscription rows.

    step

Cases & edge cases

watch for these

client_reference_id = orgId is your way back from Stripe's customer to your tenant. Always set it.

checkout.session.completed is the canonical fulfillment trigger — webhooks survive client disconnects, redirects don't.

Embedded Checkout is the 2026 default for branded flows; hosted is fine for fastest ship.

Checklist

ship readiness
  • client_reference_id always set to orgId

    must
  • Fulfillment on webhook, not redirect

    must
  • Idempotency key on Checkout Session creation

    should

Where it lives

code references
References
  • app/api/billing/checkout/route.ts
  • lib/billing/stripe.ts

Notes

margin

Never trust the redirect. Always fulfill on webhook. This is the single most repeated bug in Stripe integrations.

04
IntegrationThe state machine

Stripe Webhook

One endpoint, ~12 event types you care about, signed by Stripe. This is where billing actually happens for your DB — every other code path is a read.

How it flows

step by step
  1. 01

    Stripe POSTs to /api/webhooks/stripe with stripe-signature header.

    step
  2. 02

    Handler verifies signature with STRIPE_WEBHOOK_SECRET (else 400 immediately).

    step
  3. 03

    Inserts event row (id, type, payload) with PRIMARY KEY = stripe event id → idempotent by construction.

    step
  4. 04

    Dispatches to typed handler for that event type (subscription.created, invoice.paid, ...).

    step
  5. 05

    Returns 200 within 5s — Stripe retries with backoff up to 72h on non-2xx.

    step

Cases & edge cases

watch for these

Idempotency by event.id (Stripe replays during outages). PK conflict = already processed.

Do the heavy lifting in a queued job, ack the webhook fast. 200 OK in <5s.

Whitelist event types — receiving an event you don't handle should NOT 500, just log + 200.

Test endpoint isn't just for Stripe — use the CLI: `stripe listen --forward-to localhost:3000/api/webhooks/stripe`.

Where it lives

code references
References
  • app/api/webhooks/stripe/route.ts
  • lib/billing/webhook-handlers/

Notes

margin

The 12 events that matter: checkout.session.completed, customer.subscription.{created,updated,deleted}, invoice.{paid,payment_failed,upcoming,finalized}, customer.{created,updated}, payment_method.attached. Everything else is informational.

05
PresentationStripe-hosted self-serve

Customer Portal

Stripe's hosted page where users update card, cancel, change plan, download invoices. You build a 'Manage billing' button that creates a Portal Session and redirects. That's the entire UI.

How it flows

step by step
  1. 01

    User clicks 'Manage billing' in /settings/billing.

    step
  2. 02

    Server action: stripe.billingPortal.sessions.create({ customer, return_url }).

    step
  3. 03

    Redirect to session.url.

    step
  4. 04

    User makes changes → Stripe fires the relevant subscription.updated webhook.

    step
  5. 05

    User clicks 'Return to <app>' → lands on return_url with state already updated.

    step

Cases & edge cases

watch for these

Configure the portal in Dashboard once: allowed actions (cancel, switch plan), products available, branding.

If a user cancels at-period-end in the portal, cancel_at_period_end=true on the sub — show countdown in your UI.

Tax IDs (B2B) are collected here, not by you — saves real legal headache.

Where it lives

code references
References
  • app/api/billing/portal/route.ts

Notes

margin

Resist the urge to build a custom billing UI for the first ~$1M ARR. The portal handles 95% of needs and you don't pay tax-compliance + dunning surface area.

06
PresentationConversion surface

Pricing Page

The marketing route that lists tiers and routes upgrade clicks to Checkout. Reads from the same lib/billing/plans.ts — single source of truth. Annual/monthly toggle is local state.

How it flows

step by step
  1. 01

    Page renders from plans.ts at build time (or with revalidate=3600).

    step
  2. 02

    User toggles annual ↔ monthly → swaps priceId per tier.

    step
  3. 03

    Click 'Get Pro' → server action: createCheckoutSession(priceId).

    step
  4. 04

    Already-signed-in users go straight to Checkout; logged-out users → /sign-up?next=/pricing&intent=pro.

    step

Cases & edge cases

watch for these

Logged-out clicks need to preserve the chosen tier across sign-up.

Annual savings call-out ('Save 20%') needs to be a calculation, not a hardcoded string — drift kills trust.

A/B test plan names + prices behind a feature flag; pin existing customers to the version they saw.

Checklist

ship readiness
  • Reads from plans.ts, not hardcoded

    must
  • Logged-out → sign-up preserves intent

    must
  • Annual savings calculated, not hardcoded

    should
07
OpsFailed-payment recovery

Dunning

When a card fails, Stripe retries on a configured schedule (Smart Retries) and emails the customer. Your job: surface the past_due state in-app, give them a 1-click 'update card', and downgrade gracefully if recovery fails after N days.

How it flows

step by step
  1. 01

    Stripe charge fails → invoice.payment_failed webhook → mark subscription status = past_due.

    step
  2. 02

    App shows a banner: 'Your payment failed — update card to keep Pro access'.

    step
  3. 03

    Stripe Smart Retries (configured in Dashboard) re-attempt 3-4 times over 14 days.

    step
  4. 04

    If all retries fail → invoice.marked_uncollectible → subscription.deleted → status='canceled' → revoke entitlements (with a grace day).

    step
  5. 05

    If user updates card → invoice.paid → status='active' → restore.

    step

Cases & edge cases

watch for these

Don't immediately revoke on the first failure. Give them the recovery window — that's what dunning is.

Email is owned by Stripe in default config. Mirror their language in your in-app banner so users don't feel double-pinged.

Annual subs that fail are 12x more painful than monthly — consider a personal outreach playbook for >$500 invoices.

Where it lives

code references
References
  • app/(app)/components/billing-banner.tsx
  • lib/billing/webhook-handlers/invoice-payment-failed.ts

Notes

margin

Smart Retries recover ~38% of failed payments (Stripe's own number). Don't reinvent the schedule.

08
OpsStripe Tax · VAT · sales tax

Tax Compliance

Stripe Tax calculates and collects sales tax / VAT / GST automatically based on customer location. Pay 0.5% per transaction; save weeks of nexus research. Enabled at the Checkout Session level.

How it flows

step by step
  1. 01

    Enable Stripe Tax in Dashboard for the products you sell.

    step
  2. 02

    At Checkout: pass automatic_tax: { enabled: true }.

    step
  3. 03

    Stripe geolocates the customer → calculates tax → adds to invoice.

    step
  4. 04

    Tax remittance: Stripe Tax (Standard) files in the US; you file internationally (until you upgrade tier).

    step

Cases & edge cases

watch for these

EU B2B: customers enter VAT IDs in the Customer Portal → reverse charge applies (no VAT collected).

US: nexus tracking is automatic up to a threshold; over the threshold = you must register in that state.

Crossing $1M ARR triggers more states' nexus thresholds — review quarterly.

Decisions

pick once, live with it
  • Decision 01

    Stripe Tax vs. Paddle (merchant of record)?

    pickStripe Tax for direct billing; Paddle / Lemon Squeezy if you want them to be MoR.

    whyPaddle/Lemon Squeezy become the seller of record — they handle ALL tax + chargebacks + compliance in exchange for a higher cut (~5-8%). For solo founders, MoR is worth it; for funded teams, Stripe Tax is the cheaper path.

Annotate0 strokes

Apple Pencil draws with pressure. Fingers still scroll. Saved per pillar.