05 β€” Billing & Financials

← Index

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:

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

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:

  1. Idempotency guard: rejects a duplicate execution for the same (billing_cycle_id, scheduled_date).
  2. Loads items due (getAccountItemsDueForBilling): recurring items with next_billing_date <= today + one-time items never billed; a NOT EXISTS subquery excludes items already in a processing/completed execution for that date (second idempotency layer).
  3. Inserts the execution header + one BillingCycleExecutionItem per due item ("revenue assurance" β€” every item is persisted before any job runs).
  4. Groups items by account (honouring invoice_separately).
  5. Enqueues one BullMQ BILLING_PROCESS_ACCOUNT job 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):

Atomicity: the whole per-account body runs in one db.transaction, and the invoice_id idempotency 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):

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:

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 with FOR UPDATE β€” a crash mid-match rolls back the receipt and leaves the row pending for clean retry (effectively exactly-once). createTransaction errors 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.

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:

StageIdempotency mechanismStatus
Billing per-accountexecution dedupe + invoice_id short-circuit, all in one transactionSAFE
Statement per-account(account, period) unique index β†’ DuplicateStatementError skipSAFE
Settlement matchRedis lock + FOR UPDATE single transactionhardened (was at-risk)
Usage snapshotdeterministic jobId + idempotent overwriteSAFE
Auto-send transaction (invoice/reminder send)claim-before-send conditional UPDATE (flip ever_sent_successfully / reminder_complete; loser skips) with rollback on transient failurehardened (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.

Next: 06 β€” Core provisioning & platform billing β†’