Payments & Billing
Payments are handled by Stripe. We use Stripe Checkout for subscriptions and the Stripe Customer Portal for subscription management.
Configuration
Display pricing lives in config/pricing.config.ts as pricingConfig.tiers. Each tier's id must match the pricingTier stored on the user's Firestore document. Enforcement limits are configured separately in config/quota.config.ts — see Usage Quotas.
Adding a paid tier
Entitlement is derived from the purchased Stripe price, not from subscription status alone, so tiers are additive: give the new tier a unique id, a stripePriceId, and priceMonthly > 0, then add a matching entry to quotaConfig.tiers under the same id. The webhook writes that id to pricingTier and the app resolves it everywhere.
Two rules keep it coherent:
- Never compare
pricingTierto a string literal. Use the helpers inlib/subscription-status.ts—resolveEffectiveTier(),hasPaidEntitlement(),isPaidTierId(),tierDisplayName(),defaultPaidTier().pricingTieris typedstringon purpose: a closed'free' | 'pro'union caused a real defect where a configured tier the union could not express fell through to the free limit. - A tier with no
stripePriceIdis unreachable. No Stripe price maps to it, so nobody is ever entitled to it. Either give it a price id or remove it.
defaultPaidTier() — the highlighted paid tier, else the cheapest — is what the Checkout default price and the upgrade CTA use when no specific price is requested.
export const pricingConfig = {
tiers: [
{
id: "free",
name: "Free",
priceMonthly: 0, // cents
priceCurrency: "USD",
features: ["5 actions per month", "Core features"],
},
{
id: "pro",
name: "Pro",
priceMonthly: 900, // $9.00
priceCurrency: "USD",
stripePriceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_ID_PRO, // your Stripe Price ID
highlighted: true,
features: ["50 actions per month", "Priority support"],
},
],
};
The Flow
- Checkout: User clicks "Upgrade"; the server blocks checkout if the Stripe customer already has any non-terminal subscription, reuses a still-open matching Checkout Session, and otherwise creates one behind a durable server-side lock. A different open Session must be confirmed terminal before a replacement is created.
- Success: After payment, Stripe redirects back to
/payment?session_id={CHECKOUT_SESSION_ID}. - Webhook: Stripe sends
checkout.session.completedand subscription lifecycle events to/api/webhooks/stripe. - Provisioning: The webhook handler derives entitlement from Stripe subscription state and updates the user's
stripeSubscriptionId,stripeCustomerId, andpricingTierin Firestore. - Payment page:
/paymentonly reports success after Firestore reflects the webhook-written entitling subscription state. - Portal: Users can manage billing via the pre-built Customer Portal links in Pricing, Profile, and Settings.
If Stripe may have received a Session-creation request but its response is lost,
the route returns a retryable 502 and keeps the server idempotency key. A later
request or lifecycle cleanup replays that exact key to recover the same Stripe
operation. Definite failures before Stripe creation are marked separately and
are never replayed into a new Session.
Webhook Handling
Webhooks are crucial for keeping your database in sync with Stripe. The handler is located at:
app/api/webhooks/stripe/route.ts
It handles:
checkout.session.completedcustomer.subscription.created/customer.subscription.updatedcustomer.subscription.deleted(downgrades the user tofree)invoice.payment_succeeded/invoice.payment_failed— logged, and used to stamplastPaymentFailed/lastPaymentSuccessAt. Nothing in the kit reads those fields, and entitlement never depends on them: subscription status is authoritative. Treat these handlers as a hook point for your own logic (dunning emails, in-app banners) rather than as part of the entitlement path.checkout.session.expired
Each event is signature-verified and processed idempotently — the handler claims stripe_events/{id} in a transaction so a redelivered event is never processed twice. Subscription lifecycle events also update stripe_subscription_states/{subscriptionId}. If an event is stale or has the same timestamp as the last applied state, the handler fetches the current subscription from Stripe and derives entitlement from that current object.
Entitlement requires both an entitling status and a recognized paid price:
- Status —
activeandtrialinggrant access, and apast_duesubscription keeps access through Stripe's dunning window, so a single failed renewal (a momentarily declined card) doesn't instantly revoke it. Access ends when Stripe finishes dunning and the subscription becomescanceled/unpaid. The same rule is used by the client — seelib/subscription-status.ts, which is imported by both the server andAuthContextso the two can't drift. Apast_duecustomer sees a "your last payment failed" notice and keeps their billing-portal link. - Price — the tier comes from the price the customer is actually subscribed to,
matched against
stripePriceIdinconfig/pricing.config.ts, and stored on the user document. Adding a cheaper tier therefore cannot grant a more expensive one. A price that is not in the config grants the free tier and logs an error. (If no paid tier has astripePriceIdconfigured at all, the mapping is unusable and the kit falls back to status-only entitlement rather than downgrading every paying customer — setNEXT_PUBLIC_STRIPE_PRICE_ID_PRO.)
A subscription blocks a new checkout only while it still grants access. An
incomplete or unpaid subscription does not, so the customer can always buy —
previously they were blocked from purchasing while also having no access.
Before writing users/{uid}, the handler checks deletion state and deleted_users/{uid} so delayed Stripe events cannot recreate a deleted account. Failures are recorded in failed_webhook_events and the event is released so Stripe retries.
Reconciliation
Webhooks are the primary path; the protected reconciliation route at GET /api/cron/stripe-reconcile is the self-healing backstop that repairs entitlement drift. It is a backstop, not a guarantee: its latency is bounded by your cron cadence, and an event that exhausts its retries needs a human (the run reports ok: false and opens an alert in that case — see below). Configure CRON_SECRET and schedule it from Vercel Cron or your hosting provider. The bundled vercel.json uses a once-daily Hobby-compatible schedule. For production-grade recovery latency, run the endpoint every 5–15 minutes on Vercel Pro or through an external scheduler.
If your hosting plan rejects sub-daily cron schedules, do not downgrade recovery to daily for production. Trigger
GET /api/cron/stripe-reconcile(with theCRON_SECRETbearer) from an external scheduler — cron-job.org, GitHub Actions cron, Upstash QStash, etc. — every 5–15 minutes.The expensive full user scan is throttled to
STRIPE_RECONCILE_SCAN_INTERVAL_MINUTES(default 60) when no pass is already in progress. Once a pass starts, it keeps advancing its cursor on every fire until it reaches the end, so a tighter schedule mainly speeds up the cheap recovery work without hammering the Stripe API. Treat "a frequent, authenticated reconcile schedule is configured" as a required item on your go-live checklist.The cheap recovery phases run before the scan, and the scan stops itself and checkpoints its cursor before
maxDurationis reached (scanTimeBudgetExhaustedin the response tells you it is not keeping up — lowerSTRIPE_RECONCILE_MAX_USERSor raisemaxDuration). Ordering matters: with the scan first and the cursor written only after a whole page completed, a page too large to finish in time got the invocation killed, wrote no cursor, and re-scanned the same page forever — starving webhook retry, failure alerting, and deletion resume indefinitely.
Each run:
- retries stored
failed_webhook_eventswith exponential backoff, re-deriving authoritative Stripe state — and heals even a first subscription grant whose original webhook never landed, by injecting the event's customer/subscription ids so the user isn't skipped; - cancels subscriptions directly from tombstone/event attribution when a failed lifecycle webhook is retried after
users/{uid}has already been removed; - resumes account-deletion jobs left stuck by a timed-out worker;
- then resumes generation-fenced, claimable
admin_state_operationsafter partial Firebase Auth/Firestore activation changes; - then deletes expired OTP and durable rate-limit records in bounded batches, after billing recovery has run;
- scans Firestore users with Stripe state, fetches their current Stripe subscriptions, and repairs entitlement drift;
- records
stripe_subscription_statesreconciliation timestamps; - writes
ops_alerts/stripe_failed_webhookswhen retryable failed webhook events reachFAILED_WEBHOOK_ALERT_THRESHOLD, and immediately for any event that has exhausted its retries (canRetry: false). An exhausted event is terminal — Stripe has stopped redelivering and the cron will never retry it — so if it was a checkout/subscription event a customer's entitlement may be wrong right now. Such a run reportsok: falseand the email is escalated, because only a human can clear it; - optionally sends an alert email through Resend when
ALERT_EMAIL,RESEND_API_KEY, andRESEND_FROM_EMAILare configured.
Account Deletion and Billing
Self-serve and admin deletion share the same durable deletion service. Before user data is removed, the service expires every known hosted Checkout Session, lists cancellable subscriptions for the Stripe customer, and verifies any stored stripeSubscriptionId belongs to that customer before canceling it. If a Session completes while expiry is racing, the service re-reads that Session and cancels its returned subscription ID directly. A non-harmless Checkout expiry, unresolved completed Session, or subscription-cancellation failure is fatal to that attempt: failed Session IDs are persisted on the deletion job, the profile remains available for recovery, and Firebase Auth is not disabled. Only after every known Session is terminal and every resolved subscription is canceled does the workflow disable Auth, write the tombstone, and remove data.
Deactivation and Billing
When an admin deactivates a user, the account first enters deactivation_pending while billing cleanup runs, then is suspended (Firebase Auth disabled, sessions revoked) and loses all product access — including the billing portal. During cleanup the server expires any open Stripe Checkout Sessions, blocks new Checkout/webhook/reconcile entitlement writes for the account, and then cancels active Stripe subscriptions by default. The cancellation behavior is controlled by billing.cancelSubscriptionsOnDeactivation in config/features.config.ts:
true(default): deactivation expires open Checkout Sessions and cancels active subscriptions before lockout. If Stripe cleanup fails, the route returns a retryable error, raises an ops alert, clears the pending marker, and leaves the user active so they can still manage billing.false: existing billing continues during suspension, but open Checkout Sessions are still expired so a suspended user cannot create a new subscription while locked out. Use this only for temporary holds, and provide your own cancellation path — a deactivated user cannot reach the billing portal themselves.
If a Stripe event still arrives after a lifecycle race, the webhook/reconciliation path refuses to write Pro entitlement for inactive or deactivation-pending profiles and automatically cancels cancellable subscriptions as a backstop.
After billing cleanup succeeds, Firebase Auth disable/re-enable, refresh-token revocation, Firestore state, reservation cleanup, and the audit record are tracked by a durable admin_state_operations workflow. Each generation is fenced by its worker lease, and only due claimableAt records are selected for ordered retry. A partial provider failure remains fail-closed and is automatically retried by the scheduled recovery route instead of depending on an operator repeating the mutation.
Incorrect Customer Charge — Refund Remediation Runbook
The lifecycle design assumes distributed races can happen and is built to converge. When one still produces a charge that should not have been made — a duplicate subscription, a charge after a cancellation the customer had already requested, a wrong tier — you need a defined path back. This is that path.
There is deliberately no automated refund endpoint. A refund is irreversible, rare, and needs human judgement about amount and cause; an API for it is a liability in a starter kit, not a feature. Follow these steps in the Stripe Dashboard (or via the API from a trusted operator context).
Not to be confused with
docs/BUYER_CHECKLIST.md→ "Refund Terms", which covers refunds to people who bought this starter kit. This section is about refunds your SaaS issues to your customers.
- Verify ownership before touching anything. Confirm the Stripe customer id
on the charge matches
users/{uid}.stripeCustomerId. Every server path in this kit verifies customer ownership before acting; a manual remediation must too, or you can refund one customer for another's charge. - Establish what actually happened. Read
stripe_events/{eventId}andstripe_subscription_states/{subscriptionId}for the ordering the app saw, andfailed_webhook_eventsfor anything unprocessed. Note whether the entitlement was ever written. - Stop the recurring cause first. If a subscription is still live and should not be, cancel it before refunding — otherwise the next invoice recreates the problem you are remediating. Check for a second subscription on the same customer and for an open Checkout Session.
- Issue the refund. Stripe Dashboard → the payment → Refund, full or partial.
Record the refund id (
re_…). - Correct the entitlement. Do not hand-edit
pricingTierin Firestore. Run the reconciliation so the profile is derived from authoritative Stripe state:POST /api/user/sync-subscriptionas the user, orGET /api/cron/stripe-reconcilewithCRON_SECRET. Confirm the resultingpricingTier/subscriptionStatus. - Record the incident. Write an
admin_audit_logsentry with the actor, the target uid, the Stripe charge and refund ids, and the reason. Nothing in the app writes this for you — a manual remediation that leaves no trace is the one an auditor will ask about. - Notify the customer. State what was charged, what was refunded, and when they should expect it (Stripe: typically 5–10 business days).
- Verify convergence. Re-check that the customer has no unexpected active
subscription, no open Checkout Session, no unresolved
failed_webhook_eventsrow, and that the next reconcile run reportsok: true.
If you later automate any of this, the invariants to keep are the ones the rest of the billing code already holds: verify customer ownership before acting, make the refund idempotent so a retry cannot refund twice, and write the audit record in the same transaction as the entitlement correction.
Local Testing
Use the Stripe CLI to test webhooks locally:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
Copy the whsec_... signing secret printed by that command into the local
STRIPE_WEBHOOK_SECRET; it is different from the signing secret of a Dashboard
webhook endpoint. Complete a real test-mode Checkout as well as using
stripe trigger, because generic trigger fixtures do not represent your seeded
application user or full Checkout flow.
Next: Read about Usage Quotas.