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
- Service —
ethica-api/src/finance/reconciliation/reconciliation.service.ts - Controller —
ethica-api/src/finance/reconciliation/reconciliation.controller.ts - DTO —
ethica-api/src/finance/reconciliation/dto/resolve-exception.dto.ts - Models —
ethica-api/src/models/reconciliation-run.model.ts,reconciliation-exception.model.ts - Frontend —
ethica-erp/app/(auth)/u/finance/reconciliation/page.tsx - Hooks —
useReconciliationRuns,useReconciliationExceptions,useRunDailyReconciliation,useAcknowledgeException,useResolveException(inethica-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.
checkName | Subledger source | GL prefix | Severity (when exception) |
|---|---|---|---|
client_cash_control | client_subledger.cash sum | 6101 per fund | error |
client_securities_control | client_subledger.units | 6102 per fund | warn |
fund_unit_control | fund_subledger outstanding | 6001* per fund | info until NAV engine lands |
custodian_position | getReconciliationDelta | n/a — counts only | error if any mismatch |
broker_payable | broker_trade.net where pending | 2001 per broker | error |
suspense_unallocated | n/a | 7001 (must be 0) | error if non-zero |
suspense_settlement_mismatch | n/a | 7002 (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
- Insert a
ReconciliationRunrow withstatus='running'. - 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.
- Enumerate distinct brokers from
broker_trades(noBrokermaster yet) and runreconcileBrokerPayablefor each. - Run the two suspense checks (entity-wide).
- For each result with
isException=true, insert aReconciliationExceptionrow keyed to the run. - 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:
| Method | Path | Permission | Notes |
|---|---|---|---|
| POST | /run-daily | FINANCE_APPROVE | Manual trigger; records the actor in triggeredById |
| GET | /runs?from=&to=&limit= | FINANCE_VIEW | History, default limit 50, max 500 |
| GET | /exceptions?status=&runId=&limit= | FINANCE_VIEW | Filter by status / run |
| POST | /exceptions/:id/acknowledge | FINANCE_MANAGE | Open → acknowledged |
| POST | /exceptions/:id/resolve | FINANCE_APPROVE | Any → 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:
- Header banner with manual "Run daily check now" button (visible to
FINANCE_APPROVE). - Latest run card showing date, run type, started/completed timestamps, check + exception counts, and overall status pill.
- 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_MANAGEforopenrows) - Resolve button (visible to
FINANCE_APPROVEfor non-resolved rows) — opens a modal that requires a ≥5-char resolution note
- Acknowledge button (visible to
Design choices worth knowing
- Tolerance of ₦0.01.
|delta| > 0.01becomes 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
ReconciliationRunrow. 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
errorlevel. - Brokers from data, not config. Until a
Brokermaster entity is created, the orchestrator runs broker-payable reconciliation against everybrokerIdthat appears inbroker_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_controlexception with the correct delta anderrorseverity. - Non-zero suspense balance is flagged.
- Acknowledge → resolve lifecycle works end-to-end.
- Empty resolution note is rejected.
- Acknowledging an unknown id raises
NotFoundException.