05 β Billing & Financials
The financial domain of the tenant-api: how a tenant bills its members/customers. (How the platform bills the tenant is separate β see 06.) All amounts are stored in integer cents; every table carries tenant_id.
Two adjacent service directories matter and are easily confused:
services/financial/β the ledger (FinancialService.ts, disbursements, PDFs, sending).services/financials/β billing cycles, statements, ageing, numbering glue (BillingCycleExecutionService.ts,StatementService.ts, β¦).
1. Accounts & the ledger
Account (tenant-schema.ts:2446) is a polymorphic ledger account: it holds a balance for some entity via entity_type (member / customer / supplier / location) + entity_id. Columns include account_code (unique per tenant), account_name, billing contact, balance (bigint cents), currency_code, status.
Transaction is the ledger document. Types (FinancialService.ts:14-24): receipt, invoice, creditnote, refund, journal, and quote (non-posting). impact_type is debit or credit. Status lifecycle: draft β pending β approved / rejected / void. Each transaction has TransactionLine rows (qty, unit price, per-line tax & discount, computed totals).
- Create (
createTransaction,:321): totals computed from lines (inclusive/exclusive tax, per-line and transaction-level discounts) or passed directly for simple receipts; multi-currency storesexchange_rate+base_currency_total; a document number is generated (see Numbering); always created indraft. - Approve (
approveTransaction,:638) is where posting happens: validates credit allocations, setsapproved, initializesamount_due(debits) /unallocated_amount(credits) β quotes post nothing β recalculates allocations, updates the account balance, and (for receipts) triggersDisbursementServiceto split the receipt. - Balance (
updateAccountBalance,:1008) is recomputed from scratch: sum oftotal_in_vatover all approved, non-deleted, non-quote transactions;balance = totalDebit β totalCredit(positive = customer owes). It also refreshes the ageing snapshot (AgeingService, best-effort). - Allocations (
TransactionAllocation): a credit (receipt/refund/credit note) is allocated to a debit (invoice); both must be approved. Allocating doesn't change the net balance but moves arrears between ageing buckets. - Quote β invoice (
convertQuoteToInvoice,:816) clones a quote's lines onto a new invoice, linked viasource_transaction_id, single-conversion enforced.
2. Billing cycles (recurring billing)
Purpose: turn recurring/one-time AccountItems (a customer's subscribed products on an account) into draft invoices on a schedule.
Orchestration β BillingCycleExecutionService.executeBillingCycle (:504), inside a DB transaction:
- Idempotency guard: rejects a duplicate execution for the same
(billing_cycle_id, scheduled_date). - Loads items due (
getAccountItemsDueForBilling): recurring items withnext_billing_date <= today+ one-time items never billed; aNOT EXISTSsubquery excludes items already in a processing/completed execution for that date (second idempotency layer). - Inserts the execution header + one BillingCycleExecutionItem per due item ("revenue assurance" β every item is persisted before any job runs).
- Groups items by account (honouring
invoice_separately). - Enqueues one BullMQ
BILLING_PROCESS_ACCOUNTjob per account group.
The daily driver runDailyBillingExecution finds cycles due today and calls the above per cycle.
Per-account processing β BillingJobProcessor.processBillingAccount (BillingJobProcessor.ts:78):
- License-gated on
financials:automated_billingβ if not licensed, items are marked skipped, never charged. - Idempotency: skips items whose
invoice_idis already set. - Builds invoice lines via three pricing paths: usage-channel items (Β§5), order-management legacy usage, and fixed items. Adds pending proration lines.
- Creates ONE
invoice/debittransaction (left in draft), marks prorations applied, sets execution itemscompleted, and advances each account item'slast_billed_date/next_billing_dateandtimes_billed.
Atomicity: the whole per-account body runs in onedb.transaction, and theinvoice_ididempotency guard is committed atomically with invoice creation β so a crash mid-job rolls back and a BullMQ retry produces exactly one invoice. (Billing was audited SAFE for crash-retry.)
Proration β ProRataService: on a mid-period change, credit/charge = (days_remaining / total_days) Γ amount; deferred adjustments land in PendingProration and are pulled into the next cycle.
Numbering β NumberingService.generateNumber (:37) is module-agnostic and Redis-locked per tenant:module:entityType to avoid races. It reads a NumberingConfiguration (prefix/suffix/date format/reset frequency), increments a per-period NumberingSequence, formats (e.g. INV-202606-0001), and writes a NumberingAudit. Financial defaults: RCT-, INV-, CN-, RF-, JE-, monthly reset.
3. Statements
A per-account, per-period statement (opening/closing balance, period ledger, ageing snapshot, secure PDF).
StatementRunExecutionService fans out one BullMQ STATEMENT_GENERATE_ACCOUNT job per account; StatementRunJobProcessor (:37) handles each (license-gated on financials:statement_runs β skip when off). StatementService.generateStatement (:44):
- Opening balance = signed net of approved transactions posted before
period_from; closing = opening + period debits β credits. - Computes an ageing snapshot as at period end.
- Inserts an AccountStatement keyed uniquely per
(tenant, account, period_from, period_to). A duplicate hits the partial unique indexuq_account_statement_account_periodβDuplicateStatementError, which the processor treats as a no-op skip β this is the idempotency mechanism (audited SAFE). - Renders a PDF β private object storage; download is a 300s signed URL.
4. Collections & settlement
Purpose: pull settlement data from payment gateways, match each line to an account/instalment/payment-link, and post a receipt (or reversal) into the ledger.
A settlement report is a per-channel, per-date gateway fetch (not an uploaded file). SettlementJobProcessor dispatches three fetch shapes (direct fetch, polling, paginated blocks) per provider. SettlementService.processSettlementReport imports rows into SettlementTransaction (reconciliation_status='pending'), marks the SettlementReport completed, then enqueues a reconcile job per row.
Matching β matchSettlementTransaction (SettlementService.ts:1041), per row, selected FOR UPDATE and restricted to pending/unmatched:
- Payment-link path (no authorization, e.g. Payat): match the gateway reference against
PaymentLink.reference; if the link has anaccount_id, post a receipt and setmatched, elseunmatched. - Instalment path (with authorization): match an
Instalmentby authorization + scheduled date + amount + status; post the financial entry, setmatched, flip the instalment tosuccess. Gateway status drives the entry type: success βreceipt/credit; reversed/declined β debitjournal.
Idempotency (hardened): settlement matching is at-least-once and money-touching. It is protected by a Redis lock in the processor and the entire match wrapped in one DB transaction withFOR UPDATEβ a crash mid-match rolls back the receipt and leaves the rowpendingfor clean retry (effectively exactly-once).createTransactionerrors propagate (rollback) rather than being swallowed. (This was the AT-RISK processor identified and fixed; see the engineering note at the end.)
Payment-provider abstraction β IPaymentChannelHandler is the generic interface for all providers (authorizations, instalments, settlement fetch, payment methods, payment links). Concrete handlers in collections/handlers/: Netcash, NuPay, Payat, Payfast, Stripe. PaymentChannelHandlerFactory.getHandler loads the channel, asserts it's active, license-validates, and constructs the handler. Controllers operate on the generic interface β never branch on provider in business logic.
5. Usage billing
Metered/partner usage (e.g. AI usage from the Riddler partner) β priced invoice lines.
- A
UsageBillingChannelis a tenant config selecting a usage-pull handler. An AccountItem opts in viause_usage_billing=true. - Ingest: partner push (
POST /internal/partner-usage/events, bearer +X-Tenant-ID, one row per event inPartnerUsageEvent, idempotent on(tenant, idempotency_key)), or internal live read. - Snapshot:
UsageBillingSnapshotServicedenormalizes current usage + tier price onto the AccountItem for grids/reporting (a daily three-level BullMQ fan-out, idempotent via deterministic day-bucketedjobId+ idempotent overwrite). - Usage β charge:
buildUsageBillingLinesForCycle(called by the billing cycle) re-pulls usage live, resolves the unit price viaTierPricingResolver(single-rate tier lookup, not marginal tiering), applies tax, and emits the usage line.
6. Async idempotency β the cross-cutting concern
Every async stage runs on at-least-once BullMQ; if a worker dies mid-job (e.g. a VM reboot) the job is retried from the start. Each financial stage is made idempotent so a retry can't double-charge:
| Stage | Idempotency mechanism | Status |
|---|---|---|
| Billing per-account | execution dedupe + invoice_id short-circuit, all in one transaction | SAFE |
| Statement per-account | (account, period) unique index β DuplicateStatementError skip | SAFE |
| Settlement match | Redis lock + FOR UPDATE single transaction | hardened (was at-risk) |
| Usage snapshot | deterministic jobId + idempotent overwrite | SAFE |
| Auto-send transaction (invoice/reminder send) | claim-before-send conditional UPDATE (flip ever_sent_successfully / reminder_complete; loser skips) with rollback on transient failure | hardened (was at-risk) |
Engineering note (2026-06). A crash-retry audit found two money-/customer-touching processors that could double-execute on reboot: Settlement (duplicate ledger posting + double PaymentLink.times_used) and AutoSendTransaction (duplicate customer invoice/reminder + duplicate billable SMS/WhatsApp). Both were hardened with the patterns above β Settlement made atomic/exactly-once, AutoSend made at-most-once via claim-before-send. The general rule for new async side-effects: claim the row with a conditional UPDATE before the side-effect, in the same transaction, and give irreversible external effects a deterministic idempotency key.
Key source paths: services/financial/FinancialService.ts, services/financials/{BillingCycleExecutionService,StatementService}.ts, services/collections/{SettlementService,IPaymentChannelHandler}.ts + handlers/, services/usage-billing/*, services/billing/{usageBillingCycleInvoice,TierPricingResolver,ProRataService}.ts, services/numbering/NumberingService.ts, queue/processors/{BillingJobProcessor,StatementRunJobProcessor,SettlementJobProcessor,AutoSendTransactionJobProcessor,UsageBillingSnapshotJobProcessor}.ts.