diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index c287f5517..543cdae30 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -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' @@ -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/, 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) @@ -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 ( @@ -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) { @@ -524,10 +517,6 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { isVisible={isSupportedTokensModalOpen} /> )} - setShowKycStatusModal(false)} - /> {sharedModals} ) diff --git a/src/components/AddWithdraw/__tests__/AddWithdrawCountriesList.test.tsx b/src/components/AddWithdraw/__tests__/AddWithdrawCountriesList.test.tsx new file mode 100644 index 000000000..972c92fd6 --- /dev/null +++ b/src/components/AddWithdraw/__tests__/AddWithdrawCountriesList.test.tsx @@ -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 = { 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) => ( + + ), +})) +jest.mock('@/components/Global/NavHeader', () => ({ + __esModule: true, + default: () =>
, +})) +jest.mock('@/components/Global/Badges/StatusBadge', () => ({ __esModule: true, default: () => })) +jest.mock('@/components/Profile/AvatarWithBadge', () => ({ __esModule: true, default: () => })) +jest.mock('@/components/Global/EmptyStates/EmptyState', () => ({ __esModule: true, default: () =>
})) +jest.mock('@/components/AddWithdraw/DynamicBankAccountForm', () => ({ DynamicBankAccountForm: () =>
})) +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 ?
: 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() + 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() + 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() + 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() + 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() + fireEvent.click(screen.getByText('To Bank')) + + expect(mockPush).not.toHaveBeenCalled() + expect(screen.getByTestId('initiate-kyc-modal')).toBeInTheDocument() + }) +}) diff --git a/src/utils/capability-gate.test.ts b/src/utils/capability-gate.test.ts index c3b84f030..3532eb3b5 100644 --- a/src/utils/capability-gate.test.ts +++ b/src/utils/capability-gate.test.ts @@ -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 " 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') + }) +})