Skip to main content

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:

  1. React to domain changes โ€” when a Specialist is assigned, re-rated, or unassigned, create / update / cancel the corresponding provider subscription.
  2. 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/billing and /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 fee per 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_assignments is @Unique(["orgId", "specialistId"]) (not unique on orgId) โ€” multiple Specialists per org is already allowed.
  • Per-assignment rate (monthly_rate_config) and effective dates already exist.
  • Conversation.specialist_id already 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 startDate to 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 @Entity classes live in *.storage.ts files (per project convention), registered explicitly in both app.module.ts and data-source.ts (no globs). domain/*.ts entities 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)โ€‹

ColumnTypeNotes
iduuid PK
org_iduuid
specialist_iduuid
assignment_iduuid UNIQUE1:1 with org_specialist_assignments.id; idempotency anchor
external_subscription_idtext, nullablenull until provider confirms (covers sync_failed)
external_customer_idtext
statusenumpending | active | canceled | sync_failed
current_rate_centsbigintmirror of synced rate (skip-if-unchanged on update)
currencytext
anchor_dayintorg onboarded_at day-of-month
providertextlago | mock
created_at / updated_at / canceled_attimestamptzcanceled_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.

ColumnTypeNotes
iduuid PK
org_iduuid, nullableresolved from the provider customer; null only if unresolvable (per ยง9)
specialist_iduuid, nullablenormally null (invoice spans Specialists)
billing_subscription_iduuid, nullablenormally null (invoice spans subscriptions)
external_invoice_idtext UNIQUEidempotency anchor for webhook upsert
external_customer_idtext
statusenumdraft | issued | paid | payment_failed | void
amount_centsbigintinvoice total (sum of line items)
currencytext
hosted_url / pdf_urltext, nullableprovider-hosted invoice + PDF
line_itemsjsonb, nullablethe per-Specialist itemization โ€” Lago fees[] normalized to { specialistId, specialistName, periodStart, periodEnd, amountCents }[]. Display projection, not a billing source.
period_start / period_enddate, nullablethe invoice's billing period
issued_at / paid_attimestamptz, nullable
created_at / updated_attimestamptz

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):

OperationMechanism
createSubscriptionCheck billing_subscription by assignment_id; also pass assignment_id as provider idempotency key. Retry โ†’ provider returns existing subscription; insert no-ops.
updateSubscriptionPriceRe-applying same rate is a provider no-op; current_rate_cents lets us skip unchanged.
cancelSubscriptionGuarded by status === 'active'; canceling a canceled sub is a no-op.
Webhook mirrorUpsert keyed on external_invoice_id; duplicate / replayed delivery updates the same row.

9. Error Handling & Edge Casesโ€‹

CaseHandling
Provider API down during syncIn-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 webhookUpsert 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 subscriptionStill 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 SpecialistNew assignment row โ†’ new assignment_id โ†’ new subscription. Old canceled subscription stays as history.
Forged webhookSignature verification (LAGO_WEBHOOK_SECRET) inside parseWebhook; failure โ†’ reject with 401.
Agent / provider unreachable on readReads 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:

ConsumerUseMigration action
conversations.service.ts hasActiveSubscription()trial-expiry gate (raw SELECT โ€ฆ FROM subscriptions WHERE status='active') โ€” behavioralRepoint to "org has โ‰ฅ1 billing_subscription with status='active'". Keep fail-open semantics.
organizations.service.ts:306 subRepo.find()list all subscriptionsRepoint to billing-subscription read (or remove if unused by callers).
organizations.service.ts:2259 subRepo.findOne({orgId})org subscription lookupRepoint to billing-subscription read.
admin.service.ts:383 subRepo.findOne({orgId})admin dashboard subscription panelRepoint 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โ€‹

VariablePurpose
BILLING_PROVIDERlago | mock โ€” selects the adapter
LAGO_API_URLBase URL of the self-hosted Lago API instance
LAGO_API_KEYLago API credential
LAGO_WEBHOOK_SECRETLago webhook signature verification
LAGO_SPECIALIST_PLAN_CODEThe 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).

  • MockProvider implements the full BillingProviderPort in-memory; all command/query tests run against it (no network).
  • Command tests: assign โ†’ createSubscription called once with assignment_id key; re-rate โ†’ updateSubscriptionPrice; unassign โ†’ cancelSubscription; sync failure โ†’ sync_failed persisted; retry path re-runs cleanly.
  • Webhook tests: every BillingEvent variant 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_failed surfaces 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).