From 744b9db60af06b47bf36569eec1b131be87e97e2 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 15 Jun 2026 13:42:04 -0700 Subject: [PATCH 1/4] fix(withdraw): Continue button can never silently die MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleAmountContinue threw 'Failed to get country from bank account' inside the onClick when getCountryFromAccount returned undefined. A synchronous throw in a click handler aborts the router transition with zero UI feedback — the button just goes dead ('press Continue, nothing happens'). Surfaced as a customer report + Sentry incomplete-app-router-transaction (6 users/14d). Three changes make the handler correct-by-construction: - Route Manteca (AR/BR) accounts by method type BEFORE the generic saved-bank branch. Manteca accounts also set selectedBankAccount, so they previously fell into that branch and either mis-routed to the Bridge bank page or threw. - Replace the throw with setError + console.error so an unresolved country is a recoverable, visible error, not a dead button. - Add a terminal else that sets an error, so no selected-method shape can ever leave the handler having done nothing. The real country-resolution fix is the API counterpart (Manteca flat metadata shape); this is the defense-in-depth that guarantees the symptom can't recur. Adds the first tests for the no-country path and Manteca routing — the harness previously mocked getCountryFromAccount to always succeed, so this path was entirely uncovered. --- .../__tests__/withdraw-states.test.tsx | 48 +++++++++++++++++++ src/app/(mobile-ui)/withdraw/page.tsx | 44 +++++++++++++---- 2 files changed, 84 insertions(+), 8 deletions(-) diff --git a/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx b/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx index 5251fd287..9595236cf 100644 --- a/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx +++ b/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx @@ -472,3 +472,51 @@ describe('GROUP 5: Navigation', () => { expect(mockSetSelectedBankAccount).toHaveBeenCalledWith(null) }) }) + +// ============================================================ +// GROUP 6: Continue must never silently die (regression) +// ============================================================ +describe('GROUP 6: Continue never dead-buttons', () => { + test('Unresolved bank-account country shows an error instead of throwing (dead button)', () => { + // Regression for the "press Continue, nothing happens" report: when + // getCountryFromAccount can't resolve a country, the handler used to + // `throw` inside onClick — aborting the router transition with no UI + // feedback (Sentry: incomplete-app-router-transaction, 6 users/14d). + const { getCountryFromAccount } = require('@/utils/bridge.utils') + getCountryFromAccount.mockReturnValue(undefined) + + mockUseWallet.mockReturnValue({ spendableBalance: parseUnits('100', 6) }) + mockWithdrawFlow.selectedMethod = { type: 'bridge', countryPath: 'us' } + mockWithdrawFlow.selectedBankAccount = { type: 'iban', details: { countryName: '', countryCode: '' } } + mockWithdrawFlow.amountToWithdraw = '50' + + renderWithdraw() + + // Pressing Continue must NOT throw and must NOT navigate... + expect(() => fireEvent.click(screen.getByText('Continue'))).not.toThrow() + expect(mockRouterPush).not.toHaveBeenCalled() + // ...it surfaces a recoverable error instead. + expect(mockSetError).toHaveBeenCalledWith({ + showError: true, + errorMessage: "We couldn't determine this account's country. Please contact support.", + }) + + getCountryFromAccount.mockReturnValue({ iso2: 'US', path: 'us' }) + }) + + test('Manteca account routes to the Manteca flow, not the bank branch', () => { + // Manteca (AR/BR) accounts set selectedBankAccount too; the manteca + // method check must win over the generic bank branch so they reach + // /withdraw/manteca rather than the Bridge bank page (or the throw). + mockUseWallet.mockReturnValue({ spendableBalance: parseUnits('100', 6) }) + mockWithdrawFlow.selectedMethod = { type: 'manteca', countryPath: 'argentina', title: 'Bank Transfer' } + mockWithdrawFlow.selectedBankAccount = { type: 'manteca', details: { countryName: 'argentina' } } + mockWithdrawFlow.amountToWithdraw = '50' + + renderWithdraw() + + fireEvent.click(screen.getByText('Continue')) + expect(mockRouterPush).toHaveBeenCalledWith(expect.stringContaining('/withdraw/manteca')) + expect(mockRouterPush).toHaveBeenCalledWith(expect.stringContaining('country=argentina')) + }) +}) diff --git a/src/app/(mobile-ui)/withdraw/page.tsx b/src/app/(mobile-ui)/withdraw/page.tsx index bb8dffaf2..fb84273e0 100644 --- a/src/app/(mobile-ui)/withdraw/page.tsx +++ b/src/app/(mobile-ui)/withdraw/page.tsx @@ -273,21 +273,37 @@ export default function WithdrawPage() { if (selectedMethod.type === 'crypto') { const queryParams = isFromSendFlow ? `?${methodQueryParam}` : '' router.push(`/withdraw/crypto${queryParams}`) + } else if (selectedMethod.type === 'manteca') { + // Manteca (AR/BR) accounts route to the Manteca flow. Checked BEFORE + // the generic saved-bank-account branch below — that branch targets + // the Bridge bank page via getCountryFromAccount and would both + // mis-route a Manteca account and throw when its country can't be + // resolved. Route directly with method + country params instead. + const mantecaMethodParam = selectedMethod.title?.toLowerCase().replace(/\s+/g, '-') || 'bank-transfer' + const additionalParams = isFromSendFlow ? `&${methodQueryParam}` : '' + router.push( + `/withdraw/manteca?method=${mantecaMethodParam}&country=${selectedMethod.countryPath}${additionalParams}` + ) } else if (selectedBankAccount) { const country = getCountryFromAccount(selectedBankAccount) if (country) { const queryParams = isFromSendFlow ? `?${methodQueryParam}` : '' router.push(withdrawBankUrl(country.path, queryParams)) } else { - throw new Error('Failed to get country from bank account') + // Never throw inside the click handler: a synchronous throw aborts + // the router transition with no UI feedback, so the button silently + // dies ("press Continue, nothing happens"). Surface a recoverable + // error and log for observability instead. + console.error('[withdraw] could not resolve country from saved bank account', { + type: selectedBankAccount.type, + countryName: selectedBankAccount.details?.countryName, + countryCode: selectedBankAccount.details?.countryCode, + }) + setError({ + showError: true, + errorMessage: "We couldn't determine this account's country. Please contact support.", + }) } - } else if (selectedMethod.type === 'manteca') { - // Route directly to Manteca with method and country params - const mantecaMethodParam = selectedMethod.title?.toLowerCase().replace(/\s+/g, '-') || 'bank-transfer' - const additionalParams = isFromSendFlow ? `&${methodQueryParam}` : '' - router.push( - `/withdraw/manteca?method=${mantecaMethodParam}&country=${selectedMethod.countryPath}${additionalParams}` - ) } else if (selectedMethod.type === 'bridge' && selectedMethod.countryPath) { // Bridge countries go to country page for bank account form const queryParams = isFromSendFlow ? `?${methodQueryParam}` : '' @@ -296,6 +312,18 @@ export default function WithdrawPage() { // Other countries go to their country pages const queryParams = isFromSendFlow ? `?${methodQueryParam}` : '' router.push(withdrawCountryUrl(selectedMethod.countryPath, queryParams)) + } else { + // No branch matched the selected method — surface an error rather + // than leaving the user with a silently-dead Continue button. + console.error('[withdraw] no route matched for selected method', { + type: selectedMethod.type, + countryPath: selectedMethod.countryPath, + hasBankAccount: !!selectedBankAccount, + }) + setError({ + showError: true, + errorMessage: 'Something went wrong setting up your withdrawal. Please contact support.', + }) } } } From ce268ff70609f93a2b1c3bd1b49c42b1628426bd Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 15 Jun 2026 14:00:23 -0700 Subject: [PATCH 2/4] test(withdraw): hoist getCountryFromAccount mock to keep new test lint-clean Avoid adding a no-require-imports eslint error: drive the bridge.utils mock through a hoisted mock fn (matching the file's mockUseWallet idiom) instead of require()-ing the mocked module inside the test. --- .../withdraw/__tests__/withdraw-states.test.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx b/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx index 9595236cf..14f16ff6d 100644 --- a/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx +++ b/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx @@ -96,8 +96,11 @@ jest.mock('@/utils/general.utils', () => ({ formatNumberForDisplay: jest.fn((v: any) => v ?? '0'), })) +const mockGetCountryFromAccount = jest.fn( + () => ({ iso2: 'US', path: 'us' }) as { iso2: string; path: string } | undefined +) jest.mock('@/utils/bridge.utils', () => ({ - getCountryFromAccount: jest.fn(() => ({ iso2: 'US', path: 'us' })), + getCountryFromAccount: mockGetCountryFromAccount, getCountryFromPath: jest.fn(() => ({ iso2: 'US', id: 'US' })), getMinimumAmount: jest.fn(() => 1), railJurisdictionForBank: jest.fn(() => 'US'), @@ -482,8 +485,7 @@ describe('GROUP 6: Continue never dead-buttons', () => { // getCountryFromAccount can't resolve a country, the handler used to // `throw` inside onClick — aborting the router transition with no UI // feedback (Sentry: incomplete-app-router-transaction, 6 users/14d). - const { getCountryFromAccount } = require('@/utils/bridge.utils') - getCountryFromAccount.mockReturnValue(undefined) + mockGetCountryFromAccount.mockReturnValue(undefined) mockUseWallet.mockReturnValue({ spendableBalance: parseUnits('100', 6) }) mockWithdrawFlow.selectedMethod = { type: 'bridge', countryPath: 'us' } @@ -501,7 +503,7 @@ describe('GROUP 6: Continue never dead-buttons', () => { errorMessage: "We couldn't determine this account's country. Please contact support.", }) - getCountryFromAccount.mockReturnValue({ iso2: 'US', path: 'us' }) + mockGetCountryFromAccount.mockReturnValue({ iso2: 'US', path: 'us' }) }) test('Manteca account routes to the Manteca flow, not the bank branch', () => { From c025fd4c049bca623aafe11ed9ed5d3c7a0ea659 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 15 Jun 2026 14:08:33 -0700 Subject: [PATCH 3/4] test(withdraw): restore country mock in beforeEach for order-independent isolation --- .../(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx b/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx index 14f16ff6d..bd4f8effa 100644 --- a/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx +++ b/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx @@ -269,6 +269,10 @@ beforeEach(() => { jest.clearAllMocks() mockSearchParams.clear() applyDefaults() + // clearAllMocks() resets call history but not implementations, so restore + // the default country resolution here — tests that override it (GROUP 6) + // then don't leak into later tests regardless of order or early failure. + mockGetCountryFromAccount.mockReturnValue({ iso2: 'US', path: 'us' }) }) // ============================================================ @@ -502,8 +506,6 @@ describe('GROUP 6: Continue never dead-buttons', () => { showError: true, errorMessage: "We couldn't determine this account's country. Please contact support.", }) - - mockGetCountryFromAccount.mockReturnValue({ iso2: 'US', path: 'us' }) }) test('Manteca account routes to the Manteca flow, not the bank branch', () => { From 62e67bd3172d87f9e16461f06436a9ecc73e75bc Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 15 Jun 2026 14:44:39 -0700 Subject: [PATCH 4/4] test(withdraw): fix mockUseWallet to use spendableBalance, not balance CodeRabbit nit: the component destructures spendableBalance; the harness default mocked the wrong key (balance), leaving maxDecimalAmount=0 for existing tests. --- .../(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx b/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx index bd4f8effa..a6602a68c 100644 --- a/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx +++ b/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx @@ -248,7 +248,8 @@ function applyDefaults() { mockWithdrawFlow.selectedBankAccount = null mockUseWallet.mockReturnValue({ - balance: parseUnits('100', 6), + // component destructures `spendableBalance` (not `balance`) — CodeRabbit nit + spendableBalance: parseUnits('100', 6), }) mockUseGetExchangeRate.mockReturnValue({