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..a6602a68c 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'), @@ -245,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({ @@ -266,6 +270,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' }) }) // ============================================================ @@ -472,3 +480,48 @@ 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). + mockGetCountryFromAccount.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.", + }) + }) + + 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.', + }) } } }