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 area (apps/web/src/components/settings/sections/plan/subscription-tab.tsx) - receives plans, billing status and the channel header from loadSubscriptionSection() in apps/web/src/lib/settings/loaders/plan.ts and drives Stripe Checkout. Its settings gate is owner-only.
  • Invoices area (apps/web/src/components/settings/sections/plan/invoices-tab.tsx) - receives recent invoices from loadBillingSection() 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 later BILLING_DISABLED invoice 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 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, 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:

PlanMax overlaysMax widgetsStorageUpload sizeIntegrationsChat retentionMax accountsMax commands
Free13100 MB10 MB2365 days125
Pro5201 GB50 MB101095 days3200

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​

  1. 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.

  2. The owner picks a plan and interval (monthly or yearly). Any eligible coupon is applied to the checkout session; the area has no coupon field.

  3. 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.

  4. Frontend calls POST /api/billing/checkout → GraphQL createCheckout(acceptWithdrawalWaiver: true) → Stripe Checkout Session.

  5. Browser redirects to the returned Stripe URL and completes payment.

  6. Stripe posts checkout.session.completed to the webhook.

  7. 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.

    It then stores the withdrawal-waiver proof and sends the order confirmation.

  8. Stripe redirects the user back to /dashboard?billing=success.

Managing / Cancelling​

  1. User opens /dashboard/settings/channel/billing and clicks Open customer portal.
  2. Frontend calls POST /api/billing/portal → GraphQL createPortal → Stripe Billing Portal Session.
  3. 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_update disabled, 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 tag lumio_purpose = no_plan_change_v1 or 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.
  4. 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 = true when 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.completed and customer.subscription.updated copy the cadence from the subscription's first price (recurring.interval; anything other than month/year is stored as null) and the flag from Stripe's cancel_at_period_end - so resuming a cancelled subscription in the Stripe portal clears the flag again.
  • An ordinary cancelSubscription / POST /v1/billing/cancel sets the flag when Stripe accepts the cancellation. An extraordinary request leaves it unchanged pending operator review.
  • customer.subscription.deleted clears 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 (component WithdrawalWaiver, apps/web/src/components/billing-hub/withdrawal-waiver.tsx). Until it is ticked the buy buttons are aria-disabled; pressing one shows an error under the box. The proxy POST /api/billing/checkout forwards accept_withdrawal_waiver as acceptWithdrawalWaiver and sends true only for a literal true from the browser. The texts live in apps/web/messages/{en,de}.json under withdrawalWaiver (plan, extensionPayment, extensionSubscription); apps/web/__tests__/billing/withdrawal-waiver-texts.test.ts fails if they differ from the backend map. The store has no purchase dialog yet, so the web app does not call POST /v1/extension-purchases today. All three checkout entry points require accept_withdrawal_waiver / acceptWithdrawalWaiver to be true. A missing or false value creates no Stripe session and fails with 400 VALIDATION_ERROR and the message You must accept the withdrawal waiver (accept_withdrawal_waiver) before checkout, identical on REST and GraphQL. On GraphQL the argument is a required Boolean!, so an omitted argument is a schema error.
  • Texts. The wording is versioned in code (lo_email::withdrawal, map version → kind → language; current version 2026-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). On checkout.session.completed the webhook stores one row per Stripe session in withdrawal_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; metadata kind, text_version, accepted_at, checkout_session_id, plan_id or extension_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/terms shipped with the same release. A failed send never fails the webhook; a sweeper retries unsent confirmations every 5 minutes, up to 10 attempts, and stores confirmation_sent_at once 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​

  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; 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.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.paidSend the channel owner a configurable invoice notice, in-app by default.
invoice.payment_failedNotify the channel owner in-app and by email while Stripe handles retries; the notice is locked on.
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 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):

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 by the settings section's owner-only check before the loader runs, 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, billingInterval (month | year | null), cancelAtPeriodEnd
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, acceptWithdrawalWaiver: Boolean!)SessionCreate a Stripe Checkout Session; returns hosted URL. acceptWithdrawalWaiver must be true
createPortalplan:edit + sessionCreate a Stripe Customer Portal Session (no plan change); returns hosted URL
cancelSubscription(kind, reason, effective, effectiveDate, receiptEmail, name, clientRequestId)plan:edit + sessionRecord 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: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/checkoutSessionCreate Stripe Checkout Session. Body: \{ plan, interval?, coupon_code?, accept_withdrawal_waiver \}; accept_withdrawal_waiver must be true
POST/v1/billing/portalplan:edit + sessionCreate Stripe Customer Portal Session (no plan change)
GET/v1/billing/statusAuthCurrent plan + subscription state, incl. billing_interval (month | year | null) and cancel_at_period_end
POST/v1/billing/cancelplan:edit + sessionRecord an ordinary or extraordinary cancellation and return its reference and status; only an ordinary request changes Stripe automatically
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?, 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:

KeyENV varDescription
stripe.enabledLUMIO__STRIPE__ENABLEDMaster switch. When false, billing endpoints other than the status and plans fail with BILLING_DISABLED.
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.