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) —Planenum (Free,Pro,Enterprise),PlanLimits::for_plan, andmerge_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 viaPlanSelector. - 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 viaserverGql. - 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:
- Checkout Sessions — one-time hosted pages for signup / plan change. Returned URL is redirected to.
success_urlandcancel_urlboth land on/dashboard?billing=success|cancelled. - Customer Portal — Stripe-hosted page for payment method updates, invoice history, plan change, and self-service cancellation. Lumio creates the session with a
return_urlback to/dashboard. - Webhooks — Stripe pushes events to
/v1/webhooks/stripe. Lumio handlescheckout.session.completed,customer.subscription.updated,customer.subscription.deleted, andinvoice.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.
| Plan | Max overlays | Storage | Upload size | Integrations | Chat retention | Max commands |
|---|---|---|---|---|---|---|
| Free | 3 | 100 MB | 5 MB | 2 | 7 days | 25 |
| Pro | 25 | 2 GB | 25 MB | Unlimited | 90 days | 200 |
| Enterprise | 100 | 10 GB | 100 MB | Unlimited | Unlimited (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
- User opens
/account/subscription— page SSR-fetchesplans,me, andbillingStatusin parallel. - User picks a plan + interval (
monthlyoryearly) and optionally enters a coupon code. - Frontend calls
POST /api/billing/checkout→ GraphQLcreateCheckout→ Stripe Checkout Session. - Browser redirects to the returned Stripe URL and completes payment.
- Stripe posts
checkout.session.completedto the webhook. - Webhook handler resolves the plan from the subscription's price ID, then either:
- Existing account (metadata has
account_id): updatesbilling_accountswith the new Stripe customer id, subscription id, plan id, and period end; invalidates the account feature cache. - New user (metadata has
user_id): creates anaccountsrow with the paid plan, seeds default roles, adds an owner membership, and stores the billing info.
- Existing account (metadata has
- Stripe redirects the user back to
/dashboard?billing=success.
Managing / Cancelling
- User opens
/account/billingand clicks Manage in Stripe. - Frontend calls
POST /api/billing/portal→ GraphQLcreatePortal→ Stripe Billing Portal Session. - Browser redirects to the Stripe-hosted portal. Changes made there (card update, plan change, cancellation) come back to Lumio via webhooks.
- A direct cancel button (GraphQL
cancelSubscription/POST /v1/billing/cancel) setscancel_at_period_end = trueon the subscription — the user keeps access until period end, then the webhook downgrades them tofree.
Coupon Redemption
- User enters a code in the plan selector. Frontend calls
GET /api/billing/validate-coupon?code=...→ GraphQLvalidateCoupon. - Backend queries
Stripe::PromotionCode.list(code, active=true). Valid codes return the coupon id, percent/amount off, currency, and name. - If valid, the code is passed through to
createCheckoutascouponCode. It is applied as a StripeDiscounton the Checkout Session. - Admin-defined coupons (stored in the
couponstable viaadminCreateCoupon) are independent of Stripe Promotion Codes and are consumed by future server-side redemption flows.
Webhook Event Handling
| Stripe event | Action |
|---|---|
checkout.session.completed | Link Stripe customer + subscription to account (or create the account); set plan; set period end; invalidate feature cache |
customer.subscription.updated | Re-resolve plan from subscription items' price IDs; update plan_id and subscription_period_end; invalidate feature cache |
customer.subscription.deleted | Downgrade account to free; clear stripe_subscription_id and subscription_period_end; invalidate feature cache |
invoice.payment_failed | Log a warning (no state change today — Stripe retries automatically) |
| anything else | Logged 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):
| Permission | Purpose |
|---|---|
billing:read | Read billing info (plan, subscription status, period end) |
billing:edit | Modify billing (trigger checkout, open portal, cancel subscription) |
plan:read | View the account's current plan |
plan:edit | Change the account's plan / manage subscription |
Admin-global permissions:
| Permission | Purpose |
|---|---|
plans:read / plans:create / plans:edit / plans:delete | Manage plan catalog and plan→feature mapping |
coupons:read / coupons:create / coupons:edit / coupons:delete | Manage admin-issued coupon codes |
subscriptions:read / subscriptions:edit | View and act on cross-account subscriptions |
accounts:edit | Required 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
| Query | Auth | Description |
|---|---|---|
plans | Public | List all plans with prices, limits, and feature flags |
publicFeatureEnabled(key: String!) | Public | Check whether a global feature flag is on |
billingStatus | Auth | Current plan, Stripe customer/subscription ids, active flag, period end |
accountLimits | Auth | Merged per-account limits (override > plan default) |
invoices(limit: Int) | Auth | Recent Stripe invoices (1–100, default 12) |
validateCoupon(code: String!, planSlug: String) | Auth | Validate a Stripe promotion code |
adminCoupons(active, limit, offset) | coupons:read | List admin-issued coupons |
adminCoupon(id: UUID!) | coupons:read | Get a single coupon |
GraphQL Mutations
| Mutation | Auth | Description |
|---|---|---|
createCheckout(planSlug: String!, interval, couponCode) | Auth | Create a Stripe Checkout Session; returns hosted URL |
createPortal | Auth | Create a Stripe Customer Portal Session; returns hosted URL |
cancelSubscription | Auth | Mark subscription cancel_at_period_end = true |
adminCreateCoupon(input: CreateCouponInput!) | coupons:create | Create an admin coupon |
adminUpdateCoupon(id, input) | coupons:edit | Update description, max redemptions, valid-until, active flag |
adminDeactivateCoupon(id: UUID!) | coupons:edit | Soft-deactivate a coupon |
adminUpdateAccountPlan(accountId, planId) | accounts:edit | Change an account's plan; invalidates feature cache |
adminUpsertAccountLimits(accountId, input) | accounts:edit | Override any of the five per-account limits |
adminSetPlanFeature(planId, featureId, enabled) | plans:edit | Toggle 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.
| Method | Path | Permission | Description |
|---|---|---|---|
POST | /v1/billing/checkout | Auth | Create Stripe Checkout Session. Body: \{ plan, interval?, coupon_code? \} |
POST | /v1/billing/portal | Auth | Create Stripe Customer Portal Session |
GET | /v1/billing/status | Auth | Current plan + subscription state |
POST | /v1/billing/cancel | Auth | Cancel subscription at period end |
GET | /v1/billing/invoices?limit=N | Auth | Recent Stripe invoices (1–100, default 12) |
GET | /v1/billing/validate-coupon?code=X&plan=Y | Auth | Validate a Stripe promotion code |
POST | /v1/webhooks/stripe | Stripe-Signature | Stripe 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:
| Key | ENV var | Description |
|---|---|---|
stripe.enabled | LUMIO__STRIPE__ENABLED | Master switch. When false, all billing endpoints return "Billing is not enabled". |
stripe.secret_key | LUMIO__STRIPE__SECRET_KEY | Stripe API secret key (sk_live_... or sk_test_...) |
stripe.webhook_secret | LUMIO__STRIPE__WEBHOOK_SECRET | Webhook 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.