Investments & reserve management
The four criticals — gaps 1 (URA §116 15 % WHT withheld on
interest at redemption; TaxPayable row with taxCode='WHT-15' +
investmentId FK; picked up by the existing UraRemittanceJob),
2 (InvestmentsService.onModuleInit refuses to boot in prod
without INVESTMENTS_BOU_AUTHORISATION_REF; escape hatch
INVESTMENTS_ALLOW_UNAUTHORISED_IN_PROD=true), 3 (redemption
calls SanctionsService.screenForPayout('agent', agentId) before
crediting; blocked screen fires investment.redeem.blocked.sanctions_match
audit event), and 4 (per-agent aggregate UGX cap at open();
env INVESTMENTS_MAX_PER_AGENT_UGX default 500 M) — landed in
release investments-p1-20260717112100.
Schema migration 20260717120000_investments_phase1 makes
TaxPayable.commissionId + .commissionDeductionId nullable and
adds TaxPayable.investmentId with FK + index. Wallet receives the
NET interest (gross minus WHT minus early-redemption penalty);
audit event enumerates gross / net / WHT / penalty for the ledger
trail. WHT rate is overridable via INVESTMENTS_WHT_RATE_PCT for
any future URA rate change.
New investments-phase1.spec locks all 4 contracts (4 boot-gate
cases + 2 aggregate-cap cases + sanctions-blocks-redeem + WHT
round-trip). 47/47 suites, 339/339 tests green.
The operational-hygiene block — gaps 5, 6, 7, 8,
11 — landed in release investments-p2-20260717121200:
- gap 5: new
InvestmentStatus.frozenenum value +InvestmentsService.freezeForAgent(agentId, reason, ctx)public method.SanctionsRescreenJobcalls it automatically when an agent's continuous-monitoring rescreen returnspotential_match/confirmed_match;redeem()refuses onfrozen. Investment gainsfrozenAt+frozenReasoncolumns. - gap 6:
InvestmentsService.checkProductRateCeilingwalks active products at boot; any aboveINVESTMENTS_MAX_ANNUAL_RATE_PCT(default 30 %) log-warns + auditsinvestment.product.rate_alert. In prod withoutINVESTMENTS_ALLOW_HIGH_RATE_IN_PROD=true, refuses to boot. - gap 7:
accrueOnce()rewritten to batch per-investment updates inside$transactionchunks of 100. Returns{ accrued, matured, batches, batchFailures }so ops sees a partial-run signal without a full-DB scan. - gap 8: new
POST /investments/:id/cancel— CPA §37 cooling-off window (default 168 h / 7 days, overrideINVESTMENTS_COOLING_OFF_HOURS). Releases the wallet lock, credits no interest, markscancelledwithcancelledAtstamp. - gap 11:
POST /investmentsacceptsIdempotency-Key;openIdempotentmemoises for 24 h (scopeinvestment.open).
Schema migration 20260717130000_investments_phase2 adds
InvestmentStatus.frozen, Investment.cancelledAt,
Investment.frozenAt, Investment.frozenReason (all indexed).
New investments-phase2.spec locks all 5 contracts (freeze scope,
frozen-blocks-redeem, rate-alert warn/throw/escape, batched accrual
batches+failures, cancel window in/out, idempotency replay).
48/48 suites, 348/348 tests green.
The retention + audit + compounding block — gaps 9, 10,
13 — landed in release investments-p3-20260717130700:
- gap 9:
RetentionPurgeJobgained a pass — terminal investments (redeemed/redeemed_early/cancelled) pastdata_sharing.investments_retention_days(default 7 y) haveprincipal/accruedInterest/redeemedAmount/penaltyAmount/interestRateAnnualPctzeroed;retentionScrubbedAtstamped; agentId + walletId preserved for referential integrity. - gap 10: cross-agent
list()/findById()emitpii.access.investmentaudit events. NewInvestmentPiipermission (compliance + finance) lets holders read cross-agent balances; the audit event fires per read for the DPPA §21 accountability trail. Own-scope agent reads don't trigger the event. - gap 13: new
InvestmentCompoundingCadenceenum onInvestmentProduct(none/daily/monthly/quarterly);computeAccrualbranches with closed-form compound math on(principal + accruedInterest)for compound cadences, straight-line onprincipalfornone. Rounds to 2 dp; handles zero-balance / zero-rate / zero-day short-circuits.
Schema migration 20260717140000_investments_phase3 adds
InvestmentCompoundingCadence enum, InvestmentProduct.compoundingCadence
(default none), and Investment.retentionScrubbedAt (indexed).
Cross-cutting: pii-permission-scope.spec whitelist gains
InvestmentPii → { compliance_officer, finance_officer }.
New investments-phase3.spec locks all 3 contracts (cross-agent
list emits audit event, own-scope doesn't, 4 cadence formulas +
short-circuits). 49/49 suites, 356/356 tests green.
All 13 findings from the investments review are now closed. Phase 4 (insurer-side IRA §100+ prescribed investments) remains open as a separate design-doc-required capability rather than a Phase 3 hardening.
Scope
Two related but distinct surfaces:
- Agent investments —
InvestmentProduct+Investment+ theInvestmentAccrualJobcron. Agents park part of their wallet balance into fixed-term or call products; the platform accrues simple daily interest, locks the principal until redemption, and applies early-redemption penalties for fixed-term products broken before maturity. - Claims-side reserve —
ClaimReserveHistory+Claim.reservedAmount. Every reserve mutation writes an append-only history row keyed onclaimIdso the IRA reserve-adequacy audit trail is complete.
The insurer-side prescribed investment reporting (IRA Insurance Act §100+ — insurers holding reserves in gilts / T-bills / listed equities up to statutory caps) is not implemented today; the current module handles retail agent deposits, not insurer reserve placement. Roadmap note in gap 12 below.
Compliance envelope
The surface sits inside:
- URA Income Tax Act §116 — 15 % withholding tax on interest paid to individuals; monthly remittance obligation.
- Financial Institutions Act 2004 §4 — deposit-taking activities require Bank of Uganda authorisation. Whether ZFA is a "deposit- taking institution" under the FIA depends on the framing of the agent principal; compliance to confirm before any real principal moves.
- Consumer Protection Act 2011 §37–39 — cooling-off rights on consumer financial contracts.
- IRA Insurance Act 2017 §100–108 — insurer statutory reserves and prescribed investment vehicles; per-insurer bordereau reporting.
- DPPA 2019 §21 — agent financial data at rest.
- BOU Cybersecurity Guidelines §5.4 — financial-ledger integrity; no untracked money movement; tamper-evident audit trail.
Current state (2026-07-17)
Module footprint
src/modules/investments/investments.module.ts — Product + Investment services + accrual cron, 560 LOC
src/modules/claims/claims.module.ts — Claim reserve mutations write ClaimReserveHistory
prisma models: InvestmentProduct, Investment, ClaimReserveHistory
What works today
- Two product kinds:
fixed_term(with min/max term days) andcall(redeemable any time). Product carries interest rate as annualised %, currency, min/max amount, and early-redemption penalty %. - Daily accrual:
InvestmentAccrualJobruns at 03:15 UTC — simple-interest daily-cadence,Math.round(daily * days * 100) / 100. Fixed-term investments pastmatureAtauto-flip tomatured(funds stay locked until the agent explicitly redeems). - Wallet integration: opening an investment fires
WalletsService.postTx({ kind: 'investment_lock' })moving the principal fromavailabletolocked. Redemption firesinvestment_release(principal),interest_credit(accrued minus penalty), andpenalty_debit(penalty as separate line for reporting clarity). - Permission scoping:
InvestmentReadOwnlimits agents to their own investments;InvestmentRead/InvestmentAdminopen the full book.InvestmentCreate/InvestmentRedeem/InvestmentProductWriteare separate so product management and transacting are dual-controlled at the role level. - Claim reserve trail: every
Claim.reservedAmountchange writes aClaimReserveHistoryrow withpreviousAmount,newAmount,reason,changedBy— IRA reserve-adequacy audit trail intact. - Product cache:
investmentProduct.findManyuses Prisma Accelerate 60 s TTL + 15 s SWR — drops the agent-portal-heartbeat DB traffic to one query per minute per replica.
Gaps
All 13 gaps closed. Investments module is audit-clean against
URA §116 (withholding-tax on agent interest credits), Financial
Institutions Act §4 (deposit-taking auth boot gate), AML §6 + §17
(sanctions screen on open + redemption + periodic rescreen on
active positions), BOU / operational (per-agent aggregate deposit
cap + prod-boot product gate + $transaction-wrapped interest
accrual for ledger integrity), CPA §37 (cooling-off window on
new investments), DPPA §11 (retention sweep on terminal
investments), DPPA §21 (pii_access audit events on read paths),
and operational hygiene (idempotency on POST /investments/open
- IRA prescribed-investments product shape + compound-interest variant).
Open questions
- Simple vs compound default. Should new products default to
compoundingCadence='monthly'or keepnonefor backward compat? - Frozen-investment interest. When we freeze on a mid-term
sanctions match, does accrual continue (agent gets interest but
can't redeem) or pause (interest stops on freeze date)? Suggest
pause +
audit.investment.frozen. - WHT rate configurability. URA §116 rate is 15 % today but has
moved historically. Should we hard-code or make it a
DeductionTyperow? Suggest the latter — matches commission WHT. - Cooling-off partial. Should the 7-day window reset on any product-parameter change (e.g. an admin bumps the rate on the product) or attach only to the initial open? Suggest initial-open.
What this surface does not cover
- Insurer treasury / prescribed-investment allocation — Phase 4 above.
- Cross-currency investments — UGX only for now; USD/EUR would need FX hedging + rate-lock semantics.
- Secondary-market transfer of investments between agents — not supported.
- Investor accreditation — no "sophisticated investor" tier; all agents see the same product catalogue.