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:

  1. Subscription — investor puts money into a fund, gets units at the published NAV.
  2. Redemption — investor wants money back; system retires units at NAV, then pays out cash.
  3. Equity trade — fund manager buys/sells stocks (Dangote Cement, MTN, …) through a broker.
  4. 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.

PropertyExisting MudarabahNew NAV-fund (this block)
TenorFixed (90/180/365 days)Open-ended
Return basisContractual profit rate set at investment timeNAV revalues daily based on market portfolio
Customer can withdrawAt maturity or via liquidation requestAny time, priced at next NAV
Subscription postingDr 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 portalExisting wizard, untouchedNot 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 lifecycle
  • redemption-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 add phase: 'trade_date' | 'settlement' and tradeRef)

Posting handlers (ethica-api/src/finance/posting/handlers/):

  • subscription.handler.ts
  • redemption.handler.ts
  • equity-trade.handler.ts
  • sukuk-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.ts
  • redemptions/redemption.service.ts
  • trades/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 units
  • redemptions/page.tsx — submit, approve, price at NAV, pay, reject
  • trades/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 (subscription requests 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_subscriptions row with status cash_received and 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 published and 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_issued row
  • Update the fund_subscriptions row → status units_allocated, with navAtAllocation, 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_requests row with status requested

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():

  1. Creates a broker_trades row with status pending (rejects duplicates by (tradeRef, brokerId)).
  2. Routes through PostingEngine.post({ type: 'equity_trade', phase: 'trade_date', ... }).
  3. The engine calls ShariahRulesEngine.evaluatePosting first — the BLOCK_HARAM_SECTOR rule rejects the trade if the security is unscreened or screened as haram. On rejection, the broker_trades row is marked failed (the rejection error stored in metadata) and a ForbiddenException is raised.
  4. 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
  5. The handler's postSubledgers updates the custodian_positions row 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/:

MethodPathPermission
POST/subscriptions/cash-receiptFINANCE_MANAGE
POST/subscriptions/:id/allocateFINANCE_APPROVE
POST/subscriptions/:id/cancelFINANCE_APPROVE
GET/subscriptionsFINANCE_VIEW
GET/subscriptions/:idFINANCE_VIEW
POST/redemptionsFINANCE_MANAGE
POST/redemptions/:id/approveFINANCE_APPROVE
POST/redemptions/:id/priceFINANCE_APPROVE
POST/redemptions/:id/payFINANCE_APPROVE
POST/redemptions/:id/rejectFINANCE_APPROVE
GET/redemptionsFINANCE_VIEW
GET/redemptions/:idFINANCE_VIEW
POST/tradesFINANCE_MANAGE
POST/trades/:id/settleFINANCE_APPROVE
GET/tradesFINANCE_VIEW
GET/trades/:idFINANCE_VIEW

Design choices worth knowing

  • Lifecycle row first, journal secondrecordCashReceipt inserts the fund_subscriptions row before calling the posting engine, so the engine's handler can reference subscriptionRef in 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 rejectionTradesService.recordTrade wraps the engine call in try/catch. On rejection, the just-created broker_trades row is marked failed with 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 metadatabroker_trades has no assetClass column; instead metadata.assetClass carries 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 yetRedemptionService.autoPriceForFund exists 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.