From 744b9db60af06b47bf36569eec1b131be87e97e2 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 15 Jun 2026 13:42:04 -0700 Subject: [PATCH 1/5] 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/5] 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/5] 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/5] 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({ From fdf12fa0df2254908c9b052512aa316b9e2de4af Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 15 Jun 2026 14:39:09 -0700 Subject: [PATCH 5/5] fix(send): hide 'Exchange or Wallet' card when the flow can't honor it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PaymentMethodActionList renders an 'Exchange or Wallet' card (soon:false, so enabled) whose tap only does anything when the caller passes onPayWithExternalWallet. The semantic-request flow passes it; the direct-send flow (SendInputView, non- logged-in users) does NOT — and direct-send has no external-wallet path at all (only Input/Status/Success views). So a guest who entered an amount and tapped the card got nothing: a dead button (audit HIGH finding, same class as the withdraw Continue throw). Fix at the component level so it's correct-by-construction for every caller: only render the exchange-or-wallet card when an onPayWithExternalWallet handler is provided. No caller can surface a card it can't honor. Semantic-request is unchanged (still passes the handler). Adds the component's first tests. --- .../components/PaymentMethodActionList.tsx | 11 +++- .../PaymentMethodActionList.test.tsx | 66 +++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 src/features/payments/shared/components/__tests__/PaymentMethodActionList.test.tsx 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() + }) +})