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 3d4c2ff78..a704a2db1 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -422,7 +422,8 @@ export default function OnrampBankPage() { await sumsubFlow.handleInitiateKyc( getRegionIntent(selectedCountry?.region ?? 'rest-of-the-world'), undefined, - gate.kind === 'needs-enrollment' || undefined + gate.kind === 'needs-enrollment' || undefined, + selectedCountry?.id ) } }} diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index 568ba377b..4f91076d9 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -535,7 +535,8 @@ export default function WithdrawBankPage() { await sumsubFlow.handleInitiateKyc( getRegionIntent(getCountryFromPath(country)?.region ?? 'rest-of-the-world'), undefined, - gate.kind === 'needs-enrollment' || undefined + gate.kind === 'needs-enrollment' || undefined, + getCountryFromPath(country)?.id ) } }} diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index 543cdae30..0005b3f54 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -229,7 +229,12 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { // scenario (2): if the user hasn't completed kyc yet // name and email are now collected by sumsub sdk — no need to save them beforehand if (!isUserKycApproved) { - await sumsubFlow.handleInitiateKyc(getRegionIntent(currentCountry?.region ?? 'rest-of-the-world')) + await sumsubFlow.handleInitiateKyc( + getRegionIntent(currentCountry?.region ?? 'rest-of-the-world'), + undefined, + undefined, + currentCountry?.id + ) } return {} @@ -338,7 +343,8 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { await sumsubFlow.handleInitiateKyc( getRegionIntent(currentCountry?.region ?? 'rest-of-the-world'), undefined, - gate.kind === 'needs-enrollment' || undefined + gate.kind === 'needs-enrollment' || undefined, + currentCountry?.id ) } }} diff --git a/src/components/Claim/Link/MantecaFlowManager.tsx b/src/components/Claim/Link/MantecaFlowManager.tsx index cf81cdc38..870f2830e 100644 --- a/src/components/Claim/Link/MantecaFlowManager.tsx +++ b/src/components/Claim/Link/MantecaFlowManager.tsx @@ -147,7 +147,7 @@ const MantecaFlowManager: FC = ({ claimLinkData, amount } else if (mantecaRejection.state === 'fixable') { await sumsubFlow.handleSelfHealResubmit('MANTECA') } else { - await sumsubFlow.handleInitiateKyc('LATAM', undefined, true) + await sumsubFlow.handleInitiateKyc('LATAM', undefined, true, selectedCountry?.id) } setShowKycModal(false) }} diff --git a/src/components/Claim/Link/views/BankFlowManager.view.tsx b/src/components/Claim/Link/views/BankFlowManager.view.tsx index 12c210953..975ab3a9b 100644 --- a/src/components/Claim/Link/views/BankFlowManager.view.tsx +++ b/src/components/Claim/Link/views/BankFlowManager.view.tsx @@ -297,7 +297,12 @@ export const BankFlowManager = (props: IClaimScreenProps) => { // scenario 1: receiver needs KYC // name and email are now collected by sumsub sdk — no need to save them beforehand if (bankClaimType === BankClaimType.ReceiverKycNeeded && !justCompletedKyc) { - await sumsubFlow.handleInitiateKyc(getRegionIntent(selectedCountry?.region ?? 'rest-of-the-world')) + await sumsubFlow.handleInitiateKyc( + getRegionIntent(selectedCountry?.region ?? 'rest-of-the-world'), + undefined, + undefined, + selectedCountry?.id + ) return {} } @@ -567,7 +572,8 @@ export const BankFlowManager = (props: IClaimScreenProps) => { await sumsubFlow.handleInitiateKyc( getRegionIntent(selectedCountry?.region ?? 'rest-of-the-world'), undefined, - gate.kind === 'needs-enrollment' || undefined + gate.kind === 'needs-enrollment' || undefined, + selectedCountry?.id ) } // only close if sdk opened — if it errored, keep modal open to show error diff --git a/src/hooks/__tests__/useSumsubKycFlow.test.ts b/src/hooks/__tests__/useSumsubKycFlow.test.ts index e97ac60b1..6d660a5ad 100644 --- a/src/hooks/__tests__/useSumsubKycFlow.test.ts +++ b/src/hooks/__tests__/useSumsubKycFlow.test.ts @@ -98,3 +98,135 @@ describe('useSumsubKycFlow — cross-region routing', () => { await waitFor(() => expect(onKycSuccess).toHaveBeenCalledTimes(1)) }) }) + +describe('useSumsubKycFlow — targetCountry gating', () => { + beforeEach(() => { + mockInitiate.mockReset() + mockWs.handler = undefined + }) + + // The BE only ever consumes targetCountry as a Manteca geo, and an unsupported + // stamp poisons the verification metadata (first-write-wins). Call sites pass the + // raw country for every `latam`-region country, so the hook is the choke point + // that must forward AR/BR and drop everything else. + it('forwards a Manteca-supported targetCountry (AR) to the BE', async () => { + mockInitiate.mockResolvedValue({ + data: { token: 'tok_1', applicantId: 'app_1', status: 'APPROVED', actionType: 'manteca' }, + }) + const { result } = renderHook(() => useSumsubKycFlow({})) + + await act(async () => { + await result.current.handleInitiateKyc('LATAM', undefined, true, 'AR') + }) + + expect(mockInitiate).toHaveBeenCalledWith( + expect.objectContaining({ regionIntent: 'LATAM', crossRegion: true, targetCountry: 'AR' }) + ) + }) + + it('uppercases a lowercase targetCountry (br → BR) before forwarding', async () => { + mockInitiate.mockResolvedValue({ + data: { token: 'tok_1', applicantId: 'app_1', status: 'APPROVED', actionType: 'manteca' }, + }) + const { result } = renderHook(() => useSumsubKycFlow({})) + + await act(async () => { + await result.current.handleInitiateKyc('LATAM', undefined, true, 'br') + }) + + expect(mockInitiate).toHaveBeenCalledWith(expect.objectContaining({ targetCountry: 'BR' })) + }) + + it('drops a non-Manteca targetCountry (MX) instead of stamping a poisoned geo', async () => { + mockInitiate.mockResolvedValue({ + data: { token: 'tok_1', applicantId: 'app_1', status: 'APPROVED', actionType: 'manteca' }, + }) + const { result } = renderHook(() => useSumsubKycFlow({})) + + await act(async () => { + await result.current.handleInitiateKyc('LATAM', undefined, true, 'MX') + }) + + expect(mockInitiate).toHaveBeenCalledWith( + expect.objectContaining({ regionIntent: 'LATAM', crossRegion: true, targetCountry: undefined }) + ) + }) +}) + +describe('useSumsubKycFlow — terminal-error exits clear the user-initiated guard', () => { + beforeEach(() => { + mockInitiate.mockReset() + mockWs.handler = undefined + }) + + // Same race the unsupported-region branch closes, on the other terminal exits: + // restoring prevStatusRef while leaving userInitiatedRef set lets a late websocket + // event fire onKycSuccess on top of the rendered error. The PENDING→APPROVED + // two-event sequence isolates the userInitiatedRef guard — PENDING advances + // prevStatusRef first, so the prevStatus !== 'APPROVED' guard alone cannot save a + // regression that re-leaks the ref. + it('response.error → late PENDING→APPROVED websocket events do NOT fire onKycSuccess', async () => { + mockInitiate.mockResolvedValue({ error: 'region_not_supported' }) + const onKycSuccess = jest.fn() + + const { result } = renderHook(() => useSumsubKycFlow({ onKycSuccess })) + + await act(async () => { + await result.current.handleInitiateKyc('LATAM', undefined, true, 'AR') + }) + expect(result.current.error).toBe('region_not_supported') + + await act(async () => { + mockWs.handler?.('PENDING') + }) + await act(async () => { + mockWs.handler?.('APPROVED') + }) + + await waitFor(() => expect(onKycSuccess).not.toHaveBeenCalled()) + }) + + it('thrown initiate → late PENDING→APPROVED websocket events do NOT fire onKycSuccess', async () => { + mockInitiate.mockRejectedValue(new Error('network down')) + const onKycSuccess = jest.fn() + + const { result } = renderHook(() => useSumsubKycFlow({ onKycSuccess })) + + await act(async () => { + await result.current.handleInitiateKyc('EU', undefined, true) + }) + expect(result.current.error).toBe('network down') + + await act(async () => { + mockWs.handler?.('PENDING') + }) + await act(async () => { + mockWs.handler?.('APPROVED') + }) + + await waitFor(() => expect(onKycSuccess).not.toHaveBeenCalled()) + }) + + // Control: a real flow that opens the SDK keeps the guard armed — the user + // completing KYC afterwards must still fire onKycSuccess via the transition effect. + it('successful SDK open keeps the guard armed: later APPROVED fires onKycSuccess', async () => { + mockInitiate.mockResolvedValue({ + data: { token: 'tok_1', applicantId: 'app_1', status: 'PENDING' }, + }) + const onKycSuccess = jest.fn() + + const { result } = renderHook(() => useSumsubKycFlow({ onKycSuccess })) + + await act(async () => { + await result.current.handleInitiateKyc('EU') + }) + expect(result.current.error).toBeNull() + expect(result.current.showWrapper).toBe(true) + + await act(async () => { + mockWs.handler?.('APPROVED') + }) + + await waitFor(() => expect(onKycSuccess).toHaveBeenCalledTimes(1)) + }) +}) diff --git a/src/hooks/useSumsubKycFlow.ts b/src/hooks/useSumsubKycFlow.ts index 2ff6a52f5..85a928081 100644 --- a/src/hooks/useSumsubKycFlow.ts +++ b/src/hooks/useSumsubKycFlow.ts @@ -4,6 +4,7 @@ import { useWebSocket } from '@/hooks/useWebSocket' import { useUserStore } from '@/redux/hooks' import { initiateSumsubKyc, initiateSelfHealResubmission, restartIdentityVerification } from '@/app/actions/sumsub' import { type KYCRegionIntent, type SumsubKycStatus } from '@/app/actions/types/sumsub.types' +import { isMantecaSupportedCountryCode } from '@/constants/manteca.consts' import { isCapacitor } from '@/utils/capacitor' interface UseSumsubKycFlowOptions { @@ -138,7 +139,23 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: }, [isVerificationProgressModalOpen]) const handleInitiateKyc = useCallback( - async (overrideIntent?: KYCRegionIntent, levelName?: string, crossRegion?: boolean, targetCountry?: string) => { + async ( + overrideIntent?: KYCRegionIntent, + levelName?: string, + crossRegion?: boolean, + rawTargetCountry?: string + ) => { + // targetCountry is only ever consumed by the BE as a Manteca geo + // (pendingMantecaGeo stamp + action externalId suffix). Call sites + // pass the raw destination country for EVERY `latam`-region country + // (MX, CL, …), but Manteca only serves AR/BR — an unsupported stamp + // poisons the verification metadata (first-write-wins) and bails + // every later geo resolution, so drop it at this choke point. + const normalizedTargetCountry = rawTargetCountry?.toUpperCase() + const targetCountry = + normalizedTargetCountry && isMantecaSupportedCountryCode(normalizedTargetCountry) + ? normalizedTargetCountry + : undefined userInitiatedRef.current = true initiatingRef.current = true selfHealProviderRef.current = null @@ -162,6 +179,11 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: }) if (response.error) { + // same race the unsupported-region branch closes below: restoring + // prevStatusRef while leaving userInitiatedRef set lets a late/stale + // websocket APPROVED event fire onKycSuccess on top of this error. + // every terminal-error exit must clear the user-initiated guard. + userInitiatedRef.current = false if (crossRegion) prevStatusRef.current = savedPrevStatus setError(response.error) return @@ -226,14 +248,19 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: try { const SNSMobileSDK = (window as any).SNSMobileSDK if (!SNSMobileSDK) { + userInitiatedRef.current = false setError('KYC SDK not available. Please update the app.') return } const effectiveRegionIntent = overrideIntent ?? regionIntent const sdk = SNSMobileSDK.init(response.data.token, async () => { + // keep parity with the web refreshToken below — dropping + // targetCountry here would mint a token for a different + // (suffix-less) applicant action than the one the user is in. const r = await initiateSumsubKyc({ regionIntent: effectiveRegionIntent, levelName: levelNameRef.current, + targetCountry: targetCountryRef.current, }) return r.data?.token || '' }) @@ -256,6 +283,7 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: } } catch (nativeErr) { console.error('[useSumsubKycFlow] native SDK error:', nativeErr) + userInitiatedRef.current = false setError('Verification failed. Please try again.') } return @@ -265,9 +293,11 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: setIsActionFlow(!!response.data.actionType) setShowWrapper(true) } else { + userInitiatedRef.current = false setError('Could not initiate verification. Please try again.') } } catch (e: unknown) { + userInitiatedRef.current = false if (crossRegion) prevStatusRef.current = savedPrevStatus const message = e instanceof Error ? e.message : 'An unexpected error occurred' setError(message) @@ -351,6 +381,7 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: try { const response = await restartIdentityVerification() if (response.error) { + userInitiatedRef.current = false setError(response.error) return } @@ -358,9 +389,11 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: setAccessToken(response.data.token) setShowWrapper(true) } else { + userInitiatedRef.current = false setError('Could not restart identity verification. Please try again.') } } catch (e: unknown) { + userInitiatedRef.current = false const message = e instanceof Error ? e.message : 'An unexpected error occurred' setError(message) } finally { @@ -380,6 +413,7 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: const response = await initiateSelfHealResubmission(provider) if (response.error) { + userInitiatedRef.current = false selfHealProviderRef.current = null setError(response.error) return @@ -389,10 +423,12 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: setAccessToken(response.data.token) setShowWrapper(true) } else { + userInitiatedRef.current = false selfHealProviderRef.current = null setError('Could not initiate document resubmission. Please try again.') } } catch (e: unknown) { + userInitiatedRef.current = false selfHealProviderRef.current = null const message = e instanceof Error ? e.message : 'An unexpected error occurred' setError(message)