Skip to main content

Payout accounts (agent bank + mobile-money destinations)

Scope

One model, one module, five endpoints — the payout-accounts surface is thin but load-bearing: every outbound money movement resolves to a PayoutAccount row. Commission batches pick the agent's verified primary account; wallet withdrawals require an explicit account id; claim disbursements route through the same lookup.

  • payout-accounts/PayoutAccountsService (list / findById / create / update / verify) + controller (GET, POST, GET /:id, PATCH /:id, POST /:id/verify).
  • PayoutAccount Prisma model — plaintext accountNumber, accountHolder, bankCode, branchCode, currency, verification stamps, isPrimary flag, deletedAt soft-delete.
  • Downstream callersPaymentBatchesService.approve() (gates on isVerified=true + isPrimary=true), WalletsService.withdraw() (gates on verifiedAt != null + currency match), PaymentsService.retry() (reads the account without re-gating).

Compliance envelope

  • AML Act 2013 §6 — CDD on every counterparty ZFA transacts with. The account holder (not the owning agent) is the party actually receiving money — a sanctioned holder is a §6 finding regardless of how clean the owning agent's KYC is.
  • AML Act 2013 §17 — payment operators must screen every payee before releasing funds. Screening at batch-submit time is too late for the "verified account" invariant; the gate belongs at verify().
  • NPS Act 2020 — internal-control obligations on payment operations: separation of duties, dual approval on high-risk ops (a single-actor "verify this bank account" flip is on the same trust axis as the dual-approval already shipped for PaymentBatch.secondApprovedBy).
  • IRA Insurance Act 2017 §102 — quarterly commission-payout returns to the IRA must be reconstructable to the destination account. Verification lifecycle (verify / re-verify / revoke) must carry an audit trail.
  • DPPA 2019 §11 + §21accountNumber + accountHolder are clear PII. They must be minimised at rest (encrypted +/- hash siblings for equality search) and scrubbed past the retention window when the account is soft-deleted.

Current state (2026-07-18)

Module footprint

  • src/modules/payout-accounts/payout-accounts.module.ts — 212 lines. DTOs + service + controller + module in one file.
  • Prisma model PayoutAccount — 19 columns, one @@index([agentId]), soft-delete via deletedAt. No hash siblings, no cipher stamp, no retention-scrub stamp.
  • Six audit-event actions currently fire: payout_account.create, payout_account.update, payout_account.verify.

What works today

  • Agent-scope enforcement: assertAgentAccess(user, account.agent) is called on findById(); agents holding only agent:read:own cannot list another agent's accounts.
  • Batch-time verification gate: PaymentBatchesService.approve() refuses to include a payment whose destination is not isVerified=true + isPrimary=true. WalletsService.withdraw() rejects verifiedAt=null explicitly.
  • Account-number masking on read: the read path returns mask(accountNumber) (last-4 only) so screen scrapes / operator-console UIs don't render clear PII.
  • Account-number immutability on update: UpdatePayoutAccountDto intentionally omits accountNumber — a mutable account number would let ops verify with one number then swap to another.

Gaps

All 12 gaps closed. See shipped-notes below.

Phase 1 shipped (2026-07-18)

Release payout-p1-20260718-1530. Closes gaps 1–4:

  • gap 1 — SUBJECT_TYPES extended with payout_account_holder; SanctionsService.loadSubject('payout_account_holder', id) reads the holder name (post-decrypt). PayoutAccountsService.verify() runs screenForPayout('payout_account_holder', ...); block → refuse + emit payout_account.verify.sanctions_block.
  • gap 2 — Migration 20260718140000_payout_accounts_phase1 adds accountNumberHash, accountHolderHash, piiEncryptedAt + indexes. create() + update() cipher plaintext + write hash siblings on every touch via PiiCipher; read paths round-trip legacy plaintext via PiiCipher.decrypt fallback.
  • gap 3 — update() resets isVerified, verifiedAt, verifiedBy when any of accountHolder, bankCode, branchCode, currency change; emits payout_account.verify.reset audit event listing the material fields that changed.
  • gap 4 — Migration adds PayoutAccount.retentionScrubbedAt. New data_sharing.payout_accounts_retention_days policy (default 2555). RetentionPurgeJob scrubs accountNumber / accountHolder / bankCode / branchCode on soft-deleted rows past window + clears hashes + emits payout_account.retention.pii_scrubbed.

Locked in by src/modules/payout-accounts/payout-accounts-phase1.spec.ts (5 cases). Test suite: 509/509 (74 suites).

Phase 2 shipped (2026-07-18)

Release payout-p2-20260718-1550. Closes gaps 5–8:

  • gap 5 — New POST /payout-accounts/:id/unverify endpoint gated by new payout_account:unverify permission (granted to super_admin + compliance_officer). Clears verificationStatus / isVerified / verifiedAt / verifiedBy / secondVerifiedAt / secondVerifiedBy. Stamps metadata.unverifiedReason + unverifiedAt. Emits payout_account.unverify audit event.
  • gap 6 — Migration adds verificationStatus (unverified / pending_second_approval / verified) + secondVerifiedBy + secondVerifiedAt. First verify() call flips to pending_second_approval (emits payout_account.verify.first_approval); a second distinct- actor verify() flips to verified + isVerified=true (emits payout_account.verify). Same-actor second call → 409.
  • gap 7 — New holderRelationship soft-enum (self | family | joint | corporate). create() fetches the owning agent's KYC first + last name and refuses when holderRelationship='self' (default) and accountHolder doesn't match (normalised: trim + lowercase + collapse whitespace). Non-self values persist for compliance-officer review at verification.
  • gap 8 — Migration adds partial unique index (agentId, currency) WHERE isPrimary=true AND deletedAt IS NULL. update() wraps the write in $transaction and auto-clears any existing primary in the same currency before flipping the target to primary — DB-level guarantee against two primaries co-existing.

Locked in by src/modules/payout-accounts/payout-accounts-phase2.spec.ts (6 cases). Test suite: 515/515 (75 suites).

Phase 3 shipped (2026-07-18)

Release payout-p3-20260718-1605. Closes gaps 9–12:

  • gap 9 — PayoutAccountsService exposes createIdempotent / verifyIdempotent / unverifyIdempotent wrappers keyed on the Idempotency-Key header via shared IdempotencyService. Controller threads the header through every mutation.
  • gap 10 — @Throttle on every mutation: create 10/min, update 20/min, verify + unverify 20/min. Excess → 429.
  • gap 11 — New validateFormat() runs on create + update. For accountType='mobile_money': accountNumber must match +256[0-9]{9}. For accountType='bank': bankCode is required + must match 0[0-9]{2} (BOU 3-digit range); accountNumber restricted to digits + hyphens. Invalid → validation error at API time.
  • gap 12 — CreatePayoutAccountDto.currency now @IsIn against SUPPORTED_CURRENCIES (UGX, USD, EUR, GBP, KES, TZS, RWF). Default flipped from USD to UGX. Currency change on update auto-resets isVerified (gap 3 rule).

Locked in by src/modules/payout-accounts/payout-accounts-phase3.spec.ts (6 cases). Test suite: 521/521 (76 suites).

All 12 gaps closed. Payout-accounts module is audit-clean against AML §6 + §17, NPS Act 2020, IRA §102, and DPPA §11 + §21.


Phased implementation plan

Complete — every phase shipped.

Each phase deploys to prod + sandbox at 157.173.99.48 and ships a locked spec suite. Success gate: all 12 gaps closed for the module.