Skip to content
GitLab
Projects Groups Topics Snippets
  • /
  • Help
    • Help
    • Support
    • Community forum
    • Submit feedback
    • Contribute to GitLab
  • Register
  • Sign in
  • A Ahad Mobile
  • Project information
    • Project information
    • Activity
    • Labels
    • Members
  • Repository
    • Repository
    • Files
    • Commits
    • Branches
    • Tags
    • Contributor statistics
    • Graph
    • Compare revisions
  • Issues 0
    • Issues 0
    • List
    • Boards
    • Service Desk
    • Milestones
  • Merge requests 0
    • Merge requests 0
  • CI/CD
    • CI/CD
    • Pipelines
    • Jobs
    • Schedules
  • Deployments
    • Deployments
    • Environments
    • Releases
  • Packages and registries
    • Packages and registries
    • Package Registry
    • Terraform modules
  • Monitor
    • Monitor
    • Incidents
  • Analytics
    • Analytics
    • Value stream
    • CI/CD
    • Repository
  • Wiki
    • Wiki
  • Snippets
    • Snippets
  • Activity
  • Graph
  • Create a new issue
  • Jobs
  • Commits
  • Issue Boards
Collapse sidebar
  • holatech
  • Ahad Mobile
  • Merge requests
  • !106

feat(fiat-payout): withdraw usd from the wallet to a us bank via airwallex

  • Review changes

  • Download
  • Patches
  • Plain diff
Merged Khanh Pham requested to merge feat/fiat-withdraw into develop Oct 07, 2026
  • Overview 0
  • Commits 19
  • Pipelines 3
  • Changes 120

Merge order: stacked on !104 (merged) (fix/withdraw-reconcile-stuck). Merge !104 (merged) first; GitLab then retargets this MR to develop. Ships dark: FIAT_PAYOUT_ENABLED defaults to false.

Summary

Users can withdraw USD from the in-app fiat wallet to their own US bank account through Airwallex payouts. v1 supports US domestic USD only (transfer_method LOCAL, ACH). The user pays a flat fee, which is deducted from the amount and posted to fee_revenue.USD.

Backend (be/src/modules/fiat-payout)

  • Tables (migration 1790500000000): bank_beneficiaries stores the Airwallex beneficiary id, routing number and last 4 digits only; the full account number lives only at Airwallex. fiat_payouts carries a status machine PENDING_SUBMISSION → SUBMITTED → PAID → RETURNED, plus FAILED and NEEDS_REVIEW. Uniqueness on (user_id, request_id), a net + fee = amount CHECK, and a partial unique index so a payout returns once. Migration 1790500000001 seeds fiat-payout:read (SUPER_ADMIN, OPERATIONS_ADMIN, AUDITOR) and fiat-payout:resolve (SUPER_ADMIN, OPERATIONS_ADMIN).
  • Bank accounts: POST/GET/DELETE /fiat-payouts/beneficiaries, passcode-guarded create. Requires KYC. The account holder name is free text (Latin, 2-100 chars; product decision), and the address comes from KYC. ABA checksum, US state required for US KYC addresses, at most 5 per user, no duplicates, and no delete while a payout is in flight.
  • Request: POST /fiat-payouts (passcode, requestId). In one transaction under FOR UPDATE it holds amount in locked_balance, then submits to Airwallex outside the transaction with request_id = payout id. Limits are 10 min, 2,000 per order and 5,000 per rolling 24 h, checked under the account lock. Before reserving, it checks the Airwallex balance. A 4xx releases the hold; a 5xx or timeout keeps it (outcome unknown).
  • Settlement (applyProviderStatus, one path for webhook, submit, cron and admin):
    • PAID: DR user (from locked) / CR house for net, and DR user (from locked) / CR fee_revenue for the fee.
    • FAILED before PAID: release the hold, no ledger rows.
    • FAILED after PAID (bank return): refund net and fee once via FIAT_PAYOUT_RETURN.
    • All under one lock order (account rows by id, then the payout row) with status guards.
  • Webhooks: payout.transfer.* go through the existing Airwallex intake (signature on the raw body, unique event_id). Bank account numbers are masked before the payload is stored.
  • Reconciler (every 5 min, withSchedulerLock):
    • Resolves PENDING_SUBMISSION by request_id. After repeated misses it moves the payout to NEEDS_REVIEW and never auto-releases.
    • Polls stale SUBMITTED payouts.
    • Re-polls PAID payouts for 7 days (at most every 6 h) to catch ACH returns whose webhook was lost.
  • Admin: GET /admin/fiat-payouts, POST /admin/fiat-payouts/:id/resolve.
    • LINK verifies amount, currency and beneficiary, and refuses a foreign request_id.
    • RELEASE is refused while Airwallex still finds a transfer.
    • RETURN applies to PAID only.
    • Each records the admin and a note.

App (fe)

  • Withdraw now opens a Bank (USD) / Crypto sheet; Bank is hidden while the flag is off.
  • New screens: add bank account (holder name prefilled with the KYC name and editable, no country field), withdraw USD, status (polls until a terminal state).
  • Money is handled with bignumber.js. Confirm checks the USD wallet balance.
  • A request id is kept per intent until the server gives a definite answer, and expires after 24 h.
  • Both POST routes are on isCredentialCheck.
  • The generated orval client is only the fiat-payout additions, because the committed output was already stale against develop.

Admin

  • New Fiat payouts page (fiat-payout:read) with a status filter and server paging.
  • Resolve actions require fiat-payout:resolve. TAGS gains Admin - Fiat Payouts.

Before turning it on

  • The holder name is not matched to KYC (product decision, 2026-10-08), so a user can withdraw to someone else's account. Confirm with Airwallex (AUP: payouts on behalf of others) and legal/AML before enabling.
  • Airwallex must approve "payouts to our own users" for our account; it may require Payments for Platforms. Get demo credentials and check the real transfer, webhook and failure payload shapes, including where account_number appears.
  • USD that users got from Convert (reserve.USD) is a book entry with no Airwallex cash behind it. Fund the Airwallex balance to match before enabling.
  • Set FIAT_PAYOUT_FEE_USD, FIAT_PAYOUT_MIN_USD, FIAT_PAYOUT_MAX_USD and FIAT_PAYOUT_DAILY_MAX_USD, and subscribe the payout.transfer.* webhooks in the Airwallex console. The runbook is in be/src/modules/fiat-payout/README.md.
  • Migration 1790500000000 was amended on this branch (last_polled_at). It has never been deployed, but a local DB that already ran it must migration:revert twice and run again.

Known, not fixed

  • checkFiatLedger sums every USD account including house, so it always reports a mismatch. It was not changed here and needs its own ticket.
  • The admin audit fields are written separately from the money move, so a crash between the two loses only the attribution.
  • Out of scope: card → wallet (KeySecure Out) is a separate follow-up; SWIFT and other currencies too.
  • Unrelated failing specs: lending-fiat-rail-schema.postgres.spec (test DB state) and the admin stuck-orders-section test.

Test plan

  • BE unit (fiat-payout, airwallex, transactions, config, passcode coverage) pass
  • BE DB specs on moanapay_test (fiat-payout, transactions, migrations): 234 passed. They cover hold/settle/return exactly once, concurrent duplicates, limits under lock, lock order without deadlock, and the PAID return poll
  • yarn build clean, eslint clean on changed files, tsc clean for fiat-payout
  • FE type-check, lint 0 errors, jest pass
  • Admin type-check, lint 0 errors, 616 tests pass
  • Every new test mutation-checked
  • Moved a test file out of fe/src/app (expo-router bundled it as a route and the app crashed on launch)
  • 7 tasks reviewed one by one, then the review gate passed after 3 rounds

🤖 Generated with Claude Code

Edited Oct 08, 2026 by Khanh Pham
Assignee
Assign to
Reviewers
Request review from
Time tracking
Source branch: feat/fiat-withdraw