Skip to main content

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.tswithdraw() path.
  • src/modules/payment-batches/payment-batches.module.ts — batch create / approve / submit / retry.
  • src/modules/payments/payments.module.tsingestWebhook().
  • 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; AuditChainVerifyJob exists but NOT registered in the scheduler.
  • DPPA §21PayoutAccount PII ciphered ✓; Payment.accountHolder is a plaintext copy from the cipher source.
  • DPPA §11 — no 7-y sweep on PayoutAccount PII beyond soft-delete; retained forever.
  • BOU §5.4ProviderCircuitBreaker present ✓; 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_threshold policy.
  • ProviderCircuitBreaker opens on sustained provider failures.
  • PayoutAccount.accountNumber + .accountHolder ciphered via PiiCipher at 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.

Phase 2 + Phase 3 shipped — 2026-07-20 (pg-p23-20260720-0941)

Bundled migration 20260720120000_payment_gateway_phase2 adds:

  • Payment.accountHolderEncrypted + accountHolderHash + index (DPPA §21 — batch-create can copy the ciphered snapshot from source PayoutAccount when the beneficiary is captured on the payment row).
  • PayoutAccount.retentionUntil + partial index (DPPA §11 — policy-driven purge anchor).
  • PaymentProviderRotation model + 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 in PaymentProviderRegistry.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.

Phase 1 shipped — 2026-07-20 (pg-p1-20260720-0937)

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.ingestWebhook now persists a PaymentWebhookEvent row with signatureValid=false + eventType='signature_invalid' before rethrowing on HMAC failure, and emits payment.webhook.signature_invalid audit — 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 (default mobile_money / bankflutterwave) before surfacing the failure. AuditChainVerifyJob was already registered in ScheduledJobsModule with @Cron daily 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's approvedBy or secondApprovedBy; segregation of duties on manual provider-outage retries. Escape hatch PAYMENTS_ALLOW_SAME_ACTOR_RETRY=true for narrow ops windows.
  • gap 9 — PaymentBatchesService.approve() re-evaluates PaymentsDualApprovalThreshold policy at approve-time; a batch created below the ceiling that stalled while the threshold dropped auto-upgrades to dualApprovalRequired before the first signature flips it to approved + emits payment_batch.dual_approval.upgraded_at_approve.
  • gap 7 — HmacTimestampStrategy already enforced the tolerance-seconds window (5 min default); HmacBareStrategy now enforces the same window whenever the caller ships an x-webhook-timestamp header, extending replay defence to bare-HMAC providers.
  • gap 8 — PayoutAccountsService.update() re-runs SanctionsService.screenForPayout('payout_account_holder') on material-field change (which already triggers a verification reset); live hit throws + emits payout_account.update.sanctions_hit.
  • gap 12 — WalletsService.withdraw() reads payments.wallet_withdraw_daily_limit_ugx policy and refuses when the trailing-24 h withdrawal_debit aggregate + attempted amount exceeds the cap; emits wallet.withdraw.daily_cap_exceeded audit. 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.