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 indirection)unitsis required onentryType='units'rows and forbidden onentryType='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 insertsgetClientStatement(clientId, fundId, fromDate, toDate)— opening balance + per-row running cash and unit balances + closing balancegetControlBalance(fundId)— aggregated cash + units across all clients in the fund
Routes (prefixed /finance/subledgers/client):
| Method | Path | Permission |
|---|---|---|
| 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 enginegetUnitHistory(fundId, fromDate, toDate)— running outstanding count over a windowgetControlBalance(fundId)—unitsOutstanding,totalIncome,totalExpense,totalNavAdjustment
Routes (prefixed /finance/subledgers/fund):
| Method | Path | Permission |
|---|---|---|
| GET | /:fundId/control-balance | FINANCE_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 keyimportCustodianStatement({ 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 dategetReconciliationDelta(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):
| Method | Path | Permission |
|---|---|---|
| POST | /import | FINANCE_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 computedgross,fees,net; the GL trade journal itself is posted by the trade handler in Block 1.D (this subledger row links to that journal viajournalEntryId)markSettled(tradeId, opts?)— moves the row tosettledwith timestampflagUnmatched(opts?)— scans forpendingrows aged past 48h; flipssuspenseFlag=true; if anactor: Staffis provided, postsDr 7001 / Cr 2001viaJournalEntriesService.createSystemJournaland records the journal id back on the rowgetOpenTrades(brokerId)— pending trades for one broker (used in broker reconciliation + UI blotter)@Cron('EVERY_6_HOURS') scheduledFlagUnmatched()— runsflagUnmatched()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 viaPOST /flag-unmatched.
Routes (prefixed /finance/subledgers/broker):
| Method | Path | Permission |
|---|---|---|
| POST | /trade | FINANCE_MANAGE |
| POST | /trade/:id/settle | FINANCE_MANAGE |
| POST | /flag-unmatched | FINANCE_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 viametadata.reversalOfmirroring 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 forNUMERIC(20,4)+decimal.js— I held to the existing repo convention (nodecimal.jsis used anywhere inethica-api). If a single materiality issue ever surfaces from this precision choice we revisit it. - Transactional integrity. Every
postEntryaccepts an optionalTransaction. 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).