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).PayoutAccountPrisma model — plaintextaccountNumber,accountHolder,bankCode,branchCode, currency, verification stamps,isPrimaryflag,deletedAtsoft-delete.- Downstream callers —
PaymentBatchesService.approve()(gates onisVerified=true+isPrimary=true),WalletsService.withdraw()(gates onverifiedAt != 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 + §21 —
accountNumber+accountHolderare 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 viadeletedAt. 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 onfindById(); agents holding onlyagent:read:owncannot list another agent's accounts. - Batch-time verification gate:
PaymentBatchesService.approve()refuses to include a payment whose destination is notisVerified=true+isPrimary=true.WalletsService.withdraw()rejectsverifiedAt=nullexplicitly. - 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:
UpdatePayoutAccountDtointentionally omitsaccountNumber— a mutable account number would let ops verify with one number then swap to another.
Gaps
All 12 gaps closed. See shipped-notes below.
Release payout-p1-20260718-1530. Closes gaps 1–4:
- gap 1 —
SUBJECT_TYPESextended withpayout_account_holder;SanctionsService.loadSubject('payout_account_holder', id)reads the holder name (post-decrypt).PayoutAccountsService.verify()runsscreenForPayout('payout_account_holder', ...); block → refuse + emitpayout_account.verify.sanctions_block. - gap 2 — Migration
20260718140000_payout_accounts_phase1addsaccountNumberHash,accountHolderHash,piiEncryptedAt+ indexes.create()+update()cipher plaintext + write hash siblings on every touch viaPiiCipher; read paths round-trip legacy plaintext viaPiiCipher.decryptfallback. - gap 3 —
update()resetsisVerified,verifiedAt,verifiedBywhen any ofaccountHolder,bankCode,branchCode,currencychange; emitspayout_account.verify.resetaudit event listing the material fields that changed. - gap 4 — Migration adds
PayoutAccount.retentionScrubbedAt. Newdata_sharing.payout_accounts_retention_dayspolicy (default 2555).RetentionPurgeJobscrubsaccountNumber/accountHolder/bankCode/branchCodeon soft-deleted rows past window + clears hashes + emitspayout_account.retention.pii_scrubbed.
Locked in by src/modules/payout-accounts/payout-accounts-phase1.spec.ts
(5 cases). Test suite: 509/509 (74 suites).
Release payout-p2-20260718-1550. Closes gaps 5–8:
- gap 5 — New
POST /payout-accounts/:id/unverifyendpoint gated by newpayout_account:unverifypermission (granted to super_admin + compliance_officer). ClearsverificationStatus/isVerified/verifiedAt/verifiedBy/secondVerifiedAt/secondVerifiedBy. Stampsmetadata.unverifiedReason+unverifiedAt. Emitspayout_account.unverifyaudit event. - gap 6 — Migration adds
verificationStatus(unverified/pending_second_approval/verified) +secondVerifiedBy+secondVerifiedAt. Firstverify()call flips topending_second_approval(emitspayout_account.verify.first_approval); a second distinct- actorverify()flips toverified+isVerified=true(emitspayout_account.verify). Same-actor second call → 409. - gap 7 — New
holderRelationshipsoft-enum (self|family|joint|corporate).create()fetches the owning agent's KYC first + last name and refuses whenholderRelationship='self'(default) andaccountHolderdoesn't match (normalised: trim + lowercase + collapse whitespace). Non-selfvalues 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$transactionand 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).
Release payout-p3-20260718-1605. Closes gaps 9–12:
- gap 9 —
PayoutAccountsServiceexposescreateIdempotent/verifyIdempotent/unverifyIdempotentwrappers keyed on theIdempotency-Keyheader via sharedIdempotencyService. Controller threads the header through every mutation. - gap 10 —
@Throttleon every mutation: create 10/min, update 20/min, verify + unverify 20/min. Excess → 429. - gap 11 — New
validateFormat()runs on create + update. ForaccountType='mobile_money':accountNumbermust match+256[0-9]{9}. ForaccountType='bank':bankCodeis required + must match0[0-9]{2}(BOU 3-digit range);accountNumberrestricted to digits + hyphens. Invalid → validation error at API time. - gap 12 —
CreatePayoutAccountDto.currencynow@IsInagainstSUPPORTED_CURRENCIES(UGX,USD,EUR,GBP,KES,TZS,RWF). Default flipped fromUSDtoUGX. Currency change on update auto-resetsisVerified(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.