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
planstable (apps/api/src/db/plans.rs). The nineaccountLimitslimits apply a per-accountaccount_limitsoverride over the plan default viaCOALESCE(override, plan)at each read/enforcement site;max_accountsandmax_commandsread the plan row directly (no override). There is no hard-coded Rust limits table - the divergentPlanLimits::for_planwas 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 area (
apps/web/src/components/settings/sections/plan/subscription-tab.tsx) - receives plans, billing status and the channel header fromloadSubscriptionSection()inapps/web/src/lib/settings/loaders/plan.tsand drives Stripe Checkout. Its settings gate is owner-only. - Invoices area (
apps/web/src/components/settings/sections/plan/invoices-tab.tsx) - receives recent invoices fromloadBillingSection()and opens the Customer Portal in a new tab. Its settings gate is owner-only. - Without Stripe - the Subscription area still loads and shows the plan cards, while checkout and cancellation controls are disabled. The Invoices area sends no invoice query and shows "Billing is not set up in this environment." Platform admins can also see the missing
LUMIO__STRIPE__*keys. A laterBILLING_DISABLEDinvoice error returns the same disabled state without logging it as a failure. - 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, billingStatus still answers (with billingEnabled: false) and plans still lists the plans, while every other billing endpoint and mutation fails with the code BILLING_DISABLED (HTTP 409 on REST, extensions.code on GraphQL) - this is the kill-switch used by the dev and test environments, which run without paid tiers. Clients read billingEnabled first instead of provoking the error.
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:
| Plan | Max overlays | Max widgets | Storage | Upload size | Integrations | Chat retention | Max accounts | Max commands |
|---|---|---|---|---|---|---|---|---|
| Free | 1 | 3 | 100 MB | 10 MB | 2 | 365 days | 1 | 25 |
| Pro | 5 | 20 | 1 GB | 50 MB | 10 | 1095 days | 3 | 200 |
0 in max_commands means unlimited. 0 in chat_retention_days does not: the retention sweep treats it as the 1095-day (3-year) maximum for all channel data, and every surface renders it as "3 years". 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
-
The owner opens the Subscription area through
/dashboard/settings/channel/subscription. The settings section route checks ownership, then loads the plans, billing status, channel header and any locked API-key notice on the server. -
The owner picks a plan and interval (
monthlyoryearly). Any eligible coupon is applied to the checkout session; the area has no coupon field. -
Before the redirect the owner ticks the withdrawal-waiver checkbox (unticked by default, see Withdrawal waiver and order confirmation). Without it no checkout session is created.
-
Frontend calls
POST /api/billing/checkout→ GraphQLcreateCheckout(acceptWithdrawalWaiver: true)→ 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.
It then stores the withdrawal-waiver proof and sends the order confirmation.
- Existing account (metadata has
-
Stripe redirects the user back to
/dashboard?billing=success.
Managing / Cancelling
- User opens
/dashboard/settings/channel/billingand clicks Open customer portal. - Frontend calls
POST /api/billing/portal→ GraphQLcreatePortal→ Stripe Billing Portal Session. - The Stripe-hosted portal opens in a new tab. It offers invoices, payment methods, billing details and cancellation, but no plan change: every portal session uses Lumio's own portal configuration with
subscription_updatedisabled, because a plan change is a new contract that needs the withdrawal-waiver declaration, which only the checkout collects. The configuration is found by its metadata taglumio_purpose = no_plan_change_v1or created once per Stripe account. If Stripe cannot provide it, the portal does not open (500 Billing portal is unavailable). Changes made in the portal come back to Lumio via webhooks. - The Verträge hier kündigen button opens a dialog for an ordinary or extraordinary cancellation. The declaration and receipt are stored first. An ordinary request sets
cancel_at_period_end = truewhen Stripe accepts it; an extraordinary request waits for an operator without an automatic Stripe change. See Withdrawal & Cancellation.
Billing cadence and cancellation state
billingStatus / GET /v1/billing/status report the subscription's cadence (billingInterval / billing_interval: month, year, or null without a subscription) and whether it is cancelled at period end (cancelAtPeriodEnd / cancel_at_period_end; the end date is currentPeriodEnd). Both are stored on the account (accounts.billing_interval, accounts.subscription_cancel_at_period_end):
checkout.session.completedandcustomer.subscription.updatedcopy the cadence from the subscription's first price (recurring.interval; anything other than month/year is stored asnull) and the flag from Stripe'scancel_at_period_end- so resuming a cancelled subscription in the Stripe portal clears the flag again.- An ordinary
cancelSubscription/POST /v1/billing/cancelsets the flag when Stripe accepts the cancellation. An extraordinary request leaves it unchanged pending operator review. customer.subscription.deletedclears the cadence, the flag and the period end.
Withdrawal waiver and order confirmation
EU consumers have a 14-day right of withdrawal (§ 355 BGB). Lumio starts the service at once, so the buyer must expressly request that before paying and confirm what that means for the right of withdrawal (§ 356 Abs. 5 BGB for plans and extension subscriptions, Abs. 6 for one-time extension purchases). The same mechanism covers every paid Stripe checkout: plan checkout (createCheckout / POST /v1/billing/checkout) and extension purchase (POST /v1/extension-purchases).
- Checkbox before the redirect. The web app shows an unticked checkbox with the declaration text and a link to the withdrawal information in the terms (
/legal/terms#withdrawal-information, de#widerrufsbelehrung) above the plan cards in Settings → Channel → Subscription (componentWithdrawalWaiver,apps/web/src/components/billing-hub/withdrawal-waiver.tsx). Until it is ticked the buy buttons arearia-disabled; pressing one shows an error under the box. The proxyPOST /api/billing/checkoutforwardsaccept_withdrawal_waiverasacceptWithdrawalWaiverand sendstrueonly for a literaltruefrom the browser. The texts live inapps/web/messages/{en,de}.jsonunderwithdrawalWaiver(plan,extensionPayment,extensionSubscription);apps/web/__tests__/billing/withdrawal-waiver-texts.test.tsfails if they differ from the backend map. The store has no purchase dialog yet, so the web app does not callPOST /v1/extension-purchasestoday. All three checkout entry points requireaccept_withdrawal_waiver/acceptWithdrawalWaiverto betrue. A missing orfalsevalue creates no Stripe session and fails with400 VALIDATION_ERRORand the messageYou must accept the withdrawal waiver (accept_withdrawal_waiver) before checkout, identical on REST and GraphQL. On GraphQL the argument is a requiredBoolean!, so an omitted argument is a schema error. - Texts. The wording is versioned in code (
lo_email::withdrawal, map version → kind → language; current version2026-10-22). Plans use text A, one-time extension purchases text B, extension subscriptions text C. A wording change is a new version; old versions stay so the proof can always show the exact text a buyer agreed to. - Proof. The checkout session carries the declaration in its metadata (
withdrawal_waiver_kind,withdrawal_waiver_version,withdrawal_waiver_accepted_at= server time of the checkout request,withdrawal_waiver_user_id,withdrawal_waiver_locale). Oncheckout.session.completedthe webhook stores one row per Stripe session inwithdrawal_waiver_consents(kind, text version, time, buyer, account, plan or extension id and a snapshot of the order facts). A webhook redelivery changes nothing. The row is deleted with the account; if the buyer's profile is deleted, the row stays with the account without the buyer. A session without a declaration (created before this existed) stores nothing, and the buyer keeps the full right of withdrawal. - Audit. Each stored proof emits
billing:withdrawal_waiver_accepted(account scope, actor = buyer; metadatakind,text_version,accepted_at,checkout_session_id,plan_idorextension_id). - Order confirmation (§ 312f BGB). Right after storing the proof, Lumio mails the buyer an order confirmation in their language: provider, product, term, price (with the § 19 UStG note), discount, time of contract, reference, next renewal and how to cancel (subscriptions), the declaration in its exact wording with time and version, the withdrawal information including the model withdrawal form, and the full terms of use. The withdrawal information and the terms are the texts of
/legal/termsshipped with the same release. A failed send never fails the webhook; a sweeper retries unsent confirmations every 5 minutes, up to 10 attempts, and storesconfirmation_sent_atonce the mail went out. A buyer without an e-mail address (or a deleted buyer) is logged as an error and not retried. - Renewals of the same subscription need no new declaration. A plan change goes through the checkout and needs a new one; the customer portal offers no plan change (see above).
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; store the withdrawal-waiver proof and send the order confirmation. If the proof of an existing account cannot be stored, the webhook answers 500 so Stripe redelivers |
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.paid | Send the channel owner a configurable invoice notice, in-app by default. |
invoice.payment_failed | Notify the channel owner in-app and by email while Stripe handles retries; the notice is locked on. |
| 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 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 an interactive sign-in session (ZAF-1750): neither a delegated popout token nor a user's own API key (lm_usr_, whatever its scopes) can open the Stripe portal or cancel the subscription. Checkout (createCheckout) requires an interactive sign-in session only (a brand-new account has no plan permission yet). The session check runs before the billing-enabled check on both protocols, so an API key always gets 403 / FORBIDDEN This action requires an interactive sign-in session and Stripe is never called - see Authentication → Session-only Surfaces. An active account is still required on the account-scoped routes; the Stripe webhook stays public (HMAC-verified):
| Permission | Purpose |
|---|---|
plan:read | Read billing status, account limits, and invoices |
plan:edit | Open 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:
| 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 by the settings section's owner-only check before the loader runs, 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, billingInterval (month | year | null), cancelAtPeriodEnd |
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, acceptWithdrawalWaiver: Boolean!) | Session | Create a Stripe Checkout Session; returns hosted URL. acceptWithdrawalWaiver must be true |
createPortal | plan:edit + session | Create a Stripe Customer Portal Session (no plan change); returns hosted URL |
cancelSubscription(kind, reason, effective, effectiveDate, receiptEmail, name, clientRequestId) | plan:edit + session | Record a plan cancellation declaration; an ordinary request sets cancel_at_period_end when Stripe accepts it, while an extraordinary request waits for review |
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 | Session | Create Stripe Checkout Session. Body: \{ plan, interval?, coupon_code?, accept_withdrawal_waiver \}; accept_withdrawal_waiver must be true |
POST | /v1/billing/portal | plan:edit + session | Create Stripe Customer Portal Session (no plan change) |
GET | /v1/billing/status | Auth | Current plan + subscription state, incl. billing_interval (month | year | null) and cancel_at_period_end |
POST | /v1/billing/cancel | plan:edit + session | Record an ordinary or extraordinary cancellation and return its reference and status; only an ordinary request changes Stripe automatically |
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?, accept_withdrawal_waiver }
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, billing endpoints other than the status and plans fail with BILLING_DISABLED. |
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.