# Withdrawal recipient and response correction

## Production evidence

Read-only inspection of production Laravel logs found withdrawal #64 for NGN300 at
server timestamp 2026-10-21 15:48:19. Transfer initiation was rejected with
`Recipient specified is invalid`, `is_ambiguous: false`. The failure handler logged
`Funds returned to wallet`. No recipient-creation entry precedes this attempt.
Earlier withdrawals #61, #62 and #63 created recipients immediately before Paystack
accepted their transfer requests. Acceptance does not establish bank settlement.
The deployed service uses user metadata's cached recipient whenever present, so #64
used that cached path. Logs do not establish why the cached code became invalid.
No provider request, retry, refund, production DB command or configuration change was
performed during this investigation.

## Change

WithdrawalService creates a recipient using the withdrawal's selected account name,
account number and bank code on every claimed attempt. It no longer reads or updates
the unbound legacy `paystack_recipient_code`. Both creation and transfer continue to
use PaystackService's transfer client and existing disbursement-key selection. A missing
or malformed recipient code aborts before transfer and uses the existing refund handler.
Creation timeouts can refund because no transfer has been attempted. Transfer timeouts
and duplicate references retain processing status, hold the debit, and are never retried
or refunded automatically. Atomic claims and deterministic transfer/refund references
are unchanged. One additional recipient request is made on attempts previously using cache.

Borrower and affiliate withdrawal endpoints return HTTP422 and `success: false` with
`status: failed` for confirmed failures. Returning a response rather than throwing commits
the debit/refund transaction. Processing and pending outcomes expose their status and
provider message without claiming completion. Nonterminal responses retain HTTP200 for
client compatibility. UI consumers must read status; processing is not a completed bank
payment. The mobile app source is absent from this workspace, so its hardcoded dialog
title cannot be changed or tested here.

## Callers and blast radius

- WalletController::withdraw (`POST /wallet/withdraw`): fresh recipient through the service;
  confirmed failure changes from HTTP200 to HTTP422 with structured status.
- AffiliateController::withdraw (`POST /affiliate/withdraw`): same fresh recipient and failed
  response; manual-approval submissions stay pending, limits and verified profile unchanged.
- Admin WithdrawalController::approve: fresh recipient via shared process; its existing
  HTTP400 failure response, approval rules and audit trail stay unchanged.
- PaystackWebhookController: existing transfer success/failure/reversal processing and
  refund idempotency remain unchanged; its direct failAndRefund caller is unmodified.
- PaystackService::createTransferRecipient/initiateTransfer: unchanged implementations and
  payout key resolution; loan services using these methods are unchanged.

Borrower wallets across salary, retail, local government and legacy accounts, plus affiliate
wallets, use the corrected Paystack withdrawal path. Pay Later loan payouts, scheduler,
manual loan disbursement, Mono/Monnify, mandates and repayments are unchanged. No migration,
new settings, existing wallet data repair or credential changes are required.

## Validation

Tests cover cached recipient replacement using the selected destination, new borrowers,
malformed creation responses, recipient timeouts, confirmed rejection/refund, borrower
processing/ambiguous responses and affiliate rejection/refund. Existing duplicate webhook,
reversal, balance shortage, atomic claim and duplicate-reference tests are retained.
The existing borrower rejection assertion was strengthened from HTTP200 to HTTP422 while
retaining every balance and exactly-once refund assertion. Providers are mocked.

Focused result: `OK (26 tests, 134 assertions)`.
Live provider behavior and mobile rendering were not tested. Changes are on
`codex/withdrawal-recipient-response`; no production deployment has been performed.

Full suite result:

```text
OK, but some tests were skipped!
Tests: 1249, Assertions: 7301, Skipped: 3.
```

`git diff --check` passed. No admin/affiliate frontend files changed.
