NAV calculator — 12-step engine (Phase 1 Block 1.C)
Status: ✅ Complete
Plan reference: Tasks 1.C.1 – 1.C.4 in 2026-04-30-FINANCE_MODULE_IMPLEMENTATION_PLAN.md (operations workspace root)
What NAV is — and why this code exists
NAV = Net Asset Value. The question every regulated fund has to answer every day:
"What is one unit of this fund worth, right now?"
The formula is simple:
NAV per unit = (Total assets − Total liabilities) ÷ Units outstanding
But the calculation is not. You have to value every holding at today's price, translate foreign assets via today's FX rate, account for fees that have accrued but not been paid, separate impure income that shouldn't be distributed, then divide by the right number of units (which depends on which subscriptions have settled by the cut-off). Get any of it wrong and unitholders pay or receive the wrong amount.
NAV is the price. Subscriptions and redemptions transact at NAVPU. It's also the performance — every fact sheet starts from yesterday's NAVPU. And it's the fairness mechanism — strict cut-offs prevent late trades from diluting existing unitholders.
This page describes the engine that produces NAV.
What Block 1.C built
| Component | Files |
|---|---|
| Security master | ethica-api/src/models/security.model.ts, ethica-api/src/finance/pricing/price-source.service.ts |
| Price source + stale detection | ethica-api/src/models/security-price.model.ts, price-source.service.ts |
| FX rate source | ethica-api/src/models/fx-rate.model.ts, ethica-api/src/finance/pricing/fx-rate.service.ts |
| NAV snapshot models | ethica-api/src/models/nav-snapshot.model.ts, nav-snapshot-line.model.ts |
| 12-step calculator | ethica-api/src/finance/nav/nav-calculator.service.ts |
| HTTP routes | pricing.controller.ts, fx-rate.controller.ts, nav.controller.ts |
| Frontend | /u/finance/nav (index), /u/finance/nav/[id] (detail) |
The 12 steps
calculate(fundId, valuationDate) runs all of these in order, in one Sequelize transaction, and persists a draft NavSnapshot with its contributing lines.
Step 1 — Snapshot lock
Capture the moment of calculation (calculatedAt). Trades dated after this moment are not included in this snapshot.
Step 2 — Value investments
Pull every internal CustodianPosition for the fund (the latest snapshot per security on or before valuationDate). For each, look up the latest SecurityPrice via PriceSourceService.getLatestPrice(). If the price is in a non-base currency, translate via FxRateService.getRateOrNull(). The contribution is:
valueInBase = quantity × unitPrice × fxRate
Each is recorded as a NavSnapshotLine of type equity/sukuk/mutual_fund. Stale prices are flagged via isStale=true and counted into staleLineCount.
Step 3 — Cash reconciliation
Sum GL accounts 1001 (Bank — Operating) + 1002 (Bank — Client Settlement) scoped to the fund. Recorded as a cash line.
Step 4 — Accrue expenses (deferred to Phase 2)
The plan calls for daily management / trustee / custody fee accrual here (annualRate × AUM / 365). That requires per-fund fee config which lands in Phase 2.D. The hook is in place; the calculator currently skips the accrual posting. The GL line for accrued fees (2100) is still read into the lines view.
Step 5 — Purification adjustment
Read GL 6201 Suspended Income — Haram for the fund. The balance is added as a negative purification_reserve line so net assets reflects only the distributable amount. Even though the cash physically sits in the bank, this slice doesn't belong to unitholders.
Step 6 — (spec skips)
Step 7 — FX revaluation
The unrealised-FX balance in GL 6302 already affects asset balances through earlier postings, but the engine surfaces it as an explicit fx_revaluation line so operators see how much of net assets is just FX translation.
Step 8 — Total assets
Sum every line whose type is in the asset set (cash, equity, sukuk, mutual_fund, receivable, accrued_income, fx_revaluation).
Step 9 — Total liabilities
Sum payables (2000), accrued expenses (2100), redemption payable (2003), zakat reserve (3202), and the purification reserve line from step 5. Liability lines are stored as negative valueInBase so the sum naturally subtracts.
Step 10 — Net assets
totalAssets + totalLiabilities (the latter is already signed negative).
Step 11 — Unit adjustments
Read FundSubledgerService.getControlBalance(fundId).unitsOutstanding. This already reflects every issuance / redemption that has been posted — pre-cutoff subscriptions are included when their unit-allocation journal posts. Post-cutoff subscriptions are excluded by Step 1's lock.
Step 12 — NAV per unit
netAssets / unitsOutstanding. When units are zero the engine returns 0 (not NaN) — happens for the very first calculation of a brand-new fund before any units have been issued.
State machine
draft → frozen → published
- draft — calculation has run, lines persisted, operations can review. Re-running on the same
(fundId, valuationDate)replaces the draft. - frozen — locked. No more edits. Submitted to Shariah board / compliance for review. The calculator returns
409 Conflictif asked to recalculate. - published — official. NAVPU appears on the investor portal; pending subscriptions / redemptions price at this NAV. Snapshot is immutable.
Each transition records who did it + when (frozenAt/frozenById, publishedAt/publishedById).
Why publish doesn't post the NAV-adjustment journal yet
The plan calls for publish to post Dr/Cr 1301 Securities / Cr/Dr 6002 Fund NAV Adjustment for the mark-to-market delta vs the previous period. That posting is deferred to Block 2.E (month-end close) for three reasons:
- Per-fund 6002 account resolution. Phase 0 auto-provisions
6002000001-style custom accounts per fund. The calculator doesn't yet have a helper to resolve which custom account belongs to which fund. The month-end close orchestrator will, since it needs the same resolution for several other postings. - Co-dependent journals. The publish step also needs to post the management fee accrual and FX revaluation journals — those handlers don't exist yet (Phase 2.C/2.D). Better to land all three together.
- The load-bearing piece works now. The state transition + NAVPU freeze + audit trail is what unblocks subscription/redemption pricing. The mark-to-market GL posting is bookkeeping for month-end reports — important, not urgent for the unit-pricing path.
Operators can manually post the adjustment until the orchestrator lands. The deferral is documented in nav-calculator.service.ts:publish so it's findable.
Stale-price gating
publish() checks snapshot.staleLineCount. If non-zero, publish returns 400 BadRequestException unless the caller passes { allowStale: true }. The frontend wraps this in an explicit confirmation modal — operators must consciously override.
The stale threshold per security is in SecurityPrice.staleAfterDays (default 5 days for equities; sukuk can be longer because amortised cost moves more slowly).
Endpoints
All routes are under /finance/:
| Method | Path | Permission | Notes |
|---|---|---|---|
| GET | /pricing/securities | FINANCE_VIEW | List security master |
| POST | /pricing/securities | FINANCE_MANAGE | Upsert by code |
| GET | /pricing/securities/:idOrCode | FINANCE_VIEW | Fetch one |
| POST | /pricing/record | FINANCE_MANAGE | Record one price |
| POST | /pricing/bulk-upload | FINANCE_MANAGE | Up to 1000 prices in one call |
| GET | /pricing/latest?securityId=&asOfDate= | FINANCE_VIEW | Latest price on/before date |
| GET | /pricing/stale-report?asOfDate= | FINANCE_VIEW | Every stale or unpriced security |
| POST | /fx-rates | FINANCE_MANAGE | Manual FX entry |
| GET | /fx-rates/latest?from=&to=&date= | FINANCE_VIEW | Direct, inverse, or pivot-derived |
| GET | /fx-rates/history?... | FINANCE_VIEW | History list |
| POST | /nav/calculate | FINANCE_MANAGE | Pure calc, returns draft |
| POST | /nav/run | FINANCE_APPROVE | Calculate + freeze |
| POST | /nav/:id/publish | FINANCE_APPROVE | Publish; { allowStale: bool } to override |
| GET | /nav/history?fundId=&from=&to= | FINANCE_VIEW | Snapshot history |
| GET | /nav/:id | FINANCE_VIEW | Snapshot + lines |
Frontend
Two pages under /u/finance/nav/:
- Index (
/u/finance/nav) — fund selector + valuation date + Calculate / Run+Freeze buttons; below, a paginated history table per fund with status badges and NAVPU shown to 4 decimal places. - Detail (
/u/finance/nav/[id]) — summary cards (assets, liabilities, net, NAVPU), stale-price banner when applicable, two line tables (Assets and Liabilities) with stale-row highlighting, and the Publish action for approvers when the snapshot is frozen. Override-on-stale opens a confirmation modal.
Added to ERP sidebar as Finance → NAV.
Design choices worth knowing
- Snapshots are dated and unique. The unique index
(fundId, valuationDate)makes "what was NAV on 2026-05-18?" a single-row lookup with no ambiguity. Re-running calculation replaces a draft but never creates parallel snapshots. - Lines are first-class persisted rows. Each contribution to the NAV is a row, not a number in JSON. Operators can drill into "why is net assets ₦142m?" and see every component, every quantity, every price, every FX rate.
- The price/FX layer is reused beyond NAV. Block 1.D's trade handlers will use the same
PriceSourceServicefor trade-date pricing. Block 2.C's FX revaluation cron will use the sameFxRateService. We didn't build NAV-private versions. - CSV-free price import. Custodian uses CSV (Block 1.A); price loading is JSON-only for now because feeds are operator-entered in small batches. Bulk-upload endpoint accepts up to 1000 records per call.
- Cross rates via NGN pivot. If we have
USD→NGNandNGN→GBPbut notUSD→GBPdirectly, the engine derives it. Reduces the number of manual FX entries operators have to type.
Tests
12 specs in nav-calculator.service.spec.ts covering: full 12-step calculation with equities + sukuk + cash; FX translation; stale-price flagging; units=0 returns NAVPU=0 (not NaN); inactive/unknown fund rejection; draft → frozen → published transitions; publish blocked on stale, allowed with override; recalculate-on-frozen returns 409; recalculate-on-draft replaces in place.
Plus 5 specs each for PriceSourceService and FxRateService. Block 1.C ships with 22 new specs; the full finance suite is now 111/111 passing.