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 five hard limits (overlays, storage, upload size, integrations, chat retention) 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.

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 crate (crates/lo-auth/src/rbac.rs) — Plan enum (Free, Pro, Enterprise), PlanLimits::for_plan, and merge_limits() that overlays per-account overrides on plan defaults.
  • 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/(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/(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 for self-hosted deployments that don't want paid tiers.

Plan Tiers

Three tiers are defined in code (Plan::Free | Pro | Enterprise), with defaults seeded into the plans table. The database is the runtime source of truth — admins can adjust prices, Stripe price IDs, and feature mappings without a deploy; the code defaults only act as a safety net if a plan row is missing.

PlanMax overlaysStorageUpload sizeIntegrationsChat retentionMax commands
Free3100 MB5 MB27 days25
Pro252 GB25 MBUnlimited90 days200
Enterprise10010 GB100 MBUnlimitedUnlimited (0)Unlimited (0)

0 in chat_retention_days or max_commands means unlimited. i32::MAX is used internally for "unlimited integrations".

Account Limit Overrides

Admins may override any of these limits per account via adminUpsertAccountLimits. Overrides are stored in account_limits and, when present, replace the plan default. accountLimits (the authenticated query) returns the merged result.

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 permissions (account scope):

PermissionPurpose
billing:readRead billing info (plan, subscription status, period end)
billing:editModify billing (trigger checkout, open portal, cancel subscription)
plan:readView the account's current plan
plan:editChange the account's plan / manage subscription

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.