Withdrawal — Offramp flow

Flow
USDT → fiat (offramp)
Status
Shipped · US blocked (Bug #2)
Works
MX · AR · BR · CO · DO  ·  Off-only: CN · CL · BO · PE
User picks a registered bank account; the canonical pipeline takes over and dispatches Alfred /offramp only after auto-approve clears. QA simulates the terminal webhook via POST /psp/alfred/__dev/emit-offramp (success or failure) from the Dev playground.

Sequence — withdrawal with FAILURE recovery

Step-by-step actor sequence. Color-coded lanes show who does what.

Scenario B — BR / MX / AR / CO / DO withdrawal with FAILURE recovery
QA
Widget
vakotrade BE
Alfred
  1. 1
    QA
    Setup with country=BR via seed-kyc + approve-kyc (as deposit Scenario A).
  2. 2
    Widget
    User picks Withdraw → POST /fiat-accounts {type: PIX, pixKey, document, name}. Returns fiatAccountId.
  3. 3
    Widget
    User enters 50 BRL → POST /session {type: withdrawal, …, fiat_account_id}POST /initiate-withdrawal.
  4. 4
    BE
    Canonical pipeline: WithdrawalFiatCreateJob debits user balance → WithdrawalAutoApproveJob (limits/KYT/Travel-Rule) → WithdrawalFiatPspRequestJob.
  5. 5
    BE
    WithdrawalFiatPspRequestJob calls POST /offramp on Alfred. Payment moves to processing.
  6. 6
    QA
    Fire __dev/emit-offramp with scenario: failure.
  7. 7
    Alfred
    Webhook fires: FAILED with failureReason: QA_SIMULATED_FAILURE.
  8. BE
    Result: WithdrawalRejectJob dispatched. Balance restored. Payment rejected. Widget shows "withdrawal failed" + restored amount.
Scenario D — Session security (cross-user attack)
User A
User B
vakotrade BE
Alfred
  1. 1
    User A
    Creates a session via POST /session. Receives session_id. Logs the ID (e.g. shares it on Slack).
  2. 2
    User B
    Signs in (different JWT, different traderId). Calls POST /initiate-withdrawal with User A's session_id.
  3. 3
    BE
    requireSession(session_id, traderId): data.user_id !== traderId → throws ALFRED_SESSION_NOT_OWNED.
  4. BE
    Result: 403. No debit on either account. No Alfred call made.

End-to-end withdrawal flow

The full happy path from widget mount to terminal webhook. Steps marked QA use dev endpoints; everything else is the production widget path.

Withdrawal (Offramp)
USDT → fiat
User picks a registered bank account; canonical pipeline takes over and dispatches Alfred /offramp only after auto-approve clears.
1
Widget mount + KYC gates
Same GET /psp/alfred/eligibility as deposit. Same error codes.
2
Add or pick bank account
GET /psp/alfred/fiat-accounts lists; POST /psp/alfred/fiat-accounts creates. Schema per rail comes from GET /psp/alfred/fiat-accounts/requirements. Body-shape is rail-specific (SPEI: CLABE; PIX: chave+document; ACH_DOM: cuenta+documentoTitular; BANK_USA: routing+account).
3
Create withdrawal session
POST /psp/alfred/session with type: "withdrawal". Same gate stack as deposit. Returns session_id.
4
Initiate withdrawal
POST /psp/alfred/initiate-withdrawal debits the user (canonical WithdrawalFiatCreateJob) and enqueues the auto-approve pipeline. NO Alfred call yet — partner call lives in WithdrawalFiatPspRequestJob after limits / KYT / Travel-Rule clear.
5
PSP request — POST /offramp
When auto-approve clears, PspAlfredService.executeWithdrawalPspRequest creates the Alfred quote + offramp. Payment moves to processing; widget shows "sending to bank".
6
Alfred webhook → terminal state
FIAT_TRANSFER_COMPLETED → WithdrawalCompleteJob; FAILED → WithdrawalRejectJob (re-credit + reverse).
QA POST /psp/alfred/__dev/emit-offramp with scenario: "success" | "failure" walks the chain.

Per-country test cases

Withdrawal / Offramp — MX SPEI · BR PIX · AR COELSA · CO ACH-PSE · DO ACH_DOM · US BANK_USA (blocked).

TC-W1
MX user · add SPEI bank · initiate withdrawal · simulate OFFRAMP success
expect: balance -= amount, Payment completed
PASS
TC-W2
BR user · add PIX bank · withdrawal
expect: 201 offramp on PIX rail
PASS
TC-W3
AR user · add COELSA bank · ARS withdrawal
expect: 201 offramp, fiat instructions returned
PASS
TC-W4
CO user · add ACH (PSE) bank · COP withdrawal
expect: 201 offramp (Tetiana 5+20 USDT confirmed 2026-05-23)
PASS
TC-W5
DO user · add ACH_DOM bank (e.g. BANCO POPULAR) · DOP withdrawal
expect: 201 offramp (tx e845ff4a-…)
PASS
TC-W6
US user · attempt BANK_USA add
expect: 409 / 111485 (Path A) or 422 / 110003 (Path B) — Bug #2
BLOCKED
TC-W7
Simulate OFFRAMP failure → WithdrawalRejectJob
expect: balance refunded; Payment rejected
PASS
TC-W8
Withdrawals freeze ON · call initiate-withdrawal
expect: throws ALFRED_WITHDRAWALS_FROZEN
PASS
TC-W9
FAILED webhook arriving AFTER FIAT_TRANSFER_COMPLETED
expect: ALFRED_INCORRECT_STATUS_CHANGE — no double-debit
PASS

Fiat accounts — list / add / delete / requirements

TC-F1
GET /fiat-accounts/requirements?type=PIX returns required-field schema
expect: 200 with pixKey, documentNumber, accountName required
PASS
TC-F2
POST add SPEI account (CLABE 18 digits) for MX customer
expect: 201 with returned fiatAccountId
PASS
TC-F3
POST add ACH_DOM account (BANCO POPULAR)
expect: 201 (verified 2026-05-23 → c66836ae-…)
PASS
TC-F4
Leaked session_id — attacker tries to add fiat account on victim
expect: throws ALFRED_SESSION_NOT_OWNED (traderId from JWT, not body)
PASS
TC-F5
POST BANK_USA (US Path A — bridged customer)
expect: 409 / 111485 — Bug #2
BLOCKED
TC-F6
DELETE existing fiat account
expect: 204, list no longer contains it
PASS

US BANK_USA — open Alfred-side blocker

One open issue remains on Alfred's side after the 2026-05-29 retest. US withdrawal cannot be tested at all today.

BUG #2 US BANK_USA
WherePOST /fiatAccounts
Path A409 / 111485
Path B422 / 110003

Both bridged and non-bridged US customers fail to register a BANK_USA fiat account. Cascade: no fiat account ⇒ no offramp. Workaround: none — US withdrawal blocked entirely.

QA take: Bug #2 is NOT a vakotrade bug. Do not file it in our tracker. Verify the widget surfaces "BANK_USA unavailable, contact support" or similar; do not retry-loop on the 409/422. Track Alfred's response on the bug report.

curl — full happy path (BR)

POST /psp/alfred/fiat-accounts
{ "type":"PIX", "session_id":"<…>",
  "fiatAccountFields": { "pixKey":"…", "documentNumber":"12345678909", "accountName":"…" } }

POST /psp/alfred/session
{ "data": { "type":"withdrawal", "amount":100, "source_currency_id":"BRL", "payment_route_id":"<…>",
            "wallet_id":"MAIN", "fiat_account_id":"<from previous step>" } }

POST /psp/alfred/initiate-withdrawal
{ "session_id":"<…>" }
→ 200 (job dispatched, payment moves through auto-approve → /offramp → webhook)

POST /psp/alfred/__dev/emit-offramp
{ "session_id":"<…>", "scenario":"success" }
→ webhook chain → WithdrawalCompleteJob
Run it. Open the Dev playground → configure base URL / JWT / dev secret once → seed + approve a customer → add a fiat account and initiate the withdrawal in the widget → fire the emit-offramp runner with scenario: success or failure to drive WithdrawalCompleteJob / WithdrawalRejectJob.
Fire the offramp runner
emit-offramp · seed-kyc-and-provision · provision-customer · approve-kyc.