Synexa
How It Works

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.

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

  1. 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.
  2. Success: After payment, Stripe redirects back to /payment?session_id={CHECKOUT_SESSION_ID}.
  3. Webhook: Stripe sends checkout.session.completed and subscription lifecycle events to /api/webhooks/stripe.
  4. Provisioning: The webhook handler derives entitlement from Stripe subscription state and updates the user's stripeSubscriptionId, stripeCustomerId, and pricingTier in Firestore.
  5. Payment page: /payment only reports success after Firestore reflects the webhook-written entitling subscription state.
  6. 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.completed
  • customer.subscription.created / customer.subscription.updated
  • customer.subscription.deleted (downgrades the user to free)
  • invoice.payment_succeeded / invoice.payment_failed
  • 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 follows the Stripe subscription status: active and trialing grant Pro, and a past_due subscription keeps Pro through Stripe's dunning window — so a single failed renewal (a momentarily declined card) doesn't instantly revoke access. Access is revoked only when Stripe finishes dunning and the subscription becomes canceled/unpaid.

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 guarantees entitlements never drift from Stripe. 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 the CRON_SECRET bearer) 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) no matter how often the route fires, so a tighter schedule only 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.

Each run:

  • retries stored failed_webhook_events with 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_operations after 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_states reconciliation timestamps;
  • writes ops_alerts/stripe_failed_webhooks when retryable failed webhook events reach FAILED_WEBHOOK_ALERT_THRESHOLD;
  • optionally sends an alert email through Resend when ALERT_EMAIL, RESEND_API_KEY, and RESEND_FROM_EMAIL are 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.

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.