NAV-fund subscriptions, redemptions, trades (Phase 1 Block 1.D)
Status: ✅ Complete
Plan reference: Tasks 1.D.1 – 1.D.4 in 2026-04-30-FINANCE_MODULE_IMPLEMENTATION_PLAN.md
What this block does — in plain terms
Up to this point we built infrastructure: a hash-chained GL (Phase 0), subledgers + reconciliation (1.A), a posting engine + Shariah rules (1.B), and a NAV calculator (1.C). All powerful, none of it actually used by a single end-user-visible flow.
Block 1.D wires that infrastructure into the four flows where money actually moves:
- Subscription — investor puts money into a fund, gets units at the published NAV.
- Redemption — investor wants money back; system retires units at NAV, then pays out cash.
- Equity trade — fund manager buys/sells stocks (Dangote Cement, MTN, …) through a broker.
- Sukuk trade — same shape as equity, but for sukuk instruments.
It does this as a parallel product to the existing Mudarabah fixed-tenor investment flow. The Mudarabah flow (customer portal New Investment wizard → bank transfer → CFO approval → fixed-rate profit accrual) is entirely unchanged. Block 1.D adds NAV-priced unit funds alongside it.
Path A — parallel product, not a refactor
The original plan called for refactoring the existing investments.service.ts subscription posting onto the new posting engine behind a feature flag. We rejected that scope (2026-05-20). Reason: the two products are fundamentally different.
| Property | Existing Mudarabah | New NAV-fund (this block) |
|---|---|---|
| Tenor | Fixed (90/180/365 days) | Open-ended |
| Return basis | Contractual profit rate set at investment time | NAV revalues daily based on market portfolio |
| Customer can withdraw | At maturity or via liquidation request | Any time, priced at next NAV |
| Subscription posting | Dr Custodian / Cr Customer Liability (one journal) | Dr Cash / Cr Client Cash Control, then Dr Client Cash Control / Cr Fund Units at NAV cut-off (two journals + 3 subledger rows) |
| Customer portal | Existing wizard, untouched | Not in this block — staff operate from ERP |
The two coexist. Customers eventually choose which product to use. Existing Mudarabah investments continue to mature, accrue profit, and be liquidated through their existing path.
Files
Models (ethica-api/src/models/):
fund-subscription.model.ts— NAV-fund subscription lifecycleredemption-request.model.ts— redemption lifecycle
Posting types (extended posting-request.types.ts):
SubscriptionPosting { phase: 'cash_receipt' | 'unit_allocation', ... }RedemptionPosting { phase: 'pricing' | 'payment', ... }EquityTradePosting,SukukTradePosting(both addphase: 'trade_date' | 'settlement'andtradeRef)
Posting handlers (ethica-api/src/finance/posting/handlers/):
subscription.handler.tsredemption.handler.tsequity-trade.handler.tssukuk-trade.handler.ts
All four registered with PostingEngine in its constructor. The engine routes by request.type.
Lifecycle services (ethica-api/src/finance/...):
subscriptions/fund-subscription.service.tsredemptions/redemption.service.tstrades/trades.service.ts(unified entry point for equity + sukuk)
Controllers: subscriptions/fund-subscription.controller.ts, redemptions/redemption.controller.ts, trades/trades.controller.ts.
Migration: 0020-block-1d-subscriptions-redemptions.ts — creates fund_subscriptions and redemption_requests tables. Applied 2026-05-20.
ERP pages (ethica-erp/app/(auth)/u/finance/):
subscriptions/page.tsx— record cash receipt, allocate unitsredemptions/page.tsx— submit, approve, price at NAV, pay, rejecttrades/page.tsx— record trade, settle (Shariah-gated)
The four flows, step by step
1. Subscription (Task 1.D.1)
Step A — Cash receipt. A staff member records that an investor's wire has arrived.
POST /finance/subscriptions/cash-receipt with { clientId, clientType, fundId, amount, currency, paymentReference? }.
Inside one Sequelize transaction, the posting engine:
- Validates the request structurally
- Runs the Shariah engine (
subscriptionrequests have no per-rule gating today, so it passes) - Posts the GL journal:
Dr Cash—Settlement / Cr Client Cash Control - Writes a client subledger cash-debit row (so the client now shows "₦5m pending" on their statement)
- Creates a
fund_subscriptionsrow with statuscash_receivedand pins the journal id
Step B — Unit allocation. After NAV publishes, a staff member allocates units.
POST /finance/subscriptions/:id/allocate with { navSnapshotId? } (omit to use the latest published NAV).
Inside one transaction:
- Verify the NAV snapshot is
publishedand belongs to the same fund - Compute
units = amount / navPerUnit - Post the unit-allocation journal:
Dr Client Cash Control / Cr Fund Units Issued - Write a client subledger cash-credit row (clears the pending pool)
- Write a client subledger units-debit row (the client now owns units)
- Write a fund subledger
units_issuedrow - Update the
fund_subscriptionsrow → statusunits_allocated, withnavAtAllocation,unitsAllocated, and the second journal id
2. Redemption (Task 1.D.2)
A four-state lifecycle: requested → approved → priced → paid. Plus a rejected terminal state reachable from requested or approved.
POST /finance/redemptions with { clientId, fundId, units }. The service:
- Validates the client actually holds ≥ that many units (via the client subledger's running balance)
- Inserts a
redemption_requestsrow with statusrequested
POST /finance/redemptions/:id/approve — moves to approved. No GL yet.
POST /finance/redemptions/:id/price with { navSnapshotId } — uses the published snapshot to compute gross = units × NAVPU, net = gross − fee. Posts the pricing journal Dr Fund Units / Cr Redemption Fees (if any) / Cr Redemption Payable. Writes a client subledger units-credit + a fund subledger units_redeemed row.
POST /finance/redemptions/:id/pay with { paymentReference, cashAccount? } — posts the payment journal Dr Redemption Payable / Cr Cash. Writes a client subledger cash-credit row (the final outflow).
RedemptionService.autoPriceForFund(fundId, snapshotId, actor) is the hook the NAV controller will eventually call when a snapshot publishes — it iterates every approved redemption for the fund and prices them automatically. Per-request failures are logged but do not abort the batch.
3. Equity trade (Task 1.D.3)
POST /finance/trades with { assetClass: 'equity', side, brokerId, fundId, tradeRef, securityId, quantity, price, fees?, tradeDate, settlementDate? }.
TradesService.recordTrade():
- Creates a
broker_tradesrow with statuspending(rejects duplicates by(tradeRef, brokerId)). - Routes through
PostingEngine.post({ type: 'equity_trade', phase: 'trade_date', ... }). - The engine calls
ShariahRulesEngine.evaluatePostingfirst — theBLOCK_HARAM_SECTORrule rejects the trade if the security is unscreened or screened as haram. On rejection, thebroker_tradesrow is markedfailed(the rejection error stored in metadata) and aForbiddenExceptionis raised. - On a Shariah-permitted trade, the equity handler builds the trade-date journal:
- BUY:
Dr 1301 Securities / Cr 2001 Trade Payable+Dr 5004 Brokerage / Cr 2001 - SELL:
Dr 2002 Receivable / Cr 1301 Securities+Dr 5004 Brokerage
- BUY:
- The handler's
postSubledgersupdates thecustodian_positionsrow for buys (records the holding internally).
POST /finance/trades/:id/settle — when cash actually moves (T+2 typically), the settlement journal posts Dr 2001 / Cr 1002 (buy) or Dr 1002 / Cr 2002 (sell), and the broker_trades row flips to settled.
4. Sukuk trade (Task 1.D.4)
Identical lifecycle to equity, but the handler posts to 1302 Securities — Sukuk (rather than 1301) and records the holding as securityType: 'sukuk' in the custodian ledger. The unified trades.service.ts routes between equity and sukuk handlers via payload.assetClass. Asset class is persisted in broker_trade.metadata.assetClass so settlement can route to the correct handler.
Endpoints reference
All under /finance/:
| Method | Path | Permission |
|---|---|---|
| POST | /subscriptions/cash-receipt | FINANCE_MANAGE |
| POST | /subscriptions/:id/allocate | FINANCE_APPROVE |
| POST | /subscriptions/:id/cancel | FINANCE_APPROVE |
| GET | /subscriptions | FINANCE_VIEW |
| GET | /subscriptions/:id | FINANCE_VIEW |
| POST | /redemptions | FINANCE_MANAGE |
| POST | /redemptions/:id/approve | FINANCE_APPROVE |
| POST | /redemptions/:id/price | FINANCE_APPROVE |
| POST | /redemptions/:id/pay | FINANCE_APPROVE |
| POST | /redemptions/:id/reject | FINANCE_APPROVE |
| GET | /redemptions | FINANCE_VIEW |
| GET | /redemptions/:id | FINANCE_VIEW |
| POST | /trades | FINANCE_MANAGE |
| POST | /trades/:id/settle | FINANCE_APPROVE |
| GET | /trades | FINANCE_VIEW |
| GET | /trades/:id | FINANCE_VIEW |
Design choices worth knowing
- Lifecycle row first, journal second —
recordCashReceiptinserts thefund_subscriptionsrow before calling the posting engine, so the engine's handler can referencesubscriptionRefin subledger metadata. If the posting fails, the row stays with a placeholder journal id; a follow-up sweep is the cleanest way to surface those for ops. (Currently rare — engine failures only happen on Shariah rejection or DB error.) - Trade rollback on Shariah rejection —
TradesService.recordTradewraps the engine call in try/catch. On rejection, the just-createdbroker_tradesrow is markedfailedwith the error in metadata, so ops can audit why a trade didn't book. The DB row stays as evidence; we don't silently delete. - Asset class lives in trade metadata —
broker_tradeshas noassetClasscolumn; insteadmetadata.assetClasscarries it. Trade-settle reads back from metadata to pick the right handler. Cleaner than a polymorphic column for now; adding the column is a Phase 2 polish. - Auto-pricer hook not wired yet —
RedemptionService.autoPriceForFundexists but the NAV controller doesn't call it on publish yet. That hook lands when month-end orchestration (Phase 2.E) cleans up the NAV-publish side effects. Until then, ops prices redemptions manually after NAV publish. - No customer portal touch — every action in Block 1.D is ERP staff-driven. Customer-portal integration is a future block. Customers continue to see only their Mudarabah investments today; nothing in the existing portal flow changes.
Tests
The existing 111-test finance suite still passes end-to-end after Block 1.D. New handlers were added to the posting engine's constructor; the existing posting-engine spec was updated to stub them (tests only exercise the manual path). Per-handler integration specs against a real DB are reserved for a follow-up — the handler code paths are small and the bigger value is the end-to-end smoke test at the Phase 1 final checkpoint.