Billing (Stripe)
The template ships one first-class Stripe integration: subscriptions via Checkout + Customer Portal, webhook-driven local state, and an entitlement API the rest of the app calls. There is deliberately no multi-provider abstraction: payment providers differ at the merchant-of-record/legal layer, not the API layer, and an interface that pretends otherwise ships untested adapters. Want Paddle or Lemon Squeezy? See "Swapping providers" below. This module is the reference implementation.
Billing is optional as a whole: no STRIPE_SECRET_KEY → the module
is dormant, /pricing 404s, the settings section hides, the webhook
route 404s. A partial config fails boot (same pattern as EMAIL_*).
Stripe dashboard setup
Everything the app needs from the Stripe side, in order. Stripe has two parallel environments: Test mode and Live mode (toggle, top right of the dashboard). Every step below exists separately in each: test keys/prices/webhooks for development and staging, live ones for production. Do the full list twice.
1. Product + price
Product catalog → Add product. One product ("Pro"), one recurring
monthly price. Copy the price id (price_…, on the price row, not the
product id prod_…) into STRIPE_PRO_PRICE_ID.
The amount you set here is what customers pay. The number on the
template's pricing page is display copy (app/pricing/PricingCards.tsx
and the landing-page tiers in app/page.tsx). Keep them in sync when
you change the price.
2. API key
Developers → API keys. Copy the secret key (sk_test_… /
sk_live_…) into STRIPE_SECRET_KEY. The publishable key (pk_…) is
not used: the template never touches Stripe from the browser; both
Checkout and the Portal are server-minted redirects.
For a tighter blast radius in production, create a restricted key (same page) instead, with write access to Checkout Sessions, Customers, Subscriptions, and Billing Portal, plus read access to Charges: that's the template's whole API surface.
3. Webhook endpoint
Developers → Webhooks → Add endpoint.
Endpoint URL:
https://<your-backend-host>/webhooks/stripe(the backend origin, not the frontend origin).Subscribe to exactly these six events:
checkout.session.completed customer.subscription.created customer.subscription.updated customer.subscription.deleted charge.refunded charge.dispute.funds_withdrawnAfter creating it, reveal the signing secret (
whsec_…) on the endpoint's page →STRIPE_WEBHOOK_SECRET.
A missing event fails silently: the endpoint 200s everything it does
receive, it just never hears about the rest. In particular, an endpoint
without charge.dispute.funds_withdrawn never sets the dispute lock,
and one without customer.subscription.deleted never downgrades
churned customers. Don't subscribe to "all events" either: the handler
skips unknown types harmlessly, but your audit log fills with
skipped_unhandled noise.
The Webhooks page shows delivery attempts and responses: it's the
first place to look when subscription state seems stale. Cross-check
against the billing_webhook_events table, which records what the
backend did with each delivery.
4. Customer Portal
Settings → Billing → Customer portal → Activate. Downgrades, cancellations, payment-method updates, and invoice history all happen here: the app deliberately reimplements none of it. Recommended configuration: allow customers to cancel subscriptions (immediately or at period end, your call), allow updating payment methods, show invoice history. With a single paid plan there's nothing to configure under plan switching.
The portal is per-mode too: activating it in test mode does not
activate it in live mode. An inactive portal makes POST /billing/portal
fail at request time.
5. Stripe Tax (optional)
Only if you set STRIPE_AUTOMATIC_TAX=true: complete Settings → Tax
first (origin address, registrations). With the flag on and Tax
unconfigured, every checkout fails at session creation. Remember tax
registration and filing stay with you: Stripe Tax calculates and
collects, it doesn't file.
6. backend/.env
STRIPE_SECRET_KEY=sk_test_… # step 2
STRIPE_WEBHOOK_SECRET=whsec_… # step 3 (or yarn dev:stripe locally)
STRIPE_PRO_PRICE_ID=price_… # step 1
FRONTEND_URL=http://localhost:3000 # checkout/portal return origin
# STRIPE_AUTOMATIC_TAX=true # step 5, optional
Dev loop (no dashboard webhook needed)
Locally, the Stripe CLI replaces step 3:
cd backend && yarn dev:stripe # wraps: stripe listen --forward-to localhost:4000/webhooks/stripe
stripe listen prints a whsec_… signing secret on startup: that's
your dev STRIPE_WEBHOOK_SECRET (it differs from any dashboard
endpoint's secret). It forwards all events, so no event selection
applies in dev. Test cards: 4242 4242 4242 4242, any future
expiry/CVC. Trigger individual events with
stripe trigger checkout.session.completed.
Smoke test the full loop
With test keys configured and yarn dev:stripe running: sign in →
/pricing → Upgrade to Pro → pay with 4242 4242 4242 4242 → land on
/billing/success (it may show "Finishing up…" for a second while the
webhook races the redirect) → Settings shows Pro with a renewal date →
"Manage billing" opens the Portal → cancel there → Settings drops to
Free. If any step stalls, read the stripe listen output and the
billing_webhook_events table in that order.
Architecture
/pricing (RSC) → POST /billing/checkout → Stripe Checkout (hosted)
/settings billing section → POST /billing/portal → Customer Portal (hosted)
Stripe → POST /webhooks/stripe (raw body, signature-verified)
└→ billing_subscriptions (guarded upsert)
└→ billing_webhook_events (audit log)
└→ billing_payment_hook_claims (payment-hook dedupe)
any feature → BillingService.getEntitlement(userId)
billing.config.ts: env parsing, enabled/partial detection, pinned API version.billing.service.ts: Stripe client, Checkout/Portal sessions,getEntitlement.billing-webhook.service.ts: event interpretation + dispatch.billing-subscription-store.ts: the ONLY writer ofbilling_subscriptions: idempotency cursor, disputed lock, race retry, same-second tie resolution.billing-events.service.ts: insert-only audit trail (swept after 30 days bysession-cleanup.worker).billing-payment-hook-claim-store.ts: BILLING_PAYMENT_HOOK dedupe - claims the Stripe event id before the hook runs (swept after 30 days bysession-cleanup.worker).billing-status.ts: pure status/field mapping +grantsPaidAccess.
Webhook semantics
| Event | Effect |
|---|---|
checkout.session.completed (subscription) |
upsert plan/status/period |
checkout.session.completed (payment) |
fires BILLING_PAYMENT_HOOK (see below) |
customer.subscription.created / updated |
upsert plan/status/period |
customer.subscription.deleted |
plan=free, status=canceled |
charge.refunded (full only) |
plan=free, status=refunded: also cancel the subscription in Stripe when refunding; refunded isn't locked, so a still-active subscription's next event restores access |
charge.dispute.funds_withdrawn |
plan=free, status=disputed (locked) |
| anything else | logged skipped_unhandled, 200 |
invoice.payment_failed is deliberately unhandled: Stripe moves the
subscription itself to past_due and fires customer.subscription.updated
for the same transition; a second writer on the invoice stream only adds
ordering hazards.
Idempotency. Subscription-stream writes go through a guarded upsert
(BillingSubscriptionStore): the UPDATE only lands if the row isn't
disputed, the exact event id hasn't been applied, and event.created is
strictly newer than the last applied event. First-delivery races resolve
via INSERT … ON CONFLICT DO NOTHING, then one more guarded pass (so a
strictly-newer event that lost the insert race still applies). Because
event.created is second-resolution, two distinct events can tie: the
store then fetches live subscription state from Stripe and writes that
(truth is order-independent). Every delivery, applied, skipped (with
the precise reason), or errored, gets a row in billing_webhook_events.
Access policy. past_due keeps paid access (Stripe is retrying the
card; punishing a hiccup churns customers: if dunning fails Stripe
fires subscription.deleted). incomplete never grants (first payment
never confirmed). The effective plan is free whenever the status
doesn't grant paid access: a canceled pro row never leaks pro.
Disputes. When chargeback funds actually leave the account, the row
locks: Stripe keeps reporting the subscription as active during a
dispute, so without the lock the next subscription.updated would hand
access back to someone doing a chargeback. The lock is one-way. If you
win the dispute (or want to forgive), unlock manually:
UPDATE billing_subscriptions
SET status = 'canceled' -- they can re-subscribe self-serve
WHERE user_id = '<id>' AND status = 'disputed';
Gating features
One call, one branch: the Items module is the live example
(items.service.ts, free plan caps at 100):
const entitlement = await this.billingService.getEntitlement(userId);
if (!entitlement.paidAccess && count >= FREE_PLAN_ITEM_LIMIT) {
throw new ForbiddenException('Free plan limit reached. Upgrade to Pro.');
}
On deploys without billing, everyone is free-plan: gate on limits, not on "is billing enabled".
Account deletion
GDPR account deletion runs local erasure first, via
UsersService.ghostDelete - the legally binding half, which must never
be blocked by a Stripe outage or a bad key. Only after it succeeds does
UserDeletionWorker call BillingService.deleteCustomerData, a no-op
when billing is dormant or the user's billing_subscriptions row (at
most one, per the UNIQUE constraint on user_id) has no
stripe_customer_id yet. Otherwise it deletes that Stripe customer,
cascading cancellation of any live subscription. A non-missing Stripe
failure throws, so the queue's existing retry re-runs the whole item:
ghostDelete again (idempotent and cheap) then deleteCustomerData
again, since ghostDelete never touches billing_subscriptions.
The local row itself isn't touched by deletion directly. Deleting the
Stripe customer makes Stripe emit customer.subscription.deleted
(already a handled event), which drops the row to plan=free, status=canceled through the normal webhook path.
stripe_customer_id is then left pointing at a deleted Stripe
customer - a harmless dead pointer for a deleted account: the only
code that re-reads it (createCheckoutSession) is unreachable for a
ghosted user, who can no longer sign in.
One-time payments
mode: 'payment' Checkout Sessions route through the same webhook and
fire an optional hook instead of touching subscription state. Bind it in
app.module.ts:
// app.module.ts: replaces the plain BillingModule entry
BillingModule.withPaymentHook(PurchasesModule, PurchasesService),
PurchasesModule must export PurchasesService, which implements
BillingPaymentHook. Don't declare the BILLING_PAYMENT_HOOK token in
your own module: Nest provider scopes are per-module, so the webhook
service (which lives in BillingModule) would never see it and the
@Optional injection silently resolves to nothing.
UsersModule already imports BillingModule (the account-deletion
worker calls BillingService.deleteCustomerData). If your hook module
also imports UsersModule - e.g. to reuse UsersService - the two
edges close a cycle: BillingModule → PurchasesModule →
UsersModule → BillingModule. Wrap that one import in your hook
module with forwardRef(() => UsersModule) to break it.
PurchasesService.onPaymentCompleted(session) owns what a purchase
means (create a record, grant access, send an email). Throw to make
Stripe retry. crabstack.dev's license delivery is built on exactly this
hook.
Delivery semantics. Stripe delivers webhooks at-least-once: the same
event id can arrive twice (a retry after a slow 200, or - rarely - two
genuinely concurrent deliveries). BillingWebhookService claims the
event id in billing_payment_hook_claims (INSERT … ON CONFLICT DO NOTHING) before invoking the hook, not after, so a redelivery sees the
claim and skips instead of re-invoking - the hook runs at most once per
event id. If the hook throws, the claim is released so Stripe's retry
can claim and run it again.
The one gap this doesn't close: if the process crashes between the claim succeeding and the hook finishing (not a thrown error - an actual process death), the claim row is never released, and Stripe's retry is then silently skipped. Note the trade this makes: the old bug double-granted on every ordinary redelivery; this rare crash case is a LOST grant instead - the customer paid and the hook never ran - so monitor for stuck claim rows if your grant is irreversible. It's a narrow window inherent to claiming before running without a lease/TTL, and it's the reason the belt-and-braces advice below still stands.
Belt-and-braces: key on session.id, not just the event. Dedupe by
Stripe event id protects the hook's own invocation count, but it's still
your app's job to make the grant itself idempotent - put a UNIQUE
constraint on the stripe_checkout_session_id column of whatever table
records the grant. If your payment hook ever runs twice for the same
session (a backfill, an admin replay, a future refactor of this dedupe),
that unique constraint turns a double-grant into a caught conflict
instead of a silent double-credit.
Pinned API version
billing.config.ts pins STRIPE_API_VERSION so a dashboard default
bump can't ship silent breaking changes (the Dahlia release moved
invoice.subscription and relocated billing periods onto subscription
items). To upgrade: read the Stripe API changelog, bump the constant,
run the billing specs, exercise the dev loop once.
Swapping providers
The seam is the module boundary: the rest of the app only knows
BillingService.getEntitlement(userId) and the two redirect endpoints.
To use another provider, keep billing_subscriptions + the entitlement
shape and replace the Stripe specifics:
- Checkout/portal session creation in
billing.service.ts→ your provider's hosted equivalents. billing-webhook.service.ts→ your provider's webhook verification and event names. Keep the guarded-upsert pattern: every provider redelivers and reorders webhooks.billing.config.tsenv surface + the boot validation.
Mind the merchant-of-record difference: with MoR providers (Paddle, Lemon Squeezy) the provider is the seller and handles VAT; with Stripe you are the merchant (Stripe Tax calculates, you register/file).
Related
- Environment Variables:
STRIPE_*,FRONTEND_URL - Architecture: where billing sits in the module graph
- Adding a Module: the conventions this module follows