Skip to main content

Billing

Overview

The Billing module manages everything related to paid subscriptions: plans, per-account limits, feature gating, Stripe Checkout, the Stripe Customer Portal, invoices, and coupon validation. Lumio delegates all payment processing to Stripe — no card data ever touches Lumio infrastructure. Plans and prices are stored in the database and mapped to Stripe products by price ID, so pricing can be changed in Stripe without redeploying the API.

Every account has exactly one active plan at a time. The plan drives nine hard limits (overlays, widgets, storage, upload size, integrations, chat retention, sound count, sound file size, sound storage) plus a feature-flag set that gates individual modules. Limits can be overridden per account by admins via account_limits rows; plan defaults apply otherwise. Two further plan columns — max_accounts (multi-account ownership) and max_commands (bot command count) — are enforced by their own modules rather than through accountLimits; both read straight from the canonical plans row.

Architecture

Backend

  • GraphQL (apps/api/src/graphql/billing.rs) — queries and mutations for billing status, plans, limits, invoices, coupon validation, checkout, portal, and cancellation.
  • REST (apps/api/src/routes/billing.rs) — parity endpoints under /v1/billing/* plus the Stripe webhook at /v1/webhooks/stripe.
  • Admin (apps/api/src/graphql/admin.rs) — coupon CRUD (adminCoupons, adminCreateCoupon, adminUpdateCoupon, adminDeactivateCoupon), plan/feature mapping (adminSetPlanFeature), per-account plan override (adminUpdateAccountPlan), and per-account limit override (adminUpsertAccountLimits).
  • Plan limits — resolved from the canonical plans table (apps/api/src/db/plans.rs). The nine accountLimits limits apply a per-account account_limits override over the plan default via COALESCE(override, plan) at each read/enforcement site; max_accounts and max_commands read the plan row directly (no override). There is no hard-coded Rust limits table — the divergent PlanLimits::for_plan was removed in ZAF-1092.
  • Webhook handler (apps/api/src/routes/billing.rs) — verifies Stripe-Signature via HMAC-SHA256, then dispatches on event type.

Frontend

  • Subscription page (apps/web/src/app/(main)/(app)/account/subscription/page.tsx) — SSR-renders plans + current billing status, and drives the upgrade flow via PlanSelector.
  • Billing/invoice page (apps/web/src/app/(main)/(app)/account/billing/page.tsx) — SSR-renders recent invoices and a "Manage in Stripe" button that opens the Customer Portal. Owner-only.
  • Proxy route (apps/web/src/app/api/billing/[...path]/route.ts) — the browser calls /api/billing/{status,plans,limits,invoices,validate-coupon,checkout,portal,cancel}; each maps internally to a GraphQL query or mutation via serverGql.
  • Admin coupons UI (apps/admin/src/app/(admin)/coupons/), admin plans UI (apps/admin/src/app/(admin)/plans/), and admin subscriptions/billing views.

Stripe Integration

Three Stripe surfaces are used:

  1. Checkout Sessions — one-time hosted pages for signup / plan change. Returned URL is redirected to. success_url and cancel_url both land on /dashboard?billing=success|cancelled.
  2. Customer Portal — Stripe-hosted page for payment method updates, invoice history, plan change, and self-service cancellation. Lumio creates the session with a return_url back to /dashboard.
  3. Webhooks — Stripe pushes events to /v1/webhooks/stripe. Lumio handles checkout.session.completed, customer.subscription.updated, customer.subscription.deleted, and invoice.payment_failed.

All Stripe calls use the stripe Rust crate with the secret key from config.stripe.secret_key. When config.stripe.enabled = false, every endpoint and mutation returns "Billing is not enabled" (HTTP 400 / GraphQL error) — this is the kill-switch used by the dev and test environments, which run without paid tiers.

Plan Tiers

Two tiers are sellable: Free and Pro. Their rows are seeded into the plans table, and the database is the runtime source of truth — admins can adjust prices, Stripe price IDs, and feature mappings without a deploy. An Enterprise tier once existed, but its plan row was removed in migration 20260415000009_drop_enterprise_plan and existing Enterprise accounts were moved to Pro, so nothing resolves to it at runtime.

Seeded plan-row values:

PlanMax overlaysMax widgetsStorageUpload sizeIntegrationsChat retentionMax accountsMax commands
Free13100 MB10 MB27 days125
Pro5201 GB50 MB1030 days3200

0 in chat_retention_days (and in max_commands) means unlimited. The seeded plans rows are the single canonical source for every limit — there are no hard-coded Rust fallbacks (ZAF-1092). If a plan row cannot be resolved, the bot-command cap fails open (unlimited) rather than blocking legitimate command creation.

Account Limit Overrides

Admins may override any of these limits per account via adminUpsertAccountLimits. UpsertAccountLimitsInput accepts maxOverlays, maxWidgets, maxStorageBytes, maxUploadSizeBytes, maxIntegrations, chatRetentionDays, maxSounds, maxSoundFileSize, maxSoundStorageBytes, and a free-text notes. Overrides are stored in account_limits and, when present, replace the plan default. accountLimits (the authenticated query) returns the merged result as AccountLimitsResult.

User Flow

Subscribing

  1. User opens /account/subscription — page SSR-fetches plans, me, and billingStatus in parallel.
  2. User picks a plan + interval (monthly or yearly) and optionally enters a coupon code.
  3. Frontend calls POST /api/billing/checkout → GraphQL createCheckout → Stripe Checkout Session.
  4. Browser redirects to the returned Stripe URL and completes payment.
  5. Stripe posts checkout.session.completed to the webhook.
  6. Webhook handler resolves the plan from the subscription's price ID, then either:
    • Existing account (metadata has account_id): updates billing_accounts with the new Stripe customer id, subscription id, plan id, and period end; invalidates the account feature cache.
    • New user (metadata has user_id): creates an accounts row with the paid plan, seeds default roles, adds an owner membership, and stores the billing info.
  7. Stripe redirects the user back to /dashboard?billing=success.

Managing / Cancelling

  1. User opens /account/billing and clicks Manage in Stripe.
  2. Frontend calls POST /api/billing/portal → GraphQL createPortal → Stripe Billing Portal Session.
  3. Browser redirects to the Stripe-hosted portal. Changes made there (card update, plan change, cancellation) come back to Lumio via webhooks.
  4. A direct cancel button (GraphQL cancelSubscription / POST /v1/billing/cancel) sets cancel_at_period_end = true on the subscription — the user keeps access until period end, then the webhook downgrades them to free.

Coupon Redemption

  1. User enters a code in the plan selector. Frontend calls GET /api/billing/validate-coupon?code=... → GraphQL validateCoupon.
  2. Backend queries Stripe::PromotionCode.list(code, active=true). Valid codes return the coupon id, percent/amount off, currency, and name.
  3. If valid, the code is passed through to createCheckout as couponCode. It is applied as a Stripe Discount on the Checkout Session.
  4. Admin-defined coupons (stored in the coupons table via adminCreateCoupon) are independent of Stripe Promotion Codes and are consumed by future server-side redemption flows.

Webhook Event Handling

Stripe eventAction
checkout.session.completedLink Stripe customer + subscription to account (or create the account); set plan; set period end; invalidate feature cache
customer.subscription.updatedRe-resolve plan from subscription items' price IDs; update plan_id and subscription_period_end; invalidate feature cache
customer.subscription.deletedDowngrade account to free; clear stripe_subscription_id and subscription_period_end; invalidate feature cache
invoice.payment_failedLog a warning (no state change today — Stripe retries automatically)
anything elseLogged and ignored

Signature verification: the handler parses t=<timestamp>,v1=<signature> from the Stripe-Signature header and recomputes HMAC-SHA256 over {timestamp}.{body} using config.stripe.webhook_secret. Invalid signatures return 401.

Permissions

User-facing account-scope permissions that gate the billing routes (ZAF-735 #4). Reads (GET /v1/billing/status, /invoices, and the GraphQL billingStatus / accountLimits / invoices) require plan:read. Subscription-management mutations (createPortal, cancelSubscription) require plan:edit and a first-party user, so a delegated popout token cannot open the owner's Stripe portal or cancel their subscription. Checkout (createCheckout) requires a first-party user only (a brand-new account has no plan permission yet). An active account is still required on the account-scoped routes; the Stripe webhook stays public (HMAC-verified):

PermissionPurpose
plan:readRead billing status, account limits, and invoices
plan:editOpen the Stripe customer portal and cancel the subscription

billing:read / billing:edit are admin-global permissions for the admin panel's billing surface (checked against admin_permissions), not these account routes.

Admin-global permissions:

PermissionPurpose
plans:read / plans:create / plans:edit / plans:deleteManage plan catalog and plan→feature mapping
coupons:read / coupons:create / coupons:edit / coupons:deleteManage admin-issued coupon codes
subscriptions:read / subscriptions:editView and act on cross-account subscriptions
accounts:editRequired for adminUpdateAccountPlan and adminUpsertAccountLimits

Invoices and the customer portal are gated behind owner-only UI (isOwner check on the Billing page) in addition to backend auth.

API

GraphQL Queries

QueryAuthDescription
plansPublicList all plans with prices, limits, and feature flags
publicFeatureEnabled(key: String!)PublicCheck whether a global feature flag is on
billingStatusAuthCurrent plan, Stripe customer/subscription ids, active flag, period end
accountLimitsAuthMerged per-account limits (override > plan default)
invoices(limit: Int)AuthRecent Stripe invoices (1–100, default 12)
validateCoupon(code: String!, planSlug: String)AuthValidate a Stripe promotion code
adminCoupons(active, limit, offset)coupons:readList admin-issued coupons
adminCoupon(id: UUID!)coupons:readGet a single coupon

GraphQL Mutations

MutationAuthDescription
createCheckout(planSlug: String!, interval, couponCode)AuthCreate a Stripe Checkout Session; returns hosted URL
createPortalAuthCreate a Stripe Customer Portal Session; returns hosted URL
cancelSubscriptionAuthMark subscription cancel_at_period_end = true
adminCreateCoupon(input: CreateCouponInput!)coupons:createCreate an admin coupon
adminUpdateCoupon(id, input)coupons:editUpdate description, max redemptions, valid-until, active flag
adminDeactivateCoupon(id: UUID!)coupons:editSoft-deactivate a coupon
adminUpdateAccountPlan(accountId, planId)accounts:editChange an account's plan; invalidates feature cache
adminUpsertAccountLimits(accountId, input)accounts:editOverride any of the five per-account limits
adminSetPlanFeature(planId, featureId, enabled)plans:editToggle a feature flag for a plan; invalidates all accounts on that plan

REST Endpoints

All paths are under /v1/. All JSON bodies are snake_case.

MethodPathPermissionDescription
POST/v1/billing/checkoutAuthCreate Stripe Checkout Session. Body: \{ plan, interval?, coupon_code? \}
POST/v1/billing/portalAuthCreate Stripe Customer Portal Session
GET/v1/billing/statusAuthCurrent plan + subscription state
POST/v1/billing/cancelAuthCancel subscription at period end
GET/v1/billing/invoices?limit=NAuthRecent Stripe invoices (1–100, default 12)
GET/v1/billing/validate-coupon?code=X&plan=YAuthValidate a Stripe promotion code
POST/v1/webhooks/stripeStripe-SignatureStripe webhook receiver (not user-callable)

Proxy Endpoints (Browser)

The Next.js app exposes REST-shaped proxy endpoints for the browser. All live under /api/billing/ and map 1:1 to the GraphQL operations above:

GET /api/billing/status
GET /api/billing/plans
GET /api/billing/limits
GET /api/billing/invoices?limit=N
GET /api/billing/validate-coupon?code=X&plan=Y
POST /api/billing/checkout { plan_slug, interval?, coupon_code? }
POST /api/billing/portal
POST /api/billing/cancel

The browser should never call /v1/billing/* directly — always go through /api/billing/* so the JWT is read from cookies and permission guards run.

Configuration

Stripe configuration lives under [stripe] in the TOML config:

KeyENV varDescription
stripe.enabledLUMIO__STRIPE__ENABLEDMaster switch. When false, all billing endpoints return "Billing is not enabled".
stripe.secret_keyLUMIO__STRIPE__SECRET_KEYStripe API secret key (sk_live_... or sk_test_...)
stripe.webhook_secretLUMIO__STRIPE__WEBHOOK_SECRETWebhook signing secret (whsec_...)

Each plan's stripe_monthly_price_id and stripe_yearly_price_id are stored in the plans table and must match the price IDs in your Stripe dashboard. A plan with a missing price id for the chosen interval will fail checkout with "Stripe price ID not configured for this plan".

Domains

Checkout + portal success_url, cancel_url, and return_url are all derived from config.server.public_url:

  • Production: https://lumio.vision
  • Staging: https://lumio.web.staging.zaflun.dev

The webhook is always posted to {api_public_url}/v1/webhooks/stripe:

  • Production: https://api.lumio.vision/v1/webhooks/stripe
  • Staging: https://lumio.api.staging.zaflun.dev/v1/webhooks/stripe

Register both URLs with the corresponding Stripe account (live vs test) and copy the generated whsec_ secret into stripe.webhook_secret for each environment.