Merge order: stacked on !104 (merged) (
fix/withdraw-reconcile-stuck). Merge !104 (merged) first; GitLab then retargets this MR todevelop. Ships dark:FIAT_PAYOUT_ENABLEDdefaults tofalse.
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_beneficiariesstores the Airwallex beneficiary id, routing number and last 4 digits only; the full account number lives only at Airwallex.fiat_payoutscarries a status machinePENDING_SUBMISSION → SUBMITTED → PAID → RETURNED, plusFAILEDandNEEDS_REVIEW. Uniqueness on(user_id, request_id), anet + fee = amountCHECK, and a partial unique index so a payout returns once. Migration1790500000001seedsfiat-payout:read(SUPER_ADMIN, OPERATIONS_ADMIN, AUDITOR) andfiat-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 underFOR UPDATEit holdsamountinlocked_balance, then submits to Airwallex outside the transaction withrequest_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. -
FAILEDbeforePAID: release the hold, no ledger rows. -
FAILEDafterPAID(bank return): refund net and fee once viaFIAT_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, uniqueevent_id). Bank account numbers are masked before the payload is stored. -
Reconciler (every 5 min,
withSchedulerLock):- Resolves
PENDING_SUBMISSIONbyrequest_id. After repeated misses it moves the payout toNEEDS_REVIEWand never auto-releases. - Polls stale
SUBMITTEDpayouts. - Re-polls
PAIDpayouts for 7 days (at most every 6 h) to catch ACH returns whose webhook was lost.
- Resolves
-
Admin:
GET /admin/fiat-payouts,POST /admin/fiat-payouts/:id/resolve.-
LINKverifies amount, currency and beneficiary, and refuses a foreignrequest_id. -
RELEASEis refused while Airwallex still finds a transfer. -
RETURNapplies toPAIDonly. - 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.TAGSgainsAdmin - 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_numberappears. - 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_USDandFIAT_PAYOUT_DAILY_MAX_USD, and subscribe thepayout.transfer.*webhooks in the Airwallex console. The runbook is inbe/src/modules/fiat-payout/README.md. - Migration
1790500000000was amended on this branch (last_polled_at). It has never been deployed, but a local DB that already ran it mustmigration:reverttwice and run again.
Known, not fixed
-
checkFiatLedgersums 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 adminstuck-orders-sectiontest.
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 buildclean, eslint clean on changed files,tscclean 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