Stripe

Billing. Subscription checkout + webhook-driven plan state.

Files

  • src/lib/billing/stripe.ts — client + helpers, including listBillableSubscriptions.
  • src/lib/billing/plans.ts — plan matrix.
  • src/lib/billing/settlement.tssettleFinalUsage, the invoice that bills a leaving customer's tail.
  • src/app/api/internal/billing/route.ts — checkout session create.
  • src/app/api/webhooks/stripe/route.ts — inbound events.

Client

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2026-03-25.dahlia",
});

API version pinned — upgrades should be intentional.

Environment

  • STRIPE_SECRET_KEY — secret key.
  • STRIPE_WEBHOOK_SECRET — webhook signing secret.
  • STRIPE_PRO_PRICE_IDprice_... for the Pro plan (referenced in checkout; required at runtime for upgrade).

Checkout flow

See ../features/billing.md.

Webhook events handled

Five, not three. The three subscription events share one handler.

Event Effect
checkout.session.completed Apply the tier from metadata.plan (payg, else pro)
customer.subscription.created Re-derive the tier from the customer's live subscriptions; check for a period settlement already closed
customer.subscription.updated Same — this is how a move between Pro and Pay as you go arrives
customer.subscription.deleted Settle the final usage, then re-derive (free when nothing is left)
invoice.payment_failed logError("stripe.payment_failed") (TODO: dunning)

The three subscription events never trust their own status snapshot: Stripe does not guarantee ordering, so the handler re-lists the customer's subscriptions and prices the org off whatever is live right now. A stale updated arriving after deleted therefore cannot restore a paid tier.

Deliveries are deduped on event.id through Upstash Redis, and the key is a claim released on failure — a 500 has to be re-delivered, not swallowed as a duplicate.

Other events are ignored — the Stripe dashboard → webhook configuration should select only the above for efficiency.

Final usage settlement

Usage is metered a month behind, by the report-overage cron, through a metered price on the customer's live subscription. A cancellation ends that: the cron answers no_subscription, and everything sent between the 1st and the cancel would go unbilled.

So customer.subscription.deleted calls settleFinalUsage — and only when the re-listed subscriptions come back empty, since a customer moving between tiers still has one for the cron to bill through. It invoices the tail directly (an invoice needs no subscription), at the rate on the channel's configured overage price, and closes the same overage_reports ledger rows the cron claims, so neither can bill a period the other already did. Failure is logged and the downgrade still lands.

It runs in after(), so none of those round-trips sit in front of the 200 — this endpoint is shared, and a handler slow enough to time out takes subscription.deleted and invoice.payment_failed down for every other customer too. The route sets maxDuration = 300 all the same: after() still runs inside the invocation, and a kill between a ledger claim and its close strands the row in pending, which neither writer will resolve on its own.

customer.subscription.created runs the mirror check: a customer resubscribing into a period settlement already closed is billed for the rest of that month by nobody, so it raises billing.settlement.resumed_after_settlement.

Full contract, including the org-delete path that reaches the same function: ../features/billing.md.

Webhook verification

stripe.webhooks.constructEvent(body, signature, STRIPE_WEBHOOK_SECRET)

Rejects body with invalid signature as 400.

Local webhook testing

stripe listen --forward-to localhost:3000/api/webhooks/stripe

The CLI prints a signing secret — paste into .env.local as STRIPE_WEBHOOK_SECRET.

Metadata

Checkout sessions carry metadata.orgId and metadata.plan. The handler matches the org by stripe_customer_id, not by orgId — but it does read plan, because checkout.session.completed can land before the new subscription is listable as active, and re-deriving the tier there would write the customer onto the wrong one.