Billing System โ Provider-Leveraged Design
๐ข SHIPPED โ 2026-05. Implemented via #1069 (LagoProvider), #1105 (dual-strategy pricing), #1155 (FE billing UI + webhook), and #1016 (Phase-7 legacy teardown) with Lago as the provider. PR #1165 was closed-unmerged, superseded by #1155. Architecture is now codified in
docs/decisions/2026-05-29-lago-pricing-strategy.md. This design document is retained for historical context.
Status: Approved (design); revised 2026-05-27 per stakeholder input (see Revision note)
Date: 2026-05-26
Supersedes: the original per-Specialist billing design from PR #762 (docs(billing): design spec for per-Specialist monthly Orb billing). PR #762 was closed-unmerged on 2026-05-29; the predecessor 2026-05-26-billing-system-design.md file therefore never landed in the repo. Issue: #702. See ยง10 Coexistence for the resolution path.
Issue: #702 (per-Specialist monthly billing)
Revision (2026-05-27): Stakeholder confirmed three points that adjust the design: (1) each Specialist bills independently (already the model), but the client receives one consolidated invoice per month with one itemized line per Specialist โ not N separate invoices; (2) the target provider is Lago, self-hosted (open-source) โ not Orb; payment collection via Lago's Stripe PSP integration; (3) proration is calendar-day (
monthlyRate ร activeDays รท daysInThatMonth, so รท31 / รท30 / รท28 by month). Billing remains arrears. The provider-agnostic architecture is unchanged; the affected decisions are consolidation (ยง3.1, ยง7), the provider adapter (ยง6, ยง11), and the proration convention (ยง3.2).
1. Summaryโ
Bill clients per Specialist, per month (monthly cycle anchored to the org's onboarding day-of-month), through a pluggable billing provider (Lago, self-hosted today). Each Specialist is billed independently (its own subscription, own lifecycle, own day-prorated amount), but the provider groups all of an org's Specialist subscriptions due in the same cycle into one consolidated invoice with one itemized line per Specialist (see ยง3.1, ยง7). This design delegates scheduling, proration, invoice generation, consolidation, and dunning to the provider's native subscription engine and reduces our own surface to two responsibilities:
- React to domain changes โ when a Specialist is assigned, re-rated, or unassigned, create / update / cancel the corresponding provider subscription.
- React to provider events โ when the provider issues / pays / fails an invoice, mirror that state locally for fast reads.
This is a deliberate departure from the #702 "thin provider, own all billing logic" design. Eight rounds of review on #702 found bugs clustered almost entirely in the reinvented orchestration layer (cron timing, day-granular proration, webhook self-heal, idempotency races) and near-zero in the genuinely domain-specific rate-resolution layer. The empirical lesson: the orchestration we hand-built is exactly what a billing provider exists to do. This design deletes it.
What we keep owning (domain specificity)โ
- Per-Specialist rates with AM overrides (
org_specialist_assignments.monthly_rate_config) and rate history. - The mapping from "a Specialist is assigned to an org" to "a billing subscription exists."
- Client-legible presentation of invoices in
/client/billingand/ops/billing.
What the provider now owns (was hand-built in #702)โ
- Billing cycle scheduling (was: anchor-day cron).
- Calendar-day proration of partial periods on add / change / cancel (was:
PeriodResolver+LineItemBuilder). - Invoice generation, numbering, and consolidation of an org's Specialist subscriptions onto one itemized invoice (was:
IssueInvoiceCommand+ idempotency-key + adopt-or-create self-heal, and would have been our own roll-up logic). - Dunning / payment retries (via Lago's Stripe PSP integration).
2. Architecture โ React, Don't Pushโ
The system is event-reactive in both directions and holds no schedule of its own.
โโโโโโโโโโโโโโโโโโโโโโโโ h.work โโโโโโโโโโโโโโโโโโโโโโโโ
โ โ
Domain change โ SyncSpecialistSubscriptionCommand โ
(assign / โ CancelSpecialistSubscriptionCommand โโโโโโโโโโโโโโโผโโโถ Provider
re-rate / โ (create / update / cancel subscription) โ (Lago today)
unassign) โ โ owns: cycles,
โ โ proration,
Provider event โ HandleBillingWebhookCommand โ consolidation,
(invoice.* ) โโผโโโโโโ (mirror invoice + subscription status) โ invoices, dunning
โ โ
Client / Ops โ ClientBillingSummaryQuery / OpsBillingOverviewQuery โ
read โ (read the thin local mirror) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- No cron. The provider drives the billing calendar.
- No proration math. We pass
effectiveDate; the provider computes partial periods by day. - No invoice origination. We never create invoices; the provider does, and we mirror them.
Hexagonal layering (unchanged convention)โ
domain/ (pure entities + ports) โ application/ (commands + queries) โ infrastructure/ (persistence + provider adapters) โ interface/ (controllers + DTOs). No cron/ directory.
3. Billing Model & Multi-Specialist Cardinalityโ
3.1 One subscription per (org ร Specialist), one consolidated invoice per orgโ
Each org_specialist_assignments row maps 1:1 to one provider subscription (independent lifecycle, independent day-prorated amount). There is no consolidation into a single org-level subscription โ each Specialist stays a distinct subscription.
The client, however, receives one invoice per org per billing cycle, with one itemized line per Specialist, e.g.:
Acme Financial โ May 2026
Bob Hethson (May 1 โ May 31) $3,600.00
Chris Lean (May 14 โ May 31) $2,214.22
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Total $5,814.22
This consolidation is provider-native, not ours: because every Specialist subscription for an org is anchored to the same day-of-month (ยง3.3), the provider bills them together and emits a single invoice whose line items (Lago fees) are the per-Specialist breakdown. We do not assemble or roll up invoices ourselves โ we mirror the consolidated invoice the provider produces.
Verification gate (Lago): this rests on Lago grouping a customer's same-cycle subscriptions onto one invoice (one
feeper subscription). This is believed to be Lago's behavior and is a Lago strength, but must be confirmed by a spike before implementation (see plan Task 0.x). If Lago does not group, the fallback is to model one subscription per org with one charge per Specialist โ re-evaluate then.
This maps directly onto the existing, already-multi-Specialist assignment model:
org_specialist_assignmentsis@Unique(["orgId", "specialistId"])(not unique onorgId) โ multiple Specialists per org is already allowed.- Per-assignment rate (
monthly_rate_config) and effective dates already exist. Conversation.specialist_idalready pins each thread to a persona, so clients already converse with multiple Specialists in parallel.
Consequence: no change to account-management or assignment logic is required. The assign / unassign / rate-change paths already fire per-assignment; this design hooks those existing events.
3.2 Mid-period changes are independent and freeโ
Because each (org ร Specialist) is its own subscription with its own lifecycle:
- Disconnecting one Specialist mid-period leaves the others untouched. No shared period to reconcile.
- Adding a Specialist mid-period โ provider prorates the first partial period from
startDateto the next anchor boundary. - Re-rating mid-period โ provider prorates the rate delta from
effectiveDate. - Removing mid-period โ provider prorates the final partial period up to
effectiveDate.
Proration convention โ calendar-day. A partial period charges monthlyRate ร activeDays รท daysInThatMonth, where the denominator is the actual number of days in the affected month (31 / 30 / 28-29). E.g. a $3,813.59/mo Specialist active May 14โ31 (18 of 31 days) โ 3813.59 ร 18 รท 31 = $2,214.22. This is the provider's native day-proration; we do not compute it (the LineItemBuilder of #702 is gone). The adapter configures the provider for calendar-day proration; the convention must be confirmed against Lago's proration settings during implementation.
3.3 Anchor dayโ
Each subscription is created with anchorDay = the org's onboarded_at day-of-month. The Lago adapter maps this to the provider's billing-cycle-anchor configuration so that all of an org's Specialist subscriptions bill on the same day-of-month. This shared anchor is what makes one consolidated invoice per cycle possible (ยง3.1) โ subscriptions that bill on the same date for the same customer are grouped onto one invoice. Month-end clamping (anchor 31 โ Feb 28/29) is handled by the provider's anchor configuration.
3.4 Billing timing โ arrearsโ
Billing is in arrears: the provider charges at period end for the cycle just elapsed. Mid-period disconnect therefore produces a single clean final partial invoice for days used โ no refunds, no credit notes, no credit-note path in the webhook handler.
4. Module Structureโ
A single billing/ module, hexagonally layered. No cron/, no line-item-builder, no period-resolver.
api/src/billing/
โโโ domain/
โ โโโ billing-subscription.ts # entity (mirror of a provider subscription)
โ โโโ invoice.ts # entity (mirror of a provider invoice)
โ โโโ billing-subscription-status.ts # pending | active | canceled | sync_failed
โ โโโ invoice-status.ts # draft | issued | paid | payment_failed | void
โ โโโ billing-event.ts # normalized webhook event (+ 'ignored' variant)
โ โโโ errors.ts
โ โโโ ports/
โ โโโ billing-subscription.repository.port.ts
โ โโโ invoice.repository.port.ts
โ โโโ billing-provider.port.ts # subscription-shaped (see ยง6)
โโโ application/
โ โโโ commands/
โ โ โโโ sync-specialist-subscription.command.ts # create OR update
โ โ โโโ cancel-specialist-subscription.command.ts
โ โ โโโ handle-billing-webhook.command.ts # mirror upsert
โ โโโ queries/
โ โโโ client-billing-summary.query.ts # list provider-consolidated invoices (ยง7)
โ โโโ ops-billing-overview.query.ts # cross-org health + sync_status
โโโ infrastructure/
โ โโโ billing-subscription.storage.ts # TypeORM @Entity (*.storage.ts)
โ โโโ invoice.storage.ts
โ โโโ billing-subscription.repository.ts
โ โโโ invoice.repository.ts
โ โโโ billing-subscription.mapper.ts
โ โโโ invoice.mapper.ts
โ โโโ providers/
โ โโโ lago.provider.ts
โ โโโ mock.provider.ts
โโโ interface/
โ โโ โ client-billing.controller.ts # /client/billing/*
โ โโโ ops-billing.controller.ts # /ops/billing/*
โ โโโ billing-webhook.controller.ts # POST /billing/webhooks/lago
โโโ billing.module.ts
Persistence naming: TypeORM
@Entityclasses live in*.storage.tsfiles (per project convention), registered explicitly in bothapp.module.tsanddata-source.ts(no globs).domain/*.tsentities are pure and have no TypeORM decorators.