Skip to content

Billing: Stripe setup (operator runbook)

How money and access fit together, and exactly what to set up in Stripe before flipping BILLING_ENABLED=true. Read this once end to end; the checklist at the bottom is the part you come back to.

The model in one picture

                 ┌──────────────── ONE Stripe subscription per agency ────────────────┐
                 │  Agency plan   $149/mo   quantity 1   lookup key  early-bird-agency    │
                 │  Workspace     $199/mo   quantity N   lookup key  early-bird-workspace │
                 └───────────────────────────────┬────────────────────────────────────┘
                                                 │ webhook (customer.subscription.*)

   accounts row:  subscription_status · seats_paid (= N) · subscription_trial_end · pricing_plan
                  subscription_discount (the coupon, display-only)
                  plan_comped (+ plan_comped_until) · seats_comped       ← internal dashboard


   billingAccess(account)  =  subscription active/trialing  OR  assigned plan in force
   capacity                =  seats_paid + seats_comped
   live (seat-consuming)   =  live workspaces  −  the agency site (agency_workspace_id)
  • Stripe owns money, Orbit owns access. Orbit never renders an invoice or stores a card. It mirrors three facts from the subscription (status, workspace quantity, trial end) and gates on them — plus, display-only, a summary of the coupon on the subscription (subscription_discount), which nothing gates on.
  • Assigned by Luniq is the second source: a plan switch (optionally with an end date) and a workspace counter on the internal dashboard. Free, test, trial, separately billed — Orbit does not know or care which; the internal dashboard's contract notes are where the "why" lives.
  • The agency site is included in the plan (2026-08-29): accounts.agency_workspace_id points at the agency's OWN website workspace, and every canonical live count excludes it — it never consumes a seat, so a subscription with quantity 0 workspaces is a valid plan (Checkout already omits the workspace line at 0). It gates like everything else: a lapsed plan pauses the agency site too. Details in docs/spine/workspace.md (agency section).
  • Access is derived, never stored. accounts.status (active/suspended) is only the operator's brake; there is no approval step — a new agency is active from registration and the plan is the gate. The plan verdict is computed on every read from the mirror + the assigned columns (core/services/account-billing.js: billingAccess), so a failed payment stops the agency by itself and the next successful one restarts it — no operator action, no state machine.

What stops when the plan is not in force

billingAccess returns payment_required when the subscription is anything other than active/trialing (so past_due right after a failed payment, unpaid, canceled, incomplete) and no assigned plan is in force — and over_capacity when the agency has more live workspaces than paid + assigned-in-force (an assignment ended, or an operator lowered it). Either way, for every workspace of the agency:

SurfaceEffect
SchedulerisWorkspaceActive is false → no cron fires, and none is scheduled at boot/reload
Billable routesrequireActiveWorkspace answers 403 workspace_inactive (generation, agent, publish, refresh)
Workspace UIWorkspaceGateBanner shows "The agency plan is not active…" with a Billing link; read access stays
Agency UIAgencyLayout shows a red band on every tab except Billing; /home says "One step left: your agency plan"
Create workspacegate reason payment_required (before no_seats)

Read access is never revoked; nothing is deleted. The mirror and both assigned levers reload the agency's crons whenever the verdict flips, so resumption is immediate.

The combinations, spelled out

SituationVerdict
Paid plan + N paid workspaces, ≤ N liveruns
Paid plan + N paid + M assigned (in force), ≤ N+M liveruns; the in-app floor for lowering N is live − M
…the M assigned endcapacity drops to N; if live > N → over_capacity, everything paused until the admin adds them (Change dialog, floor = live) or archives
Assigned plan (open-ended, e.g. Luniq) + assigned workspacesruns; the admin can still buy workspaces — the subscription always carries the plan line too
Assigned plan with an end date, no subscription, date passespayment_required, everything paused; Billing offers Checkout; assigned workspaces (if still in force) stay counted once a subscription exists
Assigned plan + subscription both in forceruns on the subscription; assignment is harmless overlap
Assigned plan (in force) + subscription past_dueruns (the assignment carries access); paid seats still count toward capacity
Subscription past_due / unpaid / canceled, no assignmentpayment_required at once; canceled also mirrors 0 paid seats
Cancel at period endStripe keeps active until the period ends, then canceled
Checkout abandonednothing mirrored; the agency is active but has no plan (payment_required), the wizard's step 4 offers Checkout again
Operator lowers assigned workspaces below liverefused (clamped to live − paid); lowering via an end date is allowed and produces over_capacity on the date
Reactivating an archived workspaceneeds a free seat under the same capacity rule

Trials: two mechanisms, pick per case

Stripe trialAssigned plan with an end date
WhereSTRIPE_TRIAL_DAYS on Checkout (or a subscription created in the Stripe dashboard with a trial end)Internal dashboard → Accounts → the agency → Agency plan: Assigned by Luniq, until <date> (+ assigned workspaces, which carry their own optional end date; after it they count as 0 and the agency has to buy them)
CardUp front — converts to paid by itselfNone — the agency must subscribe before the date or it stops
Orbit seessubscription_status = trialing, subscription_trial_endplan_comped = true, plan_comped_until
EndsStripe charges; on failure → past_due → stopsDate passes → payment_required → stops; Billing page offers Checkout
Use forSelf-serve signupsThe two agencies already on a free month, tests, anything hand-arranged

Both are simply "in force" to every gate. To convert an assigned trial to paid, the admin sets up the subscription on Agency → Billing (the Set up subscription / Change dialog) at any time; leave the assignment in place until the subscription is active (the two overlap harmlessly), then untick it. An agency with an open-ended assigned plan (Luniq's own) can still buy workspaces: the subscription always carries the agency plan line, so that agency pays the plan too — for Luniq that is money to itself, for anyone else it is the intended conversion.

Discount codes (coupons / promotion codes)

All Stripe-native — Orbit has no code-redemption machinery of its own:

  • Create in the Stripe dashboard: a Coupon (prefer percent_off — it is currency-agnostic, one coupon covers USD/EUR/GBP; duration: once = one month free on a monthly subscription) and a Promotion Code on it carrying the human-readable code. Restrict the coupon to the Workspace product (applies_to) when the agency plan should still bill in the free month.
  • Redemption: new agencies type the code at Checkout (allow_promotion_codes is on). For an agency already subscribed, attach the coupon to its subscription in the Stripe dashboard — the portal has no code entry.
  • What Orbit shows: the webhook/sync mirror a summary onto accounts.subscription_discount (webhook payloads carry bare discount ids, so the handler does one expanded re-read when a discount is present), and the internal dashboard's Accounts row states coupon <code> (100% off, first invoice) next to the Stripe status. Display-only: billingAccess and monthlyPriceFor never read it, so the Billing pages keep stating catalog prices while Stripe invoices the discounted amount.
  • Free workspaces / free plan are NOT coupons — that is the assigned-by-Luniq levers above.

Stripe: what to create (test mode first, then live — separate ids each)

  1. Products and Prices (Product catalog → Add product), both recurring, monthly, USD as the Price's base currency plus EUR and GBP under "Add another currency" (currency_options), each with a lookup key (the "Lookup key" field under the Price's advanced options):

    • Orbit Agency plan — $149 · €139 · £119 / month → lookup key early-bird-agency
    • Orbit Workspace — $199 · €179 · £159 / month, per unit → lookup key early-bird-workspace

    The amounts must match PRICING_PLANS in core/services/account-billing.js — that catalog is what the app displays; Stripe is what it charges. No Price ids go into env: Checkout finds the Prices by these keys and passes the agency's currency (accounts.billing_currency, chosen in the wizard's first step, USD default; fixed once a subscription exists — the webhook mirrors subscription.currency back). A Price without a lookup key is invisible to Orbit. Do not add usage-based pricing or tiers; quantity is the lever.

    Settlement: your Stripe balance settles in EUR; USD and GBP charges convert at payout (Stripe's FX fee, ~1–2%). Reports and the dashboard show everything in the settlement currency (Settings → Business → Currency conversion), so "see it all in EUR" is a Stripe setting, not code.

  2. Webhook endpoint (Developers → Webhooks → Add endpoint): https://<api-host>/stripe/webhook, events:

    • checkout.session.completed
    • customer.subscription.created, customer.subscription.updated, customer.subscription.deleted
    • invoice.payment_failed

    Copy the signing secret → STRIPE_WEBHOOK_SECRET. Locally: stripe listen --forward-to localhost:3001/stripe/webhook.

  3. Customer portal (Settings → Billing → Customer portal): enable invoice history, update payment method, cancel subscription (at period end). Turn OFF "update subscription / switch plans / change quantities": workspace counts change in the app (POST /api/agency/billing/seats), where the live-workspace floor is enforced; the portal cannot know that floor. Set the return URL to https://orbit.luniq.io/agency/billing.

  4. Dunning (Settings → Billing → Subscriptions and emails): turn on Smart Retries and "Send emails when card payments fail" — Stripe's mail is the customer's payment-failure notice; Orbit only alerts operators (invoice.payment_failed → admin email) and shows the in-app band. Under "When all retries fail", pick cancel the subscription (the mirror then goes canceled, seats to 0, and Billing offers a fresh Checkout) — or leave it past_due/unpaid if you would rather chase by hand; both stop the agency.

  5. Stripe Tax — required, not optional. Every catalog price is ex VAT; Checkout runs with automatic_tax on, so Stripe adds the right tax on top per customer. The whole setup is three settings, one registrations page, and one portal toggle (verified against docs.stripe.com/tax/set-up, 2026-08-27):

    Settings → Tax → Business information (the only tax settings page):

    • Head office: Belgium (pre-filled from the Stripe business address; confirm it).
    • Preset product category: Software as a service (SaaS) – business use (txcd_10103001). This is the default for every Product without its own tax code, and it is also what threshold monitoring assumes you sell. Setting it here means the two Products need no per-product code; set one on a Product only if it should differ.
    • Include tax in prices: No (= tax_behavior: exclusive for every Price that has none of its own). This is the ex-VAT rule; leave the Prices' own tax behaviour unset so the default applies (a Price's behaviour cannot be changed once set to inclusive or exclusive). Do NOT pick "Automatic": it makes EUR and GBP prices tax-inclusive, which would turn €139 into €139 including VAT.
    • Taxes on shipping: irrelevant (no shipping rates exist).

    Tax → Locations → + Add registration (registrations do NOT live in settings):

    • Belgium — Standard, with "Small seller" ticked (your BE VAT number; set up 2026-08-27). Without an active registration in the customer's location Stripe calculates zero tax, so this is what makes Belgian customers pay 21%. Small seller is the EU €10k micro-business rule: EU customers without a VAT id also pay 21% Belgian VAT, reported in the normal BE return, so no OSS is needed yet. Businesses with a VAT id are reverse-charged regardless.
    • EU — One Stop Shop, Union scheme (oss_union) only once intra-EU sales to customers without a VAT id pass €10,000 in a calendar year (all EU countries combined; Stripe's threshold monitor tracks it). Then: add the OSS registration, untick Small seller, file quarterly. Not the non-Union scheme — that is for non-EU sellers.
    • Others (UK, US states, …) only when Threshold monitoring says so (Tax → Locations → Needs attention; live mode only, starts reporting at ~10k USD yearly revenue, never monitors your home country). Add the registration there the day you register; nothing in Orbit changes. Whether you must register somewhere is your accountant's call — Stripe's tool flags, it does not decide.

    Settings → Billing → Customer portal → Billing information: turn Tax ID on (it is off by default; Billing address is on). A VAT id added there later flips the next invoice to reverse charge by itself.

    How Orbit's Checkout uses it: it requires a billing address and shows the VAT id field for supported countries (both saved on the Customer via customer_update: auto), so renewals and in-app seat changes (POST /billing/seats re-asserts automatic_tax) are taxed the same way. Stripe applies reverse charge as soon as the VAT id has a valid format; VIES validation runs asynchronously and shows in the customer's details. Stripe Tax charges a fee per transaction where it calculates tax in a registered location (0.5% at the time of writing — check the current pricing page).

    What the customer sees, by case (all from the same ex-VAT catalog price):

    CustomerCheckout / invoice
    Belgian business or consumerprice + 21% BE VAT
    EU business with a VAT idprice, 0% VAT, reverse-charge note (Stripe's invoice adds it)
    EU consumer / business without VAT idprice + 21% Belgian VAT (Small seller); their own country's rate only after the OSS registration replaces it
    UK businessprice, 0% (reverse charge with a GB VAT id; without one, zero tax until a UK registration exists)
    US / rest of worldprice, zero tax until the threshold monitor says a registration is due

    Also on this page: promotion codes (Checkout has allow_promotion_codes on, so a 100%-off-for-1-month coupon is a third, card-up-front trial shape), and the branding of Checkout/portal/receipts (Settings → Branding).

  6. Env (Railway, both services that run the API):

    BILLING_ENABLED=true
    STRIPE_SECRET_KEY=sk_live_…
    STRIPE_WEBHOOK_SECRET=whsec_…
    STRIPE_TRIAL_DAYS=0          # >0 = every new Checkout starts with a Stripe trial
    FRONTEND_URL=https://orbit.luniq.io

Adding a pricing plan later (existing agencies stay where they are)

Today's prices are the early bird plan. When a new price list arrives:

  1. Add an entry to PRICING_PLANS in backend/src/core/services/account-billing.js ({ id: 'standard-2027', name: 'Standard', prices: { USD: {…}, EUR: {…}, GBP: {…} } } — one amount pair per supported currency) and point DEFAULT_PRICING_PLAN at it. npm test asserts the entry covers every currency.
  2. In Stripe create the two Prices with lookup keys standard-2027-agency and standard-2027-workspace, each with the same three currencies. Leave the early-bird Prices active (or archive them — existing subscriptions keep using archived Prices).
  3. Done. New Checkouts use the default plan. Every existing agency keeps pricing_plan = 'early-bird': its subscription still holds the early-bird Prices, the webhook keeps mirroring that plan, in-app workspace changes edit the early-bird workspace item, and the Billing page keeps stating early-bird numbers. An agency assigned by Luniq that subscribes later also starts on the plan its row already carries (early-bird for everyone existing today) — change accounts.pricing_plan by hand if that agency should pay the new price.

Moving an agency to a new plan is a Stripe action (swap the two items on its subscription to the new Prices); the next webhook mirrors the new pricing_plan. Never edit pricing_plan to move someone — the Prices on the subscription are the truth, the column follows them.

Existing agencies: what "backfill" means here

There is nothing to migrate. Every agency on Orbit today runs on an assigned plan and assigned workspaces (the migration ticked plan_comped for them), which is outside Stripe entirely: no customer, no subscription, no invoice, no tax. The moment one of them subscribes (Agency → Billing → Set up subscription), Checkout collects its billing address and VAT id like any new agency, and tax applies from that first invoice. Their pricing plan is already early-bird on the row, so they subscribe at early-bird prices in the currency chosen on the wizard (USD by default — set billing_currency on the internal dashboard, or have the admin re-open wizard step 1, if a legacy agency should be in EUR before it subscribes).

The one exception is any test subscription created before Stripe Tax was on (Luniq's own): Stripe does not retro-tax an existing subscription. Either cancel it and subscribe again from the app (cleanest), or give the customer a billing address in the Stripe dashboard and enable automatic tax on the subscription (Tax → Migrations lists every subscription without it, or the subscription's own edit page); the next in-app seat change also switches it on. A subscription whose customer has no usable address silently drops automatic tax again (disabled_reason: requires_location_inputs) — the address is the thing to fix.

Go-live checklist (in this order)

  1. Apply backend/db/agency-plan-migration.sql — applied 2026-08-27 (adds plan_comped, plan_comped_until, subscription_trial_end, subscription_synced_at, pricing_plan default early-bird; ticked plan_comped for every active agency running on assigned workspaces, so nothing stops on the flip). The 2026-08-29 follow-up in the same file (subscription_discount) is not yet applied — apply it before the flip, or the first mirror write fails on the missing column.
  2. Internal dashboard → Accounts: for Luniq's own agency leave "Assigned by Luniq" with no end date. For the two agencies on a free month set the end date of their month. Any agency that should pay from day one: untick it (they get the Billing page's Checkout on next visit).
  3. Stripe test mode: enable Stripe Tax (head office BE, preset category SaaS business use, include tax in prices = No, BE standard registration on Locations), create the Prices + webhook, set the env on staging, BILLING_ENABLED=true, run a signup through /agency-setup step 4 with card 4242 4242 4242 4242, confirm the accounts row mirrors (subscription_status=active, seats_paid=N), that a workspace can be created, and that changing the count on Agency → Billing prorates in Stripe.
  4. Fail a payment in test (card 4000 0000 0000 0341 on the second invoice, or stripe trigger invoice.payment_failed): the band appears, crons skip, the operator mail arrives.
  5. Live mode: repeat step 3's Stripe setup with live ids, set prod env, flip the flag.
  6. Watch the internal dashboard's Accounts page for the first week: every agency row now states Stripe: <status> and "no plan in force: nothing runs" in red when a gate is closing. Sync from Stripe on a row re-reads the subscription if a webhook was ever missed.

What Orbit deliberately does not do

  • No proration math, no invoice rendering, no card storage, no tax logic — Stripe's.
  • No seat count is ever written by the agency directly: paid counts come back through the mirror after Stripe accepts the change; assigned counts only from the internal dashboard.
  • No grace period of its own on a failed payment: past_due stops the agency at once. The grace you want is Stripe's retry schedule and the customer email it sends; if that proves too hard, the change is one line in billingAccess (treat past_due as paid for N days), not a new state.
  • No reminder emails for an assigned plan nearing its end date. The internal dashboard shows the date; set a calendar reminder when you set the date.