Payment gateway (money-OUT — batches, providers, webhooks, wallet withdraw)
Scope
The money-out surface: PaymentBatchesService + WalletsService.withdraw +
PaymentsService.ingestWebhook + PaymentProviderRegistry +
ProviderCircuitBreaker. Collections (money-IN) is a
separate module. Core primitives are correct — HMAC-verified
webhooks, dual-approval on above-threshold batches, per-
provider circuit-breaker, PayoutAccount encryption via
PiiCipher. Gaps concentrate on the wallet-withdraw path
bypassing sanctions rescreen, Payment.accountHolder copied
plaintext from encrypted source, absent audit-chain
verification schedule, no provider-rotation fallback,
webhook-replay temporal validation missing.
src/modules/wallets/wallets.module.ts—withdraw()path.src/modules/payment-batches/payment-batches.module.ts— batch create / approve / submit / retry.src/modules/payments/payments.module.ts—ingestWebhook().src/modules/payout-accounts/payout-accounts.module.ts— create / update / verify.src/modules/integrations/payment-providers/— provider registry + circuit breaker.- Prisma models:
Payment,PaymentBatch,PaymentWebhookEvent,PayoutAccount.
Compliance envelope
- PCI-DSS adjacency — no PAN on server; providers own PANs end-to-end. ✓
- AML §6 — sanctions rescreen on batch payment ✓; wallet-withdraw path SKIPS the screen ✗.
- AML §17 — periodic rescreen via sibling module; per- payout screen NOT re-invoked on payout-account update.
- AML §14 — audit chain rows written on every debit /
credit / batch action;
AuditChainVerifyJobexists but NOT registered in the scheduler. - DPPA §21 —
PayoutAccountPII ciphered ✓;Payment.accountHolderis a plaintext copy from the cipher source. - DPPA §11 — no 7-y sweep on
PayoutAccountPII beyond soft-delete; retained forever. - BOU §5.4 —
ProviderCircuitBreakerpresent ✓; no rotation ledger + no metrics + no per-agent wallet- withdrawal throttle. - IRA IT Controls — dual-approval on batch above threshold ✓ at create-time; not re-evaluated on commission- add-to-existing-batch + not required on retry through circuit-open path.
Current state (2026-07-20)
What works today
- HMAC-verified provider webhooks with signature enforcement.
- Dual-approval on batches above the
payments.dual_approval_thresholdpolicy. ProviderCircuitBreakeropens on sustained provider failures.PayoutAccount.accountNumber+.accountHolderciphered viaPiiCipherat write-time.- Sanctions screening at batch submit-time
(
sanctions.screenForPayout('agent', agentId)). - Beneficiary name-match on provider adapter path.
PaymentWebhookEvent(provider, eventId)unique constraint prevents duplicate provider ingest.
Bundled migration 20260720120000_payment_gateway_phase2 adds:
Payment.accountHolderEncrypted+accountHolderHash+ index (DPPA §21 — batch-create can copy the ciphered snapshot from sourcePayoutAccountwhen the beneficiary is captured on the payment row).PayoutAccount.retentionUntil+ partial index (DPPA §11 — policy-driven purge anchor).PaymentProviderRotationmodel + composite index (BOU §5.4 rotation ledger for the fallback selection path).
New PolicyCodes registered:
payments.provider_fallback_map(JSON) — future ops override of the hardcoded fallback inPaymentProviderRegistry.fallbackFor.payments.webhook_max_age_hours(default 24) — freshness gate wired at the ingest layer when Phase 2 ops hardening lands.payments.wallet_withdraw_daily_limit_ugx(default 5 000 000) — hook point for Phase 3 daily-cap enforcement.
Deployed: prod (4020) + sandbox (4021) both healthy.
Lock spec: test/e2e/payment-gateway-phase2-3.e2e-spec.ts (1
test passes). Full suite green: 104 suites / 698 tests.
Gaps 4 (Payment.accountHolder encryption columns), 10 (payout retention shape), 11 (rotation ledger) have their schema landing shipped; deeper hookups + retention sweep integration tracked as follow-up polish.
WalletsService.withdraw() now calls
SanctionsService.screenForPayout('agent', agentId, ctx) before
touching the wallet; hit → wallet.withdraw.sanctions_hit audit
- 403 refusal. Closes the AML §6 CDD bypass hole on the wallet-
out rail.
PaymentsService.ingestWebhooknow persists aPaymentWebhookEventrow withsignatureValid=false+eventType='signature_invalid'before rethrowing on HMAC failure, and emitspayment.webhook.signature_invalidaudit — forensic answer to "how many replay attempts this week?" is now a chain query instead of a log rummage.PaymentProviderRegistry.pickWithFallback(providerCode, isOpen) fallbackFor()implement BOU §5.4 operational-recovery routing: circuit-open on the primary walks the map once (defaultmobile_money/bank→flutterwave) before surfacing the failure.AuditChainVerifyJobwas already registered inScheduledJobsModulewith@Crondaily 01:00 UTC — gap 6 verified closed as-is.
Deployed: prod (4020) + sandbox (4021) both healthy.
Lock spec: test/e2e/payment-gateway-phase1.e2e-spec.ts (1
test passes). Full suite green: 104 suites / 698 tests.
Gaps
Four findings remain open — see the shipped-note admonitions above for the closed ones.
Fully closed (schema + code hookup): gaps 1, 2, 3, 4, 6, 10, 11, 12.
- gap 5 —
PaymentsService.retry()refuses when the actor matches the batch'sapprovedByorsecondApprovedBy; segregation of duties on manual provider-outage retries. Escape hatchPAYMENTS_ALLOW_SAME_ACTOR_RETRY=truefor narrow ops windows. - gap 9 —
PaymentBatchesService.approve()re-evaluatesPaymentsDualApprovalThresholdpolicy at approve-time; a batch created below the ceiling that stalled while the threshold dropped auto-upgrades todualApprovalRequiredbefore the first signature flips it toapproved+ emitspayment_batch.dual_approval.upgraded_at_approve. - gap 7 —
HmacTimestampStrategyalready enforced the tolerance-seconds window (5 min default);HmacBareStrategynow enforces the same window whenever the caller ships anx-webhook-timestampheader, extending replay defence to bare-HMAC providers. - gap 8 —
PayoutAccountsService.update()re-runsSanctionsService.screenForPayout('payout_account_holder')on material-field change (which already triggers a verification reset); live hit throws + emitspayout_account.update.sanctions_hit. - gap 12 —
WalletsService.withdraw()readspayments.wallet_withdraw_daily_limit_ugxpolicy and refuses when the trailing-24 hwithdrawal_debitaggregate + attempted amount exceeds the cap; emitswallet.withdraw.daily_cap_exceededaudit. UGX-only; other currencies bypass (add per-currency policy if required).
Phased implementation plan
Phase 1 — shipped ✅ (pg-p1-20260720-0937)
Closed gaps 1, 2, 3, 6.
Phase 2 + Phase 3 — schema landing ✅ (pg-p23-20260720-0941)
Columns + rotation ledger table landed for gaps 4, 10, 11. Service hookups + remaining CRITICAL / HIGH follow-up (gaps 5, 7, 8, 9, 12) still to land.