Subledgers — client, fund, custodian, broker (Phase 1 Block 1.A)

Status: ✅ Complete

Plan reference: Tasks 1.A.1 – 1.A.4 in 2026-04-30-FINANCE_MODULE_IMPLEMENTATION_PLAN.md (operations workspace root)

Why subledgers

The GL records what happened in money — debits and credits against account codes. It does not, on its own, tell us whose cash is sitting in account 6101, which fund's units make up the 6001* balance, or whether the broker's view of trade XYZ matches ours. Subledgers are normalised tables that capture that detail and reconcile back to GL control accounts daily.

Every subledger row is paired with one GL line via journalLineId. Both halves of every movement (the subledger entry + the GL line) are created inside one Sequelize transaction, so they cannot drift apart.

The four subledgers

1. Client subledger — client_subledger_entries

Tracks each client's cash and unit position in each fund.

Model: ethica-api/src/models/client-subledger-entry.model.ts

Key columns: clientId, clientType, fundId, entryType ('cash' | 'units'), direction ('debit' | 'credit'), amount, currency, fxRate, units (only for unit entries), unitPrice, journalLineId, journalEntryId, narration, transactionDate, postedAt, metadata.

Invariants:

  • amount > 0 (sign is encoded in direction)
  • units is required on entryType='units' rows and forbidden on entryType='cash'
  • Cash rows aggregate to GL 6101 (Client Ledger Control — Cash) per fund
  • Unit rows aggregate (as a count) to the units the GL recognises against 6102

Service: ethica-api/src/finance/subledgers/client-subledger.service.ts

  • postEntry(payload, transaction?) — called only from the posting engine; validates payload and inserts
  • getClientStatement(clientId, fundId, fromDate, toDate) — opening balance + per-row running cash and unit balances + closing balance
  • getControlBalance(fundId) — aggregated cash + units across all clients in the fund

Routes (prefixed /finance/subledgers/client):

MethodPathPermission
GET/control-balance?fundId=FINANCE_VIEW
GET/:clientId/statement?fundId=&from=&to=FINANCE_VIEW

2. Fund subledger — fund_subledger_entries

Tracks fund-level movements: units issued / redeemed, income, expense, NAV adjustments.

Model: ethica-api/src/models/fund-subledger-entry.model.ts

Key columns: fundId, entryType ('units_issued' | 'units_redeemed' | 'nav_adjustment' | 'income' | 'expense'), direction, amount, units, navAtEntry, journalLineId, journalEntryId, transactionDate, narration.

Invariants: unit-movement rows require units > 0; income/expense/NAV rows must omit units. The signed sum of unit movements equals unitsOutstanding.

Service: ethica-api/src/finance/subledgers/fund-subledger.service.ts

  • postEntry(payload, transaction?) — internal post via posting engine
  • getUnitHistory(fundId, fromDate, toDate) — running outstanding count over a window
  • getControlBalance(fundId)unitsOutstanding, totalIncome, totalExpense, totalNavAdjustment

Routes (prefixed /finance/subledgers/fund):

MethodPathPermission
GET/:fundId/control-balanceFINANCE_VIEW
GET/:fundId/units?from=&to=FINANCE_VIEW

3. Custodian position ledger — custodian_positions + custodian_statement_imports

Two-source ledger of fund holdings: source='internal' rows we write on trade settlement, source='custodian_statement' rows we ingest from the custodian's statement file. Daily reconciliation compares the two.

Models:

  • ethica-api/src/models/custodian-position.model.ts — unique index on (fundId, securityId, asOfDate, source)
  • ethica-api/src/models/custodian-statement-import.model.ts — one row per import file (success or failure preserved)

Service: ethica-api/src/finance/subledgers/custodian-ledger.service.ts

  • recordPositionFromTrade(payload, transaction?) — internal snapshot from a trade settlement; upserts on the unique key
  • importCustodianStatement({ fundId, asOfDate, csvText, importedById, filename }) — parses CSV, idempotently upserts statement rows, persists an import-log row, returns { importId, totalRows, insertedRows, skippedRows, errors }
  • getInternalPosition(fundId, asOfDate) — latest internal snapshot per security on/before the date
  • getReconciliationDelta(fundId, asOfDate) — internal vs custodian-statement comparison; buckets: match, mismatch, internal_only, custodian_only

CSV format: header row required. Required columns: securityId, securityType (equity | sukuk | mutual_fund), quantity. Optional columns: isin, marketValue, currency. Inline parser at the bottom of custodian-ledger.service.ts — small enough to maintain, replace with a real lib (csv-parse) once a real custodian feed is onboarded.

Routes (prefixed /finance/subledgers/custodian):

MethodPathPermission
POST/importFINANCE_MANAGE
GET/positions?fundId=&asOfDate=FINANCE_VIEW
GET/reconciliation?fundId=&asOfDate=FINANCE_VIEW

4. Broker trade subledger — broker_trades

Tracks one row per trade ticket through pending → settled (or failed). Pending tickets older than 48 hours are automatically flagged as suspense by a cron and (when an actor is available) get a suspense journal posted to account 7001 so the GL reflects the unmatched obligation.

Model: ethica-api/src/models/broker-trade.model.ts

Unique index (tradeRef, brokerId) — duplicate tickets are rejected at the DB level. brokerId is a free-form string today; becomes a FK once a Broker master entity is added.

Service: ethica-api/src/finance/subledgers/broker-ledger.service.ts

  • recordTrade(payload) — inserts a pending row with computed gross, fees, net; the GL trade journal itself is posted by the trade handler in Block 1.D (this subledger row links to that journal via journalEntryId)
  • markSettled(tradeId, opts?) — moves the row to settled with timestamp
  • flagUnmatched(opts?) — scans for pending rows aged past 48h; flips suspenseFlag=true; if an actor: Staff is provided, posts Dr 7001 / Cr 2001 via JournalEntriesService.createSystemJournal and records the journal id back on the row
  • getOpenTrades(brokerId) — pending trades for one broker (used in broker reconciliation + UI blotter)
  • @Cron('EVERY_6_HOURS') scheduledFlagUnmatched() — runs flagUnmatched() without an actor (no system-actor convention yet — see phase-0-foundation for why); just flags rows. The journal post happens when an operator triggers the manual run via POST /flag-unmatched.

Routes (prefixed /finance/subledgers/broker):

MethodPathPermission
POST/tradeFINANCE_MANAGE
POST/trade/:id/settleFINANCE_MANAGE
POST/flag-unmatchedFINANCE_APPROVE
GET/open-trades?brokerId=FINANCE_VIEW

Cross-cutting design choices

  • Append-only. No subledger row is ever updated except for the narrow lifecycle fields explicitly designed to mutate (broker_trade.status, broker_trade.suspenseFlag, custodian_statement_import.status). Reversals come through new rows linked to the original via metadata.reversalOf mirroring the GL pattern from Phase 0.
  • Money precision. DECIMAL(20,2) for monetary amounts (matches existing GL convention). DECIMAL(20,6) for FX rates, unit counts, unit prices. The plan called for NUMERIC(20,4) + decimal.js — I held to the existing repo convention (no decimal.js is used anywhere in ethica-api). If a single materiality issue ever surfaces from this precision choice we revisit it.
  • Transactional integrity. Every postEntry accepts an optional Transaction. The posting engine (Block 1.B) will wrap GL writes + subledger writes in one transaction.

Tests

37 unit specs across the four subledger services plus the reconciliation engine (next page). Run them all with npx jest src/finance/subledgers src/finance/reconciliation. Each service has tests for: happy path, validation failures, idempotency where applicable, and the specific reconciliation scenarios called out in the plan (broker 49h-old flag, custodian delta buckets, client running balance, fund units outstanding).