diff --git a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx index 63c669149..9c99f92cb 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -13,7 +13,7 @@ import { useAuth } from '@/context/authContext' import { useCapabilities } from '@/hooks/useCapabilities' import { getKycModalVariant, getGateUserMessage } from '@/utils/capability-gate' import { useModalsContext } from '@/context/ModalsContext' -import { useCreateOnramp } from '@/hooks/useCreateOnramp' +import { useCreateOnramp, GENERIC_ONRAMP_ERROR } from '@/hooks/useCreateOnramp' import { useParams, useSearchParams } from 'next/navigation' import { useCallback, useEffect, useMemo, useState } from 'react' import countryCurrencyMappings, { isNonEuroSepaCountry, isUKCountry } from '@/constants/countryCurrencyMapping' @@ -33,10 +33,13 @@ import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' import { useTosGuard } from '@/hooks/useTosGuard' import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' +import { KycReverificationPendingModal } from '@/components/Kyc/KycReverificationPendingModal' +import { useWaitingOnProviderModal } from '@/hooks/useWaitingOnProviderModal' import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' import AdvisoryPreemptModal from '@/components/Kyc/AdvisoryPreemptModal' import { useAdvisoryPreempt } from '@/hooks/useAdvisoryPreempt' import { useEeaUpliftFunnel } from '@/hooks/useEeaUpliftFunnel' +import { upliftTriggerFromGate, upliftTriggerFromAdvisory } from '@/utils/eea-uplift.utils' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { addMoneyCountryUrl } from '@/utils/native-routes' @@ -68,12 +71,11 @@ export default function OnrampBankPage() { // Local UI state (not URL-appropriate - transient) const [showWarningModal, setShowWarningModal] = useState(false) const [showKycModal, setShowKycModal] = useState(false) - const [isRiskAccepted, setIsRiskAccepted] = useState(false) const { setError, error, setOnrampData, onrampData } = useOnrampFlow() const { balance } = useWallet() const { user, fetchUser } = useAuth() - const { createOnramp, isLoading: isCreatingOnramp, error: onrampError } = useCreateOnramp() + const { createOnramp, isLoading: isCreatingOnramp } = useCreateOnramp() // inline sumsub kyc flow for bridge bank onramp // regionIntent is NOT passed here to avoid creating a backend record on mount. @@ -131,6 +133,9 @@ export default function OnrampBankPage() { const { gateFor } = useCapabilities() const bankCountry = useMemo(() => railJurisdictionForBank(selectedCountry?.id), [selectedCountry?.id]) const gate = useMemo(() => gateFor('deposit', { channel: 'bank', country: bankCountry }), [gateFor, bankCountry]) + // bridge re-verification ("we're reviewing your details") modal for the + // waiting-on-provider gate — keeps the status poll alive + auto-dismisses. + const pendingModal = useWaitingOnProviderModal(gate) // A ready bank rail can still carry a pending Bridge requirement (the gate's // `advisory`). Enforce it as a mandatory, non-skippable pre-empt at the // proceed step — the deposit cannot continue until it's completed. @@ -141,9 +146,10 @@ export default function OnrampBankPage() { // Route through the self-heal resubmit path (reheal-tagged action) so the // completed submission round-trips to Bridge. start-action mints a plain // token whose webhook completion has no Bridge relay → answers are dropped. + // note: eea_uplift_started is fired at modal-open (handleAmountContinue), + // not here, so abandoners are captured too. onCompleteNow: () => { if (!advisory) return Promise.resolve() - trackUpliftStarted(advisory) return sumsubFlow.handleSelfHealResubmit('BRIDGE', advisory.requirementKey) }, }) @@ -254,14 +260,25 @@ export default function OnrampBankPage() { if (!validateAmount(rawTokenAmount)) return if (gate.kind !== 'ready') { - // capabilities still loading OR provider doing internal review — - // silently no-op instead of flashing a misleading needs_kyc modal. - // `waiting-on-provider` means the user has nothing to do; opening - // a KYC modal would imply otherwise. - if (gate.kind === 'loading' || gate.kind === 'waiting-on-provider') return + // capabilities still loading — silently no-op instead of flashing + // a misleading needs_kyc modal. + if (gate.kind === 'loading') return + // `waiting-on-provider` means bridge is re-reviewing submitted info + // (e.g. right after an eea uplift) — the user has nothing to do but + // wait. Show the pending modal instead of a dead button, and re-arm + // the capability poller so we pick up bridge's latest status live and + // the modal auto-dismisses the moment the gate clears. + if (gate.kind === 'waiting-on-provider') { + pendingModal.open() + return + } if (gate.kind === 'accept-tos') { guardWithTos() } else { + // urgent (post-cliff) eea uplift lands here as a fixable-rejection — + // fire the funnel event as this KYC modal opens. + const upliftTrigger = upliftTriggerFromGate(gate) + if (upliftTrigger) trackUpliftStarted(upliftTrigger) setShowKycModal(true) } return @@ -271,6 +288,10 @@ export default function OnrampBankPage() { // (record the amount-entered event, open the confirmation modal) only // runs once there's no pending requirement; while one exists the modal // blocks and this never fires, so the event can't double-count. + // upcoming (future-dated) eea uplift opens the advisory modal here — fire + // the funnel event as it opens. + const advisoryTrigger = upliftTriggerFromAdvisory(advisory) + if (advisoryTrigger) trackUpliftStarted(advisoryTrigger) advisoryIntercept(() => { posthog.capture(ANALYTICS_EVENTS.DEPOSIT_AMOUNT_ENTERED, { amount_usd: usdEquivalent, @@ -291,7 +312,6 @@ export default function OnrampBankPage() { } setShowWarningModal(false) - setIsRiskAccepted(false) try { const onrampDataResponse = await createOnramp({ amount: rawTokenAmount, @@ -314,23 +334,24 @@ export default function OnrampBankPage() { } } catch (error) { setShowWarningModal(false) - const errorMessage = error instanceof Error ? error.message : 'Unknown error' + const isError = error instanceof Error + const errorMessage = isError ? error.message : GENERIC_ONRAMP_ERROR posthog.capture(ANALYTICS_EVENTS.DEPOSIT_FAILED, { method_type: 'bank', - error_message: errorMessage, + // keep the distinct label for truly-unexpected non-Error throws + error_message: isError ? errorMessage : 'Unknown error', + }) + // show the caught message directly — createOnramp carries the specific + // reason on the thrown Error, so we don't read any hook state here. + setError({ + showError: true, + errorMessage, }) - if (onrampError) { - setError({ - showError: true, - errorMessage: onrampError, - }) - } } } const handleWarningCancel = () => { setShowWarningModal(false) - setIsRiskAccepted(false) } // Redirect to inputAmount if showDetails is accessed without required data (deep link / back navigation) @@ -454,7 +475,12 @@ export default function OnrampBankPage() { setShowKycModal(false)} + onClose={() => { + // dismiss = abandon: clear the uplift latch so a later + // unrelated KYC success can't mis-fire eea_uplift_completed. + setShowKycModal(false) + resetUpliftFunnel() + }} onVerify={async () => { if (gate.kind === 'restart-identity') { await sumsubFlow.handleRestartIdentity() @@ -471,6 +497,7 @@ export default function OnrampBankPage() { }} onContactSupport={() => { setShowKycModal(false) + resetUpliftFunnel() setIsSupportModalOpen(true) }} isLoading={sumsubFlow.isLoading} @@ -482,6 +509,12 @@ export default function OnrampBankPage() { + + ) { // then push it onto the useCapabilities mock. The page reads `gateFor(...)`, // so the mock returns a stub gateFor closing over the desired state; it also // exposes `bankRails()` for the few sites that read it directly. -type Gate = 'ready' | 'accept-tos' | 'fixable-rejection' | 'blocked-rejection' | 'needs-identity' | 'needs-enrollment' +type Gate = + | 'ready' + | 'accept-tos' + | 'fixable-rejection' + | 'blocked-rejection' + | 'needs-identity' + | 'needs-enrollment' + | 'waiting-on-provider' function setGate(kind: Gate) { let rails: any[] = [] @@ -833,6 +840,22 @@ function setGate(kind: Gate) { rails = [] gateState = { kind: 'needs-identity' } break + case 'waiting-on-provider': + // provider reviewing submitted info (e.g. eea-uplift docs) — user + // has nothing to do but wait + rails = [ + { + id: 'bridge.ach_us', + provider: 'bridge', + method: 'ACH_US', + country: 'US', + currency: 'USD', + status: 'requires-info', + blockingActions: ['wait:bridge'], + }, + ] + gateState = { kind: 'waiting-on-provider', reason: { code: 'bridge_processing' } } + break } mockUseCapabilities.mockReturnValue({ @@ -898,7 +921,6 @@ function applyDefaults() { mockUseCreateOnramp.mockReturnValue({ createOnramp: jest.fn(), isLoading: false, - error: null, }) mockUseLimitsValidation.mockReturnValue({ @@ -1286,7 +1308,6 @@ describe('GROUP 5: Bridge Bank Onramp', () => { mockUseCreateOnramp.mockReturnValue({ createOnramp: mockCreateOnramp, isLoading: false, - error: null, }) resetQueryState({ step: 'inputAmount', amount: '100' }) @@ -1308,10 +1329,11 @@ describe('GROUP 5: Bridge Bank Onramp', () => { test('onramp error displays ErrorAlert', async () => { const mockCreateOnramp = jest.fn().mockRejectedValue(new Error('Service unavailable')) + // the page must surface the caught error's message directly — the hook + // exposes no error state to read (that channel was the stale-closure trap). mockUseCreateOnramp.mockReturnValue({ createOnramp: mockCreateOnramp, isLoading: false, - error: 'Service unavailable', }) resetQueryState({ step: 'inputAmount', amount: '100' }) @@ -1327,8 +1349,31 @@ describe('GROUP 5: Bridge Bank Onramp', () => { fireEvent.click(screen.getByTestId('confirm-onramp')) }) - // After error, the setError should have been called - expect(mockOnrampFlow.setError).toHaveBeenCalled() + // the caught message must be shown to the user + expect(mockOnrampFlow.setError).toHaveBeenCalledWith({ + showError: true, + errorMessage: 'Service unavailable', + }) + }) + + test('waiting-on-provider gate shows the reverification pending modal instead of a dead button', async () => { + setGate('waiting-on-provider') + const mockCreateOnramp = jest.fn() + mockUseCreateOnramp.mockReturnValue({ + createOnramp: mockCreateOnramp, + isLoading: false, + }) + resetQueryState({ step: 'inputAmount', amount: '100' }) + + renderWithProviders() + + await act(async () => { + fireEvent.click(screen.getByText('Continue')) + }) + + // no doomed transfer attempt; the user sees the bridge-review pending modal + expect(mockCreateOnramp).not.toHaveBeenCalled() + expect(await screen.findByText(/reviewing your details/i)).toBeInTheDocument() }) test('limits blocking disables Continue and shows LimitsWarningCard', () => { diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index 060ddb02c..8dde7460d 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -29,10 +29,13 @@ import { useTosGuard } from '@/hooks/useTosGuard' import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' +import { KycReverificationPendingModal } from '@/components/Kyc/KycReverificationPendingModal' +import { useWaitingOnProviderModal } from '@/hooks/useWaitingOnProviderModal' import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' import AdvisoryPreemptModal from '@/components/Kyc/AdvisoryPreemptModal' import { useAdvisoryPreempt } from '@/hooks/useAdvisoryPreempt' import { useEeaUpliftFunnel } from '@/hooks/useEeaUpliftFunnel' +import { upliftTriggerFromGate, upliftTriggerFromAdvisory } from '@/utils/eea-uplift.utils' import { useCapabilities } from '@/hooks/useCapabilities' import { getKycModalVariant, getGateUserMessage } from '@/utils/capability-gate' import { useModalsContext } from '@/context/ModalsContext' @@ -90,6 +93,9 @@ export default function WithdrawBankPage() { const { gateFor } = useCapabilities() const bankCountry = useMemo(() => railJurisdictionForBank(getCountryFromPath(country)?.id), [country]) const gate = useMemo(() => gateFor('withdraw', { channel: 'bank', country: bankCountry }), [gateFor, bankCountry]) + // bridge re-verification ("we're reviewing your details") modal for the + // waiting-on-provider gate — keeps the status poll alive + auto-dismisses. + const pendingModal = useWaitingOnProviderModal(gate) // EEA-uplift funnel events (PostHog): started on launch, completed on KYC // success. trackCompleted no-ops unless an uplift was started this session. const { @@ -117,9 +123,10 @@ export default function WithdrawBankPage() { // Route through the self-heal resubmit path (reheal-tagged action) so the // completed submission round-trips to Bridge. start-action mints a plain // token whose webhook completion has no Bridge relay → answers are dropped. + // note: eea_uplift_started is fired at modal-open (the handlers below), + // not here, so abandoners are captured too. onCompleteNow: () => { if (!advisory) return Promise.resolve() - trackUpliftStarted(advisory) return sumsubFlow.handleSelfHealResubmit('BRIDGE', advisory.requirementKey) }, }) @@ -211,12 +218,23 @@ export default function WithdrawBankPage() { const proceedWithOfframp = async () => { if (gate.kind !== 'ready') { - // Loading and waiting-on-provider both mean "user has no action to - // take" — silently no-op instead of bouncing them through Sumsub. - if (gate.kind === 'loading' || gate.kind === 'waiting-on-provider') return + // capabilities still loading — silently no-op. + if (gate.kind === 'loading') return + // `waiting-on-provider` means bridge is re-reviewing submitted info + // (e.g. right after an eea uplift) — show the pending modal instead of + // a dead button, and re-arm the capability poller so we pick up + // bridge's latest status live and the modal auto-dismisses on clear. + if (gate.kind === 'waiting-on-provider') { + pendingModal.open() + return + } if (gate.kind === 'accept-tos') { guardWithTos() } else { + // urgent (post-cliff) eea uplift lands here as a fixable-rejection — + // fire the funnel event as this KYC modal opens. + const upliftTrigger = upliftTriggerFromGate(gate) + if (upliftTrigger) trackUpliftStarted(upliftTrigger) setShowKycModal(true) } return @@ -346,7 +364,13 @@ export default function WithdrawBankPage() { // Enforce the mandatory verification pre-empt, then run the offramp. When the // gate isn't `ready` (or there's no pending requirement) this is a no-op and // proceedWithOfframp runs straight away (it handles the not-ready cases). - const handleCreateAndInitiateOfframp = () => advisoryIntercept(() => void proceedWithOfframp()) + // upcoming (future-dated) eea uplift opens the advisory modal here — fire the + // funnel event as it opens. + const handleCreateAndInitiateOfframp = () => { + const advisoryTrigger = upliftTriggerFromAdvisory(advisory) + if (advisoryTrigger) trackUpliftStarted(advisoryTrigger) + advisoryIntercept(() => void proceedWithOfframp()) + } const countryCodeForFlag = () => { if (!bankAccount?.details?.countryCode) return '' @@ -543,7 +567,12 @@ export default function WithdrawBankPage() { setShowKycModal(false)} + onClose={() => { + // dismiss = abandon: clear the uplift latch so a later + // unrelated KYC success can't mis-fire eea_uplift_completed. + setShowKycModal(false) + resetUpliftFunnel() + }} onVerify={async () => { if (gate.kind === 'restart-identity') { await sumsubFlow.handleRestartIdentity() @@ -560,6 +589,7 @@ export default function WithdrawBankPage() { }} onContactSupport={() => { setShowKycModal(false) + resetUpliftFunnel() setIsSupportModalOpen(true) }} isLoading={sumsubFlow.isLoading} @@ -569,6 +599,12 @@ export default function WithdrawBankPage() { regionName={getCountryFromPath(country)?.title} /> + + ) diff --git a/src/components/Kyc/KycReverificationPendingModal.tsx b/src/components/Kyc/KycReverificationPendingModal.tsx new file mode 100644 index 000000000..a9b0e42f2 --- /dev/null +++ b/src/components/Kyc/KycReverificationPendingModal.tsx @@ -0,0 +1,50 @@ +import { useRouter } from 'next/navigation' +import ActionModal from '@/components/Global/ActionModal' + +interface KycReverificationPendingModalProps { + isOpen: boolean + onClose: () => void + // backend-specific review copy (gate.userMessage); falls back to generic text. + message?: string +} + +/** + * Shown when the user tries to on/offramp via Bridge while Bridge is still + * re-reviewing their submitted info (e.g. right after an EEA uplift). This maps + * to the `waiting-on-provider` gate — the user has no action to take but wait. + * + * The parent ties `isOpen` to the gate, so the modal auto-dismisses the moment + * Bridge finishes (the capability poller — re-armed via markSubmitted when this + * opens — flips the gate away from `waiting-on-provider`). + */ +export const KycReverificationPendingModal = ({ isOpen, onClose, message }: KycReverificationPendingModalProps) => { + const router = useRouter() + + return ( + + {message ?? + "Your verification is being reviewed — this usually takes a few minutes. We'll let you know as soon as you're ready to continue. You can wait here or head home."} +

+ } + ctas={[ + { + text: 'Go to Home', + onClick: () => { + onClose() + router.push('/home') + }, + variant: 'purple', + className: 'w-full', + shadowSize: '4', + }, + ]} + /> + ) +} diff --git a/src/hooks/__tests__/useCreateOnramp.test.ts b/src/hooks/__tests__/useCreateOnramp.test.ts new file mode 100644 index 000000000..f9b3a46a2 --- /dev/null +++ b/src/hooks/__tests__/useCreateOnramp.test.ts @@ -0,0 +1,89 @@ +import { renderHook, act } from '@testing-library/react' +import { useCreateOnramp, GENERIC_ONRAMP_ERROR } from '@/hooks/useCreateOnramp' +import { type CountryData } from '@/components/AddMoney/consts' + +// regression test for the eea-uplift silent-failure incident: the backend +// returns { error: '...' } on 400 (e.g. "Could not create transfer: customer +// under review") but the hook used to throw a hardcoded generic message +// without reading the body — users retried blind with no reason shown. +const apiFetchMock = jest.fn() +jest.mock('@/utils/api-fetch', () => ({ + apiFetch: (...args: unknown[]) => apiFetchMock(...args), +})) + +jest.mock('@/utils/bridge.utils', () => ({ + getCurrencyConfig: () => ({ currency: 'eur', paymentRail: 'sepa' }), +})) + +jest.mock('@/app/actions/currency', () => ({ + getCurrencyPrice: jest.fn(), +})) + +const country = { id: 'DE', type: 'country', path: 'germany' } as unknown as CountryData + +describe('useCreateOnramp', () => { + beforeEach(() => { + apiFetchMock.mockReset() + jest.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('throws the backend error body on a 4xx response', async () => { + apiFetchMock.mockResolvedValue({ + ok: false, + status: 400, + json: async () => ({ error: 'Could not create transfer: customer is under review' }), + }) + const { result } = renderHook(() => useCreateOnramp()) + + await act(async () => { + await expect(result.current.createOnramp({ amount: '100', country })).rejects.toThrow( + 'Could not create transfer: customer is under review' + ) + }) + }) + + // the leak guard: a 500's body carries raw internal text (the BE global + // handler only sanitizes Prisma), so it must NOT be surfaced — the user + // gets the generic string and the body is never even read. + it('does not surface a 5xx error body, uses the generic message', async () => { + const jsonSpy = jest.fn(async () => ({ error: 'Request failed with status code 401 at /internal/bridge' })) + apiFetchMock.mockResolvedValue({ ok: false, status: 500, json: jsonSpy }) + const { result } = renderHook(() => useCreateOnramp()) + + await act(async () => { + await expect(result.current.createOnramp({ amount: '100', country })).rejects.toThrow(GENERIC_ONRAMP_ERROR) + }) + expect(jsonSpy).not.toHaveBeenCalled() + }) + + it('falls back to the generic message when a 4xx body is unparseable', async () => { + apiFetchMock.mockResolvedValue({ + ok: false, + status: 400, + json: async () => { + throw new Error('not json') + }, + }) + const { result } = renderHook(() => useCreateOnramp()) + + await act(async () => { + await expect(result.current.createOnramp({ amount: '100', country })).rejects.toThrow(GENERIC_ONRAMP_ERROR) + }) + }) + + it('returns the onramp data on success', async () => { + const data = { transferId: 't-1', depositInstructions: { amount: '100', currency: 'eur' } } + apiFetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => data }) + const { result } = renderHook(() => useCreateOnramp()) + + let response: unknown + await act(async () => { + response = await result.current.createOnramp({ amount: '100', country }) + }) + expect(response).toEqual(data) + }) +}) diff --git a/src/hooks/__tests__/useWaitingOnProviderModal.test.ts b/src/hooks/__tests__/useWaitingOnProviderModal.test.ts new file mode 100644 index 000000000..88b58ff36 --- /dev/null +++ b/src/hooks/__tests__/useWaitingOnProviderModal.test.ts @@ -0,0 +1,79 @@ +import { act, renderHook } from '@testing-library/react' +import { useWaitingOnProviderModal } from '@/hooks/useWaitingOnProviderModal' +import type { GateState } from '@/utils/capability-gate' + +const markSubmittedMock = jest.fn() +jest.mock('@/hooks/useSubmissionWindow', () => ({ + markSubmitted: () => markSubmittedMock(), +})) + +const waiting: GateState = { kind: 'waiting-on-provider', userMessage: 'Reviewing your proof of address' } +const ready: GateState = { kind: 'ready' } + +beforeEach(() => { + jest.useFakeTimers() + markSubmittedMock.mockClear() +}) +afterEach(() => { + jest.runOnlyPendingTimers() + jest.useRealTimers() +}) + +describe('useWaitingOnProviderModal', () => { + test('closed until open() is called, even while waiting', () => { + const { result } = renderHook(({ gate }) => useWaitingOnProviderModal(gate), { + initialProps: { gate: waiting }, + }) + expect(result.current.isOpen).toBe(false) + + act(() => result.current.open()) + expect(result.current.isOpen).toBe(true) + expect(markSubmittedMock).toHaveBeenCalledTimes(1) + }) + + test('surfaces the gate userMessage while open', () => { + const { result } = renderHook(({ gate }) => useWaitingOnProviderModal(gate), { + initialProps: { gate: waiting }, + }) + act(() => result.current.open()) + expect(result.current.message).toBe('Reviewing your proof of address') + }) + + test('keeps re-arming the poller on an interval while open', () => { + const { result } = renderHook(({ gate }) => useWaitingOnProviderModal(gate), { + initialProps: { gate: waiting }, + }) + act(() => result.current.open()) + markSubmittedMock.mockClear() + + act(() => jest.advanceTimersByTime(60_000)) + // ~20s cadence over 60s → at least a couple more re-arms (poll never lapses) + expect(markSubmittedMock.mock.calls.length).toBeGreaterThanOrEqual(2) + }) + + test('auto-dismisses and drops the flag when the gate resolves', () => { + const { result, rerender } = renderHook, { gate: GateState }>( + ({ gate }) => useWaitingOnProviderModal(gate), + { initialProps: { gate: waiting } } + ) + act(() => result.current.open()) + expect(result.current.isOpen).toBe(true) + + // bridge finished → gate clears + rerender({ gate: ready }) + expect(result.current.isOpen).toBe(false) + + // a transient re-flip must NOT reopen it (stale-flag guard) + rerender({ gate: waiting }) + expect(result.current.isOpen).toBe(false) + }) + + test('close() hides the modal', () => { + const { result } = renderHook(({ gate }) => useWaitingOnProviderModal(gate), { + initialProps: { gate: waiting }, + }) + act(() => result.current.open()) + act(() => result.current.close()) + expect(result.current.isOpen).toBe(false) + }) +}) diff --git a/src/hooks/useCreateOnramp.ts b/src/hooks/useCreateOnramp.ts index ef3836f1b..f41dfa809 100644 --- a/src/hooks/useCreateOnramp.ts +++ b/src/hooks/useCreateOnramp.ts @@ -20,23 +20,28 @@ export type CreateOnrampParams = { } ) +// shared so the hook, the page catch, and their tests can't drift apart. +export const GENERIC_ONRAMP_ERROR = 'Failed to create bank transfer. Please try again or contact support.' + export interface UseCreateOnrampReturn { createOnramp: (params: CreateOnrampParams) => Promise isLoading: boolean - error: string | null } /** - * Custom hook for creating onramp transactions + * Custom hook for creating onramp transactions. + * + * The specific failure reason travels via the thrown Error's message — the + * caller shows `error.message` directly. We deliberately do NOT expose a React + * `error` state: reading it in a synchronous catch sees the pre-flush (stale) + * value, which is the silent-failure bug this hook was fixed for. */ export const useCreateOnramp = (): UseCreateOnrampReturn => { const [isLoading, setIsLoading] = useState(false) - const [error, setError] = useState(null) const createOnramp = useCallback( async ({ amount, country, chargeId, recipientAddress, usdAmount }: CreateOnrampParams) => { setIsLoading(true) - setError(null) try { const { currency, paymentRail } = getCurrencyConfig(country.id, 'onramp') @@ -59,19 +64,22 @@ export const useCreateOnramp = (): UseCreateOnrampReturn => { }) if (!response.ok) { - // parse error body from backend to get specific message - let errorMessage = 'Failed to create bank transfer. Please try again or contact support.' - setError(errorMessage) - throw new Error(errorMessage) + // Only trust the backend's error copy for client errors (4xx) — + // those are deliberate, user-actionable messages from the route + // (ToS-not-accepted, KYC/endorsement needed, Bridge decline). + // 5xx bodies carry raw internal messages (the global handler only + // sanitizes Prisma), so surfacing them would leak internals — fall + // back to the generic string instead. + const isClientError = response.status >= 400 && response.status < 500 + const body = isClientError ? await response.json().catch(() => null) : null + throw new Error(body?.error || body?.message || GENERIC_ONRAMP_ERROR) } const onrampData = await response.json() return onrampData } catch (err) { console.error('Error creating onramp:', err) - // only set generic fallback if no specific error was already set by the !response.ok block - setError((prev) => prev ?? 'Failed to create bank transfer. Please try again or contact support.') - throw err + throw err instanceof Error ? err : new Error(GENERIC_ONRAMP_ERROR) } finally { setIsLoading(false) } @@ -82,6 +90,5 @@ export const useCreateOnramp = (): UseCreateOnrampReturn => { return { createOnramp, isLoading, - error, } } diff --git a/src/hooks/useEeaUpliftFunnel.test.ts b/src/hooks/useEeaUpliftFunnel.test.ts index f6699601e..5c9edff91 100644 --- a/src/hooks/useEeaUpliftFunnel.test.ts +++ b/src/hooks/useEeaUpliftFunnel.test.ts @@ -1,46 +1,87 @@ import { act, renderHook } from '@testing-library/react' import posthog from 'posthog-js' import { useEeaUpliftFunnel } from './useEeaUpliftFunnel' +import type { UpliftStartTrigger } from '@/utils/eea-uplift.utils' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' -import type { GateAdvisory } from '@/utils/capability-gate' jest.mock('posthog-js', () => ({ capture: jest.fn() })) const capture = posthog.capture as jest.Mock -const advisory: GateAdvisory = { - effectiveDate: '2026-06-29', +// future-dated advisory (upcoming) trigger +const advisoryTrigger: UpliftStartTrigger = { + effectiveDate: '2026-12-31', actionKey: 'sumsub:eea_uplift', - requirementKey: 'sof_individual_primary_purpose', + requirementKey: 'has_foreign_tax_registration', + source: 'advisory', +} + +// post-cliff blocking trigger — no effective date, reason code as the key +const blockingTrigger: UpliftStartTrigger = { + requirementKey: 'eea_uplift', + source: 'blocking', } beforeEach(() => capture.mockClear()) describe('useEeaUpliftFunnel', () => { - test('trackStarted fires eea_uplift_started with channel + advisory props', () => { + test('trackStarted fires eea_uplift_started with channel + trigger props (advisory = not urgent)', () => { const { result } = renderHook(() => useEeaUpliftFunnel('deposit')) - act(() => result.current.trackStarted(advisory)) + act(() => result.current.trackStarted(advisoryTrigger)) expect(capture).toHaveBeenCalledTimes(1) expect(capture).toHaveBeenCalledWith(ANALYTICS_EVENTS.EEA_UPLIFT_STARTED, { channel: 'deposit', - requirement_key: 'sof_individual_primary_purpose', + requirement_key: 'has_foreign_tax_registration', action_key: 'sumsub:eea_uplift', - effective_date: '2026-06-29', + effective_date: '2026-12-31', + source: 'advisory', + urgent: false, }) }) - test('trackCompleted fires eea_uplift_completed only after a start', () => { + test('blocking trigger is flagged urgent', () => { + const { result } = renderHook(() => useEeaUpliftFunnel('deposit')) + act(() => result.current.trackStarted(blockingTrigger)) + + expect(capture).toHaveBeenCalledWith( + ANALYTICS_EVENTS.EEA_UPLIFT_STARTED, + expect.objectContaining({ source: 'blocking', urgent: true, requirement_key: 'eea_uplift' }) + ) + }) + + test('trackStarted is idempotent per armed attempt (repeat clicks do not re-emit)', () => { + const { result } = renderHook(() => useEeaUpliftFunnel('deposit')) + act(() => result.current.trackStarted(blockingTrigger)) + act(() => result.current.trackStarted(blockingTrigger)) + expect(capture).toHaveBeenCalledTimes(1) + + // a genuinely different requirement re-arms and fires again + act(() => result.current.trackStarted(advisoryTrigger)) + expect(capture).toHaveBeenCalledTimes(2) + }) + + test('after reset, the same trigger fires again (a new attempt)', () => { + const { result } = renderHook(() => useEeaUpliftFunnel('deposit')) + act(() => result.current.trackStarted(blockingTrigger)) + act(() => result.current.reset()) + act(() => result.current.trackStarted(blockingTrigger)) + expect(capture).toHaveBeenCalledTimes(2) + }) + + test('trackCompleted fires eea_uplift_completed only after a start, echoing the trigger', () => { const { result } = renderHook(() => useEeaUpliftFunnel('withdraw')) - act(() => result.current.trackStarted(advisory)) + act(() => result.current.trackStarted(advisoryTrigger)) capture.mockClear() act(() => result.current.trackCompleted()) expect(capture).toHaveBeenCalledTimes(1) expect(capture).toHaveBeenCalledWith(ANALYTICS_EVENTS.EEA_UPLIFT_COMPLETED, { channel: 'withdraw', - requirement_key: 'sof_individual_primary_purpose', + requirement_key: 'has_foreign_tax_registration', action_key: 'sumsub:eea_uplift', - effective_date: '2026-06-29', + effective_date: '2026-12-31', + source: 'advisory', + urgent: false, }) }) @@ -52,7 +93,7 @@ describe('useEeaUpliftFunnel', () => { test('trackCompleted only fires once per start (ref cleared)', () => { const { result } = renderHook(() => useEeaUpliftFunnel('deposit')) - act(() => result.current.trackStarted(advisory)) + act(() => result.current.trackStarted(advisoryTrigger)) capture.mockClear() act(() => result.current.trackCompleted()) @@ -62,7 +103,7 @@ describe('useEeaUpliftFunnel', () => { test('reset clears a pending start so a later success cannot mis-fire completed', () => { const { result } = renderHook(() => useEeaUpliftFunnel('deposit')) - act(() => result.current.trackStarted(advisory)) + act(() => result.current.trackStarted(advisoryTrigger)) capture.mockClear() // user abandoned the flow → reset, then an unrelated KYC success fires diff --git a/src/hooks/useEeaUpliftFunnel.ts b/src/hooks/useEeaUpliftFunnel.ts index c6eb11fe7..ef35b9f1a 100644 --- a/src/hooks/useEeaUpliftFunnel.ts +++ b/src/hooks/useEeaUpliftFunnel.ts @@ -1,54 +1,65 @@ import { useCallback, useRef } from 'react' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' -import type { GateAdvisory } from '@/utils/capability-gate' +import type { UpliftStartTrigger } from '@/utils/eea-uplift.utils' type UpliftChannel = 'deposit' | 'withdraw' +function upliftEventProps(channel: UpliftChannel, trigger: UpliftStartTrigger) { + return { + channel, + requirement_key: trigger.requirementKey, + action_key: trigger.actionKey, + effective_date: trigger.effectiveDate, + source: trigger.source, + // blocking = the cliff date has passed → already gating → urgent. + urgent: trigger.source === 'blocking', + } +} + +function sameTrigger(a: UpliftStartTrigger | null, b: UpliftStartTrigger): boolean { + return !!a && a.requirementKey === b.requirementKey && a.source === b.source +} + /** * Fires the EEA-uplift funnel events for PostHog so the flow can be filtered - * directly: `eea_uplift_started` when the user launches the verification, and - * `eea_uplift_completed` on KYC success. + * directly (and session recordings tagged): `eea_uplift_started` when the uplift + * modal OPENS, and `eea_uplift_completed` on KYC success. + * + * Firing on modal-open (not on the modal's CTA) means abandoners are captured + * too — the whole point is to watch who attempts the uplift and whether they + * finish. `trackStarted` is idempotent per armed attempt: re-clicking Continue + * while the same requirement is already latched does not re-emit `started` + * (until `reset`/`trackCompleted` clears the latch, at which point a genuine new + * attempt fires again). * - * `trackCompleted` only emits if a start was recorded in this session — the KYC + * `trackCompleted` only emits if a start was recorded in this session: the KYC * success callback on the bank pages is shared with non-uplift KYC, so the - * `startedRef` guard stops a generic success from mis-firing the completed - * event. The advisory snapshot is captured at start time because the gate's - * `advisory` clears once the requirement resolves, so it's gone by completion. + * latch stops a generic success from mis-firing `completed`. The trigger + * snapshot is captured at start because the gate's advisory/reason clears once + * the requirement resolves, so it's gone by completion. * - * `reset` clears the pending start on abandonment (KYC modal closed without - * success). Without it the latch would survive an abandoned attempt, and a later - * unrelated KYC success on the same mounted page could mis-fire `completed`. + * `reset` clears the pending start on abandonment (uplift modal dismissed + * without success), so a later unrelated KYC success can't mis-fire. */ export function useEeaUpliftFunnel(channel: UpliftChannel) { - const startedRef = useRef(null) + const startedRef = useRef(null) const trackStarted = useCallback( - // `advisory` is required: callers gate on it before launching, and the - // funnel contract needs requirement_key / action_key / effective_date - // always present on the event. - (advisory: GateAdvisory) => { - startedRef.current = advisory - posthog.capture(ANALYTICS_EVENTS.EEA_UPLIFT_STARTED, { - channel, - requirement_key: advisory.requirementKey, - action_key: advisory.actionKey, - effective_date: advisory.effectiveDate, - }) + (trigger: UpliftStartTrigger) => { + // idempotent per armed attempt — don't re-emit on repeat clicks. + if (sameTrigger(startedRef.current, trigger)) return + startedRef.current = trigger + posthog.capture(ANALYTICS_EVENTS.EEA_UPLIFT_STARTED, upliftEventProps(channel, trigger)) }, [channel] ) const trackCompleted = useCallback(() => { - const advisory = startedRef.current - if (!advisory) return + const trigger = startedRef.current + if (!trigger) return startedRef.current = null - posthog.capture(ANALYTICS_EVENTS.EEA_UPLIFT_COMPLETED, { - channel, - requirement_key: advisory.requirementKey, - action_key: advisory.actionKey, - effective_date: advisory.effectiveDate, - }) + posthog.capture(ANALYTICS_EVENTS.EEA_UPLIFT_COMPLETED, upliftEventProps(channel, trigger)) }, [channel]) const reset = useCallback(() => { diff --git a/src/hooks/useWaitingOnProviderModal.ts b/src/hooks/useWaitingOnProviderModal.ts new file mode 100644 index 000000000..5637854b9 --- /dev/null +++ b/src/hooks/useWaitingOnProviderModal.ts @@ -0,0 +1,52 @@ +import { useCallback, useEffect, useState } from 'react' +import { markSubmitted } from '@/hooks/useSubmissionWindow' +import type { GateState } from '@/utils/capability-gate' + +// Re-arm cadence: comfortably under SUBMISSION_WINDOW_MS (30s) so the singleton +// user-poller never lapses while the user waits on the modal. +const REARM_INTERVAL_MS = 20_000 + +/** + * Drives the "Bridge is re-reviewing, please wait" modal for the + * `waiting-on-provider` gate (e.g. right after an EEA uplift, when the user + * tries to on/offramp while Bridge re-verifies). + * + * `waiting-on-provider` rails sit at `requires-info` — NOT `pending` — so the + * auto-refresh poller ({@link useUserAutoRefresh}) isn't self-sustaining here: + * a single markSubmitted() window (30s) would lapse mid-review and freeze the + * modal open forever. So while the modal is open we re-arm the submission + * window on an interval, keeping the user query refetching (~4s) until Bridge's + * decision flips the gate. `isOpen` is gated on the LIVE gate kind, so the modal + * auto-dismisses the moment the wait clears; we also drop the request flag then, + * so a later transient re-flip to `waiting-on-provider` can't reopen it on its own. + */ +export function useWaitingOnProviderModal(gate: GateState) { + const [requested, setRequested] = useState(false) + const isWaiting = gate.kind === 'waiting-on-provider' + const isOpen = requested && isWaiting + // narrow on the discriminated union rather than casting, so a rename of + // `userMessage` fails the build instead of silently returning undefined. + const message = isOpen && gate.kind === 'waiting-on-provider' ? (gate.userMessage ?? undefined) : undefined + + const open = useCallback(() => { + markSubmitted() // arm the poller immediately + setRequested(true) + }, []) + + const close = useCallback(() => setRequested(false), []) + + // drop the flag once the gate resolves, so it can't spuriously reopen. + useEffect(() => { + if (requested && !isWaiting) setRequested(false) + }, [requested, isWaiting]) + + // keep the poller alive for the whole wait (the 30s window would otherwise + // lapse mid-review, and the modal's auto-dismiss would never fire). + useEffect(() => { + if (!isOpen) return + const id = setInterval(() => markSubmitted(), REARM_INTERVAL_MS) + return () => clearInterval(id) + }, [isOpen]) + + return { isOpen, open, close, message } +} diff --git a/src/utils/eea-uplift.utils.test.ts b/src/utils/eea-uplift.utils.test.ts new file mode 100644 index 000000000..79adfdace --- /dev/null +++ b/src/utils/eea-uplift.utils.test.ts @@ -0,0 +1,58 @@ +import { upliftTriggerFromGate, upliftTriggerFromAdvisory, EEA_UPLIFT_REQUIREMENT_KEYS } from './eea-uplift.utils' +import type { GateState, GateAdvisory } from '@/utils/capability-gate' + +describe('upliftTriggerFromGate (blocking path)', () => { + test('returns a blocking trigger for an eea_uplift fixable-rejection', () => { + const gate = { kind: 'fixable-rejection', userMessage: 'x', reason: { code: 'eea_uplift', userMessage: 'x' } } + expect(upliftTriggerFromGate(gate as GateState)).toEqual({ requirementKey: 'eea_uplift', source: 'blocking' }) + }) + + test('matches the eea_uplift_with_tin variant', () => { + const gate = { + kind: 'fixable-rejection', + userMessage: 'x', + reason: { code: 'eea_uplift_with_tin', userMessage: 'x' }, + } + expect(upliftTriggerFromGate(gate as GateState)?.requirementKey).toBe('eea_uplift_with_tin') + }) + + test('returns null for a non-uplift rejection', () => { + const gate = { + kind: 'fixable-rejection', + userMessage: 'x', + reason: { code: 'document_rejected', userMessage: 'x' }, + } + expect(upliftTriggerFromGate(gate as GateState)).toBeNull() + }) + + test('returns null for a gate variant without a reason', () => { + expect(upliftTriggerFromGate({ kind: 'ready' } as GateState)).toBeNull() + expect(upliftTriggerFromGate({ kind: 'needs-identity' } as GateState)).toBeNull() + }) +}) + +describe('upliftTriggerFromAdvisory (advisory path)', () => { + test.each([...EEA_UPLIFT_REQUIREMENT_KEYS])('builds an advisory trigger for uplift key: %s', (requirementKey) => { + const advisory: GateAdvisory = { effectiveDate: '2026-12-31', actionKey: 'k', requirementKey } + expect(upliftTriggerFromAdvisory(advisory)).toEqual({ + requirementKey, + actionKey: 'k', + effectiveDate: '2026-12-31', + source: 'advisory', + }) + }) + + test('returns null for a co-occurring non-uplift key (proof_of_address / gov-id)', () => { + const advisory: GateAdvisory = { + effectiveDate: '2026-06-29', + actionKey: 'k', + requirementKey: 'proof_of_address_document', + } + expect(upliftTriggerFromAdvisory(advisory)).toBeNull() + }) + + test('is safe for undefined / keyless advisory', () => { + expect(upliftTriggerFromAdvisory(undefined)).toBeNull() + expect(upliftTriggerFromAdvisory({ effectiveDate: '2026-12-31', actionKey: 'k' })).toBeNull() + }) +}) diff --git a/src/utils/eea-uplift.utils.ts b/src/utils/eea-uplift.utils.ts new file mode 100644 index 000000000..4370b9c2a --- /dev/null +++ b/src/utils/eea-uplift.utils.ts @@ -0,0 +1,68 @@ +import type { GateAdvisory, GateState } from '@/utils/capability-gate' + +/** + * Bridge remediation identified as an EEA-uplift questionnaire, split by how it + * reaches the FE: + * + * - BLOCKING (post-cliff, effective date passed → gate `fixable-rejection`): the + * BE sets `reason.code` to the questionnaire cluster — `eea_uplift` / + * `eea_uplift_with_tin` (peanut-api-ts resolver). So a code prefix match is + * the clean signal. + * - ADVISORY (future-dated → gate `ready` + advisory): the BE injects only + * `effectiveDate` + `requirementKey` onto the NextAction, NOT the cluster, so + * here we match the raw requirement keys whose cluster is `eea_uplift`. + * + * Key set derived from prod remediation data (2026-07). PoA / gov-id-expired + * co-occur on the same cliff but are separate remediation (cluster `null`), so + * they're deliberately excluded — keying PoA→eea would over-fire on ordinary + * document rejections for non-EEA users. + * + * KNOWN LIMITATION: both signals mirror BE-owned strings (the resolver's cluster + * codes and requirement keys). If the BE adds/renames an uplift key or reason + * code, that segment silently drops out of the funnel until this file follows — + * accepted tradeoff for keeping this FE-only. + */ +export const EEA_UPLIFT_REQUIREMENT_KEYS = new Set([ + 'sof_individual_primary_purpose', + 'has_foreign_tax_registration', + 'place_of_birth_missing', + 'nationalities', +]) + +/** + * The uplift attempt being started. `source` distinguishes the two remediation + * paths and — since blocking = the effective date has already passed — doubles + * as the urgency signal: `blocking` = urgent post-cliff, `advisory` = upcoming. + */ +export type UpliftStartTrigger = { + requirementKey?: string + actionKey?: string + effectiveDate?: string + source: 'advisory' | 'blocking' +} + +/** + * Blocking path: the gate's reason code IS the `eea_uplift*` cluster. Returns a + * ready-to-fire trigger when it's an uplift remediation, else null. + */ +export function upliftTriggerFromGate(gate: GateState): UpliftStartTrigger | null { + // narrow on the union ('reason' is native to the gate variants that carry + // it) rather than casting, so a rename of reason.code fails the build. + const code = 'reason' in gate ? gate.reason?.code : undefined + if (!code?.startsWith('eea_uplift')) return null + return { requirementKey: code, source: 'blocking' } +} + +/** + * Advisory path: the future-dated requirement key belongs to the uplift set. + * Returns a ready-to-fire trigger, else null. + */ +export function upliftTriggerFromAdvisory(advisory: GateAdvisory | undefined): UpliftStartTrigger | null { + if (!advisory?.requirementKey || !EEA_UPLIFT_REQUIREMENT_KEYS.has(advisory.requirementKey)) return null + return { + requirementKey: advisory.requirementKey, + actionKey: advisory.actionKey, + effectiveDate: advisory.effectiveDate, + source: 'advisory', + } +}