Billing is feature-flagged
Local apps boot without Stripe credentials. Production billing only boots when required env vars are present.
Stripe without platform lock-in
Billing is optional. When BILLING_ENABLED=false, the billing route group returns 404 and the API does not instantiate Stripe. When it is true, the env validator requires the Stripe secret key, webhook secret, and price IDs before the app listens.
404
when billing is disabled
raw
webhook body verification
SQL
idempotency claim
The template ships the subscription spine, not a pricing strategy. It wires Stripe Checkout, Stripe Customer Portal, plan persistence, webhooks, audit events, and redirect safety. Forks can rename plans or add tiers once the product shape is real.
sequenceDiagram
participant UI as UI
participant API as API
participant DB as Postgres
participant Stripe
UI->>API: POST /api/v1/billing/stripe/checkout-session
API->>API: verify auth_token cookie + resolve active account
API->>API: allowlist successUrl + cancelUrl against FRONTEND_URL origin
API->>DB: upsert Free / Pro plans from STRIPE_PRICE_ID_*
API->>Stripe: create checkout session (account.stripe_customer_id)
API->>DB: update account.stripe_customer_id when first needed
API-->>UI: { url }
UI->>Stripe: redirect browser to hosted checkout
The customer portal follows the same pattern: authenticated user, returnUrl allowlisted against FRONTEND_URL, Stripe returns a hosted URL.
Local apps boot without Stripe credentials. Production billing only boots when required env vars are present.
STRIPE_PRICE_ID_FREE and STRIPE_PRICE_ID_PRO upsert default plan rows without manual SQL.
Checkout, portal, and plan reads use the same cookieAuth OpenAPI contract as the rest of the API.
Stripe-hosted flows may only return to the configured FRONTEND_URL origin.
The webhook route passes the exact request payload to Stripe signature verification.
Every Stripe event id is claimed in the same transaction as the side effect.
sequenceDiagram
participant Stripe
participant API
participant DB as Postgres
Stripe->>API: POST raw payload + Stripe-Signature
API->>API: constructWebhookEvent(payload, signature)
API->>DB: INSERT billing.stripe_webhook_events(event_id) ON CONFLICT DO NOTHING
alt first delivery
API->>DB: apply subscription side effect in same transaction
API-->>Stripe: 200 received
else duplicate delivery
API-->>Stripe: 200 already processed
end
Handled events:
checkout.session.completed: creates or updates billing.account_plans for the account and plan in session metadata.customer.subscription.updated: maps the active Stripe price id back to a local plan and updates the account plan; also tracks past_due, unpaid, paused, canceled, incomplete, trialing, and active for the feature resolver.customer.subscription.deleted: marks the row revoked so the resolver falls back to the Free plan.invoice.paid / invoice.payment_failed: status transitions for the active plan row.Unknown event types are logged at debug level and ignored. Late or out-of-order deliveries are tolerated because every status transition is keyed by (account_id, stripe_event_received_at).
Turns the route group on; false means all billing paths return 404.
Used by the Stripe SDK for Checkout, Portal, and webhook construction.
Used to verify Stripe-Signature on raw webhook payloads.
Seeds and keeps the built-in plan rows aligned with Stripe.
src/api/billing/ and src/clients/postgres/schema/billing.schema.ts on GitHub.
Read-only psql snippets for “what’s the subscription state right now?” questions. Run inside the app database (docker compose exec postgres psql -U app -d app).
-- Active subscriptions grouped by plan.SELECT plan_id, status, count(*)FROM billing.account_plansWHERE revoked_at IS NULLGROUP BY 1, 2ORDER BY 1, 2;-- Accounts on the Pro plan, with when each started + last status transition.SELECT a.name, ap.status, ap.created_at, ap.updated_atFROM billing.account_plans apJOIN app.accounts a ON a.id = ap.account_idWHERE ap.plan_id = 'pro' AND ap.revoked_at IS NULLORDER BY ap.updated_at DESC;-- Webhook deliveries in the last 24 hours by event type.SELECT event_type, count(*)FROM billing.stripe_webhook_eventsWHERE created_at > now() - interval '24 hours'GROUP BY 1ORDER BY 2 DESC;-- Failed-payment accounts that need attention.SELECT a.name, ap.plan_id, ap.status, ap.updated_atFROM billing.account_plans apJOIN app.accounts a ON a.id = ap.account_idWHERE ap.status IN ('past_due', 'unpaid', 'incomplete') AND ap.revoked_at IS NULLORDER BY ap.updated_at;-- Confirm idempotency works: every webhook event_id should appear exactly once.SELECT event_id, count(*)FROM billing.stripe_webhook_eventsGROUP BY 1HAVING count(*) > 1;/me.accounts, not users.