Skip to main content

Investments & reserve management

Phase 1 shipped (2026-07-17)

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.

Phase 2 shipped (2026-07-17)

The operational-hygiene block — gaps 5, 6, 7, 8, 11 — landed in release investments-p2-20260717121200:

  • gap 5: new InvestmentStatus.frozen enum value + InvestmentsService.freezeForAgent(agentId, reason, ctx) public method. SanctionsRescreenJob calls it automatically when an agent's continuous-monitoring rescreen returns potential_match / confirmed_match; redeem() refuses on frozen. Investment gains frozenAt + frozenReason columns.
  • gap 6: InvestmentsService.checkProductRateCeiling walks active products at boot; any above INVESTMENTS_MAX_ANNUAL_RATE_PCT (default 30 %) log-warns + audits investment.product.rate_alert. In prod without INVESTMENTS_ALLOW_HIGH_RATE_IN_PROD=true, refuses to boot.
  • gap 7: accrueOnce() rewritten to batch per-investment updates inside $transaction chunks 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, override INVESTMENTS_COOLING_OFF_HOURS). Releases the wallet lock, credits no interest, marks cancelled with cancelledAt stamp.
  • gap 11: POST /investments accepts Idempotency-Key; openIdempotent memoises for 24 h (scope investment.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.

Phase 3 shipped (2026-07-17)

The retention + audit + compounding block — gaps 9, 10, 13 — landed in release investments-p3-20260717130700:

  • gap 9: RetentionPurgeJob gained a pass — terminal investments (redeemed / redeemed_early / cancelled) past data_sharing.investments_retention_days (default 7 y) have principal / accruedInterest / redeemedAmount / penaltyAmount / interestRateAnnualPct zeroed; retentionScrubbedAt stamped; agentId + walletId preserved for referential integrity.
  • gap 10: cross-agent list() / findById() emit pii.access.investment audit events. New InvestmentPii permission (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 InvestmentCompoundingCadence enum on InvestmentProduct (none / daily / monthly / quarterly); computeAccrual branches with closed-form compound math on (principal + accruedInterest) for compound cadences, straight-line on principal for none. 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:

  1. Agent investmentsInvestmentProduct + Investment + the InvestmentAccrualJob cron. 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.
  2. Claims-side reserveClaimReserveHistory + Claim.reservedAmount. Every reserve mutation writes an append-only history row keyed on claimId so 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) and call (redeemable any time). Product carries interest rate as annualised %, currency, min/max amount, and early-redemption penalty %.
  • Daily accrual: InvestmentAccrualJob runs at 03:15 UTC — simple-interest daily-cadence, Math.round(daily * days * 100) / 100. Fixed-term investments past matureAt auto-flip to matured (funds stay locked until the agent explicitly redeems).
  • Wallet integration: opening an investment fires WalletsService.postTx({ kind: 'investment_lock' }) moving the principal from available to locked. Redemption fires investment_release (principal), interest_credit (accrued minus penalty), and penalty_debit (penalty as separate line for reporting clarity).
  • Permission scoping: InvestmentReadOwn limits agents to their own investments; InvestmentRead / InvestmentAdmin open the full book. InvestmentCreate / InvestmentRedeem / InvestmentProductWrite are separate so product management and transacting are dual-controlled at the role level.
  • Claim reserve trail: every Claim.reservedAmount change writes a ClaimReserveHistory row with previousAmount, newAmount, reason, changedBy — IRA reserve-adequacy audit trail intact.
  • Product cache: investmentProduct.findMany uses 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

  1. Simple vs compound default. Should new products default to compoundingCadence='monthly' or keep none for backward compat?
  2. 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.
  3. WHT rate configurability. URA §116 rate is 15 % today but has moved historically. Should we hard-code or make it a DeductionType row? Suggest the latter — matches commission WHT.
  4. 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.