Reconciliation engine (Phase 1 Task 1.A.5)

Status: ✅ Complete

Plan reference: Task 1.A.5 in 2026-04-30-FINANCE_MODULE_IMPLEMENTATION_PLAN.md (operations workspace root)

What it does

A daily orchestrator that runs every subledger-vs-GL check, persists a ReconciliationRun row, and creates a ReconciliationException row for every non-zero delta. Operations staff acknowledge and resolve exceptions through the UI; nothing is auto-closed.

Without this engine, drift between the four subledgers and the GL would only be discovered when a regulator asked. With it, every divergence over 1 NGN surfaces by 23:00 the same day.

Files

  • Serviceethica-api/src/finance/reconciliation/reconciliation.service.ts
  • Controllerethica-api/src/finance/reconciliation/reconciliation.controller.ts
  • DTOethica-api/src/finance/reconciliation/dto/resolve-exception.dto.ts
  • Modelsethica-api/src/models/reconciliation-run.model.ts, reconciliation-exception.model.ts
  • Frontendethica-erp/app/(auth)/u/finance/reconciliation/page.tsx
  • HooksuseReconciliationRuns, useReconciliationExceptions, useRunDailyReconciliation, useAcknowledgeException, useResolveException (in ethica-erp/services/finance.ts)

Checks

Each check is a method on ReconciliationService that returns a CheckResult { checkName, controlName, expectedBalance, actualBalance, delta, severity, isException, ... }. isException is true when |delta| > 0.01.

checkNameSubledger sourceGL prefixSeverity (when exception)
client_cash_controlclient_subledger.cash sum6101 per funderror
client_securities_controlclient_subledger.units6102 per fundwarn
fund_unit_controlfund_subledger outstanding6001* per fundinfo until NAV engine lands
custodian_positiongetReconciliationDeltan/a — counts onlyerror if any mismatch
broker_payablebroker_trade.net where pending2001 per brokererror
suspense_unallocatedn/a7001 (must be 0)error if non-zero
suspense_settlement_mismatchn/a7002 (must be 0)error if non-zero

fund_unit_control and client_securities_control are emitted at info/warn severity until the NAV engine (Phase 1 Block 1.C) gives us a monetary basis to compare against. They appear in the run results but won't generate error-grade exceptions yet.

Orchestrator — runDailyChecks

  1. Insert a ReconciliationRun row with status='running'.
  2. Look up every active fund. For each, run the four fund-scoped checks; per-check failures (DB error, missing control account) are logged and skipped — they do not abort the whole run.
  3. Enumerate distinct brokers from broker_trades (no Broker master yet) and run reconcileBrokerPayable for each.
  4. Run the two suspense checks (entity-wide).
  5. For each result with isException=true, insert a ReconciliationException row keyed to the run.
  6. Update the run row: status='completed', totalChecks, exceptionsCount.

If any unexpected error escapes the per-check try/catch the run row is marked errored with the error message and the exception is rethrown to the caller.

Endpoints

All prefixed /finance/reconciliation:

MethodPathPermissionNotes
POST/run-dailyFINANCE_APPROVEManual trigger; records the actor in triggeredById
GET/runs?from=&to=&limit=FINANCE_VIEWHistory, default limit 50, max 500
GET/exceptions?status=&runId=&limit=FINANCE_VIEWFilter by status / run
POST/exceptions/:id/acknowledgeFINANCE_MANAGEOpen → acknowledged
POST/exceptions/:id/resolveFINANCE_APPROVEAny → resolved; requires non-empty note (min 5 chars)

Scheduled run

@Cron('0 23 * * *')
async scheduledNightlyRun()

Fires at 23:00 server-local, before the hash chain verifier at 02:00. The run uses runType: 'daily' and no actor (cron). Errors are caught + logged but never crash the scheduler.

Frontend page

/u/finance/reconciliation — added to the ERP sidebar under Finance. Layout:

  1. Header banner with manual "Run daily check now" button (visible to FINANCE_APPROVE).
  2. Latest run card showing date, run type, started/completed timestamps, check + exception counts, and overall status pill.
  3. Exception list filterable by status (open | acknowledged | resolved | all). Each row shows the check, control name, expected, actual, delta (red/amber), severity + status badges, and per-row actions:
    • Acknowledge button (visible to FINANCE_MANAGE for open rows)
    • Resolve button (visible to FINANCE_APPROVE for non-resolved rows) — opens a modal that requires a ≥5-char resolution note

Design choices worth knowing

  • Tolerance of ₦0.01. |delta| > 0.01 becomes an exception. Smaller deltas are absorbed as rounding noise. Same tolerance the GL hash verifier uses for DR/CR balance checks.
  • No auto-close on re-run. Re-running on the same date adds another ReconciliationRun row. Open exceptions stay open across runs so operators can still resolve them with the audit trail intact.
  • Per-check resilience. Each check is in its own try/catch — one fund's missing control account doesn't blow up the entire run. Per-check failures are logged at error level.
  • Brokers from data, not config. Until a Broker master entity is created, the orchestrator runs broker-payable reconciliation against every brokerId that appears in broker_trades. When the master entity arrives, switch this to a proper join.

Tests

ethica-api/src/finance/reconciliation/reconciliation.service.spec.ts — six specs covering:

  • Clean run produces no exceptions.
  • Client cash subledger vs GL drift creates a client_cash_control exception with the correct delta and error severity.
  • Non-zero suspense balance is flagged.
  • Acknowledge → resolve lifecycle works end-to-end.
  • Empty resolution note is rejected.
  • Acknowledging an unknown id raises NotFoundException.