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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
// Local UI state (not URL-appropriate - transient)
const [showWarningModal, setShowWarningModal] = useState<boolean>(false)
const [showKycModal, setShowKycModal] = useState<boolean>(false)
const [isRiskAccepted, setIsRiskAccepted] = useState<boolean>(false)

Check failure on line 66 in src/app/(mobile-ui)/add-money/[country]/bank/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

'isRiskAccepted' is assigned a value but never used. Allowed unused elements of array destructuring must match /^_/u

Check failure on line 66 in src/app/(mobile-ui)/add-money/[country]/bank/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

'isRiskAccepted' is assigned a value but never used. Allowed unused elements of array destructuring must match /^_/u
const { setError, error, setOnrampData, onrampData } = useOnrampFlow()

const { balance } = useWallet()
Expand Down Expand Up @@ -121,7 +121,7 @@

useEffect(() => {
fetchUser()
}, [])

Check warning on line 124 in src/app/(mobile-ui)/add-money/[country]/bank/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'fetchUser'. Either include it or remove the dependency array

Check warning on line 124 in src/app/(mobile-ui)/add-money/[country]/bank/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'fetchUser'. Either include it or remove the dependency array

const peanutWalletBalance = useMemo(() => {
return balance !== undefined ? formatAmount(formatUnits(balance, PEANUT_WALLET_TOKEN_DECIMALS)) : ''
Expand Down Expand Up @@ -422,7 +422,8 @@
await sumsubFlow.handleInitiateKyc(
getRegionIntent(selectedCountry?.region ?? 'rest-of-the-world'),
undefined,
gate.kind === 'needs-enrollment' || undefined
gate.kind === 'needs-enrollment' || undefined,
selectedCountry?.id
)
}
}}
Expand Down
3 changes: 2 additions & 1 deletion src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
}}
Expand Down
10 changes: 8 additions & 2 deletions src/components/AddWithdraw/AddWithdrawCountriesList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down Expand Up @@ -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
)
}
}}
Expand Down
2 changes: 1 addition & 1 deletion src/components/Claim/Link/MantecaFlowManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ const MantecaFlowManager: FC<MantecaFlowManagerProps> = ({ 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)
}}
Expand Down
10 changes: 8 additions & 2 deletions src/components/Claim/Link/views/BankFlowManager.view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
}

Expand Down Expand Up @@ -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
Expand Down
132 changes: 132 additions & 0 deletions src/hooks/__tests__/useSumsubKycFlow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
})
})
38 changes: 37 additions & 1 deletion src/hooks/useSumsubKycFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 || ''
})
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -351,16 +381,19 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }:
try {
const response = await restartIdentityVerification()
if (response.error) {
userInitiatedRef.current = false
setError(response.error)
return
}
if (response.data?.token) {
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 {
Expand All @@ -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
Expand All @@ -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)
Expand Down
Loading