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.', + }) } } } diff --git a/src/features/payments/shared/components/PaymentMethodActionList.tsx b/src/features/payments/shared/components/PaymentMethodActionList.tsx index 9033825bf..f1f0802fe 100644 --- a/src/features/payments/shared/components/PaymentMethodActionList.tsx +++ b/src/features/payments/shared/components/PaymentMethodActionList.tsx @@ -59,6 +59,15 @@ export function PaymentMethodActionList({ methods: ACTION_METHODS, }) + // The "Exchange or Wallet" card only does anything when the caller passes an + // external-wallet handler (semantic-request flow). The direct-send flow has + // no external-wallet path, so without this filter the card renders enabled + // but its tap is a silent no-op — a dead button. Only offer it when we can + // actually honor it. + const visibleMethods = onPayWithExternalWallet + ? sortedMethods + : sortedMethods.filter((method) => method.id !== 'exchange-or-wallet') + const handleMethodClick = (method: PaymentMethod) => { // for all methods, save current url and redirect to setup with add-money as final destination // verification will be handled in the add-money flow after login @@ -87,7 +96,7 @@ export function PaymentMethodActionList({
{showDivider && }
- {sortedMethods.map((method) => { + {visibleMethods.map((method) => { // does this method's gate require identity verification (badge display only)? const qrMethodNeedsUnlock = ['mercadopago', 'pix'].includes(method.id) && !isQrPayEnabled const bankMethodNeedsUnlock = method.id === 'bank' && !isBankEnabled diff --git a/src/features/payments/shared/components/__tests__/PaymentMethodActionList.test.tsx b/src/features/payments/shared/components/__tests__/PaymentMethodActionList.test.tsx new file mode 100644 index 000000000..2d494d6fb --- /dev/null +++ b/src/features/payments/shared/components/__tests__/PaymentMethodActionList.test.tsx @@ -0,0 +1,66 @@ +/** + * PaymentMethodActionList — "Exchange or Wallet" visibility + * + * Regression: the direct-send flow rendered the "Exchange or Wallet" card + * without an onPayWithExternalWallet handler, so it was enabled but its tap was + * a silent no-op (dead button). The card must only render when the caller can + * honor it (i.e. provides the handler — the semantic-request flow does). + */ +import React from 'react' +import { render, screen, fireEvent } from '@testing-library/react' + +const mockRouterPush = jest.fn() +jest.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockRouterPush }), +})) + +// Return a fixed method list so the filter is the only thing under test. +const METHODS = [ + { id: 'bank', title: 'Bank', description: 'd', icons: [], soon: false }, + { id: 'exchange-or-wallet', title: 'Exchange or Wallet', description: 'd', icons: [], soon: false }, +] +jest.mock('@/hooks/useGeoFilteredPaymentOptions', () => ({ + useGeoFilteredPaymentOptions: () => ({ filteredMethods: METHODS, isLoading: false }), +})) + +jest.mock('@/hooks/useCapabilities', () => ({ + useCapabilities: () => ({ canDo: () => true, bankRails: () => [] }), +})) + +jest.mock('@/utils/general.utils', () => ({ saveRedirectUrl: jest.fn() })) + +jest.mock('@/components/0_Bruddle/Divider', () => ({ __esModule: true, default: () =>
})) +jest.mock('@/components/Global/IconStack', () => ({ __esModule: true, default: () =>
})) +jest.mock('@/components/Global/Loading', () => ({ __esModule: true, default: () =>
})) +jest.mock('@/components/Global/Badges/StatusBadge', () => ({ __esModule: true, default: () =>
})) +jest.mock('@/components/ActionListCard', () => ({ + ActionListCard: (props: { title: React.ReactNode; onClick: () => void; isDisabled?: boolean }) => ( + + ), +})) + +import { PaymentMethodActionList } from '../PaymentMethodActionList' + +beforeEach(() => jest.clearAllMocks()) + +describe('PaymentMethodActionList — Exchange or Wallet card', () => { + it('is hidden when no onPayWithExternalWallet handler is provided (direct-send)', () => { + render() + expect(screen.queryByText('Exchange or Wallet')).not.toBeInTheDocument() + // other methods still render + expect(screen.getByText('Bank')).toBeInTheDocument() + }) + + it('is shown and invokes the handler when onPayWithExternalWallet is provided (semantic-request)', () => { + const onPay = jest.fn() + render() + const card = screen.getByText('Exchange or Wallet') + expect(card).toBeInTheDocument() + fireEvent.click(card) + expect(onPay).toHaveBeenCalledTimes(1) + // the external-wallet handler short-circuits — no /setup redirect + expect(mockRouterPush).not.toHaveBeenCalled() + }) +})