Skip to content
57 changes: 55 additions & 2 deletions src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -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({
Expand All @@ -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' })
})

// ============================================================
Expand Down Expand Up @@ -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'))
})
})
44 changes: 36 additions & 8 deletions src/app/(mobile-ui)/withdraw/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}` : ''
Expand All @@ -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.',
})
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -87,7 +96,7 @@ export function PaymentMethodActionList({
<div className="space-y-2">
{showDivider && <Divider text="or" />}
<div className="space-y-2">
{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
Expand Down
Original file line number Diff line number Diff line change
@@ -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: () => <div /> }))
jest.mock('@/components/Global/IconStack', () => ({ __esModule: true, default: () => <div /> }))
jest.mock('@/components/Global/Loading', () => ({ __esModule: true, default: () => <div /> }))
jest.mock('@/components/Global/Badges/StatusBadge', () => ({ __esModule: true, default: () => <div /> }))
jest.mock('@/components/ActionListCard', () => ({
ActionListCard: (props: { title: React.ReactNode; onClick: () => void; isDisabled?: boolean }) => (
<button onClick={props.onClick} disabled={props.isDisabled}>
{props.title}
</button>
),
}))

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(<PaymentMethodActionList isAmountEntered={true} />)
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(<PaymentMethodActionList isAmountEntered={true} onPayWithExternalWallet={onPay} />)
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()
})
})
Loading