Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 18 additions & 29 deletions src/components/AddWithdraw/AddWithdrawCountriesList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import { getCountryCodeForWithdraw } from '@/utils/withdraw.utils'
import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType'
import { useAppDispatch } from '@/redux/hooks'
import { bankFormActions } from '@/redux/slices/bank-form-slice'
import KycVerifiedOrReviewModal from '../Global/KycVerifiedOrReviewModal'
import { ActionListCard } from '@/components/ActionListCard'
import TokenAndNetworkConfirmationModal from '../Global/TokenAndNetworkConfirmationModal'
import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow'
Expand Down Expand Up @@ -104,25 +103,29 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => {
(country) => country.type === 'country' && country.path === countrySlugFromUrl
)

// Provider-blind bank-channel deposit gate + "any bank rail pending"
// signal (used to open the under-review status modal AFTER the gate
// already returned `ready`). Reads through useCapabilities's role-aware
// primitives — see utils/capability-gate.ts + utils/rail-channel.ts.
// Provider-blind bank-channel deposit gate, country-scoped to the rail
// jurisdiction of the country the user is on. Reads through
// useCapabilities's role-aware primitives — see utils/capability-gate.ts.
//
// SCOPE: when the user is on /add-money/<country>, narrow the gate to
// that country's rail jurisdiction. Without this, a rejected rail in an
// unrelated jurisdiction (e.g. a Bridge BANK_TRANSFER_MX REJECTED row
// from the 2026-06-01 sync) trips `blocked-rejection` here and the user
// sees "We couldn't unlock this" on a country whose own rail is fine.
// Matches the scoping already in /add-money/[country]/bank/page.tsx.
const { isKycApproved, gateFor, bankRails } = useCapabilities()
// SCOPE rationale: without the country narrowing, a stuck/rejected rail in
// an unrelated jurisdiction (e.g. a Bridge BANK_TRANSFER_MX row from the
// 2026-06-01 sync) trips `blocked-rejection` here and the user sees
// "We couldn't unlock this" on a country whose own rail is fine.
//
// The gate's `kind` is the SOLE go/no-go signal here — same as the sibling
// /add-money/[country]/bank/page.tsx. Do NOT layer a separate "is any bank
// rail pending?" check on top: a `ready` gate already means the user has a
// working in-scope rail, and `deriveGate` deliberately ranks `ready` above
// `pending`/`waiting-on-provider` so a pending sibling rail (a legit
// second-country enrollment, a still-provisioning rail) can't re-block
// them. The prior unscoped `isBankRailUnderReview` check did exactly that
// and dead-ended ready users behind a "You're all set / Go back" modal.
const { isKycApproved, gateFor } = useCapabilities()
const isUserKycApproved = isKycApproved
const bankCountry = useMemo(() => railJurisdictionForBank(currentCountry?.id), [currentCountry?.id])
const gate = useMemo(() => gateFor('deposit', { channel: 'bank', country: bankCountry }), [gateFor, bankCountry])
const isBankRailUnderReview = useMemo(() => bankRails().some((rail) => rail.status === 'pending'), [bankRails])
const { guardWithTos, showBridgeTos, hideTos } = useTosGuard()
const { setIsSupportModalOpen } = useModalsContext()
const [showKycStatusModal, setShowKycStatusModal] = useState(false)

// stores the callback to replay after tos acceptance in the list view
const pendingAfterTosRef = useRef<(() => void) | null>(null)
Expand Down Expand Up @@ -150,13 +153,9 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => {
}
return true
}
if (isBankRailUnderReview) {
setShowKycStatusModal(true)
return true
}
return false
},
[gate, isBankRailUnderReview, guardWithTos]
[gate, guardWithTos]
)

const handleFormSubmit = async (
Expand Down Expand Up @@ -184,12 +183,6 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => {
return { error: 'gate_blocked', silent: true }
}

// bridge kyc still under review — don't initiate a new sumsub flow
if (isBankRailUnderReview) {
setShowKycStatusModal(true)
return { error: 'gate_blocked', silent: true }
}

// scenario (1): happy path: if the user has already completed kyc, we can add the bank account directly
// email and name are now collected by sumsub — no need to check them here
if (isUserKycApproved) {
Expand Down Expand Up @@ -524,10 +517,6 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => {
isVisible={isSupportedTokensModalOpen}
/>
)}
<KycVerifiedOrReviewModal
isKycApprovedModalOpen={showKycStatusModal}
onClose={() => setShowKycStatusModal(false)}
/>
{sharedModals}
</div>
)
Expand Down
222 changes: 222 additions & 0 deletions src/components/AddWithdraw/__tests__/AddWithdrawCountriesList.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
/* eslint-disable @typescript-eslint/no-explicit-any -- jest.mock factories stub component props with `any`; matches the sibling add-money-states.test.tsx style. */
/**
* Regression coverage for the deposit/withdraw method list's bank gate.
*
* P0 (2026-06-01 → 06-06): a user whose own-country bank rail was ENABLED
* (scoped gate = `ready`) but who ALSO had a sibling bank rail in `pending`
* (a second-country enrollment / a still-provisioning rail) got intercepted
* by an unscoped `isBankRailUnderReview` check and dead-ended behind a
* "You're all set / Go back" modal — unable to deposit. The gate already
* ranks `ready` above `pending`; the extra check re-litigated that and lost.
*
* Fix: the gate's `kind` is the sole go/no-go signal (matching the sibling
* /add-money/[country]/bank page). These tests assert (1) a `ready` user with
* a pending sibling rail PROCEEDS, and (2) gating is still enforced when the
* gate is NOT ready — so the fix didn't just delete the guard wholesale.
*/
import React from 'react'
import { render, screen, fireEvent } from '@testing-library/react'
import AddWithdrawCountriesList from '../AddWithdrawCountriesList'

// ---- routing ----
const mockPush = jest.fn()
const mockParams: Record<string, string> = { country: 'testland' }
jest.mock('next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
useParams: () => mockParams,
useSearchParams: () => new URLSearchParams(),
}))

// ---- consts: one country ('testland', id 'US') with a bank add-method and a
// Bridge bank withdraw-method (the withdraw path also runs checkBridgeGate). ----
jest.mock('@/components/AddMoney/consts', () => ({
countryData: [{ type: 'country', path: 'testland', id: 'US', title: 'Testland', currency: 'usd' }],
COUNTRY_SPECIFIC_METHODS: {
US: {
add: [
{
id: 'bank-add',
title: 'Bank',
description: 'Add via bank transfer',
icon: 'bank',
path: '/add-money/testland/bank',
},
],
// id contains 'default-bank-withdraw' → routes through checkBridgeGate
// (not the Manteca direct path), so it exercises the same gate.
withdraw: [
{
id: 'us-default-bank-withdraw',
title: 'To Bank',
description: 'Withdraw to your bank',
icon: 'bank',
isSoon: false,
},
],
},
},
}))

// ---- capability gate (the unit under test reads gateFor) ----
// `setCapabilities` lets each test pick the gate kind + the rail set so we can
// reproduce the exact bug fixture: ready gate + a pending sibling rail.
const mockUseCapabilities = jest.fn()
jest.mock('@/hooks/useCapabilities', () => ({
useCapabilities: () => mockUseCapabilities(),
}))
function setCapabilities(gateKind: string, rails: Array<{ status: string; channel?: string; country?: string }>) {
mockUseCapabilities.mockReturnValue({
isKycApproved: rails.some((r) => r.status === 'enabled'),
gateFor: () => ({ kind: gateKind }),
// bankRails is intentionally NOT consumed by the component any more;
// expose a faithful (scope-honoring) impl so a future re-introduction
// of an unscoped read is caught rather than silently passing.
bankRails: (opts?: { country?: string }) =>
rails.filter((r) => r.channel === 'bank' && (!opts?.country || r.country === opts.country)),
})
}

// ---- light mocks for everything else the component imports ----
jest.mock('@/context/authContext', () => ({
useAuth: () => ({ user: { accounts: [] }, fetchUser: jest.fn() }),
}))
jest.mock('@/context/WithdrawFlowContext', () => ({
useWithdrawFlow: () => ({
setSelectedBankAccount: jest.fn(),
amountToWithdraw: '',
setSelectedMethod: jest.fn(),
setAmountToWithdraw: jest.fn(),
}),
}))
jest.mock('@/context/ModalsContext', () => ({
useModalsContext: () => ({ setIsSupportModalOpen: jest.fn() }),
}))
jest.mock('@/hooks/useTosGuard', () => ({
useTosGuard: () => ({ guardWithTos: jest.fn(), showBridgeTos: false, hideTos: jest.fn() }),
}))
jest.mock('@/hooks/useMultiPhaseKycFlow', () => ({
useMultiPhaseKycFlow: () => ({
handleInitiateKyc: jest.fn(),
handleSelfHealResubmit: jest.fn(),
isLoading: false,
error: null,
showWrapper: false,
}),
}))
jest.mock('@/hooks/useSafeBack', () => ({ useSafeBack: () => jest.fn() }))
jest.mock('@/hooks/useGetDeviceType', () => ({
DeviceType: { IOS: 'IOS', ANDROID: 'ANDROID', WEB: 'WEB' },
useDeviceType: () => ({ deviceType: 'WEB' }),
}))
jest.mock('@/redux/hooks', () => ({ useAppDispatch: () => jest.fn() }))
jest.mock('@/redux/slices/bank-form-slice', () => ({ bankFormActions: { clearFormData: () => ({ type: 'noop' }) } }))
jest.mock('@/app/actions/users', () => ({ addBankAccount: jest.fn() }))
jest.mock('@/utils/native-routes', () => ({
rewriteMethodPath: (p: string) => p,
withdrawBankUrl: (p: string) => `/withdraw/${p}`,
}))
jest.mock('@/utils/capacitor', () => ({ isCapacitor: () => false }))
jest.mock('@/utils/color.utils', () => ({ getColorForUsername: () => ({ lightShade: '#fff' }) }))
jest.mock('@/utils/withdraw.utils', () => ({ getCountryCodeForWithdraw: (id: string) => id }))
// bridge.utils + regions.utils are direct util collaborators that transitively
// pull the heavy @/components/AddMoney/consts barrel (regions.utils computes a
// top-level `Object.values(BRIDGE_ALPHA3_TO_ALPHA2)` at import time, which throws
// under jest when consts is stubbed). The gate is mocked, so neither return value
// affects these assertions — stub both so the real consts is never evaluated.
jest.mock('@/utils/bridge.utils', () => ({ railJurisdictionForBank: () => 'US' }))
jest.mock('@/utils/regions.utils', () => ({ getRegionIntent: () => 'STANDARD' }))

jest.mock('@/components/ActionListCard', () => ({
ActionListCard: (props: any) => (
<button data-testid={`method-${props.title?.toLowerCase()}`} onClick={props.onClick}>
{props.title}
</button>
),
}))
jest.mock('@/components/Global/NavHeader', () => ({
__esModule: true,
default: () => <div data-testid="nav-header" />,
}))
jest.mock('@/components/Global/Badges/StatusBadge', () => ({ __esModule: true, default: () => <span /> }))
jest.mock('@/components/Profile/AvatarWithBadge', () => ({ __esModule: true, default: () => <span /> }))
jest.mock('@/components/Global/EmptyStates/EmptyState', () => ({ __esModule: true, default: () => <div /> }))
jest.mock('@/components/AddWithdraw/DynamicBankAccountForm', () => ({ DynamicBankAccountForm: () => <div /> }))
jest.mock('@/components/Global/TokenAndNetworkConfirmationModal', () => ({ __esModule: true, default: () => null }))
jest.mock('@/components/Kyc/SumsubKycModals', () => ({ SumsubKycModals: () => null }))
jest.mock('@/components/Kyc/BridgeTosStep', () => ({ BridgeTosStep: () => null }))
jest.mock('@/components/Kyc/InitiateKycModal', () => ({
InitiateKycModal: (props: any) => (props.visible ? <div data-testid="initiate-kyc-modal" /> : null),
}))
jest.mock('next/image', () => ({ __esModule: true, default: () => null }))

describe('AddWithdrawCountriesList — bank gate', () => {
beforeEach(() => {
mockPush.mockClear()
})

it('P0 regression: ready gate + a pending sibling bank rail still lets the user proceed', () => {
// own-country (US) rail enabled → scoped gate = ready; a *second* bank
// rail elsewhere is pending. Pre-fix this opened the dead-end modal.
setCapabilities('ready', [
{ status: 'enabled', channel: 'bank', country: 'US' },
{ status: 'pending', channel: 'bank', country: 'EU' },
])

render(<AddWithdrawCountriesList flow="add" />)
fireEvent.click(screen.getByTestId('method-bank'))

// navigates to the bank deposit page; no KYC/status modal intercept
expect(mockPush).toHaveBeenCalledWith('/add-money/testland/bank')
expect(screen.queryByTestId('initiate-kyc-modal')).toBeNull()
})

it('also proceeds when the pending sibling rail is in the SAME country (country-scoping alone would not fix this)', () => {
// The kyc-2.0 case the documented one-liner missed: a working Manteca-
// style rail and a pending rail share the user's own country.
setCapabilities('ready', [
{ status: 'enabled', channel: 'bank', country: 'US' },
{ status: 'pending', channel: 'bank', country: 'US' },
])

render(<AddWithdrawCountriesList flow="add" />)
fireEvent.click(screen.getByTestId('method-bank'))

expect(mockPush).toHaveBeenCalledWith('/add-money/testland/bank')
expect(screen.queryByTestId('initiate-kyc-modal')).toBeNull()
})

it('still gates: a non-ready gate blocks navigation and surfaces the KYC modal', () => {
setCapabilities('needs-identity', [])

render(<AddWithdrawCountriesList flow="add" />)
fireEvent.click(screen.getByTestId('method-bank'))

expect(mockPush).not.toHaveBeenCalled()
expect(screen.getByTestId('initiate-kyc-modal')).toBeInTheDocument()
})

// checkBridgeGate is shared by BOTH flows — cover the withdraw entry too so
// the removal can't silently regress bank withdrawals.
it('withdraw flow: ready gate + pending sibling proceeds to /withdraw (no dead-end modal)', () => {
setCapabilities('ready', [
{ status: 'enabled', channel: 'bank', country: 'US' },
{ status: 'pending', channel: 'bank', country: 'EU' },
])

render(<AddWithdrawCountriesList flow="withdraw" />)
fireEvent.click(screen.getByText('To Bank'))

expect(mockPush).toHaveBeenCalledWith('/withdraw')
expect(screen.queryByTestId('initiate-kyc-modal')).toBeNull()
})

it('withdraw flow: a non-ready gate still blocks + surfaces the KYC modal', () => {
setCapabilities('needs-identity', [])

render(<AddWithdrawCountriesList flow="withdraw" />)
fireEvent.click(screen.getByText('To Bank'))

expect(mockPush).not.toHaveBeenCalled()
expect(screen.getByTestId('initiate-kyc-modal')).toBeInTheDocument()
})
})
66 changes: 66 additions & 0 deletions src/utils/capability-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,3 +409,69 @@ describe('deriveGate — ready-first ordering (Alexandre fix)', () => {
expect(gate.kind).toBe('ready')
})
})

describe('deriveGate — country scoping (the Add Money dead-end class)', () => {
// AddWithdrawCountriesList (Add Money → Bank list) regressed by reading raw
// `bankRails()` UNSCOPED to decide whether to open an "under review" modal,
// AFTER this gate had already returned `ready`. These tests pin the
// gate-level invariant the component must rely on instead: a pending
// sibling rail — in ANY country, including the user's own — never downgrades
// a scoped `ready`. The gate is the single source of truth; there is
// nothing left for a consumer to re-check.

test('cross-jurisdiction: enabled US rail + pending EU rail, scoped to US → ready', () => {
const rails = [
bankRail({ id: 'bridge.ach_us', country: 'US', status: 'enabled' }),
bankRail({ id: 'bridge.sepa_eu', method: 'SEPA_EU', country: 'EU', currency: 'EUR', status: 'pending' }),
]
expect(deriveGate(state(rails), 'deposit', { channel: 'bank', country: 'US' }).kind).toBe('ready')
})

test('same-country: enabled + pending in the SAME country → ready (the case country-scoping alone could not fix)', () => {
// Both rails share the country, so narrowing `bankRails({country})` would
// STILL see the pending one. The gate is correct anyway: `ready` outranks
// `pending`. This is why the fix removed the extra check entirely.
const rails = [
bankRail({
id: 'manteca.bank_transfer_ar',
provider: 'manteca',
method: 'BANK_TRANSFER_AR',
country: 'AR',
currency: 'ARS',
status: 'enabled',
}),
bankRail({
id: 'bridge.bank_transfer_ar',
provider: 'bridge',
method: 'BANK_TRANSFER_AR',
country: 'AR',
currency: 'ARS',
status: 'pending',
}),
]
expect(deriveGate(state(rails), 'deposit', { channel: 'bank', country: 'AR' }).kind).toBe('ready')
})

test('verified user, no rail in the requested jurisdiction → needs-enrollment (the "Unlock Bulgaria" class)', () => {
// The cross_region "Unlock <region>" modal is the CORRECT gate output:
// identity verified, but no bank rail in the scoped country yet. (The
// prod Bulgaria bug was downstream — the BE cross-region enroll ignored
// the FE's 4-bucket regionIntent='EU'. The gate classification is right;
// a fix belongs in the enrollment path, not here.)
const rails = [
bankRail({
id: 'manteca.pix_br',
provider: 'manteca',
method: 'PIX_BR',
country: 'BR',
currency: 'BRL',
status: 'enabled',
}),
]
const gate = deriveGate(state(rails, [], /* identityVerified */ true), 'deposit', {
channel: 'bank',
country: 'EU',
})
expect(gate.kind).toBe('needs-enrollment')
})
})
Loading