Block 1.A — subledgers + reconciliation (architecture view)

For per-feature technical detail see subledgers and reconciliation. For staff-facing flow see the business finance doc.

What Block 1.A adds to the pipeline

The Phase 0 pipeline was:

Trigger → journal-entries.service.create() → GL + hash chain

Block 1.A introduces a parallel subledger layer that is written alongside every GL line. The pipeline is now:

A subledger row is the operational mirror of one GL line:

Components and their relationships

Invariants Block 1.A introduces

  1. Subledger ↔ GL parity. Every subledger entry has a journalLineId pointing to its GL counterpart. The reconciliation engine asserts sum(subledger) = GL balance (within ₦0.01) for every control account daily.
  2. Atomicity. Subledger writes are wrapped in the same Sequelize transaction as the GL write. There is no codepath where the GL commits without the subledger or vice versa. (Block 1.B's posting engine enforces this contract for new posting flows.)
  3. Append-only (with explicit lifecycle exceptions). Subledger entries are immutable. Broker trades are an exception: the entire row is one ticket and its status / suspenseFlag mutate as the ticket progresses. This is documented at the model.
  4. Exception persistence. Reconciliation exceptions persist across re-runs. Resolving one is an explicit operator action with a required note. Nothing is auto-closed.
  5. Idempotent imports. Re-importing the same custodian statement upserts on (fundId, securityId, asOfDate, source='custodian_statement'). The import log row records what file produced this but the position rows themselves are deduped.

Dependencies on Phase 0

  • AAOIFI control accounts created by aaoifi-coa-seed are the GL counterparts every reconciliation check compares against: 6101, 6102, 6001*, 2001, 7001, 7002.
  • Hash-chain integrity from hash-chain-verifier is unaffected — subledger writes do not touch journal entries, so the chain remains valid.

Dependencies Block 1.B will satisfy

  • Posting engine. client_subledger.service.postEntry, fund_subledger.service.postEntry, and custodian_ledger.service.recordPositionFromTrade are all designed to be called from inside Block 1.B's posting engine within one transaction. Today they're invokable from tests; nothing in production calls them yet.
  • System actor. The broker cron flags suspense rows but defers journal posting because there's no system actor convention to attribute the posting to. Block 1.B introduces that convention and the cron is rewired then.

Failure modes explicitly handled

  • Missing control account during a reconciliation check → per-check try/catch logs the error and the run continues with other checks. The run still completes.
  • Custodian CSV malformed → the import row is marked failed, no position rows are created, the API returns 400 with the parse error.
  • Duplicate broker trade ref → DB unique index on (tradeRef, brokerId) raises; service returns 409 Conflict.
  • Resolve exception without note400 BadRequestException from the service.
  • Subledger update path attempted outside the designed mutation points → there is no such path; if a future PR introduces one, the reviewer should flag it.

What changes when later Blocks land

BlockEffect on Block 1.A
1.B (posting engine)All current direct calls into the four subledger postEntry methods become routed through the posting engine. The engine guarantees the transaction wrap. Existing API surface unchanged.
1.C (NAV)client_securities_control and fund_unit_control checks get a monetary basis (NAV × units) and graduate from info/warn to error severity.
1.D (trade handlers)Equity / sukuk handlers populate broker_trade.journalEntryId and custodian_position.journalLineId for real trades. Today only tests exercise these paths.