From e65bf16484c9697defc890eb6a778103d1475ad6 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:27:45 +0530 Subject: [PATCH 1/8] fix: surface bridge deposit errors instead of failing silently EEA-uplift users whose docs are under Bridge review were retrying deposits with zero feedback: the onramp hook threw a hardcoded message without reading the backend's { error } body, the page's catch read the hook's error state before it flushed (always null on first attempt), and the waiting-on-provider gate dead-ended the Continue button with no message. Parse the error body, show the caught message directly, and tell under-review users their verification is being processed. --- .../add-money/[country]/bank/page.tsx | 39 ++++++---- .../__tests__/add-money-states.test.tsx | 61 ++++++++++++++- src/hooks/__tests__/useCreateOnramp.test.ts | 78 +++++++++++++++++++ src/hooks/useCreateOnramp.ts | 6 +- 4 files changed, 166 insertions(+), 18 deletions(-) create mode 100644 src/hooks/__tests__/useCreateOnramp.test.ts 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..01f4cb31d 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -73,7 +73,7 @@ export default function OnrampBankPage() { 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. @@ -254,11 +254,20 @@ 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 the user has nothing to do (e.g. bridge + // reviewing eea-uplift docs) — tell them to wait instead of a dead + // button or a doomed transfer attempt with no feedback. + if (gate.kind === 'waiting-on-provider') { + setError({ + showError: true, + errorMessage: + 'Your verification details are being reviewed. This usually takes a few minutes — please try again soon.', + }) + return + } if (gate.kind === 'accept-tos') { guardWithTos() } else { @@ -314,17 +323,21 @@ export default function OnrampBankPage() { } } catch (error) { setShowWarningModal(false) - const errorMessage = error instanceof Error ? error.message : 'Unknown error' + const errorMessage = + error instanceof Error + ? error.message + : 'Failed to create bank transfer. Please try again or contact support.' posthog.capture(ANALYTICS_EVENTS.DEPOSIT_FAILED, { method_type: 'bank', error_message: errorMessage, }) - if (onrampError) { - setError({ - showError: true, - errorMessage: onrampError, - }) - } + // show the caught message directly — the hook's error state hasn't + // flushed yet in this synchronous catch, so reading it here always + // sees the previous render's value (null on first attempt). + setError({ + showError: true, + errorMessage, + }) } } diff --git a/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx b/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx index affddb371..8cac5a91b 100644 --- a/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx +++ b/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx @@ -746,7 +746,14 @@ function setParams(params: Record) { // 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({ @@ -1308,10 +1331,13 @@ describe('GROUP 5: Bridge Bank Onramp', () => { test('onramp error displays ErrorAlert', async () => { const mockCreateOnramp = jest.fn().mockRejectedValue(new Error('Service unavailable')) + // error: null reproduces the first-attempt reality — the hook's error + // state hasn't flushed when the page's catch runs, so the page must + // surface the caught message itself, not read the hook state. mockUseCreateOnramp.mockReturnValue({ createOnramp: mockCreateOnramp, isLoading: false, - error: 'Service unavailable', + error: null, }) resetQueryState({ step: 'inputAmount', amount: '100' }) @@ -1327,8 +1353,35 @@ 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 under-review message instead of silent no-op', async () => { + setGate('waiting-on-provider') + const mockCreateOnramp = jest.fn() + mockUseCreateOnramp.mockReturnValue({ + createOnramp: mockCreateOnramp, + isLoading: false, + error: null, + }) + resetQueryState({ step: 'inputAmount', amount: '100' }) + + renderWithProviders() + + await act(async () => { + fireEvent.click(screen.getByText('Continue')) + }) + + // no transfer attempt, but the user is told why + expect(mockCreateOnramp).not.toHaveBeenCalled() + expect(mockOnrampFlow.setError).toHaveBeenCalledWith({ + showError: true, + errorMessage: expect.stringContaining('being reviewed'), + }) }) test('limits blocking disables Continue and shows LimitsWarningCard', () => { diff --git a/src/hooks/__tests__/useCreateOnramp.test.ts b/src/hooks/__tests__/useCreateOnramp.test.ts new file mode 100644 index 000000000..18b8ba05c --- /dev/null +++ b/src/hooks/__tests__/useCreateOnramp.test.ts @@ -0,0 +1,78 @@ +import { renderHook, act } from '@testing-library/react' +import { useCreateOnramp } 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('surfaces the backend error body on a non-ok response', async () => { + apiFetchMock.mockResolvedValue({ + ok: false, + 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' + ) + }) + expect(result.current.error).toBe('Could not create transfer: customer is under review') + }) + + it('falls back to the generic message when the error body is unparseable', async () => { + apiFetchMock.mockResolvedValue({ + ok: false, + json: async () => { + throw new Error('not json') + }, + }) + const { result } = renderHook(() => useCreateOnramp()) + + await act(async () => { + await expect(result.current.createOnramp({ amount: '100', country })).rejects.toThrow( + 'Failed to create bank transfer. Please try again or contact support.' + ) + }) + expect(result.current.error).toBe('Failed to create bank transfer. Please try again or contact support.') + }) + + it('returns the onramp data on success', async () => { + const data = { transferId: 't-1', depositInstructions: { amount: '100', currency: 'eur' } } + apiFetchMock.mockResolvedValue({ ok: true, 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) + expect(result.current.error).toBeNull() + }) +}) diff --git a/src/hooks/useCreateOnramp.ts b/src/hooks/useCreateOnramp.ts index ef3836f1b..fc70c9e36 100644 --- a/src/hooks/useCreateOnramp.ts +++ b/src/hooks/useCreateOnramp.ts @@ -60,7 +60,11 @@ 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.' + const body = await response.json().catch(() => null) + const errorMessage = + body?.error || + body?.message || + 'Failed to create bank transfer. Please try again or contact support.' setError(errorMessage) throw new Error(errorMessage) } From 88a5117b45416a0913f2796af1d2c761018373bd Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:24:52 +0530 Subject: [PATCH 2/8] fix: gate error-body surfacing to 4xx, drop dead hook error state Addresses code-review findings on the silent-failure fix: - only surface the backend body for 4xx (client errors); 5xx bodies carry raw internal messages the global handler doesn't sanitize, so those fall back to the generic string (no internal-detail leak). - make useCreateOnramp throw-only: its React error state had no reader after the page stopped destructuring it, and was the same stale-closure trap the page fix works around. The thrown Error carries the reason. - reuse gate.userMessage for the waiting-on-provider copy instead of hardcoding, matching every other gate consumer. - extract GENERIC_ONRAMP_ERROR so the fallback copy lives in one place. - keep the distinct 'Unknown error' analytics label for non-Error throws. --- .../add-money/[country]/bank/page.tsx | 19 +++++----- .../__tests__/add-money-states.test.tsx | 9 ++--- src/hooks/__tests__/useCreateOnramp.test.ts | 31 ++++++++++------ src/hooks/useCreateOnramp.ts | 35 ++++++++++--------- 4 files changed, 52 insertions(+), 42 deletions(-) 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 01f4cb31d..d5e4cec20 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' @@ -261,9 +261,12 @@ export default function OnrampBankPage() { // reviewing eea-uplift docs) — tell them to wait instead of a dead // button or a doomed transfer attempt with no feedback. if (gate.kind === 'waiting-on-provider') { + // prefer the backend's specific review copy (via the gate reason) + // and fall back to generic wait text. setError({ showError: true, errorMessage: + gate.userMessage ?? 'Your verification details are being reviewed. This usually takes a few minutes — please try again soon.', }) return @@ -323,17 +326,15 @@ export default function OnrampBankPage() { } } catch (error) { setShowWarningModal(false) - const errorMessage = - error instanceof Error - ? error.message - : 'Failed to create bank transfer. Please try again or contact support.' + 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 — the hook's error state hasn't - // flushed yet in this synchronous catch, so reading it here always - // sees the previous render's value (null on first attempt). + // 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, diff --git a/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx b/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx index 8cac5a91b..195105e38 100644 --- a/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx +++ b/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx @@ -921,7 +921,6 @@ function applyDefaults() { mockUseCreateOnramp.mockReturnValue({ createOnramp: jest.fn(), isLoading: false, - error: null, }) mockUseLimitsValidation.mockReturnValue({ @@ -1309,7 +1308,6 @@ describe('GROUP 5: Bridge Bank Onramp', () => { mockUseCreateOnramp.mockReturnValue({ createOnramp: mockCreateOnramp, isLoading: false, - error: null, }) resetQueryState({ step: 'inputAmount', amount: '100' }) @@ -1331,13 +1329,11 @@ describe('GROUP 5: Bridge Bank Onramp', () => { test('onramp error displays ErrorAlert', async () => { const mockCreateOnramp = jest.fn().mockRejectedValue(new Error('Service unavailable')) - // error: null reproduces the first-attempt reality — the hook's error - // state hasn't flushed when the page's catch runs, so the page must - // surface the caught message itself, not read the hook state. + // 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: null, }) resetQueryState({ step: 'inputAmount', amount: '100' }) @@ -1366,7 +1362,6 @@ describe('GROUP 5: Bridge Bank Onramp', () => { mockUseCreateOnramp.mockReturnValue({ createOnramp: mockCreateOnramp, isLoading: false, - error: null, }) resetQueryState({ step: 'inputAmount', amount: '100' }) diff --git a/src/hooks/__tests__/useCreateOnramp.test.ts b/src/hooks/__tests__/useCreateOnramp.test.ts index 18b8ba05c..f9b3a46a2 100644 --- a/src/hooks/__tests__/useCreateOnramp.test.ts +++ b/src/hooks/__tests__/useCreateOnramp.test.ts @@ -1,5 +1,5 @@ import { renderHook, act } from '@testing-library/react' -import { useCreateOnramp } from '@/hooks/useCreateOnramp' +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 @@ -31,9 +31,10 @@ describe('useCreateOnramp', () => { jest.restoreAllMocks() }) - it('surfaces the backend error body on a non-ok response', async () => { + 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()) @@ -43,12 +44,26 @@ describe('useCreateOnramp', () => { 'Could not create transfer: customer is under review' ) }) - expect(result.current.error).toBe('Could not create transfer: customer is under review') }) - it('falls back to the generic message when the error body is unparseable', async () => { + // 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') }, @@ -56,16 +71,13 @@ describe('useCreateOnramp', () => { const { result } = renderHook(() => useCreateOnramp()) await act(async () => { - await expect(result.current.createOnramp({ amount: '100', country })).rejects.toThrow( - 'Failed to create bank transfer. Please try again or contact support.' - ) + await expect(result.current.createOnramp({ amount: '100', country })).rejects.toThrow(GENERIC_ONRAMP_ERROR) }) - expect(result.current.error).toBe('Failed to create bank transfer. Please try again or contact support.') }) it('returns the onramp data on success', async () => { const data = { transferId: 't-1', depositInstructions: { amount: '100', currency: 'eur' } } - apiFetchMock.mockResolvedValue({ ok: true, json: async () => data }) + apiFetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => data }) const { result } = renderHook(() => useCreateOnramp()) let response: unknown @@ -73,6 +85,5 @@ describe('useCreateOnramp', () => { response = await result.current.createOnramp({ amount: '100', country }) }) expect(response).toEqual(data) - expect(result.current.error).toBeNull() }) }) diff --git a/src/hooks/useCreateOnramp.ts b/src/hooks/useCreateOnramp.ts index fc70c9e36..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,23 +64,22 @@ export const useCreateOnramp = (): UseCreateOnrampReturn => { }) if (!response.ok) { - // parse error body from backend to get specific message - const body = await response.json().catch(() => null) - const errorMessage = - body?.error || - body?.message || - '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) } @@ -86,6 +90,5 @@ export const useCreateOnramp = (): UseCreateOnrampReturn => { return { createOnramp, isLoading, - error, } } From 3c6a36e3d44c354920de2a60ed44aacf2f7e91ad Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:46:09 +0530 Subject: [PATCH 3/8] feat(analytics): fire eea_uplift funnel on modal open, cover all remediation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The eea_uplift_started event fired only on the advisory modal's 'Complete now' CTA, so it missed (a) users who open the uplift modal and abandon, and (b) the entire urgent post-cliff cohort, whose remediation is now blocking (fixable-rejection → KYC modal) rather than a future-dated advisory. Fire at modal-open instead, across both remediation paths and both channels (deposit + withdraw): - blocking/urgent: gate.reason.code === 'eea_uplift*' (BE sets it to the questionnaire cluster). - advisory/upcoming: requirementKey in the eea_uplift set (sof_individual_ primary_purpose, has_foreign_tax_registration, place_of_birth_missing, nationalities) — derived from prod remediation data. Event now carries source + urgent + effective_date so urgent vs upcoming (dob-type) split in PostHog. PoA / gov-id-expired co-occur on the cliff but are excluded (separate remediation) to avoid over-firing on generic doc rejections for non-EEA users. --- .../add-money/[country]/bank/page.tsx | 20 ++++- .../withdraw/[country]/bank/page.tsx | 24 +++++- src/hooks/useEeaUpliftFunnel.test.ts | 51 ++++++++---- src/hooks/useEeaUpliftFunnel.ts | 77 +++++++++++-------- src/utils/eea-uplift.utils.test.ts | 53 +++++++++++++ src/utils/eea-uplift.utils.ts | 39 ++++++++++ 6 files changed, 214 insertions(+), 50 deletions(-) create mode 100644 src/utils/eea-uplift.utils.test.ts create mode 100644 src/utils/eea-uplift.utils.ts 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 d5e4cec20..f03e09563 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -37,6 +37,7 @@ import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' import AdvisoryPreemptModal from '@/components/Kyc/AdvisoryPreemptModal' import { useAdvisoryPreempt } from '@/hooks/useAdvisoryPreempt' import { useEeaUpliftFunnel } from '@/hooks/useEeaUpliftFunnel' +import { eeaUpliftReasonCode, isEeaUpliftAdvisory } from '@/utils/eea-uplift.utils' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { addMoneyCountryUrl } from '@/utils/native-routes' @@ -141,9 +142,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. + // NB: 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) }, }) @@ -274,6 +276,12 @@ export default function OnrampBankPage() { 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 upliftCode = eeaUpliftReasonCode(gate) + if (upliftCode) { + trackUpliftStarted({ requirementKey: upliftCode, source: 'blocking' }) + } setShowKycModal(true) } return @@ -283,6 +291,16 @@ 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. + if (isEeaUpliftAdvisory(advisory)) { + trackUpliftStarted({ + requirementKey: advisory?.requirementKey, + actionKey: advisory?.actionKey, + effectiveDate: advisory?.effectiveDate, + source: 'advisory', + }) + } advisoryIntercept(() => { posthog.capture(ANALYTICS_EVENTS.DEPOSIT_AMOUNT_ENTERED, { amount_usd: usdEquivalent, diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index 060ddb02c..b20df3b2d 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -33,6 +33,7 @@ import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' import AdvisoryPreemptModal from '@/components/Kyc/AdvisoryPreemptModal' import { useAdvisoryPreempt } from '@/hooks/useAdvisoryPreempt' import { useEeaUpliftFunnel } from '@/hooks/useEeaUpliftFunnel' +import { eeaUpliftReasonCode, isEeaUpliftAdvisory } from '@/utils/eea-uplift.utils' import { useCapabilities } from '@/hooks/useCapabilities' import { getKycModalVariant, getGateUserMessage } from '@/utils/capability-gate' import { useModalsContext } from '@/context/ModalsContext' @@ -117,9 +118,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. + // NB: 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) }, }) @@ -217,6 +219,12 @@ export default function WithdrawBankPage() { 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 upliftCode = eeaUpliftReasonCode(gate) + if (upliftCode) { + trackUpliftStarted({ requirementKey: upliftCode, source: 'blocking' }) + } setShowKycModal(true) } return @@ -346,7 +354,19 @@ 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 = () => { + if (isEeaUpliftAdvisory(advisory)) { + trackUpliftStarted({ + requirementKey: advisory?.requirementKey, + actionKey: advisory?.actionKey, + effectiveDate: advisory?.effectiveDate, + source: 'advisory', + }) + } + advisoryIntercept(() => void proceedWithOfframp()) + } const countryCodeForFlag = () => { if (!bankAccount?.details?.countryCode) return '' diff --git a/src/hooks/useEeaUpliftFunnel.test.ts b/src/hooks/useEeaUpliftFunnel.test.ts index f6699601e..081387f2a 100644 --- a/src/hooks/useEeaUpliftFunnel.test.ts +++ b/src/hooks/useEeaUpliftFunnel.test.ts @@ -1,46 +1,67 @@ import { act, renderHook } from '@testing-library/react' import posthog from 'posthog-js' -import { useEeaUpliftFunnel } from './useEeaUpliftFunnel' +import { useEeaUpliftFunnel, type UpliftStartTrigger } from './useEeaUpliftFunnel' 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('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 +73,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 +83,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..82f7bd52f 100644 --- a/src/hooks/useEeaUpliftFunnel.ts +++ b/src/hooks/useEeaUpliftFunnel.ts @@ -1,55 +1,68 @@ import { useCallback, useRef } from 'react' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' -import type { GateAdvisory } from '@/utils/capability-gate' type UpliftChannel = 'deposit' | 'withdraw' +/** + * Describes 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` is the urgent post-cliff + * cohort, `advisory` is the future-dated ("upcoming") one. + */ +export type UpliftStartTrigger = { + requirementKey?: string + actionKey?: string + effectiveDate?: string + source: 'advisory' | 'blocking' +} + /** * 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. * - * `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. + * 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. `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` 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`. + * success), so a later unrelated KYC success on the same page can't mis-fire. */ export function useEeaUpliftFunnel(channel: UpliftChannel) { - const startedRef = useRef(null) + const startedRef = useRef(null) + + const eventProps = useCallback( + (trigger: UpliftStartTrigger) => ({ + 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', + }), + [channel] + ) 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) => { + startedRef.current = trigger + posthog.capture(ANALYTICS_EVENTS.EEA_UPLIFT_STARTED, eventProps(trigger)) }, - [channel] + [eventProps] ) 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, - }) - }, [channel]) + posthog.capture(ANALYTICS_EVENTS.EEA_UPLIFT_COMPLETED, eventProps(trigger)) + }, [eventProps]) const reset = useCallback(() => { startedRef.current = null diff --git a/src/utils/eea-uplift.utils.test.ts b/src/utils/eea-uplift.utils.test.ts new file mode 100644 index 000000000..e3fc03702 --- /dev/null +++ b/src/utils/eea-uplift.utils.test.ts @@ -0,0 +1,53 @@ +import { eeaUpliftReasonCode, isEeaUpliftAdvisory, EEA_UPLIFT_REQUIREMENT_KEYS } from './eea-uplift.utils' +import type { GateState, GateAdvisory } from '@/utils/capability-gate' + +describe('eeaUpliftReasonCode (blocking path)', () => { + test('returns the code for an eea_uplift fixable-rejection', () => { + const gate = { kind: 'fixable-rejection', userMessage: 'x', reason: { code: 'eea_uplift', userMessage: 'x' } } + expect(eeaUpliftReasonCode(gate as GateState)).toBe('eea_uplift') + }) + + test('matches the eea_uplift_with_tin variant', () => { + const gate = { + kind: 'fixable-rejection', + userMessage: 'x', + reason: { code: 'eea_uplift_with_tin', userMessage: 'x' }, + } + expect(eeaUpliftReasonCode(gate as GateState)).toBe('eea_uplift_with_tin') + }) + + test('returns undefined for a non-uplift rejection', () => { + const gate = { + kind: 'fixable-rejection', + userMessage: 'x', + reason: { code: 'document_rejected', userMessage: 'x' }, + } + expect(eeaUpliftReasonCode(gate as GateState)).toBeUndefined() + }) + + test('returns undefined for a gate variant without a reason', () => { + expect(eeaUpliftReasonCode({ kind: 'ready' } as GateState)).toBeUndefined() + expect(eeaUpliftReasonCode({ kind: 'needs-identity' } as GateState)).toBeUndefined() + }) +}) + +describe('isEeaUpliftAdvisory (advisory path)', () => { + test.each([...EEA_UPLIFT_REQUIREMENT_KEYS])('matches uplift requirement key: %s', (requirementKey) => { + const advisory: GateAdvisory = { effectiveDate: '2026-12-31', actionKey: 'k', requirementKey } + expect(isEeaUpliftAdvisory(advisory)).toBe(true) + }) + + test('does not match 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(isEeaUpliftAdvisory(advisory)).toBe(false) + }) + + test('is safe for undefined / keyless advisory', () => { + expect(isEeaUpliftAdvisory(undefined)).toBe(false) + expect(isEeaUpliftAdvisory({ effectiveDate: '2026-12-31', actionKey: 'k' })).toBe(false) + }) +}) diff --git a/src/utils/eea-uplift.utils.ts b/src/utils/eea-uplift.utils.ts new file mode 100644 index 000000000..bcc72b11d --- /dev/null +++ b/src/utils/eea-uplift.utils.ts @@ -0,0 +1,39 @@ +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. + */ +export const EEA_UPLIFT_REQUIREMENT_KEYS = new Set([ + 'sof_individual_primary_purpose', + 'has_foreign_tax_registration', + 'place_of_birth_missing', + 'nationalities', +]) + +/** + * Blocking path: the gate's reason code IS the `eea_uplift*` cluster. Returns + * the code when it's an uplift remediation (truthy → track it), else undefined. + */ +export function eeaUpliftReasonCode(gate: GateState): string | undefined { + const code = (gate as { reason?: { code?: string } }).reason?.code + return code?.startsWith('eea_uplift') ? code : undefined +} + +/** Advisory path: the future-dated requirement key belongs to the uplift set. */ +export function isEeaUpliftAdvisory(advisory: GateAdvisory | undefined): boolean { + return !!advisory?.requirementKey && EEA_UPLIFT_REQUIREMENT_KEYS.has(advisory.requirementKey) +} From 904efd18b1505bc903000215285359df7222a3b8 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:24:32 +0530 Subject: [PATCH 4/8] fix(analytics): reset uplift latch on kyc-modal dismiss, dedup started Addresses code-review findings on the eea_uplift funnel: - phantom completions: the blocking path arms the started latch, but the InitiateKycModal onClose/onContactSupport did not reset it (only the advisory/Sumsub path did). A later unrelated KYC approval would fire a false eea_uplift_completed. Reset the latch on modal dismiss. - re-fire: trackStarted is now idempotent per armed attempt, so repeat Continue clicks in one attempt don't inflate the started count. - DRY + convention: collapse the four duplicated trigger-construction sites into upliftTriggerFrom{Gate,Advisory} helpers and move UpliftStartTrigger out of the hook into eea-uplift.utils (per the no-types-in-hooks rule). --- .../add-money/[country]/bank/page.tsx | 32 ++++----- .../withdraw/[country]/bank/page.tsx | 34 +++++----- src/hooks/useEeaUpliftFunnel.test.ts | 22 +++++- src/hooks/useEeaUpliftFunnel.ts | 68 +++++++++---------- src/utils/eea-uplift.utils.test.ts | 39 ++++++----- src/utils/eea-uplift.utils.ts | 41 +++++++++-- 6 files changed, 141 insertions(+), 95 deletions(-) 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 f03e09563..db9bb2af4 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -37,7 +37,7 @@ import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' import AdvisoryPreemptModal from '@/components/Kyc/AdvisoryPreemptModal' import { useAdvisoryPreempt } from '@/hooks/useAdvisoryPreempt' import { useEeaUpliftFunnel } from '@/hooks/useEeaUpliftFunnel' -import { eeaUpliftReasonCode, isEeaUpliftAdvisory } from '@/utils/eea-uplift.utils' +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' @@ -142,7 +142,7 @@ 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. - // NB: eea_uplift_started is fired at modal-open (handleAmountContinue), + // note: eea_uplift_started is fired at modal-open (handleAmountContinue), // not here, so abandoners are captured too. onCompleteNow: () => { if (!advisory) return Promise.resolve() @@ -276,12 +276,10 @@ export default function OnrampBankPage() { if (gate.kind === 'accept-tos') { guardWithTos() } else { - // urgent (post-cliff) EEA uplift lands here as a fixable-rejection — + // urgent (post-cliff) eea uplift lands here as a fixable-rejection — // fire the funnel event as this KYC modal opens. - const upliftCode = eeaUpliftReasonCode(gate) - if (upliftCode) { - trackUpliftStarted({ requirementKey: upliftCode, source: 'blocking' }) - } + const upliftTrigger = upliftTriggerFromGate(gate) + if (upliftTrigger) trackUpliftStarted(upliftTrigger) setShowKycModal(true) } return @@ -291,16 +289,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 + // upcoming (future-dated) eea uplift opens the advisory modal here — fire // the funnel event as it opens. - if (isEeaUpliftAdvisory(advisory)) { - trackUpliftStarted({ - requirementKey: advisory?.requirementKey, - actionKey: advisory?.actionKey, - effectiveDate: advisory?.effectiveDate, - source: 'advisory', - }) - } + const advisoryTrigger = upliftTriggerFromAdvisory(advisory) + if (advisoryTrigger) trackUpliftStarted(advisoryTrigger) advisoryIntercept(() => { posthog.capture(ANALYTICS_EVENTS.DEPOSIT_AMOUNT_ENTERED, { amount_usd: usdEquivalent, @@ -486,7 +478,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() @@ -503,6 +500,7 @@ export default function OnrampBankPage() { }} onContactSupport={() => { setShowKycModal(false) + resetUpliftFunnel() setIsSupportModalOpen(true) }} isLoading={sumsubFlow.isLoading} diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index b20df3b2d..21895bf5e 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -33,7 +33,7 @@ import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' import AdvisoryPreemptModal from '@/components/Kyc/AdvisoryPreemptModal' import { useAdvisoryPreempt } from '@/hooks/useAdvisoryPreempt' import { useEeaUpliftFunnel } from '@/hooks/useEeaUpliftFunnel' -import { eeaUpliftReasonCode, isEeaUpliftAdvisory } from '@/utils/eea-uplift.utils' +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' @@ -118,8 +118,8 @@ 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. - // NB: eea_uplift_started is fired at modal-open (the handlers below), not - // here, so abandoners are captured too. + // 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() return sumsubFlow.handleSelfHealResubmit('BRIDGE', advisory.requirementKey) @@ -219,12 +219,10 @@ export default function WithdrawBankPage() { if (gate.kind === 'accept-tos') { guardWithTos() } else { - // urgent (post-cliff) EEA uplift lands here as a fixable-rejection — + // urgent (post-cliff) eea uplift lands here as a fixable-rejection — // fire the funnel event as this KYC modal opens. - const upliftCode = eeaUpliftReasonCode(gate) - if (upliftCode) { - trackUpliftStarted({ requirementKey: upliftCode, source: 'blocking' }) - } + const upliftTrigger = upliftTriggerFromGate(gate) + if (upliftTrigger) trackUpliftStarted(upliftTrigger) setShowKycModal(true) } return @@ -354,17 +352,11 @@ 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). - // Upcoming (future-dated) EEA uplift opens the advisory modal here — fire the + // upcoming (future-dated) eea uplift opens the advisory modal here — fire the // funnel event as it opens. const handleCreateAndInitiateOfframp = () => { - if (isEeaUpliftAdvisory(advisory)) { - trackUpliftStarted({ - requirementKey: advisory?.requirementKey, - actionKey: advisory?.actionKey, - effectiveDate: advisory?.effectiveDate, - source: 'advisory', - }) - } + const advisoryTrigger = upliftTriggerFromAdvisory(advisory) + if (advisoryTrigger) trackUpliftStarted(advisoryTrigger) advisoryIntercept(() => void proceedWithOfframp()) } @@ -563,7 +555,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() @@ -580,6 +577,7 @@ export default function WithdrawBankPage() { }} onContactSupport={() => { setShowKycModal(false) + resetUpliftFunnel() setIsSupportModalOpen(true) }} isLoading={sumsubFlow.isLoading} diff --git a/src/hooks/useEeaUpliftFunnel.test.ts b/src/hooks/useEeaUpliftFunnel.test.ts index 081387f2a..5c9edff91 100644 --- a/src/hooks/useEeaUpliftFunnel.test.ts +++ b/src/hooks/useEeaUpliftFunnel.test.ts @@ -1,6 +1,7 @@ import { act, renderHook } from '@testing-library/react' import posthog from 'posthog-js' -import { useEeaUpliftFunnel, type UpliftStartTrigger } from './useEeaUpliftFunnel' +import { useEeaUpliftFunnel } from './useEeaUpliftFunnel' +import type { UpliftStartTrigger } from '@/utils/eea-uplift.utils' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' jest.mock('posthog-js', () => ({ capture: jest.fn() })) @@ -48,6 +49,25 @@ describe('useEeaUpliftFunnel', () => { ) }) + 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(advisoryTrigger)) diff --git a/src/hooks/useEeaUpliftFunnel.ts b/src/hooks/useEeaUpliftFunnel.ts index 82f7bd52f..ef35b9f1a 100644 --- a/src/hooks/useEeaUpliftFunnel.ts +++ b/src/hooks/useEeaUpliftFunnel.ts @@ -1,20 +1,24 @@ import { useCallback, useRef } from 'react' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' +import type { UpliftStartTrigger } from '@/utils/eea-uplift.utils' type UpliftChannel = 'deposit' | 'withdraw' -/** - * Describes 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` is the urgent post-cliff - * cohort, `advisory` is the future-dated ("upcoming") one. - */ -export type UpliftStartTrigger = { - requirementKey?: string - actionKey?: string - effectiveDate?: string - source: 'advisory' | 'blocking' +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 } /** @@ -24,45 +28,39 @@ export type UpliftStartTrigger = { * * 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. `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` 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. + * 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 + * success callback on the bank pages is shared with non-uplift KYC, so the + * 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), so a later unrelated KYC success on the same page can't mis-fire. + * `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 eventProps = useCallback( - (trigger: UpliftStartTrigger) => ({ - 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', - }), - [channel] - ) - const trackStarted = useCallback( (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, eventProps(trigger)) + posthog.capture(ANALYTICS_EVENTS.EEA_UPLIFT_STARTED, upliftEventProps(channel, trigger)) }, - [eventProps] + [channel] ) const trackCompleted = useCallback(() => { const trigger = startedRef.current if (!trigger) return startedRef.current = null - posthog.capture(ANALYTICS_EVENTS.EEA_UPLIFT_COMPLETED, eventProps(trigger)) - }, [eventProps]) + posthog.capture(ANALYTICS_EVENTS.EEA_UPLIFT_COMPLETED, upliftEventProps(channel, trigger)) + }, [channel]) const reset = useCallback(() => { startedRef.current = null diff --git a/src/utils/eea-uplift.utils.test.ts b/src/utils/eea-uplift.utils.test.ts index e3fc03702..79adfdace 100644 --- a/src/utils/eea-uplift.utils.test.ts +++ b/src/utils/eea-uplift.utils.test.ts @@ -1,10 +1,10 @@ -import { eeaUpliftReasonCode, isEeaUpliftAdvisory, EEA_UPLIFT_REQUIREMENT_KEYS } from './eea-uplift.utils' +import { upliftTriggerFromGate, upliftTriggerFromAdvisory, EEA_UPLIFT_REQUIREMENT_KEYS } from './eea-uplift.utils' import type { GateState, GateAdvisory } from '@/utils/capability-gate' -describe('eeaUpliftReasonCode (blocking path)', () => { - test('returns the code for an eea_uplift fixable-rejection', () => { +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(eeaUpliftReasonCode(gate as GateState)).toBe('eea_uplift') + expect(upliftTriggerFromGate(gate as GateState)).toEqual({ requirementKey: 'eea_uplift', source: 'blocking' }) }) test('matches the eea_uplift_with_tin variant', () => { @@ -13,41 +13,46 @@ describe('eeaUpliftReasonCode (blocking path)', () => { userMessage: 'x', reason: { code: 'eea_uplift_with_tin', userMessage: 'x' }, } - expect(eeaUpliftReasonCode(gate as GateState)).toBe('eea_uplift_with_tin') + expect(upliftTriggerFromGate(gate as GateState)?.requirementKey).toBe('eea_uplift_with_tin') }) - test('returns undefined for a non-uplift rejection', () => { + test('returns null for a non-uplift rejection', () => { const gate = { kind: 'fixable-rejection', userMessage: 'x', reason: { code: 'document_rejected', userMessage: 'x' }, } - expect(eeaUpliftReasonCode(gate as GateState)).toBeUndefined() + expect(upliftTriggerFromGate(gate as GateState)).toBeNull() }) - test('returns undefined for a gate variant without a reason', () => { - expect(eeaUpliftReasonCode({ kind: 'ready' } as GateState)).toBeUndefined() - expect(eeaUpliftReasonCode({ kind: 'needs-identity' } as GateState)).toBeUndefined() + 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('isEeaUpliftAdvisory (advisory path)', () => { - test.each([...EEA_UPLIFT_REQUIREMENT_KEYS])('matches uplift requirement key: %s', (requirementKey) => { +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(isEeaUpliftAdvisory(advisory)).toBe(true) + expect(upliftTriggerFromAdvisory(advisory)).toEqual({ + requirementKey, + actionKey: 'k', + effectiveDate: '2026-12-31', + source: 'advisory', + }) }) - test('does not match a co-occurring non-uplift key (proof_of_address / gov-id)', () => { + 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(isEeaUpliftAdvisory(advisory)).toBe(false) + expect(upliftTriggerFromAdvisory(advisory)).toBeNull() }) test('is safe for undefined / keyless advisory', () => { - expect(isEeaUpliftAdvisory(undefined)).toBe(false) - expect(isEeaUpliftAdvisory({ effectiveDate: '2026-12-31', actionKey: 'k' })).toBe(false) + 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 index bcc72b11d..c0281c7c6 100644 --- a/src/utils/eea-uplift.utils.ts +++ b/src/utils/eea-uplift.utils.ts @@ -16,6 +16,11 @@ import type { GateAdvisory, GateState } from '@/utils/capability-gate' * 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', @@ -25,15 +30,37 @@ export const EEA_UPLIFT_REQUIREMENT_KEYS = new Set([ ]) /** - * Blocking path: the gate's reason code IS the `eea_uplift*` cluster. Returns - * the code when it's an uplift remediation (truthy → track it), else undefined. + * 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 function eeaUpliftReasonCode(gate: GateState): string | undefined { +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 { const code = (gate as { reason?: { code?: string } }).reason?.code - return code?.startsWith('eea_uplift') ? 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. */ -export function isEeaUpliftAdvisory(advisory: GateAdvisory | undefined): boolean { - return !!advisory?.requirementKey && EEA_UPLIFT_REQUIREMENT_KEYS.has(advisory.requirementKey) +/** + * 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', + } } From 94d684ad2474c8f37ecda41108b520e2c4598904 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:23:30 +0530 Subject: [PATCH 5/8] feat: show bridge re-verification pending modal on on/offramp attempt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user completes an EEA uplift and then tries to on/offramp via Bridge while Bridge is still re-reviewing, the deposit page showed only an inline message and the withdraw page silently did nothing. Show a dedicated pending/in-progress modal instead, driven by the waiting-on-provider gate. To reflect proper live status from Bridge, re-arm the existing submission- window poller (markSubmitted) when the modal opens — the 4s user refetch picks up Bridge's latest verification status and the modal auto-dismisses the moment the gate leaves waiting-on-provider. No new backend endpoint: the capability pipeline already mirrors Bridge status. Also closes the withdraw-side silent no-op noted in the deposit fix. --- .../add-money/[country]/bank/page.tsx | 28 ++++++----- .../__tests__/add-money-states.test.tsx | 9 ++-- .../withdraw/[country]/bank/page.tsx | 24 +++++++-- .../Kyc/KycReverificationPendingModal.tsx | 49 +++++++++++++++++++ 4 files changed, 90 insertions(+), 20 deletions(-) create mode 100644 src/components/Kyc/KycReverificationPendingModal.tsx 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 db9bb2af4..b5e8e0c35 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -33,6 +33,8 @@ 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 { markSubmitted } from '@/hooks/useSubmissionWindow' import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' import AdvisoryPreemptModal from '@/components/Kyc/AdvisoryPreemptModal' import { useAdvisoryPreempt } from '@/hooks/useAdvisoryPreempt' @@ -69,6 +71,7 @@ export default function OnrampBankPage() { // Local UI state (not URL-appropriate - transient) const [showWarningModal, setShowWarningModal] = useState(false) const [showKycModal, setShowKycModal] = useState(false) + const [showPendingModal, setShowPendingModal] = useState(false) const [isRiskAccepted, setIsRiskAccepted] = useState(false) const { setError, error, setOnrampData, onrampData } = useOnrampFlow() @@ -259,18 +262,14 @@ export default function OnrampBankPage() { // capabilities still loading — silently no-op instead of flashing // a misleading needs_kyc modal. if (gate.kind === 'loading') return - // `waiting-on-provider` means the user has nothing to do (e.g. bridge - // reviewing eea-uplift docs) — tell them to wait instead of a dead - // button or a doomed transfer attempt with no feedback. + // `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') { - // prefer the backend's specific review copy (via the gate reason) - // and fall back to generic wait text. - setError({ - showError: true, - errorMessage: - gate.userMessage ?? - 'Your verification details are being reviewed. This usually takes a few minutes — please try again soon.', - }) + markSubmitted() + setShowPendingModal(true) return } if (gate.kind === 'accept-tos') { @@ -512,6 +511,13 @@ export default function OnrampBankPage() { + setShowPendingModal(false)} + /> + { }) }) - test('waiting-on-provider gate shows under-review message instead of silent no-op', async () => { + 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({ @@ -1371,12 +1371,9 @@ describe('GROUP 5: Bridge Bank Onramp', () => { fireEvent.click(screen.getByText('Continue')) }) - // no transfer attempt, but the user is told why + // no doomed transfer attempt; the user sees the bridge-review pending modal expect(mockCreateOnramp).not.toHaveBeenCalled() - expect(mockOnrampFlow.setError).toHaveBeenCalledWith({ - showError: true, - errorMessage: expect.stringContaining('being reviewed'), - }) + 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 21895bf5e..7bd40c8a0 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -29,6 +29,8 @@ 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 { markSubmitted } from '@/hooks/useSubmissionWindow' import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' import AdvisoryPreemptModal from '@/components/Kyc/AdvisoryPreemptModal' import { useAdvisoryPreempt } from '@/hooks/useAdvisoryPreempt' @@ -126,6 +128,7 @@ export default function WithdrawBankPage() { }, }) const [showKycModal, setShowKycModal] = useState(false) + const [showPendingModal, setShowPendingModal] = useState(false) const { setIsSupportModalOpen } = useModalsContext() // close kyc modal when sumsub sdk opens @@ -213,9 +216,17 @@ 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') { + markSubmitted() + setShowPendingModal(true) + return + } if (gate.kind === 'accept-tos') { guardWithTos() } else { @@ -587,6 +598,13 @@ export default function WithdrawBankPage() { regionName={getCountryFromPath(country)?.title} /> + + setShowPendingModal(false)} + /> ) diff --git a/src/components/Kyc/KycReverificationPendingModal.tsx b/src/components/Kyc/KycReverificationPendingModal.tsx new file mode 100644 index 000000000..288776144 --- /dev/null +++ b/src/components/Kyc/KycReverificationPendingModal.tsx @@ -0,0 +1,49 @@ +import { useRouter } from 'next/navigation' +import ActionModal from '@/components/Global/ActionModal' +import { type IconName } from '@/components/Global/Icons/Icon' + +interface KycReverificationPendingModalProps { + isOpen: boolean + onClose: () => void +} + +/** + * 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 }: KycReverificationPendingModalProps) => { + const router = useRouter() + + return ( + + 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', + }, + ]} + /> + ) +} From 4e72a2d64a8053b7d14b4234ef429370446cae50 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:56:44 +0530 Subject: [PATCH 6/8] fix: keep pending modal's status poll alive + self-clear (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses code-review on the reverification pending modal: - FEATURE-BREAKING: a single 30s submission window lapsed mid-review (waiting-on-provider rails are requires-info, not pending, so the poller self-terminated) — the modal never auto-dismissed even after Bridge approved. Extract useWaitingOnProviderModal, which re-arms the window on a 20s interval while open so the ~4s user poll runs for the whole wait. - stale-flag reopen: drop the request flag when the gate resolves, so a transient re-flip to waiting-on-provider can't reopen the modal on its own. - surface gate.userMessage (bridge-specific review copy) in the modal instead of always the generic text. - DRY: the shared hook collapses the duplicated deposit/withdraw wiring. - drop the redundant 'clock' as IconName cast. --- .../add-money/[country]/bank/page.tsx | 16 ++-- .../withdraw/[country]/bank/page.tsx | 16 ++-- .../Kyc/KycReverificationPendingModal.tsx | 11 +-- .../useWaitingOnProviderModal.test.ts | 79 +++++++++++++++++++ src/hooks/useWaitingOnProviderModal.ts | 50 ++++++++++++ 5 files changed, 151 insertions(+), 21 deletions(-) create mode 100644 src/hooks/__tests__/useWaitingOnProviderModal.test.ts create mode 100644 src/hooks/useWaitingOnProviderModal.ts 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 b5e8e0c35..25400d075 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -34,7 +34,7 @@ import { useTosGuard } from '@/hooks/useTosGuard' import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' import { KycReverificationPendingModal } from '@/components/Kyc/KycReverificationPendingModal' -import { markSubmitted } from '@/hooks/useSubmissionWindow' +import { useWaitingOnProviderModal } from '@/hooks/useWaitingOnProviderModal' import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' import AdvisoryPreemptModal from '@/components/Kyc/AdvisoryPreemptModal' import { useAdvisoryPreempt } from '@/hooks/useAdvisoryPreempt' @@ -71,7 +71,6 @@ export default function OnrampBankPage() { // Local UI state (not URL-appropriate - transient) const [showWarningModal, setShowWarningModal] = useState(false) const [showKycModal, setShowKycModal] = useState(false) - const [showPendingModal, setShowPendingModal] = useState(false) const [isRiskAccepted, setIsRiskAccepted] = useState(false) const { setError, error, setOnrampData, onrampData } = useOnrampFlow() @@ -135,6 +134,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. @@ -268,8 +270,7 @@ export default function OnrampBankPage() { // 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') { - markSubmitted() - setShowPendingModal(true) + pendingModal.open() return } if (gate.kind === 'accept-tos') { @@ -512,10 +513,9 @@ export default function OnrampBankPage() { setShowPendingModal(false)} + isOpen={pendingModal.isOpen} + onClose={pendingModal.close} + message={pendingModal.message} /> diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index 7bd40c8a0..8dde7460d 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -30,7 +30,7 @@ import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' import { KycReverificationPendingModal } from '@/components/Kyc/KycReverificationPendingModal' -import { markSubmitted } from '@/hooks/useSubmissionWindow' +import { useWaitingOnProviderModal } from '@/hooks/useWaitingOnProviderModal' import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' import AdvisoryPreemptModal from '@/components/Kyc/AdvisoryPreemptModal' import { useAdvisoryPreempt } from '@/hooks/useAdvisoryPreempt' @@ -93,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 { @@ -128,7 +131,6 @@ export default function WithdrawBankPage() { }, }) const [showKycModal, setShowKycModal] = useState(false) - const [showPendingModal, setShowPendingModal] = useState(false) const { setIsSupportModalOpen } = useModalsContext() // close kyc modal when sumsub sdk opens @@ -223,8 +225,7 @@ export default function WithdrawBankPage() { // 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') { - markSubmitted() - setShowPendingModal(true) + pendingModal.open() return } if (gate.kind === 'accept-tos') { @@ -600,10 +601,9 @@ export default function WithdrawBankPage() { setShowPendingModal(false)} + isOpen={pendingModal.isOpen} + onClose={pendingModal.close} + message={pendingModal.message} /> diff --git a/src/components/Kyc/KycReverificationPendingModal.tsx b/src/components/Kyc/KycReverificationPendingModal.tsx index 288776144..a9b0e42f2 100644 --- a/src/components/Kyc/KycReverificationPendingModal.tsx +++ b/src/components/Kyc/KycReverificationPendingModal.tsx @@ -1,10 +1,11 @@ import { useRouter } from 'next/navigation' import ActionModal from '@/components/Global/ActionModal' -import { type IconName } from '@/components/Global/Icons/Icon' interface KycReverificationPendingModalProps { isOpen: boolean onClose: () => void + // backend-specific review copy (gate.userMessage); falls back to generic text. + message?: string } /** @@ -16,20 +17,20 @@ interface KycReverificationPendingModalProps { * 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 }: KycReverificationPendingModalProps) => { +export const KycReverificationPendingModal = ({ isOpen, onClose, message }: KycReverificationPendingModalProps) => { const router = useRouter() return ( - 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. + {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={[ 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/useWaitingOnProviderModal.ts b/src/hooks/useWaitingOnProviderModal.ts new file mode 100644 index 000000000..a9cc183be --- /dev/null +++ b/src/hooks/useWaitingOnProviderModal.ts @@ -0,0 +1,50 @@ +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 + const message = isOpen ? ((gate as { userMessage?: string | null }).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 } +} From 4bb67af0e3795213b5896897cd444d2604c73223 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:01:41 +0530 Subject: [PATCH 7/8] chore: remove dead write-only isRiskAccepted state (coderabbit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Write-only (declared + set, never read) — the cherry-picked handlers re-added the setters onto state main had already reduced to a bare declaration. No behavior change. --- src/app/(mobile-ui)/add-money/[country]/bank/page.tsx | 3 --- 1 file changed, 3 deletions(-) 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 25400d075..9c99f92cb 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -71,7 +71,6 @@ 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() @@ -313,7 +312,6 @@ export default function OnrampBankPage() { } setShowWarningModal(false) - setIsRiskAccepted(false) try { const onrampDataResponse = await createOnramp({ amount: rawTokenAmount, @@ -354,7 +352,6 @@ export default function OnrampBankPage() { const handleWarningCancel = () => { setShowWarningModal(false) - setIsRiskAccepted(false) } // Redirect to inputAmount if showDetails is accessed without required data (deep link / back navigation) From ae974e116f66a956dae5d1189b5bc6a1e534ba21 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:29:40 +0530 Subject: [PATCH 8/8] refactor: narrow GateState union instead of casting (coderabbit) Replace the ad-hoc gate casts in useWaitingOnProviderModal (userMessage) and upliftTriggerFromGate (reason.code) with discriminated-union narrowing, so a future rename of reason.code / userMessage fails the build instead of silently returning undefined. No behavior change. --- src/hooks/useWaitingOnProviderModal.ts | 4 +++- src/utils/eea-uplift.utils.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/hooks/useWaitingOnProviderModal.ts b/src/hooks/useWaitingOnProviderModal.ts index a9cc183be..5637854b9 100644 --- a/src/hooks/useWaitingOnProviderModal.ts +++ b/src/hooks/useWaitingOnProviderModal.ts @@ -24,7 +24,9 @@ export function useWaitingOnProviderModal(gate: GateState) { const [requested, setRequested] = useState(false) const isWaiting = gate.kind === 'waiting-on-provider' const isOpen = requested && isWaiting - const message = isOpen ? ((gate as { userMessage?: string | null }).userMessage ?? undefined) : undefined + // 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 diff --git a/src/utils/eea-uplift.utils.ts b/src/utils/eea-uplift.utils.ts index c0281c7c6..4370b9c2a 100644 --- a/src/utils/eea-uplift.utils.ts +++ b/src/utils/eea-uplift.utils.ts @@ -46,7 +46,9 @@ export type UpliftStartTrigger = { * ready-to-fire trigger when it's an uplift remediation, else null. */ export function upliftTriggerFromGate(gate: GateState): UpliftStartTrigger | null { - const code = (gate as { reason?: { code?: string } }).reason?.code + // 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' } }