Hosted billing (Polar)
Invoicerr is self-hosted-first: everything below is invisible and inert on any instance that never
sets WARNING__ENABLE_BILLING_FOR_USERS__WARNING (backend/src/modules/billing/billing-flag.ts) — no
/api/billing/* route, no Settings tab, no banner, no lifecycle sweep, no Polar client constructed.
This page documents the operator's own hosted offering.
One Polar customer per COMPANY (option A, 2026-09-16)
Invoicerr bills per company, never per user. Each company that ever starts a checkout gets its own
Polar customer, external_id = company.id, created lazily on the first checkout attempt
(backend/src/modules/billing/billing-customer.ts) — never at user sign-up. Checkout and the customer
portal are plain Nest routes calling the raw @polar-sh/sdk client directly
(checkout-session.ts/portal-session.ts), not @polar-sh/better-auth's plugin: that plugin
hard-codes the checkout/portal customer to the session's OWN user, which cannot express "bill this
company" at all.
Billing email
A company's Polar customer email defaults to its own contact email. Polar requires a customer's
email to be unique within the organization — two Invoicerr companies sharing one contact address
(one owner running several companies, e.g.) collide on the second company's first checkout. The
optional Company.billingEmail field (Settings > Billing) is the escape hatch: when set, it takes
priority over email for the Polar customer. A collision surfaces as a named 409 with
code: "BILLING_EMAIL_TAKEN" from POST /api/billing/checkout, and the Settings screen shows
"Choose a distinct billing email for this company".
A company with neither a billingEmail override nor a contact email on file is refused
before this module ever calls Polar: POST /billing/checkout and POST /billing/portal answer 422
with code: "BILLING_EMAIL_MISSING" (MissingBillingEmailError, billing-customer.ts), and the
Settings screen shows that error's own message verbatim. Before this guard existed, the empty string
reached Polar directly, which refused it with its own 422 ("An email address must have an @-sign")
that nothing on the checkout path caught — surfacing to the user as an opaque 500. The Settings
screen's billing-email field now prefills from the same priority order the backend resolves by: the
saved override, then the company's own contact email, then the signed-in user's own account email —
always a visible, editable value in the form, never a silent server-side substitution.
Who can touch Polar
Only company members with role OWNER or ADMIN can start a checkout or open the customer portal —
@Roles(CompanyRole.OWNER, CompanyRole.ADMIN) on BillingController's own POST /billing/checkout
and POST /billing/portal. A plain MEMBER never calls Polar at all; they only count toward the
billed seat quantity (seat-sync.ts).
The portal opens for the CLICKING user's own Polar member
Once a company's Polar customer is promoted to type: "team" (Polar's own automatic promotion on the
first seat-based checkout), a customer-portal session requires a memberId. Invoicerr resolves — and,
if needed, creates — the Polar member that corresponds to the clicking OWNER/ADMIN, never
unconditionally the auto-created owner member (member-resolution.ts). Resolution order:
- A member this app itself created before, addressable by its own
externalId = user.id. - Polar's own auto-created owner member (minted from "the customer's email and name" on the first
seat-based checkout, with no
externalIdInvoicerr ever set) — matched by email. Polar's ownMemberUpdateschema has noexternalIdfield, so this match can never be backfilled with our own external id; it is simply used by its own Polar-internal id from then on. - Neither matches — a fresh member is created, keyed by
externalId = user.id.
A company whose Polar customer has never been promoted past type: "individual" (never checked out)
opens the portal without any memberId at all.
Member sync follows company roles
member-sync.ts#syncCompanyMemberOnMembershipChange keeps Polar team-customer members in sync with
who holds OWNER/ADMIN in an already-subscribed company (a no-op before the company's first
checkout, or before Polar promotes the customer to team): a user promoted to OWNER/ADMIN gets a
Polar member; demoted to MEMBER, or removed from the company, loses it. Called from every place a
UserCompany row is created, changed, or removed — company creation, invitation acceptance, SSO
auto-provisioning, a role change, member removal, and account deletion. Member creation and deletion
both go through the SDK's PolarMembers operations (create/createExternal,
delete/deleteExternal) — never blocking the membership write itself: a Polar failure here is logged
and left for the next membership change to retry.
Seats — Invoicerr only ever READS the quantity, never writes it
Invoicerr never tells Polar how many seats to bill. The bought quantity lives in Polar's own
subscription and is entirely the OWNER's own affair, changed in the Polar portal — this app only reads
it, via two paths: a subscription.* webhook's own seats field (webhook-handlers.ts), and
seat-reconcile.ts's opportunistic SDK read once per lifecycle-sweep tick for every ACTIVE,
subscribed company (the retry for the rare case a webhook was missed). Neither ever calls
subscriptions.update with a seat count — seat-sync.ts/seat-reconcile.ts's own file headers, and
no-seat-quantity-write.spec.ts, a standing file-content guard scanning the whole billing module for a
subscriptions.update(...) call that sets a seats field. A company still in TRIAL (no Polar
subscription to read from at all) has the schema default of 1 seat — the one seat every plan includes.
Capacity vs. headcount are two different numbers: CompanySubscription.seats is what was bought,
UserCompany row count is who actually joined. seat-sync.ts#withSeatReservation wraps every NEW
membership (company creation, invitation acceptance, SSO auto-provisioning) in one transaction that
refuses with NoFreeSeatError (surfaced as NO_FREE_SEAT) once headcount would exceed the bought
quantity — invitations.service.ts's own useInvitation and lib/auth.ts's
markInvitationAsUsed/attachSsoProvisionedMembership translate it into a 403/APIError
respectively. The SAME transaction assigns the new row the lowest free desk number
(UserCompany.seatIndex), a purely cosmetic position on the company's own generative "Settings > Seats"
floor plan — a top-view SVG room, desks in face-to-face pairs, one deterministically-furnished desk per
(companyId, seatIndex) pair (never Math.random() at render — seats/desk-rng.ts). A desk number
never grants or revokes access — seat-holders.ts#seatHolders decides who is actually SEATED purely
from role + arrival order (UserCompany.createdAt), completely ignoring seatIndex.
Over capacity — the OWNER lowers the bought quantity in the Polar portal below the current
headcount — is handled the same way: members are ranked (OWNERs first, then everyone else, each in
arrival order) and the bought quantity is a hard ceiling on how many of them hold a seat, so a role is a
priority over the seats bought, never an exemption from buying one (promoting the whole company to
OWNER seats nobody extra). The single exception is the longest-standing OWNER, who keeps a seat even at
zero capacity so somebody can always log in and buy seats back. Whoever waits gets their seat back
automatically (no action needed) the moment a seat frees up or is bought back. A member currently WAITING is refused every write via
company-write.guard.ts/seat-gate.ts (403, SEAT_REQUIRED) and sees a full-app "waiting for a seat"
takeover ((app)/_layout.tsx, driven by GET /api/billing/seats) naming the OWNER — the same
gate write-gate.ts#assertCompanyWritable's COMPANY_BLOCKED already holds for a blocked company,
checked right alongside it.
Webhooks
POST /api/billing/webhooks/polar (own controller, not better-auth's) resolves which company a
subscription event belongs to primarily from the checkout's own customer external_id (present on the
webhook's wire payload, under data.customer.external_id), falling back to metadata.companyId —
never from the user. status-reconcile.ts's own repair path (for a status the webhook never updated)
filters Polar's subscription list by externalCustomerId = company.id, never by a raw customer id, so
it can never read another company's subscription even if two companies happened to share a Polar
customer.
No automatic migration for pre-2026-09-16 subscriptions
Before this change, a company's subscription was attached to its OWNER's own user-level Polar
customer. Polar exposes no API to transfer a subscription (or a customer) onto a different
external_id — checked directly against the SDK's available operations. A company whose stored
polarCustomerId still points at that old customer is detected (legacy-customer.ts, by comparing the
company's own external-id-keyed customer against the one actually stored — no live check needed for a
company that never subscribed) and surfaced as legacySubscription: true on GET /api/billing/status.
The Settings screen shows a plain notice: subscribe again to move the company onto its own,
company-scoped customer. There is no automatic migration.
Multi-company users
A user who belongs to several companies has a separate Polar member (and, once each company checks
out, a separate portal) per company — the Billing settings screen only ever shows the active
company's status, checkout, and portal, resolved server-side via @ActiveCompany(), never from a
client-supplied company id.