Why
Adding a bank account for withdrawal failed on staging (2026-10-09, user de6bc4de). The app said "The bank rejected these account details", but the bank details were fine. Airwallex answered:
POST /api/v1/beneficiaries/create → 400 validation_failed
beneficiary.address.postcode · beneficiary.address.street_address · beneficiary.address.city
BankBeneficiariesService.create built the beneficiary address from kyc_info (loadVerifiedName). Our Sumsub level id-and-liveness does not collect an address, so line1, city and postal_code are empty for 9 of 13 KYC-completed staging users. Every 400 was mapped to BENEFICIARY_REJECTED, so the user got a wrong message and no way to fix it. Same class of defect as !114 (merged): KYC data used for a purpose KYC never collected.
What changes
-
The add-bank form asks for the account holder's address: street, apartment (optional), city, state, postcode, country. The app validates as the user types; Save stays disabled until the form is valid. The address is prefilled once from the user's last card application (
GET /keysecure/cardholder→profile.address) when it exists, never from KYC. -
CreateBankBeneficiaryDtogains a requiredaddressobject (printable ASCII; ZIP / ZIP+4 and one of the 51 state codes for USA; 3–10 char postcode and an optional free-text state elsewhere; country as ISO alpha-3). The top-levelstatefield is gone. The server sends exactly what the user typed to Airwallex (line1, line2joined, alpha-3 → alpha-2). -
loadVerifiedNamestill gates every payout call on a completed KYC and supplies the names; its address part is removed. -
ADDRESS_INVALID(422): when Airwallex answersvalidation_failedand every readable error source is underbeneficiary.address., the API returns the field names to fix (line1,city,postalCode,state,country). The app shows "Check the address: …", marks those inputs and keeps the values so the user edits and saves again. The state input appears for a non-US address once the bank asks for it. Other 400s stayBENEFICIARY_REJECTED. Nothing from the Airwallex body beyond the source names is logged or returned. - No migration: the address lives at Airwallex only.
- The orval model for the new DTO was hand-edited (no staging BE had it yet). Run
pnpm generate-apiafter this BE is on staging and commit the result if it differs.
Release order (important)
An old app build sends top-level state and no address; the new BE answers 400 without a code and the old app shows "Something went wrong" on every add-bank attempt. Existing beneficiaries and payouts are unaffected.
- Staging: deploy BE, then publish the FE OTA.
- Production: no OTA, so BE and the FE build must ship together.
Not in this MR
AirwallexClientService.describeBody (pre-existing) logs 4xx bodies with digit runs masked; an echoed address can still land in logs. Separate ticket.
Verification
- be:
yarn jest src/modules/fiat-payout src/modules/keysecure-cardholderpasses (410 tests);yarn buildclean. New tests mutation-checked. - fe:
pnpm jest src/api/fiat-payout src/app src/components/withdrawpasses;pnpm type-checkandpnpm lint:translationsclean. New tests mutation-checked; two tests that could not fail were fixed to wait for the passcode sheet. - Opus gate: PASS after 2 rounds, 0 blocking. Round-1 should-fixes: non-US state collection/visibility (fixed), old-build compatibility (release order above).
- The Airwallex error shape used for the mapping is copied from the live staging log line of 2026-10-09 10:24:41 UTC.