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.
5. Data Flowsโ
Flow 1 โ Specialist assigned (AM action)โ
assign path โ SyncSpecialistSubscriptionCommand.execute(assignment)
โ resolve current rate (assignment override โ catalog default)
โ provider.ensureCustomer(org) [idempotent on orgId]
โ provider.createSubscription({ externalCustomerId, assignmentId (idempotency key),
specialistId, specialistName, monthlyRateCents, currency, anchorDay, startDate })
โ persist billing_subscription { assignmentId, externalSubscriptionId,
status:'active', currentRateCents, anchorDay, provider }
Flow 2 โ Rate changed (AM edits rate; rate history already written)โ
โ SyncSpecialistSubscriptionCommand.execute(assignment) [update branch]
โ if currentRateCents unchanged โ no-op
โ provider.updateSubscriptionPrice({ externalSubscriptionId, newRateCents, effectiveDate })
โ update billing_subscription.currentRateCents
Flow 3 โ Specialist removed (AM unassigns)โ
โ CancelSpecialistSubscriptionCommand.execute(assignment)
โ guard: status === 'active'
โ provider.cancelSubscription({ externalSubscriptionId, effectiveDate })
โ billing_subscription.status = 'canceled', canceled_at = now
Flow 4 โ Provider issues / pays / fails a (consolidated) invoiceโ
POST /billing/webhooks/lago
โ provider.parseWebhook(rawBody, signature) โ BillingEvent
(invoice_upsert | subscription_canceled | ignored)
โ the Lago invoice carries a fees[] array (one per Specialist subscription),
normalized into invoice.lineItems
โ HandleBillingWebhookCommand โ upsert `invoice` keyed on externalInvoiceId
(one invoice per org per cycle; store provider's status verbatim โ last-writer projection)
Flow 5 โ Client / Ops readsโ
GET /client/billing/summary โ ClientBillingSummaryQuery (list consolidated invoices + their line items)
GET /client/billing/portal-url โ provider.getCustomerPortalUrl(externalCustomerId) (Stripe PSP portal)
GET /ops/billing โ OpsBillingOverviewQuery (orgs ร subs ร sync_status ร AM)
6. The BillingProviderPort (subscription-shaped)โ
/**
* Provider-agnostic billing port. Models the common denominator of subscription
* billing engines (Lago self-hosted today; Orb / Stripe Billing substitutable). The
* provider owns the billing calendar, proration, invoice generation + consolidation,
* and dunning.
*/
export interface BillingProviderPort {
/** Idempotent on the org. Returns the provider customer id. */
ensureCustomer(input: {
orgId: string;
name: string;
email: string;
externalId: string; // our org id, used as the provider's external customer id
}): Promise<{ externalCustomerId: string }>;
/**
* Create a subscription for one (org ร Specialist). Idempotent on assignmentId
* (passed as the provider idempotency key): a retry returns the existing
* subscription rather than creating a duplicate. The provider prorates the first
* partial period from startDate to the next anchor boundary.
*/
createSubscription(input: {
externalCustomerId: string;
assignmentId: string; // idempotency key
specialistId: string;
specialistName: string; // for client-legible line items
monthlyRateCents: bigint;
currency: string;
anchorDay: number; // 1โ31; provider clamps to month end
startDate: string; // ISO date; assignment effective date
}): Promise<{ externalSubscriptionId: string }>;
/** Prorates the rate delta from effectiveDate. */
updateSubscriptionPrice(input: {
externalSubscriptionId: string;
newRateCents: bigint;
effectiveDate: string; // ISO date
}): Promise<void>;
/** Prorates the final partial period up to effectiveDate (arrears โ final invoice). */
cancelSubscription(input: {
externalSubscriptionId: string;
effectiveDate: string; // ISO date
}): Promise<void>;
/**
* Hosted portal for the org to manage payment methods / pay invoices.
* Lago is not itself a payment rail; with Lago this resolves to the Stripe PSP
* portal that Lago is configured to collect through (see ยง11). Returns null/"" if
* no PSP customer exists yet.
*/
getCustomerPortalUrl(externalCustomerId: string): Promise<string>;
/** Verify signature and normalize to a BillingEvent. Throws on bad signature. */
parseWebhook(rawBody: string, signature: string): BillingEvent;
/** Read-through for reconciliation / backfill. */
listInvoices(externalCustomerId: string): Promise<NormalizedInvoice[]>;
}
BillingEvent is a normalized discriminated union with an explicit ignored variant (provider events we receive but don't act on), so the webhook handler is total over the provider's event stream.
Plan & price strategyโ
A single generic provider plan โ "Specialist Monthly" (LAGO_SPECIALIST_PLAN_CODE) โ is used for every subscription; the per-Specialist rate is applied as a per-subscription price override at createSubscription time (Lago: subscription-level plan overrides / amount_cents). We do not create one provider plan per Specialist.
7. Data Model โ the Thin Mirrorโ
The mirror is never authoritative and never originates state; every row is a projection of something the provider told us. It exists only to serve reads without a live provider round-trip.
billing_subscription (one row per org_specialist_assignments row)โ
| Column | Type | Notes |
|---|---|---|
id | uuid PK | |
org_id | uuid | |
specialist_id | uuid | |
assignment_id | uuid UNIQUE | 1:1 with org_specialist_assignments.id; idempotency anchor |
external_subscription_id | text, nullable | null until provider confirms (covers sync_failed) |
external_customer_id | text | |
status | enum | pending | active | canceled | sync_failed |
current_rate_cents | bigint | mirror of synced rate (skip-if-unchanged on update) |
currency | text | |
anchor_day | int | org onboarded_at day-of-month |
provider | text | lago | mock |
created_at / updated_at / canceled_at | timestamptz | canceled_at nullable |
invoice (webhook-populated projection โ one row per org per cycle)โ
A mirror row is one consolidated provider invoice (covers all of an org's Specialist subscriptions for the cycle). specialist_id and billing_subscription_id are therefore normally null (an invoice spans multiple Specialists); the per-Specialist breakdown lives in line_items.
| Column | Type | Notes |
|---|---|---|
id | uuid PK | |
org_id | uuid, nullable | resolved from the provider customer; null only if unresolvable (per ยง9) |
specialist_id | uuid, nullable | normally null (invoice spans Specialists) |
billing_subscription_id | uuid, nullable | normally null (invoice spans subscriptions) |
external_invoice_id | text UNIQUE | idempotency anchor for webhook upsert |
external_customer_id | text | |
status | enum | draft | issued | paid | payment_failed | void |
amount_cents | bigint | invoice total (sum of line items) |
currency | text | |
hosted_url / pdf_url | text, nullable | provider-hosted invoice + PDF |
line_items | jsonb, nullable | the per-Specialist itemization โ Lago fees[] normalized to { specialistId, specialistName, periodStart, periodEnd, amountCents }[]. Display projection, not a billing source. |
period_start / period_end | date, nullable | the invoice's billing period |
issued_at / paid_at | timestamptz, nullable | |
created_at / updated_at | timestamptz |
Explicitly absent (these were #702 artifacts of owning the cycle): invoice_line_item table, period table, idempotency_key table, org_specialist_assignment_event table. The per-Specialist breakdown is the provider's fees, mirrored into line_items โ we never assemble it.
Client presentation โ list provider-consolidated invoicesโ
ClientBillingSummaryQuery simply lists the org's mirrored invoices (newest first), each already a consolidated cycle invoice; the UI renders the invoice total + its line_items (one line per Specialist with that Specialist's prorated period and amount). There is no roll-up logic โ the provider already consolidated. This is pure read-path projection over the mirror; no billing logic, no provider call.
8. Idempotencyโ
The entire idempotency story is two unique constraints โ no key table, no adopt-or-create, no self-heal (we never originate invoices):
| Operation | Mechanism |
|---|---|
createSubscription | Check billing_subscription by assignment_id; also pass assignment_id as provider idempotency key. Retry โ provider returns existing subscription; insert no-ops. |
updateSubscriptionPrice | Re-applying same rate is a provider no-op; current_rate_cents lets us skip unchanged. |
cancelSubscription | Guarded by status === 'active'; canceling a canceled sub is a no-op. |
| Webhook mirror | Upsert keyed on external_invoice_id; duplicate / replayed delivery updates the same row. |
9. Error Handling & Edge Casesโ
| Case | Handling |
|---|---|
| Provider API down during sync | In-process retry (3ร, backoff). On persistent failure: status='sync_failed', surfaced on /ops/billing with an SA "Retry sync" action. The domain change (assignment) still succeeds โ billing is eventual. No reconciliation cron. |
| Duplicate webhook | Upsert on external_invoice_id โ idempotent. |
Out-of-order webhooks (e.g. paid before issued) | We store the provider's status verbatim (last-writer projection) rather than inferring transitions, so ordering can't corrupt status. |
| Webhook for an unknown local subscription | Still upsert the invoice row (org/customer from payload); leave billing_subscription_id null. The mirror is a projection, not a join requirement. |
| Re-assign a previously-canceled Specialist | New assignment row โ new assignment_id โ new subscription. Old canceled subscription stays as history. |
| Forged webhook | Signature verification (LAGO_WEBHOOK_SECRET) inside parseWebhook; failure โ reject with 401. |
| Agent / provider unreachable on read | Reads hit the local mirror only; provider downtime never blocks /client/billing or /ops/billing. |
Fail-open principle: a provider failure never blocks an AM from assigning/unassigning a Specialist. The assignment is the authoritative domain act; the subscription sync is eventual and retryable.
10. Coexistence with #762โ
This design supersedes the #702 "own all billing logic" design. No code from #762 was merged, so there is nothing to revert.
- New spec (this file) + new implementation plan + new PR, branched off
dev. - PR #762 is closed without merge. Its spec and plan remain in git history as the rationale trail that motivated this redesign.
10.1 Supersession of the legacy runtime billing moduleโ
A legacy fixed-plan billing module already exists in dev (api/src/billing/{billing.service,billing.controller,payment-provider.interface,stripe.provider,request-finance.provider}.ts + the Subscription entity at common/entities.ts / subscriptions table). It models fixed tiers (agent_starter / agent_pro / enterprise) โ the model the billing initiative set out to replace. This design supersedes and removes it (the legacy code is approved for rewrite).
The legacy Subscription entity has four consumers that must be repointed or detached, not silently broken:
| Consumer | Use | Migration action |
|---|---|---|
conversations.service.ts hasActiveSubscription() | trial-expiry gate (raw SELECT โฆ FROM subscriptions WHERE status='active') โ behavioral | Repoint to "org has โฅ1 billing_subscription with status='active'". Keep fail-open semantics. |
organizations.service.ts:306 subRepo.find() | list all subscriptions | Repoint to billing-subscription read (or remove if unused by callers). |
organizations.service.ts:2259 subRepo.findOne({orgId}) | org subscription lookup | Repoint to billing-subscription read. |
admin.service.ts:383 subRepo.findOne({orgId}) | admin dashboard subscription panel | Repoint to billing-subscription read. |
Removal of the legacy module, the Subscription entity, and the subscriptions table is atomic with repointing these four consumers (one phase), so the build is never broken mid-flight.
11. Configurationโ
| Variable | Purpose |
|---|---|
BILLING_PROVIDER | lago | mock โ selects the adapter |
LAGO_API_URL | Base URL of the self-hosted Lago API instance |
LAGO_API_KEY | Lago API credential |
LAGO_WEBHOOK_SECRET | Lago webhook signature verification |
LAGO_SPECIALIST_PLAN_CODE | The generic "Specialist Monthly" plan code; per-subscription override sets the rate |
Payment collection is configured inside Lago via its Stripe PSP integration (Lago triggers card charges against issued invoices). The relevant Stripe credentials live in Lago's own configuration, not the API service; getCustomerPortalUrl resolves to the Stripe portal for the org's PSP customer.
In the absence of LAGO_API_KEY (and with BILLING_PROVIDER=mock or unset), the MockProvider is wired โ consistent with the existing "mock mode if absent" convention.
Operational note (self-hosted): Lago runs as self-hosted infrastructure (Lago API + worker + its own Postgres + Redis). It was initially deployed on Railway as a separate service (alongside the platform's API/Agent services). This is an ownership cost the cloud option would not carry; it is the deliberate tradeoff for open-source self-hosting. LAGO_API_URL points at the active Lago instance.
2026-06 update: Lago is migrating off Railway to a single AWS-hosted instance that serves dev/staging/production via separate Lago orgs (one org per environment) โ eliminating the per-env Lago deployments and consolidating webhook/customer routing.
12. Testing Strategy (implementation-phase prescription)โ
The correctness budget is event handling, not arithmetic (proration is now the provider's contract).
MockProviderimplements the fullBillingProviderPortin-memory; all command/query tests run against it (no network).- Command tests: assign โ
createSubscriptioncalled once withassignment_idkey; re-rate โupdateSubscriptionPrice; unassign โcancelSubscription; sync failure โsync_failedpersisted; retry path re-runs cleanly. - Webhook tests: every
BillingEventvariant upserts correctly; duplicate delivery idempotent; bad signature โ 401; out-of-order delivery preserves last-writer status. - Query tests: roll-up groups a cycle's invoices by period;
sync_failedsurfaces in ops overview. - Tests use better-sqlite3 in-memory per the existing API convention; CI runs against real Postgres.
13. Out of Scopeโ
- Usage / hourly billing (explicitly removed โ monthly per-Specialist only).
- Tax computation (delegated to the provider where supported).
- Multi-currency conversion (each subscription carries its own currency; no FX).
- Dunning UI (provider-hosted portal handles payment retries).
- DB-level RLS for billing tables (tracked with the platform-wide RLS effort, issue #3).