From 2a66155537dfa37d925f24871bc90125237f5270 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Fri, 13 Feb 2026 23:36:14 +0530 Subject: [PATCH 01/46] feat: sumsub sdk types and declrations --- src/app/(mobile-ui)/home/page.tsx | 4 ++-- src/app/actions/types/sumsub.types.ts | 7 +++++++ src/types/sumsub-websdk.d.ts | 26 ++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 src/app/actions/types/sumsub.types.ts create mode 100644 src/types/sumsub-websdk.d.ts diff --git a/src/app/(mobile-ui)/home/page.tsx b/src/app/(mobile-ui)/home/page.tsx index f3a542d54..68f03a0e7 100644 --- a/src/app/(mobile-ui)/home/page.tsx +++ b/src/app/(mobile-ui)/home/page.tsx @@ -10,7 +10,7 @@ import { UserHeader } from '@/components/UserHeader' import { useAuth } from '@/context/authContext' import { useWallet } from '@/hooks/wallet/useWallet' import { useUserStore } from '@/redux/hooks' -import { formatExtendedNumber, getUserPreferences, updateUserPreferences, getRedirectUrl } from '@/utils/general.utils' +import { formatExtendedNumber, getUserPreferences, updateUserPreferences } from '@/utils/general.utils' import { printableUsdc } from '@/utils/balance.utils' import { useDisconnect } from '@reown/appkit/react' import Link from 'next/link' @@ -24,7 +24,7 @@ import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts' import { PostSignupActionManager } from '@/components/Global/PostSignupActionManager' import { useWithdrawFlow } from '@/context/WithdrawFlowContext' import { useClaimBankFlow } from '@/context/ClaimBankFlowContext' -import { useDeviceType, DeviceType } from '@/hooks/useGetDeviceType' +import { useDeviceType } from '@/hooks/useGetDeviceType' import { useNotifications } from '@/hooks/useNotifications' import useKycStatus from '@/hooks/useKycStatus' import HomeCarouselCTA from '@/components/Home/HomeCarouselCTA' diff --git a/src/app/actions/types/sumsub.types.ts b/src/app/actions/types/sumsub.types.ts new file mode 100644 index 000000000..5e9c6f867 --- /dev/null +++ b/src/app/actions/types/sumsub.types.ts @@ -0,0 +1,7 @@ +export interface InitiateSumsubKycResponse { + token: string | null // null when user is already APPROVED + applicantId: string | null + status: SumsubKycStatus +} + +export type SumsubKycStatus = 'NOT_STARTED' | 'PENDING' | 'IN_REVIEW' | 'APPROVED' | 'REJECTED' | 'ACTION_REQUIRED' diff --git a/src/types/sumsub-websdk.d.ts b/src/types/sumsub-websdk.d.ts new file mode 100644 index 000000000..58400646f --- /dev/null +++ b/src/types/sumsub-websdk.d.ts @@ -0,0 +1,26 @@ +// type declarations for sumsub websdk loaded via CDN script +// https://static.sumsub.com/idensic/static/sns-websdk-builder.js + +declare global { + interface SnsWebSdkInstance { + launch(container: HTMLElement): void + destroy(): void + } + + interface SnsWebSdkBuilderChain { + withConf(conf: { lang?: string; theme?: string }): SnsWebSdkBuilderChain + withOptions(opts: { addViewportTag?: boolean; adaptIframeHeight?: boolean }): SnsWebSdkBuilderChain + on(event: string, handler: (...args: any[]) => void): SnsWebSdkBuilderChain + build(): SnsWebSdkInstance + } + + interface SnsWebSdkBuilder { + init(token: string, refreshCallback: () => Promise): SnsWebSdkBuilderChain + } + + interface Window { + snsWebSdk: SnsWebSdkBuilder + } +} + +export {} From b7a41410227c85ac3726b4e0cddb60b93ee6665c Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Fri, 13 Feb 2026 23:36:56 +0530 Subject: [PATCH 02/46] feat: initiateSumsubKyc server action --- src/app/actions/sumsub.ts | 43 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 src/app/actions/sumsub.ts diff --git a/src/app/actions/sumsub.ts b/src/app/actions/sumsub.ts new file mode 100644 index 000000000..b3401567c --- /dev/null +++ b/src/app/actions/sumsub.ts @@ -0,0 +1,43 @@ +'use server' + +import { type InitiateSumsubKycResponse } from './types/sumsub.types' +import { fetchWithSentry } from '@/utils/sentry.utils' +import { PEANUT_API_URL } from '@/constants/general.consts' +import { getJWTCookie } from '@/utils/cookie-migration.utils' + +const API_KEY = process.env.PEANUT_API_KEY! + +// initiate kyc flow (using sumsub) and get websdk access token +export const initiateSumsubKyc = async (params?: { + regionIntent?: string +}): Promise<{ data?: InitiateSumsubKycResponse; error?: string }> => { + const jwtToken = (await getJWTCookie())?.value + + try { + const response = await fetchWithSentry(`${PEANUT_API_URL}/users/identity`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwtToken}`, + 'api-key': API_KEY, + }, + body: JSON.stringify(params || {}), + }) + + const responseJson = await response.json() + + if (!response.ok) { + return { error: responseJson.message || responseJson.error || 'Failed to initiate identity verification' } + } + + return { + data: { + token: responseJson.token, + applicantId: responseJson.applicantId, + status: responseJson.status, + }, + } + } catch (e: any) { + return { error: e.message || 'An unexpected error occurred' } + } +} From 0dd89d37355529ec77b7a6d00876fe542cca318b Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Fri, 13 Feb 2026 23:47:13 +0530 Subject: [PATCH 03/46] feat: useSumsubKycFlow hook setup using initiateSumsubKyc server action --- src/hooks/useSumsubKycFlow.ts | 155 ++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 src/hooks/useSumsubKycFlow.ts diff --git a/src/hooks/useSumsubKycFlow.ts b/src/hooks/useSumsubKycFlow.ts new file mode 100644 index 000000000..96f4ef54e --- /dev/null +++ b/src/hooks/useSumsubKycFlow.ts @@ -0,0 +1,155 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import { useRouter } from 'next/navigation' +import { useWebSocket } from '@/hooks/useWebSocket' +import { useUserStore } from '@/redux/hooks' +import { initiateSumsubKyc } from '@/app/actions/sumsub' +import { type SumsubKycStatus } from '@/app/actions/types/sumsub.types' + +interface UseSumsubKycFlowOptions { + onKycSuccess?: () => void + onManualClose?: () => void + regionIntent?: string +} + +export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: UseSumsubKycFlowOptions = {}) => { + const { user } = useUserStore() + const router = useRouter() + + const [accessToken, setAccessToken] = useState(null) + const [showWrapper, setShowWrapper] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + const [isVerificationProgressModalOpen, setIsVerificationProgressModalOpen] = useState(false) + const [liveKycStatus, setLiveKycStatus] = useState(undefined) + const [rejectLabels, setRejectLabels] = useState(undefined) + const prevStatusRef = useRef(liveKycStatus) + + // listen for sumsub kyc status updates via websocket + useWebSocket({ + username: user?.user.username ?? undefined, + autoConnect: true, + onSumsubKycStatusUpdate: (newStatus, newRejectLabels) => { + setLiveKycStatus(newStatus as SumsubKycStatus) + if (newRejectLabels) setRejectLabels(newRejectLabels) + }, + }) + + // react to status transitions + useEffect(() => { + const prevStatus = prevStatusRef.current + prevStatusRef.current = liveKycStatus + + if (prevStatus !== 'APPROVED' && liveKycStatus === 'APPROVED') { + setIsVerificationProgressModalOpen(false) + onKycSuccess?.() + } else if (prevStatus !== 'REJECTED' && liveKycStatus === 'REJECTED') { + setIsVerificationProgressModalOpen(false) + } + }, [liveKycStatus, onKycSuccess]) + + // fetch current status on mount to recover from missed websocket events + useEffect(() => { + const fetchCurrentStatus = async () => { + try { + const response = await initiateSumsubKyc({ regionIntent }) + if (response.data?.status) { + setLiveKycStatus(response.data.status) + } + } catch { + // silent failure - we just show the user an error when they try to initiate the kyc flow if the api call is failing + } + } + + fetchCurrentStatus() + }, []) + + const handleInitiateKyc = useCallback(async () => { + setIsLoading(true) + setError(null) + + try { + const response = await initiateSumsubKyc({ regionIntent }) + + if (response.error) { + setError(response.error) + return + } + + // sync status from api response + if (response.data?.status) { + setLiveKycStatus(response.data.status) + } + + // if already approved, no token is returned + if (response.data?.status === 'APPROVED') { + onKycSuccess?.() + return + } + + if (response.data?.token) { + setAccessToken(response.data.token) + setShowWrapper(true) + } else { + setError('Could not initate verification. Please try again.') + } + } catch (e: any) { + setError(e.message || 'An unexpected error occurred') + } finally { + setIsLoading(false) + } + }, [regionIntent, onKycSuccess]) + + // called when sdk signals applicant submitted + const handleSdkComplete = useCallback(() => { + setShowWrapper(false) + setIsVerificationProgressModalOpen(true) + }, []) + + // called when user manually closes the sdk modal + const handleClose = useCallback(() => { + setShowWrapper(false) + onManualClose?.() + }, [onManualClose]) + + // token refresh function passed to the sdk for when the token expires + const refreshToken = useCallback(async (): Promise => { + const response = await initiateSumsubKyc({ regionIntent }) + + if (response.error || !response.data?.token) { + throw new Error(response.error || 'Failed to refresh token') + } + + setAccessToken(response.data.token) + return response.data.token + }, [regionIntent]) + + const closeVerificationProgressModal = useCallback(() => { + setIsVerificationProgressModalOpen(false) + }, []) + + const closeVerificationModalAndGoHome = useCallback(() => { + setIsVerificationProgressModalOpen(false) + router.push('/home') + }, [router]) + + const resetError = useCallback(() => { + setError(null) + }, []) + + return { + isLoading, + error, + showWrapper, + accessToken, + liveKycStatus, + rejectLabels, + handleInitiateKyc, + handleSdkComplete, + handleClose, + refreshToken, + isVerificationProgressModalOpen, + closeVerificationProgressModal, + closeVerificationModalAndGoHome, + resetError, + } +} From 2c2bf1464080dad5dbf0871149991f3e125bb930 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Fri, 13 Feb 2026 23:52:39 +0530 Subject: [PATCH 04/46] feat: handle websocket event receiving for sumsub kyc --- src/hooks/useWebSocket.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/hooks/useWebSocket.ts b/src/hooks/useWebSocket.ts index 68f006e7f..b1d551d12 100644 --- a/src/hooks/useWebSocket.ts +++ b/src/hooks/useWebSocket.ts @@ -10,6 +10,7 @@ interface UseWebSocketOptions { onHistoryEntry?: (entry: HistoryEntry) => void onKycStatusUpdate?: (status: string) => void onMantecaKycStatusUpdate?: (status: string) => void + onSumsubKycStatusUpdate?: (status: string, rejectLabels?: string[]) => void onTosUpdate?: (data: { accepted: boolean }) => void onConnect?: () => void onDisconnect?: () => void @@ -23,6 +24,7 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => { onHistoryEntry, onKycStatusUpdate, onMantecaKycStatusUpdate, + onSumsubKycStatusUpdate, onTosUpdate, onConnect, onDisconnect, @@ -37,6 +39,7 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => { onHistoryEntry, onKycStatusUpdate, onMantecaKycStatusUpdate, + onSumsubKycStatusUpdate, onTosUpdate, onConnect, onDisconnect, @@ -49,12 +52,22 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => { onHistoryEntry, onKycStatusUpdate, onMantecaKycStatusUpdate, + onSumsubKycStatusUpdate, onTosUpdate, onConnect, onDisconnect, onError, } - }, [onHistoryEntry, onKycStatusUpdate, onMantecaKycStatusUpdate, onTosUpdate, onConnect, onDisconnect, onError]) + }, [ + onHistoryEntry, + onKycStatusUpdate, + onMantecaKycStatusUpdate, + onSumsubKycStatusUpdate, + onTosUpdate, + onConnect, + onDisconnect, + onError, + ]) // Connect to WebSocket const connect = useCallback(() => { @@ -141,6 +154,14 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => { } } + const handleSumsubKycStatusUpdate = (data: { status: string; rejectLabels?: string[] }) => { + if (callbacksRef.current.onSumsubKycStatusUpdate) { + callbacksRef.current.onSumsubKycStatusUpdate(data.status, data.rejectLabels) + } else { + console.log(`[WebSocket] No onSumsubKycStatusUpdate callback registered for user: ${username}`) + } + } + const handleTosUpdate = (data: { status: string }) => { if (callbacksRef.current.onTosUpdate) { callbacksRef.current.onTosUpdate({ accepted: data.status === 'approved' }) @@ -156,6 +177,7 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => { ws.on('history_entry', handleHistoryEntry) ws.on('kyc_status_update', handleKycStatusUpdate) ws.on('manteca_kyc_status_update', handleMantecaKycStatusUpdate) + ws.on('sumsub_kyc_status_update', handleSumsubKycStatusUpdate) ws.on('persona_tos_status_update', handleTosUpdate) // Auto-connect if enabled @@ -171,6 +193,7 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => { ws.off('history_entry', handleHistoryEntry) ws.off('kyc_status_update', handleKycStatusUpdate) ws.off('manteca_kyc_status_update', handleMantecaKycStatusUpdate) + ws.off('sumsub_kyc_status_update', handleSumsubKycStatusUpdate) ws.off('persona_tos_status_update', handleTosUpdate) } }, [autoConnect, connect, username]) From 27ea649f1f76bb2eb9538f3236377b18ccdbe27d Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Sat, 14 Feb 2026 00:28:25 +0530 Subject: [PATCH 05/46] feat: SumsubKycWrapper component to handle sumsub web sdk intialization and kyc flow states --- src/components/Kyc/SumsubKycWrapper.tsx | 258 ++++++++++++++++++++++++ src/hooks/useSumsubKycFlow.ts | 5 +- src/types/sumsub-websdk.d.ts | 1 + 3 files changed, 262 insertions(+), 2 deletions(-) create mode 100644 src/components/Kyc/SumsubKycWrapper.tsx diff --git a/src/components/Kyc/SumsubKycWrapper.tsx b/src/components/Kyc/SumsubKycWrapper.tsx new file mode 100644 index 000000000..d6eea6e1b --- /dev/null +++ b/src/components/Kyc/SumsubKycWrapper.tsx @@ -0,0 +1,258 @@ +'use client' + +import { useEffect, useMemo, useState, useRef, useCallback } from 'react' +import Modal from '@/components/Global/Modal' +import ActionModal from '@/components/Global/ActionModal' +import { Icon, type IconName } from '@/components/Global/Icons/Icon' +import { Button, type ButtonVariant } from '@/components/0_Bruddle/Button' +import { useModalsContext } from '@/context/ModalsContext' +import StartVerificationView from '../Global/IframeWrapper/StartVerificationView' + +// todo: move to consts +const SUMSUB_SDK_URL = 'https://static.sumsub.com/idensic/static/sns-websdk-builder.js' + +interface SumsubKycWrapperProps { + visible: boolean + accessToken: string | null + onClose: () => void + onComplete: () => void + onError?: (error: unknown) => void + onRefreshToken: () => Promise +} + +export const SumsubKycWrapper = ({ + visible, + accessToken, + onClose, + onComplete, + onError, + onRefreshToken, +}: SumsubKycWrapperProps) => { + const [isVerificationStarted, setIsVerificationStarted] = useState(false) + const [sdkLoaded, setSdkLoaded] = useState(false) + const [sdkLoadError, setSdkLoadError] = useState(false) + const [isHelpModalOpen, setIsHelpModalOpen] = useState(false) + const [modalVariant, setModalVariant] = useState<'stop-verification' | 'trouble'>('trouble') + const sdkContainerRef = useRef(null) + const sdkInstanceRef = useRef(null) + const { setIsSupportModalOpen } = useModalsContext() + + // callback refs to avoid stale closures in sdk init effect + const onCompleteRef = useRef(onComplete) + const onErrorRef = useRef(onError) + const onRefreshTokenRef = useRef(onRefreshToken) + + useEffect(() => { + onCompleteRef.current = onComplete + onErrorRef.current = onError + onRefreshTokenRef.current = onRefreshToken + }, [onComplete, onError, onRefreshToken]) + + // stable wrappers that read from refs + const stableOnComplete = useCallback(() => onCompleteRef.current(), []) + const stableOnError = useCallback((error: unknown) => onErrorRef.current?.(error), []) + const stableOnRefreshToken = useCallback(() => onRefreshTokenRef.current(), []) + + // load sumsub websdk script + useEffect(() => { + const existingScript = document.getElementById('sumsub-websdk') + if (existingScript) { + setSdkLoaded(true) + return + } + + const script = document.createElement('script') + script.id = 'sumsub-websdk' + script.src = SUMSUB_SDK_URL + script.async = true + script.onload = () => setSdkLoaded(true) + script.onerror = () => { + console.error('[sumsub] failed to load websdk script') + setSdkLoadError(true) + } + document.head.appendChild(script) + }, []) + + // initialize sdk when verification starts and all deps are ready + useEffect(() => { + if (!isVerificationStarted || !accessToken || !sdkLoaded || !sdkContainerRef.current) return + + // clean up previous instance + if (sdkInstanceRef.current) { + try { + sdkInstanceRef.current.destroy() + } catch { + // ignore cleanup errors + } + } + + try { + const sdk = window.snsWebSdk + .init(accessToken, stableOnRefreshToken) + .withConf({ lang: 'en', theme: 'light' }) + .withOptions({ addViewportTag: false, adaptIframeHeight: true }) + .on('onApplicantSubmitted', () => stableOnComplete()) + .on('onApplicantResubmitted', () => stableOnComplete()) + .on('onError', (error: unknown) => { + console.error('[sumsub] sdk error', error) + stableOnError(error) + }) + .build() + + sdk.launch(sdkContainerRef.current) + sdkInstanceRef.current = sdk + } catch (error) { + console.error('[sumsub] failed to initialize sdk', error) + stableOnError(error) + } + + return () => { + if (sdkInstanceRef.current) { + try { + sdkInstanceRef.current.destroy() + } catch { + // ignore cleanup errors + } + sdkInstanceRef.current = null + } + } + }, [isVerificationStarted, accessToken, sdkLoaded, stableOnComplete, stableOnError, stableOnRefreshToken]) + + // reset state when modal closes + useEffect(() => { + if (!visible) { + setIsVerificationStarted(false) + setSdkLoadError(false) + if (sdkInstanceRef.current) { + try { + sdkInstanceRef.current.destroy() + } catch { + // ignore cleanup errors + } + sdkInstanceRef.current = null + } + } + }, [visible]) + + const modalDetails = useMemo(() => { + if (modalVariant === 'trouble') { + return { + title: 'Having trouble verifying?', + description: + "If the verification isn't loading or working properly, please contact our support team for help.", + icon: 'question-mark' as IconName, + iconContainerClassName: 'bg-primary-1', + ctas: [ + { + text: 'Chat with support', + icon: 'peanut-support' as IconName, + onClick: () => setIsSupportModalOpen(true), + variant: 'purple' as ButtonVariant, + shadowSize: '4' as const, + }, + ], + } + } + + return { + title: 'Stop verification?', + description: "If you exit now, your verification won't be completed and you'll need to start again later.", + icon: 'alert' as IconName, + iconContainerClassName: 'bg-secondary-1', + ctas: [ + { + text: 'Stop verification', + onClick: () => { + setIsHelpModalOpen(false) + onClose() + }, + variant: 'purple' as ButtonVariant, + shadowSize: '4' as const, + }, + { + text: 'Continue verifying', + onClick: () => setIsHelpModalOpen(false), + variant: 'transparent' as ButtonVariant, + className: 'underline text-sm font-medium w-full h-fit mt-3', + }, + ], + } + }, [modalVariant, onClose, setIsSupportModalOpen]) + + return ( + { }} // todo: implement close modal logic that also stops the sdk and resets state + classWrap="h-full w-full !max-w-none sm:!max-w-[600px] border-none sm:m-auto m-0" + classOverlay={`bg-black bg-opacity-50 ${isHelpModalOpen ? 'pointer-events-none' : ''}`} + video={false} + className={`z-[100] !p-0 md:!p-6 ${isHelpModalOpen ? 'pointer-events-none' : ''}`} + classButtonClose="hidden" + preventClose={true} + hideOverlay={false} + > + {!isVerificationStarted ? ( + // start verification view (provider-agnostic, not reusing StartVerificationView which references "Persona") + { }} // todo: handle kyc cancellation by user + onStartVerification={() => setIsVerificationStarted(true)} + /> + ) : sdkLoadError ? ( + // script failed to load — show user-facing error +
+ +

+ Failed to load verification. Please check your connection and try again. +

+ +
+ ) : ( + // SDK container + controls +
+
+
+
+ + +
+
+
+ )} + setIsHelpModalOpen(false)} + title={modalDetails.title} + description={modalDetails.description} + icon={modalDetails.icon} + iconContainerClassName={modalDetails.iconContainerClassName} + modalPanelClassName="max-w-full pointer-events-auto" + ctaClassName="grid grid-cols-1 gap-3" + contentContainerClassName="px-6 py-6" + modalClassName="!z-[10001] pointer-events-auto" + preventClose={true} + ctas={modalDetails.ctas} + /> + + ) +} diff --git a/src/hooks/useSumsubKycFlow.ts b/src/hooks/useSumsubKycFlow.ts index 96f4ef54e..d726b224b 100644 --- a/src/hooks/useSumsubKycFlow.ts +++ b/src/hooks/useSumsubKycFlow.ts @@ -92,8 +92,9 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: } else { setError('Could not initate verification. Please try again.') } - } catch (e: any) { - setError(e.message || 'An unexpected error occurred') + } catch (e: unknown) { + const message = e instanceof Error ? e.message : 'An unexpected error occurred' + setError(message) } finally { setIsLoading(false) } diff --git a/src/types/sumsub-websdk.d.ts b/src/types/sumsub-websdk.d.ts index 58400646f..4d6b7c5c7 100644 --- a/src/types/sumsub-websdk.d.ts +++ b/src/types/sumsub-websdk.d.ts @@ -10,6 +10,7 @@ declare global { interface SnsWebSdkBuilderChain { withConf(conf: { lang?: string; theme?: string }): SnsWebSdkBuilderChain withOptions(opts: { addViewportTag?: boolean; adaptIframeHeight?: boolean }): SnsWebSdkBuilderChain + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- sumsub sdk event handlers have varying untyped signatures on(event: string, handler: (...args: any[]) => void): SnsWebSdkBuilderChain build(): SnsWebSdkInstance } From 1fa243f33e4560c66f4865b2fb4861ecacc400e5 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Sat, 14 Feb 2026 00:30:12 +0530 Subject: [PATCH 06/46] feat: SumsubKycFlow entry point for sumsub kyc wrapper and pending states --- src/app/actions/sumsub.ts | 5 +-- src/components/Kyc/SumsubKycFlow.tsx | 51 ++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 src/components/Kyc/SumsubKycFlow.tsx diff --git a/src/app/actions/sumsub.ts b/src/app/actions/sumsub.ts index b3401567c..9ef6af6b5 100644 --- a/src/app/actions/sumsub.ts +++ b/src/app/actions/sumsub.ts @@ -37,7 +37,8 @@ export const initiateSumsubKyc = async (params?: { status: responseJson.status, }, } - } catch (e: any) { - return { error: e.message || 'An unexpected error occurred' } + } catch (e: unknown) { + const message = e instanceof Error ? e.message : 'An unexpected error occurred' + return { error: message } } } diff --git a/src/components/Kyc/SumsubKycFlow.tsx b/src/components/Kyc/SumsubKycFlow.tsx new file mode 100644 index 000000000..fb5bda64e --- /dev/null +++ b/src/components/Kyc/SumsubKycFlow.tsx @@ -0,0 +1,51 @@ +import { Button, type ButtonProps } from '@/components/0_Bruddle/Button' +import { SumsubKycWrapper } from '@/components/Kyc/SumsubKycWrapper' +import { KycVerificationInProgressModal } from '@/components/Kyc/KycVerificationInProgressModal' +import { useSumsubKycFlow } from '@/hooks/useSumsubKycFlow' + +interface SumsubKycFlowProps extends ButtonProps { + onKycSuccess?: () => void + onManualClose?: () => void +} + +/** + * entry point for the kyc flow + * renders a button that initiates kyc, the sumsub sdk wrapper modal, and a verification-in-progress modal + */ +export const SumsubKycFlow = ({ onKycSuccess, onManualClose, ...buttonProps }: SumsubKycFlowProps) => { + const { + isLoading, + error, + showWrapper, + accessToken, + handleInitiateKyc, + handleSdkComplete, + handleClose, + refreshToken, + isVerificationProgressModalOpen, + closeVerificationProgressModal, + } = useSumsubKycFlow({ onKycSuccess, onManualClose }) + + return ( + <> + + + {error &&

{error}

} + + + + + + ) +} From 61bd07ec1d4aaab0ca5e54a01ffb6861db1a2511 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Sat, 14 Feb 2026 00:37:55 +0530 Subject: [PATCH 07/46] fix: update verification view ux copy + add region intent type --- src/app/actions/sumsub.ts | 9 ++++----- src/app/actions/types/sumsub.types.ts | 2 ++ .../Global/IframeWrapper/StartVerificationView.tsx | 6 ++---- src/components/Kyc/SumsubKycFlow.tsx | 2 +- src/components/Kyc/SumsubKycWrapper.tsx | 10 +++++----- src/hooks/useSumsubKycFlow.ts | 4 ++-- 6 files changed, 16 insertions(+), 17 deletions(-) diff --git a/src/app/actions/sumsub.ts b/src/app/actions/sumsub.ts index 9ef6af6b5..f113d14ca 100644 --- a/src/app/actions/sumsub.ts +++ b/src/app/actions/sumsub.ts @@ -1,6 +1,6 @@ 'use server' -import { type InitiateSumsubKycResponse } from './types/sumsub.types' +import { type InitiateSumsubKycResponse, type KYCRegionIntent } from './types/sumsub.types' import { fetchWithSentry } from '@/utils/sentry.utils' import { PEANUT_API_URL } from '@/constants/general.consts' import { getJWTCookie } from '@/utils/cookie-migration.utils' @@ -9,7 +9,7 @@ const API_KEY = process.env.PEANUT_API_KEY! // initiate kyc flow (using sumsub) and get websdk access token export const initiateSumsubKyc = async (params?: { - regionIntent?: string + regionIntent?: KYCRegionIntent }): Promise<{ data?: InitiateSumsubKycResponse; error?: string }> => { const jwtToken = (await getJWTCookie())?.value @@ -37,8 +37,7 @@ export const initiateSumsubKyc = async (params?: { status: responseJson.status, }, } - } catch (e: unknown) { - const message = e instanceof Error ? e.message : 'An unexpected error occurred' - return { error: message } + } catch (e: any) { + return { error: e.message || 'An unexpected error occurred' } } } diff --git a/src/app/actions/types/sumsub.types.ts b/src/app/actions/types/sumsub.types.ts index 5e9c6f867..3922f4235 100644 --- a/src/app/actions/types/sumsub.types.ts +++ b/src/app/actions/types/sumsub.types.ts @@ -5,3 +5,5 @@ export interface InitiateSumsubKycResponse { } export type SumsubKycStatus = 'NOT_STARTED' | 'PENDING' | 'IN_REVIEW' | 'APPROVED' | 'REJECTED' | 'ACTION_REQUIRED' + +export type KYCRegionIntent = 'STANDARD' | 'LATAM' \ No newline at end of file diff --git a/src/components/Global/IframeWrapper/StartVerificationView.tsx b/src/components/Global/IframeWrapper/StartVerificationView.tsx index 5285ec405..fde280ad9 100644 --- a/src/components/Global/IframeWrapper/StartVerificationView.tsx +++ b/src/components/Global/IframeWrapper/StartVerificationView.tsx @@ -33,11 +33,9 @@ const StartVerificationView = ({

Secure Verification. Limited Data Use.

- The verification is done by Persona, which only shares a yes/no with Peanut. -

-

- Persona is trusted by millions and it operates under strict security and privacy standards. + The verification is done using a trusted provider, which only shares a yes/no with Peanut.

+

It operates under strict security and privacy standards.

Peanut never sees or stores your verification data.

- - {error &&

{error}

} +interface KycFlowProps extends ButtonProps { + regionIntent?: KYCRegionIntent +} - - - ) +// main entry point for the kyc flow. +// renders SumsubKycFlow with an optional region intent for context-aware verification. +export const KycFlow = ({ regionIntent, ...buttonProps }: KycFlowProps) => { + return } diff --git a/src/components/Kyc/SumsubKycFlow.tsx b/src/components/Kyc/SumsubKycFlow.tsx index df332f7fc..faf067bb2 100644 --- a/src/components/Kyc/SumsubKycFlow.tsx +++ b/src/components/Kyc/SumsubKycFlow.tsx @@ -2,17 +2,19 @@ import { Button, type ButtonProps } from '@/components/0_Bruddle/Button' import { SumsubKycWrapper } from '@/components/Kyc/SumsubKycWrapper' import { KycVerificationInProgressModal } from '@/components/Kyc/KycVerificationInProgressModal' import { useSumsubKycFlow } from '@/hooks/useSumsubKycFlow' +import { type KYCRegionIntent } from '@/app/actions/types/sumsub.types' interface SumsubKycFlowProps extends ButtonProps { onKycSuccess?: () => void onManualClose?: () => void + regionIntent?: KYCRegionIntent } /** * entry point for the kyc flow * renders a button that initiates kyc, the sumsub sdk wrapper modal, and a verification-in-progress modal */ -export const SumsubKycFlow = ({ onKycSuccess, onManualClose, ...buttonProps }: SumsubKycFlowProps) => { +export const SumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent, ...buttonProps }: SumsubKycFlowProps) => { const { isLoading, error, @@ -24,7 +26,7 @@ export const SumsubKycFlow = ({ onKycSuccess, onManualClose, ...buttonProps }: S refreshToken, isVerificationProgressModalOpen, closeVerificationProgressModal, - } = useSumsubKycFlow({ onKycSuccess, onManualClose }) // todo: pass region intent param + } = useSumsubKycFlow({ onKycSuccess, onManualClose, regionIntent }) return ( <> diff --git a/src/hooks/useIdentityVerification.tsx b/src/hooks/useIdentityVerification.tsx index 228722ac6..9d2b5c340 100644 --- a/src/hooks/useIdentityVerification.tsx +++ b/src/hooks/useIdentityVerification.tsx @@ -5,6 +5,7 @@ import { useMemo, useCallback } from 'react' import { useAuth } from '@/context/authContext' import { MantecaKycStatus } from '@/interfaces' import { BRIDGE_ALPHA3_TO_ALPHA2, MantecaSupportedExchanges, countryData } from '@/components/AddMoney/consts' +import { type KYCRegionIntent } from '@/app/actions/types/sumsub.types' import React from 'react' /** Represents a geographic region with its display information */ @@ -75,6 +76,11 @@ const BRIDGE_SUPPORTED_LATAM_COUNTRIES: Region[] = [ }, ] +/** maps a region path to the sumsub kyc template intent */ +export const getRegionIntent = (regionPath: string): KYCRegionIntent => { + return regionPath === 'latam' ? 'LATAM' : 'STANDARD' +} + /** * Hook for managing identity verification (KYC) status and region access. * @@ -96,7 +102,7 @@ const BRIDGE_SUPPORTED_LATAM_COUNTRIES: Region[] = [ */ export const useIdentityVerification = () => { const { user } = useAuth() - const { isUserBridgeKycApproved, isUserMantecaKycApproved } = useKycStatus() + const { isUserBridgeKycApproved, isUserMantecaKycApproved, isUserSumsubKycApproved } = useKycStatus() /** * Check if a country is supported by Manteca (LATAM countries). @@ -149,9 +155,13 @@ export const useIdentityVerification = () => { const { lockedRegions, unlockedRegions } = useMemo(() => { const isBridgeApproved = isUserBridgeKycApproved const isMantecaApproved = isUserMantecaKycApproved + const isSumsubApproved = isUserSumsubKycApproved - // Helper to check if a region should be unlocked + // helper to check if a region should be unlocked const isRegionUnlocked = (regionName: string) => { + // sumsub approval unlocks all regions (one verification per user). + // backend enforces per-rail access separately — frontend only gates on identity verification. + if (isSumsubApproved) return true return ( (isBridgeApproved && BRIDGE_SUPPORTED_REGIONS.includes(regionName)) || (isMantecaApproved && MANTECA_SUPPORTED_REGIONS.includes(regionName)) @@ -161,8 +171,8 @@ export const useIdentityVerification = () => { const unlocked = SUPPORTED_REGIONS.filter((region) => isRegionUnlocked(region.name)) const locked = SUPPORTED_REGIONS.filter((region) => !isRegionUnlocked(region.name)) - // Bridge users get QR payment access in Argentina & Brazil - // even without full Manteca KYC (which unlocks bank transfers too) + // bridge users get qr payment access in argentina & brazil + // even without full manteca kyc (which unlocks bank transfers too) if (isBridgeApproved && !isMantecaApproved) { unlocked.push(...MANTECA_QR_ONLY_REGIONS, ...BRIDGE_SUPPORTED_LATAM_COUNTRIES) } @@ -171,7 +181,7 @@ export const useIdentityVerification = () => { lockedRegions: locked, unlockedRegions: unlocked, } - }, [isUserBridgeKycApproved, isUserMantecaKycApproved]) + }, [isUserBridgeKycApproved, isUserMantecaKycApproved, isUserSumsubKycApproved]) /** * Check if a region is already unlocked by comparing region paths. From 3188926d73f5f57ce8edf002f1a0a45be5da7338 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Mon, 16 Feb 2026 23:16:25 +0530 Subject: [PATCH 13/46] feat: unified kyc status drawer --- src/components/Kyc/KycStatusDrawer.tsx | 61 ++++++++++++++++---------- src/components/Kyc/KycStatusItem.tsx | 41 +++++++++-------- 2 files changed, 60 insertions(+), 42 deletions(-) diff --git a/src/components/Kyc/KycStatusDrawer.tsx b/src/components/Kyc/KycStatusDrawer.tsx index e8a80f9fd..7744e9d4c 100644 --- a/src/components/Kyc/KycStatusDrawer.tsx +++ b/src/components/Kyc/KycStatusDrawer.tsx @@ -3,29 +3,16 @@ import { KycFailed } from './states/KycFailed' import { KycProcessing } from './states/KycProcessing' import { Drawer, DrawerContent, DrawerTitle } from '../Global/Drawer' import { type BridgeKycStatus } from '@/utils/bridge-accounts.utils' -import { type IUserKycVerification, MantecaKycStatus } from '@/interfaces' +import { type IUserKycVerification } from '@/interfaces' import { useUserStore } from '@/redux/hooks' import { useBridgeKycFlow } from '@/hooks/useBridgeKycFlow' import { useMantecaKycFlow } from '@/hooks/useMantecaKycFlow' +import { useSumsubKycFlow } from '@/hooks/useSumsubKycFlow' import { type CountryData, countryData } from '@/components/AddMoney/consts' import IFrameWrapper from '@/components/Global/IframeWrapper' - -// a helper to categorize the kyc status from the user object -const getKycStatusCategory = (status: BridgeKycStatus | MantecaKycStatus): 'processing' | 'completed' | 'failed' => { - switch (status) { - case 'approved': - case MantecaKycStatus.ACTIVE: - return 'completed' - case 'rejected': - case MantecaKycStatus.INACTIVE: - return 'failed' - case 'under_review': - case 'incomplete': - case MantecaKycStatus.ONBOARDING: - default: - return 'processing' - } -} +import { SumsubKycWrapper } from '@/components/Kyc/SumsubKycWrapper' +import { KycVerificationInProgressModal } from '@/components/Kyc/KycVerificationInProgressModal' +import { getKycStatusCategory, isKycStatusNotStarted } from '@/constants/kyc.consts' interface KycStatusDrawerProps { isOpen: boolean @@ -65,15 +52,29 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus country: country as CountryData, }) + const { + handleInitiateKyc: initiateSumsub, + showWrapper: showSumsubWrapper, + accessToken: sumsubAccessToken, + handleSdkComplete: handleSumsubComplete, + handleClose: handleSumsubClose, + refreshToken: sumsubRefreshToken, + isLoading: isSumsubLoading, + isVerificationProgressModalOpen: isSumsubProgressModalOpen, + closeVerificationProgressModal: closeSumsubProgressModal, + } = useSumsubKycFlow({ onKycSuccess: onClose, onManualClose: onClose }) + const onRetry = async () => { - if (provider === 'MANTECA') { + if (provider === 'SUMSUB') { + await initiateSumsub() + } else if (provider === 'MANTECA') { await openMantecaKyc(country as CountryData) } else { await initiateBridgeKyc() } } - const isLoadingKyc = isBridgeLoading || isMantecaLoading + const isLoadingKyc = isBridgeLoading || isMantecaLoading || isSumsubLoading const renderContent = () => { switch (statusCategory) { @@ -93,10 +94,15 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus isBridge={isBridgeKyc} /> ) - case 'failed': + case 'failed': { + // for sumsub, use reject labels as the reason + const reason = + provider === 'SUMSUB' + ? (verification?.rejectLabels?.join(', ') ?? '') + : (user?.user?.bridgeKycRejectionReasonString ?? '') return ( ) + } default: return null } } // don't render the drawer if the kyc status is unknown or not started - if (status === 'not_started' || !status) { + if (isKycStatusNotStarted(status)) { return null } @@ -124,6 +131,14 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus + + ) } diff --git a/src/components/Kyc/KycStatusItem.tsx b/src/components/Kyc/KycStatusItem.tsx index 1ca8536ed..57094b13d 100644 --- a/src/components/Kyc/KycStatusItem.tsx +++ b/src/components/Kyc/KycStatusItem.tsx @@ -10,6 +10,12 @@ import { twMerge } from 'tailwind-merge' import { type IUserKycVerification } from '@/interfaces' import StatusPill from '../Global/StatusPill' import { KYCStatusIcon } from './KYCStatusIcon' +import { + isKycStatusApproved, + isKycStatusPending, + isKycStatusFailed, + isKycStatusNotStarted, +} from '@/constants/kyc.consts' // this component shows the current kyc status and opens a drawer with more details on click export const KycStatusItem = ({ @@ -45,23 +51,18 @@ export const KycStatusItem = ({ const finalBridgeKycStatus = wsBridgeKycStatus || bridgeKycStatus || user?.user?.bridgeKycStatus const kycStatus = verification ? verification.status : finalBridgeKycStatus - // Check if KYC is approved to show points earned - const isApproved = kycStatus === 'approved' || kycStatus === 'ACTIVE' - - const isPending = kycStatus === 'under_review' || kycStatus === 'incomplete' || kycStatus === 'ONBOARDING' - const isRejected = kycStatus === 'rejected' || kycStatus === 'INACTIVE' + const isApproved = isKycStatusApproved(kycStatus) + const isPending = isKycStatusPending(kycStatus) + const isRejected = isKycStatusFailed(kycStatus) const subtitle = useMemo(() => { - if (isPending) { - return 'Under review' - } - if (isApproved) { - return 'Approved' - } - return 'Rejected' + if (isPending) return 'Under review' + if (isApproved) return 'Approved' + if (isRejected) return 'Rejected' + return 'Unknown' }, [isPending, isApproved, isRejected]) - if (!kycStatus || kycStatus === 'not_started') { + if (isKycStatusNotStarted(kycStatus)) { return null } @@ -88,12 +89,14 @@ export const KycStatusItem = ({
- + {isDrawerOpen && ( + + )} ) } From 4fb61d471dcc14ad746ea02fd9027b13535148c2 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Mon, 16 Feb 2026 23:17:26 +0530 Subject: [PATCH 14/46] feat: sumsub kyc gate for qr payments --- src/hooks/useQrKycGate.ts | 35 ++++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/src/hooks/useQrKycGate.ts b/src/hooks/useQrKycGate.ts index c025d3561..747ef0e78 100644 --- a/src/hooks/useQrKycGate.ts +++ b/src/hooks/useQrKycGate.ts @@ -3,7 +3,7 @@ import { useCallback, useState, useEffect, useRef } from 'react' import { useAuth } from '@/context/authContext' import { MantecaKycStatus } from '@/interfaces' -import { getBridgeCustomerCountry } from '@/app/actions/bridge/get-customer' +import { isKycStatusApproved, isSumsubStatusInProgress } from '@/constants/kyc.consts' export enum QrKycState { LOADING = 'loading', @@ -61,6 +61,16 @@ export function useQrKycGate(paymentProcessor?: 'MANTECA' | 'SIMPLEFI' | null): return } + // sumsub approved users (including foreign users) can proceed to qr pay. + // note: backend enforces per-rail access separately — frontend gate only checks identity verification. + const hasSumsubApproved = currentUser.kycVerifications?.some( + (v) => v.provider === 'SUMSUB' && isKycStatusApproved(v.status) + ) + if (hasSumsubApproved) { + setKycGateState(QrKycState.PROCEED_TO_PAY) + return + } + const mantecaKycs = currentUser.kycVerifications?.filter((v) => v.provider === 'MANTECA') ?? [] const hasAnyMantecaKyc = mantecaKycs.length > 0 @@ -73,18 +83,8 @@ export function useQrKycGate(paymentProcessor?: 'MANTECA' | 'SIMPLEFI' | null): return } - if (currentUser.bridgeKycStatus === 'approved' && currentUser.bridgeCustomerId) { - try { - const { countryCode } = await getBridgeCustomerCountry(currentUser.bridgeCustomerId) - // if (countryCode && countryCode.toUpperCase() === 'AR') { - if (false) { - } else { - setKycGateState(QrKycState.PROCEED_TO_PAY) - } - } catch { - // fail to require identity verification to avoid blocking pay due to rare outages - setKycGateState(QrKycState.REQUIRES_IDENTITY_VERIFICATION) - } + if (currentUser.bridgeKycStatus === 'approved') { + setKycGateState(QrKycState.PROCEED_TO_PAY) return } @@ -100,6 +100,15 @@ export function useQrKycGate(paymentProcessor?: 'MANTECA' | 'SIMPLEFI' | null): return } + // sumsub verification in progress + const hasSumsubInProgress = currentUser.kycVerifications?.some( + (v) => v.provider === 'SUMSUB' && isSumsubStatusInProgress(v.status) + ) + if (hasSumsubInProgress) { + setKycGateState(QrKycState.IDENTITY_VERIFICATION_IN_PROGRESS) + return + } + setKycGateState(QrKycState.REQUIRES_IDENTITY_VERIFICATION) }, [user?.user, isFetchingUser, paymentProcessor, fetchUser]) From 6d390ad466633bb1d4fcc56d97a78f123b614747 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Mon, 16 Feb 2026 23:25:45 +0530 Subject: [PATCH 15/46] feat: update views for sumsub flow --- .../Home/KycCompletedModal/index.tsx | 9 +- .../views/RegionsVerification.view.tsx | 93 +++++++++++++++++-- src/features/limits/views/LimitsPageView.tsx | 2 +- 3 files changed, 92 insertions(+), 12 deletions(-) diff --git a/src/components/Home/KycCompletedModal/index.tsx b/src/components/Home/KycCompletedModal/index.tsx index 1377b3a50..80cd086bc 100644 --- a/src/components/Home/KycCompletedModal/index.tsx +++ b/src/components/Home/KycCompletedModal/index.tsx @@ -13,14 +13,17 @@ const KycCompletedModal = ({ isOpen, onClose }: { isOpen: boolean; onClose: () = const { user } = useAuth() const [approvedCountryData, setApprovedCountryData] = useState(null) - const { isUserBridgeKycApproved, isUserMantecaKycApproved } = useKycStatus() + const { isUserBridgeKycApproved, isUserMantecaKycApproved, isUserSumsubKycApproved } = useKycStatus() const { getVerificationUnlockItems } = useIdentityVerification() const kycApprovalType = useMemo(() => { + // sumsub covers all regions, treat as 'all' + if (isUserSumsubKycApproved) { + return 'all' + } if (isUserBridgeKycApproved && isUserMantecaKycApproved) { return 'all' } - if (isUserBridgeKycApproved) { return 'bridge' } @@ -28,7 +31,7 @@ const KycCompletedModal = ({ isOpen, onClose }: { isOpen: boolean; onClose: () = return 'manteca' } return 'none' - }, [isUserBridgeKycApproved, isUserMantecaKycApproved]) + }, [isUserBridgeKycApproved, isUserMantecaKycApproved, isUserSumsubKycApproved]) const items = useMemo(() => { return getVerificationUnlockItems(approvedCountryData?.title) diff --git a/src/components/Profile/views/RegionsVerification.view.tsx b/src/components/Profile/views/RegionsVerification.view.tsx index 9323f71be..b67a321fd 100644 --- a/src/components/Profile/views/RegionsVerification.view.tsx +++ b/src/components/Profile/views/RegionsVerification.view.tsx @@ -5,13 +5,51 @@ import { getCardPosition } from '@/components/Global/Card/card.utils' import EmptyState from '@/components/Global/EmptyStates/EmptyState' import { Icon } from '@/components/Global/Icons/Icon' import NavHeader from '@/components/Global/NavHeader' -import { useIdentityVerification, type Region } from '@/hooks/useIdentityVerification' +import ActionModal from '@/components/Global/ActionModal' +import { SumsubKycWrapper } from '@/components/Kyc/SumsubKycWrapper' +import { KycVerificationInProgressModal } from '@/components/Kyc/KycVerificationInProgressModal' +import { useIdentityVerification, getRegionIntent, type Region } from '@/hooks/useIdentityVerification' +import { useSumsubKycFlow } from '@/hooks/useSumsubKycFlow' import Image from 'next/image' import { useRouter } from 'next/navigation' +import { useState, useCallback } from 'react' const RegionsVerification = () => { const router = useRouter() const { unlockedRegions, lockedRegions } = useIdentityVerification() + const [selectedRegion, setSelectedRegion] = useState(null) + + const regionIntent = selectedRegion ? getRegionIntent(selectedRegion.path) : undefined + + const { + isLoading, + error, + showWrapper, + accessToken, + handleInitiateKyc, + handleSdkComplete, + handleClose: handleSumsubClose, + refreshToken, + isVerificationProgressModalOpen, + closeVerificationProgressModal, + } = useSumsubKycFlow({ + regionIntent, + onKycSuccess: () => setSelectedRegion(null), + onManualClose: () => setSelectedRegion(null), + }) + + const handleRegionClick = useCallback((region: Region) => { + setSelectedRegion(region) + }, []) + + const handleModalClose = useCallback(() => { + setSelectedRegion(null) + }, []) + + const handleStartKyc = useCallback(async () => { + setSelectedRegion(null) + await handleInitiateKyc() + }, [handleInitiateKyc]) return (
@@ -37,11 +75,50 @@ const RegionsVerification = () => { -

Locked regions

-

Where do you want to send and receive money?

+ {lockedRegions.length > 0 && ( + <> +

Locked regions

+

Where do you want to send and receive money?

- + + + )}
+ + + + {error &&

{error}

} + + + + ) } @@ -51,9 +128,9 @@ export default RegionsVerification interface RegionsListProps { regions: Region[] isLocked: boolean + onRegionClick?: (region: Region) => void } -const RegionsList = ({ regions, isLocked }: RegionsListProps) => { - const router = useRouter() +const RegionsList = ({ regions, isLocked, onRegionClick }: RegionsListProps) => { return (
{regions.map((region, index) => ( @@ -71,8 +148,8 @@ const RegionsList = ({ regions, isLocked }: RegionsListProps) => { position={getCardPosition(index, regions.length)} title={region.name} onClick={() => { - if (isLocked) { - router.push(`/profile/identity-verification/${region.path}`) + if (isLocked && onRegionClick) { + onRegionClick(region) } }} isDisabled={!isLocked} diff --git a/src/features/limits/views/LimitsPageView.tsx b/src/features/limits/views/LimitsPageView.tsx index 27c4ec23c..4ea063318 100644 --- a/src/features/limits/views/LimitsPageView.tsx +++ b/src/features/limits/views/LimitsPageView.tsx @@ -175,7 +175,7 @@ const LockedRegionsList = ({ regions, isBridgeKycPending }: LockedRegionsListPro title={region.name} onClick={() => { if (!isPending) { - router.push(`/profile/identity-verification/${region.path}`) + router.push('/profile/identity-verification') } }} isDisabled={isPending} From ec0d1e98cfa5cd5ba104bcddfd50ca68284d07a4 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Mon, 16 Feb 2026 23:26:48 +0530 Subject: [PATCH 16/46] chore: remove dead code --- .../[region]/[country]/page.tsx | 6 - .../identity-verification/[region]/page.tsx | 10 - .../profile/identity-verification/layout.tsx | 54 +--- .../IdentityVerificationCountryList.tsx | 154 ----------- .../views/IdentityVerification.view.tsx | 243 ------------------ .../Profile/views/RegionsPage.view.tsx | 44 ---- 6 files changed, 1 insertion(+), 510 deletions(-) delete mode 100644 src/app/(mobile-ui)/profile/identity-verification/[region]/[country]/page.tsx delete mode 100644 src/app/(mobile-ui)/profile/identity-verification/[region]/page.tsx delete mode 100644 src/components/Profile/components/IdentityVerificationCountryList.tsx delete mode 100644 src/components/Profile/views/IdentityVerification.view.tsx delete mode 100644 src/components/Profile/views/RegionsPage.view.tsx diff --git a/src/app/(mobile-ui)/profile/identity-verification/[region]/[country]/page.tsx b/src/app/(mobile-ui)/profile/identity-verification/[region]/[country]/page.tsx deleted file mode 100644 index 8ffed617b..000000000 --- a/src/app/(mobile-ui)/profile/identity-verification/[region]/[country]/page.tsx +++ /dev/null @@ -1,6 +0,0 @@ -'use client' -import IdentityVerificationView from '@/components/Profile/views/IdentityVerification.view' - -export default function IdentityVerificationCountryPage() { - return -} diff --git a/src/app/(mobile-ui)/profile/identity-verification/[region]/page.tsx b/src/app/(mobile-ui)/profile/identity-verification/[region]/page.tsx deleted file mode 100644 index d1843f861..000000000 --- a/src/app/(mobile-ui)/profile/identity-verification/[region]/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -'use client' -import RegionsPage from '@/components/Profile/views/RegionsPage.view' -import { useParams } from 'next/navigation' - -export default function IdentityVerificationRegionPage() { - const params = useParams() - const region = params.region as string - - return -} diff --git a/src/app/(mobile-ui)/profile/identity-verification/layout.tsx b/src/app/(mobile-ui)/profile/identity-verification/layout.tsx index 29884066e..5f6049aa8 100644 --- a/src/app/(mobile-ui)/profile/identity-verification/layout.tsx +++ b/src/app/(mobile-ui)/profile/identity-verification/layout.tsx @@ -1,59 +1,7 @@ 'use client' import PageContainer from '@/components/0_Bruddle/PageContainer' -import ActionModal from '@/components/Global/ActionModal' -import { useIdentityVerification } from '@/hooks/useIdentityVerification' -import { useParams, useRouter } from 'next/navigation' -import { useEffect, useState } from 'react' export default function IdentityVerificationLayout({ children }: { children: React.ReactNode }) { - const [isAlreadyVerifiedModalOpen, setIsAlreadyVerifiedModalOpen] = useState(false) - const router = useRouter() - const { isRegionAlreadyUnlocked, isVerifiedForCountry } = useIdentityVerification() - const params = useParams() - const regionParams = params.region as string - const countryParams = params.country as string - - useEffect(() => { - const isAlreadyVerified = - (countryParams && isVerifiedForCountry(countryParams)) || - (regionParams && isRegionAlreadyUnlocked(regionParams)) - - if (isAlreadyVerified) { - setIsAlreadyVerifiedModalOpen(true) - } - }, [countryParams, regionParams, isVerifiedForCountry, isRegionAlreadyUnlocked]) - - return ( - - {children} - - { - setIsAlreadyVerifiedModalOpen(false) - router.push('/profile') - }} - title="You're already verified" - description={ -

- Your identity has already been successfully verified for this region. You can continue to use - features available in this region. No further action is needed. -

- } - icon="shield" - ctas={[ - { - text: 'Close', - shadowSize: '4', - className: 'md:py-2', - onClick: () => { - setIsAlreadyVerifiedModalOpen(false) - router.push('/profile') - }, - }, - ]} - /> -
- ) + return {children} } diff --git a/src/components/Profile/components/IdentityVerificationCountryList.tsx b/src/components/Profile/components/IdentityVerificationCountryList.tsx deleted file mode 100644 index 4146a8dad..000000000 --- a/src/components/Profile/components/IdentityVerificationCountryList.tsx +++ /dev/null @@ -1,154 +0,0 @@ -'use client' -import { Icon } from '@/components/Global/Icons/Icon' -import { SearchInput } from '@/components/SearchInput' -import { getCountriesForRegion } from '@/utils/identityVerification' -import { MantecaSupportedExchanges } from '@/components/AddMoney/consts' -import StatusBadge from '@/components/Global/Badges/StatusBadge' -import { Button } from '@/components/0_Bruddle/Button' -import * as Accordion from '@radix-ui/react-accordion' -import { useRouter } from 'next/navigation' -import { useState } from 'react' -import CountryListSection from './CountryListSection' -import ActionModal from '@/components/Global/ActionModal' - -const IdentityVerificationCountryList = ({ region }: { region: string }) => { - const [searchTerm, setSearchTerm] = useState('') - const router = useRouter() - const [isUnavailableModalOpen, setIsUnavailableModalOpen] = useState(false) - const [selectedUnavailableCountry, setSelectedUnavailableCountry] = useState(null) - - const { supportedCountries, limitedAccessCountries, unsupportedCountries } = getCountriesForRegion(region) - - // Filter both arrays based on search term - const filteredSupportedCountries = supportedCountries.filter((country) => - country.title.toLowerCase().includes(searchTerm.toLowerCase()) - ) - - const filteredLimitedAccessCountries = limitedAccessCountries.filter((country) => - country.title.toLowerCase().includes(searchTerm.toLowerCase()) - ) - - const filteredUnsupportedCountries = unsupportedCountries.filter((country) => - country.title.toLowerCase().includes(searchTerm.toLowerCase()) - ) - - const isLatam = region === 'latam' - - return ( -
-
- setSearchTerm(e.target.value)} - onClear={() => setSearchTerm('')} - placeholder="Search by country name" - /> -
- - - { - if (isLatam) { - router.push(`/profile/identity-verification/${region}/${encodeURIComponent(country.id)}`) - } else { - router.push(`/profile/identity-verification/${region}/${encodeURIComponent('bridge')}`) - } - }} - rightContent={() => (isLatam ? undefined : )} - defaultOpen - /> - - { - // Check if country is in MantecaSupportedExchanges - const countryCode = country.iso2?.toUpperCase() - const isMantecaSupported = - countryCode && Object.prototype.hasOwnProperty.call(MantecaSupportedExchanges, countryCode) - - if (isMantecaSupported && isLatam) { - // Route to Manteca-specific KYC - router.push(`/profile/identity-verification/${region}/${encodeURIComponent(country.id)}`) - } else { - // Route to Bridge KYC for all other countries - router.push(`/profile/identity-verification/${region}/${encodeURIComponent('bridge')}`) - } - }} - rightContent={() => ( -
- - -
- )} - defaultOpen - /> - - {filteredUnsupportedCountries.length > 0 && ( - { - setSelectedUnavailableCountry(country.title) - setIsUnavailableModalOpen(true) - }} - rightContent={() => ( -
- -
- )} - defaultOpen - /> - )} -
- - { - setSelectedUnavailableCountry(null) - setIsUnavailableModalOpen(false) - }} - ctas={[ - { - text: 'I Understand', - shadowSize: '4', - onClick: () => { - setSelectedUnavailableCountry(null) - setIsUnavailableModalOpen(false) - }, - }, - ]} - /> -
- ) -} - -export default IdentityVerificationCountryList diff --git a/src/components/Profile/views/IdentityVerification.view.tsx b/src/components/Profile/views/IdentityVerification.view.tsx deleted file mode 100644 index 91c402a04..000000000 --- a/src/components/Profile/views/IdentityVerification.view.tsx +++ /dev/null @@ -1,243 +0,0 @@ -'use client' -import { updateUserById } from '@/app/actions/users' -import { Button } from '@/components/0_Bruddle/Button' -import { countryData } from '@/components/AddMoney/consts' -import { UserDetailsForm, type UserDetailsFormData } from '@/components/AddMoney/UserDetailsForm' -import { CountryList } from '@/components/Common/CountryList' -import ErrorAlert from '@/components/Global/ErrorAlert' -import IframeWrapper from '@/components/Global/IframeWrapper' -import NavHeader from '@/components/Global/NavHeader' -import { - KycVerificationInProgressModal, - PeanutDoesntStoreAnyPersonalInformation, -} from '@/components/Kyc/KycVerificationInProgressModal' -import { MantecaGeoSpecificKycModal } from '@/components/Kyc/InitiateMantecaKYCModal' -import { useAuth } from '@/context/authContext' -import { useBridgeKycFlow } from '@/hooks/useBridgeKycFlow' -import { useParams, useRouter } from 'next/navigation' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import useKycStatus from '@/hooks/useKycStatus' -import { getRedirectUrl, clearRedirectUrl } from '@/utils/general.utils' -import StartVerificationModal from '@/components/IdentityVerification/StartVerificationModal' -import { useIdentityVerification } from '@/hooks/useIdentityVerification' - -const IdentityVerificationView = () => { - const router = useRouter() - const formRef = useRef<{ handleSubmit: () => void }>(null) - const [isUserDetailsFormValid, setIsUserDetailsFormValid] = useState(false) - const [isUpdatingUser, setIsUpdatingUser] = useState(false) - const [userUpdateError, setUserUpdateError] = useState(null) - const [showUserDetailsForm, setShowUserDetailsForm] = useState(false) - const [isMantecaModalOpen, setIsMantecaModalOpen] = useState(false) - const [selectedCountry, setSelectedCountry] = useState<{ id: string; title: string } | null>(null) - const [userClickedCountry, setUserClickedCountry] = useState<{ id: string; title: string } | null>(null) - const { isUserBridgeKycApproved } = useKycStatus() - const { user, fetchUser } = useAuth() - const [isStartVerificationModalOpen, setIsStartVerificationModalOpen] = useState(false) - const params = useParams() - const countryParam = params.country as string - const { isMantecaSupportedCountry, isBridgeSupportedCountry } = useIdentityVerification() - - const handleRedirect = () => { - const redirectUrl = getRedirectUrl() - if (redirectUrl) { - clearRedirectUrl() - router.push(redirectUrl) - } else { - router.push('/profile') - } - } - - const handleBridgeKycSuccess = useCallback(async () => { - await fetchUser() - handleRedirect() - }, []) - - const { - iframeOptions, - handleInitiateKyc, - isVerificationProgressModalOpen, - handleIframeClose, - closeVerificationProgressModal, - error: kycError, - isLoading: isKycLoading, - } = useBridgeKycFlow({ - onKycSuccess: handleBridgeKycSuccess, - }) - - const initialUserDetails: Partial = useMemo( - () => ({ - fullName: user?.user.fullName ?? '', - email: user?.user.email ?? '', - }), - [user] - ) - - const handleUserDetailsSubmit = useCallback( - async (data: UserDetailsFormData) => { - setIsUpdatingUser(true) - setUserUpdateError(null) - try { - if (!user?.user.userId) throw new Error('User not found') - const result = await updateUserById({ - userId: user.user.userId, - fullName: data.fullName, - email: data.email, - }) - if (result.error) { - throw new Error(result.error) - } - await fetchUser() - await handleInitiateKyc() - } catch (error: any) { - setUserUpdateError(error.message) - return { error: error.message } - } finally { - setIsUpdatingUser(false) - } - return {} - }, - [user] - ) - - const handleBack = useCallback(() => { - if (showUserDetailsForm) { - setShowUserDetailsForm(false) - } else { - handleRedirect() - } - }, [showUserDetailsForm]) - - // Bridge country object for all bridge supported countries - const bridgeCountryObject = useMemo( - () => ({ title: 'Bridge', id: 'bridge', type: 'bridge', description: '', path: 'bridge' }), - [] - ) - - // Memoized country lookup from URL param - const selectedCountryParams = useMemo(() => { - if (countryParam) { - const country = countryData.find((country) => country.id.toUpperCase() === countryParam.toUpperCase()) - if (country) { - return country - } else { - return bridgeCountryObject - } - } - return null - }, [countryParam, bridgeCountryObject]) - - // Skip country selection if coming from a supported bridge country - useEffect(() => { - if (selectedCountryParams && (isBridgeSupportedCountry(countryParam) || countryParam === 'bridge')) { - setUserClickedCountry({ title: selectedCountryParams.title, id: selectedCountryParams.id }) - setIsStartVerificationModalOpen(true) - } - }, [countryParam, isBridgeSupportedCountry, selectedCountryParams]) - - useEffect(() => { - return () => { - setIsStartVerificationModalOpen(false) - } - }, []) - - return ( -
- - - {showUserDetailsForm ? ( -
-

Provide information to begin verification

- - - - - - - - {(userUpdateError || kycError) && } - - - - { - closeVerificationProgressModal() - handleRedirect() - }} - /> -
- ) : ( -
- { - const { id, title } = country - setUserClickedCountry({ id, title }) - setIsStartVerificationModalOpen(true) - }} - showLoadingState={false} // we don't want to show loading state when clicking a country, here because there is no async operation when clicking a country - /> -
- )} - - {selectedCountry && ( - - )} - - {userClickedCountry && selectedCountryParams && ( - { - // we dont show ID issuer country list for bridge countries - if ( - isBridgeSupportedCountry(selectedCountryParams.id) || - selectedCountryParams.id === 'bridge' - ) { - handleRedirect() - } else { - setIsStartVerificationModalOpen(false) - } - }} - onStartVerification={() => { - setIsStartVerificationModalOpen(false) - if (isMantecaSupportedCountry(userClickedCountry.id)) { - setSelectedCountry(userClickedCountry) - setIsMantecaModalOpen(true) - } else { - setShowUserDetailsForm(true) - } - }} - selectedIdentityCountry={userClickedCountry} - selectedCountry={selectedCountryParams} - /> - )} -
- ) -} - -export default IdentityVerificationView diff --git a/src/components/Profile/views/RegionsPage.view.tsx b/src/components/Profile/views/RegionsPage.view.tsx deleted file mode 100644 index af40c49c5..000000000 --- a/src/components/Profile/views/RegionsPage.view.tsx +++ /dev/null @@ -1,44 +0,0 @@ -'use client' - -import NavHeader from '@/components/Global/NavHeader' -import { useIdentityVerification } from '@/hooks/useIdentityVerification' -import { useRouter } from 'next/navigation' -import IdentityVerificationCountryList from '../components/IdentityVerificationCountryList' -import { Button } from '@/components/0_Bruddle/Button' - -const RegionsPage = ({ path }: { path: string }) => { - const router = useRouter() - const { lockedRegions } = useIdentityVerification() - - const hideVerifyButtonPaths = ['latam', 'rest-of-the-world'] - - const region = lockedRegions.find((region) => region.path === path) - - if (!region) { - return null - } - - return ( -
-
- router.back()} /> - - -
- {!hideVerifyButtonPaths.includes(region.path) && ( -
- -
- )} -
- ) -} - -export default RegionsPage From d0b2764455cd1f79f518b1c44acf884bb2d8a54c Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Mon, 16 Feb 2026 23:29:57 +0530 Subject: [PATCH 17/46] chore: format --- src/hooks/useWebSocket.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/hooks/useWebSocket.ts b/src/hooks/useWebSocket.ts index d8d4db439..e6234c509 100644 --- a/src/hooks/useWebSocket.ts +++ b/src/hooks/useWebSocket.ts @@ -63,7 +63,6 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => { onError, } }, [ - onHistoryEntry, onKycStatusUpdate, From e9715afcaeea9d868619909f59ad5fb61e921eb0 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Tue, 17 Feb 2026 12:30:08 +0530 Subject: [PATCH 18/46] fix: address cr review comments --- src/app/actions/sumsub.ts | 4 ++++ .../IframeWrapper/StartVerificationView.tsx | 7 ++++-- src/components/Kyc/KycFlow.tsx | 13 +++++++++-- .../views/RegionsVerification.view.tsx | 23 ++++++++++++++----- src/constants/kyc.consts.ts | 4 ++-- src/hooks/useIdentityVerification.tsx | 2 +- src/hooks/useUnifiedKycStatus.ts | 6 ++++- src/hooks/useWebSocket.ts | 7 ------ 8 files changed, 45 insertions(+), 21 deletions(-) diff --git a/src/app/actions/sumsub.ts b/src/app/actions/sumsub.ts index a96e38003..185449496 100644 --- a/src/app/actions/sumsub.ts +++ b/src/app/actions/sumsub.ts @@ -14,6 +14,10 @@ export const initiateSumsubKyc = async (params?: { }): Promise<{ data?: InitiateSumsubKycResponse; error?: string }> => { const jwtToken = (await getJWTCookie())?.value + if (!jwtToken) { + return { error: 'Authentication required' } + } + const body: Record = { regionIntent: params?.regionIntent, } diff --git a/src/components/Global/IframeWrapper/StartVerificationView.tsx b/src/components/Global/IframeWrapper/StartVerificationView.tsx index fde280ad9..abec9c2ea 100644 --- a/src/components/Global/IframeWrapper/StartVerificationView.tsx +++ b/src/components/Global/IframeWrapper/StartVerificationView.tsx @@ -33,9 +33,12 @@ const StartVerificationView = ({

Secure Verification. Limited Data Use.

- The verification is done using a trusted provider, which only shares a yes/no with Peanut. + The verification is done using a trusted provider, which shares your verification status with + Peanut. +

+

+ It operates under industry-standard security and privacy practices.

-

It operates under strict security and privacy standards.

Peanut never sees or stores your verification data.

diff --git a/src/hooks/useSumsubKycFlow.ts b/src/hooks/useSumsubKycFlow.ts index ef3f8179d..484e9ccfa 100644 --- a/src/hooks/useSumsubKycFlow.ts +++ b/src/hooks/useSumsubKycFlow.ts @@ -23,6 +23,12 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: const [liveKycStatus, setLiveKycStatus] = useState(undefined) const [rejectLabels, setRejectLabels] = useState(undefined) const prevStatusRef = useRef(liveKycStatus) + // tracks the effective region intent across initiate + refresh so the correct template is always used + const regionIntentRef = useRef(regionIntent) + + useEffect(() => { + regionIntentRef.current = regionIntent + }, [regionIntent]) // listen for sumsub kyc status updates via websocket useWebSocket({ @@ -85,8 +91,14 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: setLiveKycStatus(response.data.status) } - // if already approved, no token is returned + // update effective intent for token refresh + const effectiveIntent = overrideIntent ?? regionIntent + if (effectiveIntent) regionIntentRef.current = effectiveIntent + + // if already approved, no token is returned. + // set prevStatusRef so the transition effect doesn't fire onKycSuccess a second time. if (response.data?.status === 'APPROVED') { + prevStatusRef.current = 'APPROVED' onKycSuccess?.() return } @@ -119,9 +131,10 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: onManualClose?.() }, [onManualClose]) - // token refresh function passed to the sdk for when the token expires + // token refresh function passed to the sdk for when the token expires. + // uses regionIntentRef so refresh always matches the template used during initiation. const refreshToken = useCallback(async (): Promise => { - const response = await initiateSumsubKyc({ regionIntent }) + const response = await initiateSumsubKyc({ regionIntent: regionIntentRef.current }) if (response.error || !response.data?.token) { throw new Error(response.error || 'Failed to refresh token') @@ -129,7 +142,7 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: setAccessToken(response.data.token) return response.data.token - }, [regionIntent]) + }, []) const closeVerificationProgressModal = useCallback(() => { setIsVerificationProgressModalOpen(false) From 9fbb5d84f8d328f83ed01f11a81ba33eed20bde7 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:19:13 +0530 Subject: [PATCH 23/46] feat: add UserRail types, rejectType and metadata to kyc verification interface --- src/interfaces/interfaces.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/interfaces/interfaces.ts b/src/interfaces/interfaces.ts index 0d955d4a3..bb84671bc 100644 --- a/src/interfaces/interfaces.ts +++ b/src/interfaces/interfaces.ts @@ -240,6 +240,8 @@ export interface IUserKycVerification { providerRawStatus?: string | null sumsubApplicantId?: string | null rejectLabels?: string[] | null + rejectType?: 'RETRY' | 'FINAL' | null + metadata?: { regionIntent?: string; [key: string]: unknown } | null createdAt: string updatedAt: string } @@ -321,6 +323,26 @@ interface userInvites { inviteeUsername: string } +export type UserRailStatus = + | 'PENDING' + | 'ENABLED' + | 'REQUIRES_INFORMATION' + | 'REQUIRES_EXTRA_INFORMATION' + | 'REJECTED' + | 'FAILED' + +export interface IUserRail { + id: string + railId: string + status: UserRailStatus + metadata?: { bridgeCustomerId?: string; [key: string]: unknown } | null + rail: { + id: string + provider: { code: string; name: string } + method: { code: string; name: string; country: string; currency: string } + } +} + export interface IUserProfile { // OLD Points V1 fields removed - use pointsV2 in stats instead // Points V2: Use stats.pointsV2.totalPoints, pointsV2.inviteCount, etc. @@ -331,6 +353,7 @@ export interface IUserProfile { totalPoints: number // Kept for backward compatibility - same as pointsV2.totalPoints hasPwaInstalled: boolean user: User + rails: IUserRail[] invitesSent: userInvites[] showEarlyUserModal: boolean invitedBy: string | null // Username of the person who invited this user From 60e9dc9f47eb546d1e7272daffe64fb0819405ef Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:19:57 +0530 Subject: [PATCH 24/46] feat: add action_required status category and human-readable sumsub reject label mappings --- src/constants/kyc.consts.ts | 13 ++- src/constants/sumsub-reject-labels.consts.ts | 83 ++++++++++++++++++++ 2 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 src/constants/sumsub-reject-labels.consts.ts diff --git a/src/constants/kyc.consts.ts b/src/constants/kyc.consts.ts index 4c633403e..f8fc5d507 100644 --- a/src/constants/kyc.consts.ts +++ b/src/constants/kyc.consts.ts @@ -7,7 +7,7 @@ import { type MantecaKycStatus } from '@/interfaces' */ export type KycVerificationStatus = MantecaKycStatus | SumsubKycStatus | string -export type KycStatusCategory = 'completed' | 'processing' | 'failed' +export type KycStatusCategory = 'completed' | 'processing' | 'failed' | 'action_required' // sets of status values by category — single source of truth const APPROVED_STATUSES: ReadonlySet = new Set(['approved', 'ACTIVE', 'APPROVED']) @@ -18,11 +18,13 @@ const PENDING_STATUSES: ReadonlySet = new Set([ 'ONBOARDING', 'PENDING', 'IN_REVIEW', - 'ACTION_REQUIRED', ]) +const ACTION_REQUIRED_STATUSES: ReadonlySet = new Set(['ACTION_REQUIRED']) const NOT_STARTED_STATUSES: ReadonlySet = new Set(['not_started', 'NOT_STARTED']) -// sumsub-specific sets for checks that only care about sumsub +// sumsub-specific set for flow-level gating (e.g. useQrKycGate blocks payments). +// ACTION_REQUIRED is intentionally included here — user hasn't completed verification +// yet, so they should still be gated from features that require approved kyc. const SUMSUB_IN_PROGRESS_STATUSES: ReadonlySet = new Set(['PENDING', 'IN_REVIEW', 'ACTION_REQUIRED']) /** check if a kyc status represents an approved/completed state */ @@ -36,6 +38,10 @@ export const isKycStatusFailed = (status: string | undefined | null): boolean => export const isKycStatusPending = (status: string | undefined | null): boolean => !!status && PENDING_STATUSES.has(status) +/** check if a kyc status represents an action-required state */ +export const isKycStatusActionRequired = (status: string | undefined | null): boolean => + !!status && ACTION_REQUIRED_STATUSES.has(status) + /** check if a kyc status means "not started" (should not render status ui) */ export const isKycStatusNotStarted = (status: string | undefined | null): boolean => !status || NOT_STARTED_STATUSES.has(status) @@ -48,5 +54,6 @@ export const isSumsubStatusInProgress = (status: string | undefined | null): boo export const getKycStatusCategory = (status: string): KycStatusCategory => { if (APPROVED_STATUSES.has(status)) return 'completed' if (FAILED_STATUSES.has(status)) return 'failed' + if (ACTION_REQUIRED_STATUSES.has(status)) return 'action_required' return 'processing' } diff --git a/src/constants/sumsub-reject-labels.consts.ts b/src/constants/sumsub-reject-labels.consts.ts new file mode 100644 index 000000000..64197c111 --- /dev/null +++ b/src/constants/sumsub-reject-labels.consts.ts @@ -0,0 +1,83 @@ +interface RejectLabelInfo { + title: string + description: string +} + +// map of sumsub reject labels to user-friendly descriptions +const REJECT_LABEL_MAP: Record = { + DOCUMENT_BAD_QUALITY: { + title: 'Low quality document', + description: 'The document image was blurry, dark, or hard to read. Please upload a clearer photo.', + }, + DOCUMENT_DAMAGED: { + title: 'Damaged document', + description: 'The document appears damaged or worn. Please use a document in good condition.', + }, + DOCUMENT_INCOMPLETE: { + title: 'Incomplete document', + description: 'Part of the document was cut off or missing. Make sure the full document is visible.', + }, + DOCUMENT_MISSING: { + title: 'Missing document', + description: 'A required document was not provided. Please upload all requested documents.', + }, + DOCUMENT_EXPIRED: { + title: 'Expired document', + description: 'The document has expired. Please use a valid, non-expired document.', + }, + SELFIE_MISMATCH: { + title: 'Selfie does not match', + description: 'The selfie did not match the photo on your document. Please try again with a clear selfie.', + }, + SELFIE_BAD_QUALITY: { + title: 'Low quality selfie', + description: 'The selfie was blurry or poorly lit. Please take a clear, well-lit selfie.', + }, + SELFIE_SPOOFING: { + title: 'Selfie issue detected', + description: 'A live selfie is required. Do not use a photo of a photo or a screen.', + }, + DOCUMENT_FAKE: { + title: 'Document could not be verified', + description: 'We were unable to verify the authenticity of your document.', + }, + GRAPHIC_EDITOR_USAGE: { + title: 'Edited document detected', + description: 'The document appears to have been digitally altered.', + }, + AGE_BELOW_ACCEPTED_LIMIT: { + title: 'Age requirement not met', + description: 'You must be at least 18 years old to use this service.', + }, + UNSUPPORTED_DOCUMENT: { + title: 'Unsupported document type', + description: "This type of document is not accepted. Please use a passport, national ID, or driver's license.", + }, + WRONG_DOCUMENT: { + title: 'Wrong document provided', + description: 'The uploaded document does not match what was requested. Please upload the correct document.', + }, + REGULATIONS_VIOLATIONS: { + title: 'Regulatory restriction', + description: 'Verification could not be completed due to regulatory requirements.', + }, +} + +const FALLBACK_LABEL_INFO: RejectLabelInfo = { + title: 'Verification issue', + description: 'There was an issue with your verification. Please try again or contact support.', +} + +// labels that indicate a permanent rejection — used as a frontend heuristic +// until backend provides rejectType +export const TERMINAL_REJECT_LABELS = new Set(['DOCUMENT_FAKE', 'GRAPHIC_EDITOR_USAGE', 'AGE_BELOW_ACCEPTED_LIMIT']) + +/** get human-readable info for a sumsub reject label, with a safe fallback */ +export const getRejectLabelInfo = (label: string): RejectLabelInfo => { + return REJECT_LABEL_MAP[label] ?? FALLBACK_LABEL_INFO +} + +/** check if any of the reject labels indicate a terminal (permanent) rejection */ +export const hasTerminalRejectLabel = (labels: string[]): boolean => { + return labels.some((label) => TERMINAL_REJECT_LABELS.has(label)) +} From fe5f07d6e3a090275da6caff1da05d1aa3041632 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:20:48 +0530 Subject: [PATCH 25/46] feat: scope region unlocking by verification regionIntent, expose action_required status --- src/hooks/useIdentityVerification.tsx | 17 +++++++++++++---- src/hooks/useUnifiedKycStatus.ts | 10 ++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/hooks/useIdentityVerification.tsx b/src/hooks/useIdentityVerification.tsx index 5a1c5a3d4..e3f396532 100644 --- a/src/hooks/useIdentityVerification.tsx +++ b/src/hooks/useIdentityVerification.tsx @@ -1,6 +1,7 @@ import { EUROPE_GLOBE_ICON, LATAM_GLOBE_ICON, NORTH_AMERICA_GLOBE_ICON, REST_OF_WORLD_GLOBE_ICON } from '@/assets' import type { StaticImageData } from 'next/image' import useKycStatus from './useKycStatus' +import useUnifiedKycStatus from './useUnifiedKycStatus' import { useMemo, useCallback } from 'react' import { useAuth } from '@/context/authContext' import { MantecaKycStatus } from '@/interfaces' @@ -103,6 +104,7 @@ export const getRegionIntent = (regionPath: string): KYCRegionIntent => { export const useIdentityVerification = () => { const { user } = useAuth() const { isUserBridgeKycApproved, isUserMantecaKycApproved, isUserSumsubKycApproved } = useKycStatus() + const { sumsubVerificationRegionIntent } = useUnifiedKycStatus() /** * Check if a country is supported by Manteca (LATAM countries). @@ -159,9 +161,16 @@ export const useIdentityVerification = () => { // helper to check if a region should be unlocked const isRegionUnlocked = (regionName: string) => { - // sumsub approval unlocks all regions (one verification per user). - // backend enforces per-rail access separately — frontend only gates on identity verification. - if (isSumsubApproved) return true + // sumsub approval scoped by the regionIntent used during verification. + // 'LATAM' intent → unlocks LATAM. 'STANDARD' intent → unlocks Bridge regions + rest of world. + // no intent (or rest-of-world) → unlocks rest of world only. + if (isSumsubApproved) { + if (sumsubVerificationRegionIntent === 'LATAM') { + return MANTECA_SUPPORTED_REGIONS.includes(regionName) || regionName === 'Rest of the world' + } + // STANDARD intent covers bridge regions + rest of world + return BRIDGE_SUPPORTED_REGIONS.includes(regionName) || regionName === 'Rest of the world' + } return ( (isBridgeApproved && BRIDGE_SUPPORTED_REGIONS.includes(regionName)) || (isMantecaApproved && MANTECA_SUPPORTED_REGIONS.includes(regionName)) @@ -181,7 +190,7 @@ export const useIdentityVerification = () => { lockedRegions: locked, unlockedRegions: unlocked, } - }, [isUserBridgeKycApproved, isUserMantecaKycApproved, isUserSumsubKycApproved]) + }, [isUserBridgeKycApproved, isUserMantecaKycApproved, isUserSumsubKycApproved, sumsubVerificationRegionIntent]) /** * Check if a region is already unlocked by comparing region paths. diff --git a/src/hooks/useUnifiedKycStatus.ts b/src/hooks/useUnifiedKycStatus.ts index 122227928..4b3bc9ff2 100644 --- a/src/hooks/useUnifiedKycStatus.ts +++ b/src/hooks/useUnifiedKycStatus.ts @@ -38,6 +38,12 @@ export default function useUnifiedKycStatus() { const sumsubRejectLabels = useMemo(() => sumsubVerification?.rejectLabels ?? null, [sumsubVerification]) + // region intent used during the sumsub verification (stored in metadata by initiate-kyc) + const sumsubVerificationRegionIntent = useMemo( + () => (sumsubVerification?.metadata?.regionIntent as string) ?? null, + [sumsubVerification] + ) + const isKycApproved = useMemo( () => isBridgeApproved || isMantecaApproved || isSumsubApproved, [isBridgeApproved, isMantecaApproved, isSumsubApproved] @@ -48,6 +54,8 @@ export default function useUnifiedKycStatus() { [user] ) + const isSumsubActionRequired = useMemo(() => sumsubStatus === 'ACTION_REQUIRED', [sumsubStatus]) + const isSumsubInProgress = useMemo(() => isSumsubStatusInProgress(sumsubStatus), [sumsubStatus]) const isKycInProgress = useMemo( @@ -66,7 +74,9 @@ export default function useUnifiedKycStatus() { isMantecaApproved, // sumsub isSumsubApproved, + isSumsubActionRequired, sumsubStatus, sumsubRejectLabels, + sumsubVerificationRegionIntent, } } From ea64d17f69e8bc0055ce5022f0f2e9ea57509c47 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:21:11 +0530 Subject: [PATCH 26/46] feat: handle sumsub_kyc_status_update websocket messages --- src/services/websocket.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/services/websocket.ts b/src/services/websocket.ts index b49418771..497df472e 100644 --- a/src/services/websocket.ts +++ b/src/services/websocket.ts @@ -1,7 +1,6 @@ import { type HistoryEntry } from '@/hooks/useTransactionHistory' import { type PendingPerk } from '@/services/perks' export type { PendingPerk } -import { jsonStringify } from '@/utils/general.utils' export type WebSocketMessage = { type: @@ -10,6 +9,7 @@ export type WebSocketMessage = { | 'history_entry' | 'kyc_status_update' | 'manteca_kyc_status_update' + | 'sumsub_kyc_status_update' | 'persona_tos_status_update' | 'pending_perk' data?: HistoryEntry | PendingPerk @@ -126,6 +126,12 @@ export class PeanutWebSocket { } break + case 'sumsub_kyc_status_update': + if (message.data && 'status' in (message.data as object)) { + this.emit('sumsub_kyc_status_update', message.data) + } + break + case 'persona_tos_status_update': if (message.data && 'status' in (message.data as object)) { this.emit('persona_tos_status_update', message.data) From 7ed541a4f0ba64deb5f97b95cba9ef51dc73a789 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:23:03 +0530 Subject: [PATCH 27/46] feat: add bridge tos acceptance flow: tos step component, reminder card, server actions --- src/components/Global/Badges/StatusBadge.tsx | 6 +- src/components/Kyc/KYCStatusDrawerItem.tsx | 4 +- src/components/Kyc/KycStatusDrawer.tsx | 29 +++-- src/components/Kyc/KycStatusItem.tsx | 23 +++- .../Kyc/states/KycActionRequired.tsx | 27 +++++ src/components/Kyc/states/KycFailed.tsx | 106 +++++++++++++----- 6 files changed, 154 insertions(+), 41 deletions(-) create mode 100644 src/components/Kyc/states/KycActionRequired.tsx diff --git a/src/components/Global/Badges/StatusBadge.tsx b/src/components/Global/Badges/StatusBadge.tsx index 8eaf7335d..0aebee174 100644 --- a/src/components/Global/Badges/StatusBadge.tsx +++ b/src/components/Global/Badges/StatusBadge.tsx @@ -41,6 +41,10 @@ const StatusBadge: React.FC = ({ status, className, size = 'sm } const getStatusText = () => { + // customText overrides the default label for any status type, + // allowing callers to use a specific status style with custom text + if (customText) return customText + switch (status) { case 'completed': return 'Completed' @@ -59,7 +63,7 @@ const StatusBadge: React.FC = ({ status, className, size = 'sm case 'closed': return 'Closed' case 'custom': - return customText + return 'Custom' default: return status } diff --git a/src/components/Kyc/KYCStatusDrawerItem.tsx b/src/components/Kyc/KYCStatusDrawerItem.tsx index 0d2cae261..f377c2ad0 100644 --- a/src/components/Kyc/KYCStatusDrawerItem.tsx +++ b/src/components/Kyc/KYCStatusDrawerItem.tsx @@ -2,13 +2,13 @@ import Card from '@/components/Global/Card' import StatusBadge, { type StatusType } from '../Global/Badges/StatusBadge' import { KYCStatusIcon } from './KYCStatusIcon' -export const KYCStatusDrawerItem = ({ status }: { status: StatusType }) => { +export const KYCStatusDrawerItem = ({ status, customText }: { status: StatusType; customText?: string }) => { return (

Identity verification

- +
) diff --git a/src/components/Kyc/KycStatusDrawer.tsx b/src/components/Kyc/KycStatusDrawer.tsx index 7744e9d4c..e0fe1bea9 100644 --- a/src/components/Kyc/KycStatusDrawer.tsx +++ b/src/components/Kyc/KycStatusDrawer.tsx @@ -1,3 +1,4 @@ +import { KycActionRequired } from './states/KycActionRequired' import { KycCompleted } from './states/KycCompleted' import { KycFailed } from './states/KycFailed' import { KycProcessing } from './states/KycProcessing' @@ -76,6 +77,13 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus const isLoadingKyc = isBridgeLoading || isMantecaLoading || isSumsubLoading + // count sumsub rejections for failure lockout. + // counts total REJECTED entries — accurate if backend creates a new row per attempt. + // if backend updates in-place (single row), this will be 0 or 1 and the lockout + // won't trigger from count alone (terminal labels and rejectType still work). + const sumsubFailureCount = + user?.user?.kycVerifications?.filter((v) => v.provider === 'SUMSUB' && v.status === 'REJECTED').length ?? 0 + const renderContent = () => { switch (statusCategory) { case 'processing': @@ -94,15 +102,16 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus isBridge={isBridgeKyc} /> ) - case 'failed': { - // for sumsub, use reject labels as the reason - const reason = - provider === 'SUMSUB' - ? (verification?.rejectLabels?.join(', ') ?? '') - : (user?.user?.bridgeKycRejectionReasonString ?? '') + case 'action_required': + return + case 'failed': return ( ) - } default: return null } } - // don't render the drawer if the kyc status is unknown or not started - if (isKycStatusNotStarted(status)) { + // don't render the drawer if the kyc status is unknown or not started. + // if a verification record exists, the user has initiated KYC — show the drawer. + if (!verification && isKycStatusNotStarted(status)) { return null } diff --git a/src/components/Kyc/KycStatusItem.tsx b/src/components/Kyc/KycStatusItem.tsx index 57094b13d..7eae92316 100644 --- a/src/components/Kyc/KycStatusItem.tsx +++ b/src/components/Kyc/KycStatusItem.tsx @@ -15,6 +15,7 @@ import { isKycStatusPending, isKycStatusFailed, isKycStatusNotStarted, + isKycStatusActionRequired, } from '@/constants/kyc.consts' // this component shows the current kyc status and opens a drawer with more details on click @@ -54,15 +55,23 @@ export const KycStatusItem = ({ const isApproved = isKycStatusApproved(kycStatus) const isPending = isKycStatusPending(kycStatus) const isRejected = isKycStatusFailed(kycStatus) + const isActionRequired = isKycStatusActionRequired(kycStatus) + // if a verification record exists with NOT_STARTED, the user has initiated KYC + // (backend creates the record on initiation). only hide for bridge's default state. + const isInitiatedButNotStarted = !!verification && isKycStatusNotStarted(kycStatus) const subtitle = useMemo(() => { + if (isInitiatedButNotStarted) return 'In progress' + if (isActionRequired) return 'Action needed' if (isPending) return 'Under review' if (isApproved) return 'Approved' if (isRejected) return 'Rejected' return 'Unknown' - }, [isPending, isApproved, isRejected]) + }, [isInitiatedButNotStarted, isActionRequired, isPending, isApproved, isRejected]) - if (isKycStatusNotStarted(kycStatus)) { + // only hide for bridge's default "not_started" state. + // if a verification record exists, the user has initiated KYC — show it. + if (!verification && isKycStatusNotStarted(kycStatus)) { return null } @@ -82,7 +91,15 @@ export const KycStatusItem = ({

Identity verification

{subtitle}

- +
diff --git a/src/components/Kyc/states/KycActionRequired.tsx b/src/components/Kyc/states/KycActionRequired.tsx new file mode 100644 index 000000000..508dba7ee --- /dev/null +++ b/src/components/Kyc/states/KycActionRequired.tsx @@ -0,0 +1,27 @@ +import { Button } from '@/components/0_Bruddle/Button' +import { KYCStatusDrawerItem } from '../KYCStatusDrawerItem' +import InfoCard from '@/components/Global/InfoCard' + +// this component shows the kyc status when sumsub requires additional action from the user. +export const KycActionRequired = ({ onResume, isLoading }: { onResume: () => void; isLoading?: boolean }) => { + return ( +
+ + + +
+ ) +} diff --git a/src/components/Kyc/states/KycFailed.tsx b/src/components/Kyc/states/KycFailed.tsx index 6c7a45fe6..8365d6608 100644 --- a/src/components/Kyc/states/KycFailed.tsx +++ b/src/components/Kyc/states/KycFailed.tsx @@ -2,46 +2,79 @@ import { Button } from '@/components/0_Bruddle/Button' import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' import { KYCStatusDrawerItem } from '../KYCStatusDrawerItem' import Card from '@/components/Global/Card' +import InfoCard from '@/components/Global/InfoCard' import { useMemo } from 'react' import { formatDate } from '@/utils/general.utils' import { CountryRegionRow } from '../CountryRegionRow' +import { getRejectLabelInfo, hasTerminalRejectLabel } from '@/constants/sumsub-reject-labels.consts' +import { useModalsContext } from '@/context/ModalsContext' + +const MAX_RETRY_COUNT = 2 // this component shows the kyc status when it's failed/rejected. -// it displays the reason for the failure and provides a retry button. +// for sumsub: maps reject labels to human-readable reasons, handles terminal vs retryable states. +// for bridge: shows raw reason string as before. export const KycFailed = ({ - reason, + rejectLabels, + bridgeReason, + isSumsub, + rejectType, + failureCount, bridgeKycRejectedAt, countryCode, isBridge, onRetry, isLoading, }: { - reason: string | null + rejectLabels?: string[] | null + bridgeReason?: string | null + isSumsub?: boolean + rejectType?: 'RETRY' | 'FINAL' | null + failureCount?: number bridgeKycRejectedAt?: string countryCode?: string | null isBridge?: boolean onRetry: () => void isLoading?: boolean }) => { + const { setIsSupportModalOpen } = useModalsContext() + const rejectedOn = useMemo(() => { if (!bridgeKycRejectedAt) return 'N/A' try { return formatDate(new Date(bridgeKycRejectedAt)) } catch (error) { - console.error('Failed to parse bridgeKycRejectedAt date:', error) + console.error('failed to parse bridgeKycRejectedAt date:', error) return 'N/A' } }, [bridgeKycRejectedAt]) - const formattedReason = useMemo(() => { - const reasonText = reason || 'There was an issue. Contact Support.' - // Split by actual newline characters (\n) or the escaped sequence (\\n) - const lines = reasonText.split(/\\n|\n/).filter((line) => line.trim() !== '') + // determine if this is a terminal (permanent) rejection + const isTerminal = useMemo(() => { + if (rejectType === 'FINAL') return true + if (failureCount && failureCount >= MAX_RETRY_COUNT) return true + if (rejectLabels?.length && hasTerminalRejectLabel(rejectLabels)) return true + return false + }, [rejectType, failureCount, rejectLabels]) - if (lines.length === 1) { - return reasonText - } + // map sumsub labels to human-readable items for InfoCard + const reasonItems = useMemo(() => { + if (!isSumsub || !rejectLabels?.length) return null + return rejectLabels.map((label) => { + const info = getRejectLabelInfo(label) + return ( + + {info.title}: {info.description} + + ) + }) + }, [isSumsub, rejectLabels]) + // formatted bridge reason (legacy display) + const formattedBridgeReason = useMemo(() => { + const reasonText = bridgeReason || 'There was an issue. Contact Support.' + const lines = reasonText.split(/\\n|\n/).filter((line) => line.trim() !== '') + if (lines.length === 1) return reasonText return (
    {lines.map((line, index) => ( @@ -49,28 +82,51 @@ export const KycFailed = ({ ))}
) - }, [reason]) + }, [bridgeReason]) return (
+ - - - + {!isSumsub && } - + + {isSumsub && reasonItems && ( + + )} + + {isTerminal ? ( +
+ + {/* TODO: auto-create crisp support ticket on terminal rejection */} + +
+ ) : ( + + )}
) } From 235724f0381eac34ff1bda29f4e507b9f1104a58 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:24:54 +0530 Subject: [PATCH 28/46] feat: handle bridge tos acceptance --- src/app/actions/users.ts | 44 ++++++ src/components/Home/HomeHistory.tsx | 7 + src/components/Kyc/BridgeTosReminder.tsx | 51 +++++++ src/components/Kyc/BridgeTosStep.tsx | 132 ++++++++++++++++++ src/components/Kyc/SumsubKycFlow.tsx | 43 +++++- src/components/Kyc/states/KycCompleted.tsx | 5 + .../views/RegionsVerification.view.tsx | 32 ++++- src/hooks/useBridgeTosStatus.ts | 17 +++ 8 files changed, 324 insertions(+), 7 deletions(-) create mode 100644 src/components/Kyc/BridgeTosReminder.tsx create mode 100644 src/components/Kyc/BridgeTosStep.tsx create mode 100644 src/hooks/useBridgeTosStatus.ts diff --git a/src/app/actions/users.ts b/src/app/actions/users.ts index e530ebf17..5929fed25 100644 --- a/src/app/actions/users.ts +++ b/src/app/actions/users.ts @@ -160,3 +160,47 @@ export async function getContacts(params: { return { error: e instanceof Error ? e.message : 'An unexpected error occurred' } } } + +// fetch bridge ToS acceptance link for users with pending ToS +export const getBridgeTosLink = async (): Promise<{ data?: { tosLink: string }; error?: string }> => { + const jwtToken = (await getJWTCookie())?.value + try { + const response = await fetchWithSentry(`${PEANUT_API_URL}/users/bridge-tos-link`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwtToken}`, + 'api-key': API_KEY, + }, + }) + const responseJson = await response.json() + if (!response.ok) { + return { error: responseJson.error || 'Failed to fetch Bridge ToS link' } + } + return { data: responseJson } + } catch (e: unknown) { + return { error: e instanceof Error ? e.message : 'An unexpected error occurred' } + } +} + +// confirm bridge ToS acceptance after user closes the ToS iframe +export const confirmBridgeTos = async (): Promise<{ data?: { accepted: boolean }; error?: string }> => { + const jwtToken = (await getJWTCookie())?.value + try { + const response = await fetchWithSentry(`${PEANUT_API_URL}/users/bridge-tos-confirm`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwtToken}`, + 'api-key': API_KEY, + }, + }) + const responseJson = await response.json() + if (!response.ok) { + return { error: responseJson.error || 'Failed to confirm Bridge ToS' } + } + return { data: responseJson } + } catch (e: unknown) { + return { error: e instanceof Error ? e.message : 'An unexpected error occurred' } + } +} diff --git a/src/components/Home/HomeHistory.tsx b/src/components/Home/HomeHistory.tsx index e73e916c7..c89f606b1 100644 --- a/src/components/Home/HomeHistory.tsx +++ b/src/components/Home/HomeHistory.tsx @@ -14,7 +14,9 @@ import Card from '../Global/Card' import { type CardPosition, getCardPosition } from '../Global/Card/card.utils' import EmptyState from '../Global/EmptyStates/EmptyState' import { KycStatusItem } from '../Kyc/KycStatusItem' +import { BridgeTosReminder } from '../Kyc/BridgeTosReminder' import { isKycStatusItem, type KycHistoryEntry } from '@/hooks/useBridgeKycFlow' +import { useBridgeTosStatus } from '@/hooks/useBridgeTosStatus' import { useWallet } from '@/hooks/wallet/useWallet' import { BadgeStatusItem } from '@/components/Badges/BadgeStatusItem' import { isBadgeHistoryItem } from '@/components/Badges/badge.types' @@ -43,6 +45,7 @@ const HomeHistory = ({ username, hideTxnAmount = false }: { username?: string; h const { fetchBalance } = useWallet() const { triggerHaptic } = useHaptic() const { fetchUser } = useAuth() + const { needsBridgeTos } = useBridgeTosStatus() const isViewingOwnHistory = useMemo( () => (isLoggedIn && !username) || (isLoggedIn && username === user?.user.username), @@ -270,6 +273,7 @@ const HomeHistory = ({ username, hideTxnAmount = false }: { username?: string; h return (

Activity

+ {isViewingOwnHistory && needsBridgeTos && } {isViewingOwnHistory && ((user?.user.bridgeKycStatus && user?.user.bridgeKycStatus !== 'not_started') || (user?.user.kycVerifications && user?.user.kycVerifications.length > 0)) && ( @@ -317,6 +321,9 @@ const HomeHistory = ({ username, hideTxnAmount = false }: { username?: string; h return (
+ {/* bridge ToS reminder for users who haven't accepted yet */} + {isViewingOwnHistory && needsBridgeTos && } + {/* link to the full history page */} {pendingRequests.length > 0 && ( <> diff --git a/src/components/Kyc/BridgeTosReminder.tsx b/src/components/Kyc/BridgeTosReminder.tsx new file mode 100644 index 000000000..e2965a62e --- /dev/null +++ b/src/components/Kyc/BridgeTosReminder.tsx @@ -0,0 +1,51 @@ +'use client' + +import { useState, useCallback } from 'react' +import Card from '@/components/Global/Card' +import { Icon } from '@/components/Global/Icons/Icon' +import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' +import { useAuth } from '@/context/authContext' +import { type CardPosition } from '@/components/Global/Card/card.utils' + +interface BridgeTosReminderProps { + position?: CardPosition +} + +// shown in the activity feed when user has bridge rails needing ToS acceptance. +// clicking opens the bridge ToS flow. +export const BridgeTosReminder = ({ position = 'single' }: BridgeTosReminderProps) => { + const { fetchUser } = useAuth() + const [showTosStep, setShowTosStep] = useState(false) + + const handleClick = useCallback(() => { + setShowTosStep(true) + }, []) + + const handleComplete = useCallback(async () => { + setShowTosStep(false) + await fetchUser() + }, [fetchUser]) + + const handleSkip = useCallback(() => { + setShowTosStep(false) + }, []) + + return ( + <> + +
+
+ +
+
+

Accept terms of service

+

Required to enable bank transfers

+
+ +
+
+ + + + ) +} diff --git a/src/components/Kyc/BridgeTosStep.tsx b/src/components/Kyc/BridgeTosStep.tsx new file mode 100644 index 000000000..ac23576ab --- /dev/null +++ b/src/components/Kyc/BridgeTosStep.tsx @@ -0,0 +1,132 @@ +'use client' + +import { useState, useCallback, useEffect } from 'react' +import ActionModal from '@/components/Global/ActionModal' +import IframeWrapper from '@/components/Global/IframeWrapper' +import { type IconName } from '@/components/Global/Icons/Icon' +import { getBridgeTosLink, confirmBridgeTos } from '@/app/actions/users' +import { useAuth } from '@/context/authContext' + +interface BridgeTosStepProps { + visible: boolean + onComplete: () => void + onSkip: () => void +} + +// shown immediately after sumsub kyc approval when bridge rails need ToS acceptance. +// displays a prompt, then opens the bridge ToS iframe. +export const BridgeTosStep = ({ visible, onComplete, onSkip }: BridgeTosStepProps) => { + const { fetchUser } = useAuth() + const [showIframe, setShowIframe] = useState(false) + const [tosLink, setTosLink] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + // reset state when visibility changes + useEffect(() => { + if (!visible) { + setShowIframe(false) + setTosLink(null) + setError(null) + } + }, [visible]) + + const handleAcceptTerms = useCallback(async () => { + setIsLoading(true) + setError(null) + + try { + const response = await getBridgeTosLink() + + if (response.error || !response.data?.tosLink) { + // if we can't get the tos link (e.g. bridge customer not created yet), + // skip this step — the activity feed will show a reminder later + setError(response.error || 'Could not load terms. You can accept them later from your activity feed.') + return + } + + setTosLink(response.data.tosLink) + setShowIframe(true) + } catch { + setError('Something went wrong. You can accept terms later from your activity feed.') + } finally { + setIsLoading(false) + } + }, []) + + const handleIframeClose = useCallback( + async (source?: 'manual' | 'completed' | 'tos_accepted') => { + setShowIframe(false) + + if (source === 'tos_accepted') { + // confirm with backend that bridge actually accepted the ToS + const result = await confirmBridgeTos() + + if (result.data?.accepted) { + await fetchUser() + onComplete() + return + } + + // bridge hasn't registered acceptance yet — poll once after a short delay + await new Promise((resolve) => setTimeout(resolve, 2000)) + const retry = await confirmBridgeTos() + + if (retry.data?.accepted) { + await fetchUser() + onComplete() + } else { + // will be caught by poller/webhook eventually + await fetchUser() + onComplete() + } + } else { + // user closed without accepting — skip, activity feed will remind them + onSkip() + } + }, + [fetchUser, onComplete, onSkip] + ) + + if (!visible) return null + + return ( + <> + {!showIframe && ( + + )} + + {tosLink && } + + ) +} diff --git a/src/components/Kyc/SumsubKycFlow.tsx b/src/components/Kyc/SumsubKycFlow.tsx index bfd7ac340..adf5a1528 100644 --- a/src/components/Kyc/SumsubKycFlow.tsx +++ b/src/components/Kyc/SumsubKycFlow.tsx @@ -1,7 +1,11 @@ +import { useState, useCallback } from 'react' import { Button, type ButtonProps } from '@/components/0_Bruddle/Button' import { SumsubKycWrapper } from '@/components/Kyc/SumsubKycWrapper' import { KycVerificationInProgressModal } from '@/components/Kyc/KycVerificationInProgressModal' +import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' import { useSumsubKycFlow } from '@/hooks/useSumsubKycFlow' +import { useBridgeTosStatus } from '@/hooks/useBridgeTosStatus' +import { useAuth } from '@/context/authContext' import { type KYCRegionIntent } from '@/app/actions/types/sumsub.types' interface SumsubKycFlowProps extends ButtonProps { @@ -11,10 +15,31 @@ interface SumsubKycFlowProps extends ButtonProps { } /** - * entry point for the kyc flow - * renders a button that initiates kyc, the sumsub sdk wrapper modal, and a verification-in-progress modal + * entry point for the kyc flow. + * renders a button that initiates kyc, the sumsub sdk wrapper modal, + * a verification-in-progress modal, and a bridge ToS step after sumsub approval. */ export const SumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent, ...buttonProps }: SumsubKycFlowProps) => { + const { fetchUser } = useAuth() + const [showBridgeTos, setShowBridgeTos] = useState(false) + const { needsBridgeTos } = useBridgeTosStatus() + + // intercept onKycSuccess to check for bridge ToS + const handleKycApproved = useCallback(async () => { + // refetch user to get latest rails (submitToProviders may have just run) + const updatedUser = await fetchUser() + const rails = updatedUser?.rails ?? [] + const bridgeNeedsTos = rails.some( + (r) => r.rail.provider.code === 'BRIDGE' && r.status === 'REQUIRES_INFORMATION' + ) + + if (bridgeNeedsTos) { + setShowBridgeTos(true) + } else { + onKycSuccess?.() + } + }, [fetchUser, onKycSuccess]) + const { isLoading, error, @@ -26,7 +51,17 @@ export const SumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent, ...bu refreshToken, isVerificationProgressModalOpen, closeVerificationProgressModal, - } = useSumsubKycFlow({ onKycSuccess, onManualClose, regionIntent }) + } = useSumsubKycFlow({ onKycSuccess: handleKycApproved, onManualClose, regionIntent }) + + const handleTosComplete = useCallback(() => { + setShowBridgeTos(false) + onKycSuccess?.() + }, [onKycSuccess]) + + const handleTosSkip = useCallback(() => { + setShowBridgeTos(false) + onKycSuccess?.() + }, [onKycSuccess]) return ( <> @@ -48,6 +83,8 @@ export const SumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent, ...bu isOpen={isVerificationProgressModalOpen} onClose={closeVerificationProgressModal} /> + + ) } diff --git a/src/components/Kyc/states/KycCompleted.tsx b/src/components/Kyc/states/KycCompleted.tsx index 0c59259b9..a28420426 100644 --- a/src/components/Kyc/states/KycCompleted.tsx +++ b/src/components/Kyc/states/KycCompleted.tsx @@ -1,6 +1,8 @@ import Card from '@/components/Global/Card' import { KYCStatusDrawerItem } from '../KYCStatusDrawerItem' import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' +import { BridgeTosReminder } from '../BridgeTosReminder' +import { useBridgeTosStatus } from '@/hooks/useBridgeTosStatus' import { useMemo } from 'react' import { formatDate } from '@/utils/general.utils' import { CountryRegionRow } from '../CountryRegionRow' @@ -21,6 +23,8 @@ export const KycCompleted = ({ countryCode?: string | null isBridge?: boolean }) => { + const { needsBridgeTos } = useBridgeTosStatus() + const verifiedOn = useMemo(() => { if (!bridgeKycApprovedAt) return 'N/A' try { @@ -34,6 +38,7 @@ export const KycCompleted = ({ return (
+ {needsBridgeTos && } diff --git a/src/components/Profile/views/RegionsVerification.view.tsx b/src/components/Profile/views/RegionsVerification.view.tsx index 7b52c041d..aa5707259 100644 --- a/src/components/Profile/views/RegionsVerification.view.tsx +++ b/src/components/Profile/views/RegionsVerification.view.tsx @@ -8,8 +8,10 @@ import NavHeader from '@/components/Global/NavHeader' import StartVerificationModal from '@/components/IdentityVerification/StartVerificationModal' import { SumsubKycWrapper } from '@/components/Kyc/SumsubKycWrapper' import { KycVerificationInProgressModal } from '@/components/Kyc/KycVerificationInProgressModal' +import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' import { useIdentityVerification, getRegionIntent, type Region } from '@/hooks/useIdentityVerification' import { useSumsubKycFlow } from '@/hooks/useSumsubKycFlow' +import { useAuth } from '@/context/authContext' import Image from 'next/image' import { useRouter } from 'next/navigation' import { useState, useCallback, useRef } from 'react' @@ -17,6 +19,7 @@ import { type KYCRegionIntent } from '@/app/actions/types/sumsub.types' const RegionsVerification = () => { const router = useRouter() + const { fetchUser } = useAuth() const { unlockedRegions, lockedRegions } = useIdentityVerification() const [selectedRegion, setSelectedRegion] = useState(null) // keeps the region display stable during modal close animation @@ -25,6 +28,28 @@ const RegionsVerification = () => { // persist region intent for the duration of the kyc session so token refresh // and status checks use the correct template after the confirmation modal closes const [activeRegionIntent, setActiveRegionIntent] = useState(undefined) + const [showBridgeTos, setShowBridgeTos] = useState(false) + + const handleFinalKycSuccess = useCallback(() => { + setSelectedRegion(null) + setActiveRegionIntent(undefined) + setShowBridgeTos(false) + }, []) + + // intercept sumsub approval to check for bridge ToS + const handleKycApproved = useCallback(async () => { + const updatedUser = await fetchUser() + const rails = updatedUser?.rails ?? [] + const bridgeNeedsTos = rails.some( + (r) => r.rail.provider.code === 'BRIDGE' && r.status === 'REQUIRES_INFORMATION' + ) + + if (bridgeNeedsTos) { + setShowBridgeTos(true) + } else { + handleFinalKycSuccess() + } + }, [fetchUser, handleFinalKycSuccess]) const { isLoading, @@ -39,10 +64,7 @@ const RegionsVerification = () => { closeVerificationProgressModal, } = useSumsubKycFlow({ regionIntent: activeRegionIntent, - onKycSuccess: () => { - setSelectedRegion(null) - setActiveRegionIntent(undefined) - }, + onKycSuccess: handleKycApproved, onManualClose: () => { setSelectedRegion(null) setActiveRegionIntent(undefined) @@ -120,6 +142,8 @@ const RegionsVerification = () => { isOpen={isVerificationProgressModalOpen} onClose={closeVerificationProgressModal} /> + +
) } diff --git a/src/hooks/useBridgeTosStatus.ts b/src/hooks/useBridgeTosStatus.ts new file mode 100644 index 000000000..46f8b86e5 --- /dev/null +++ b/src/hooks/useBridgeTosStatus.ts @@ -0,0 +1,17 @@ +import { useMemo } from 'react' +import { useUserStore } from '@/redux/hooks' +import { type IUserRail } from '@/interfaces' + +// derives bridge ToS status from the user's rails array +export const useBridgeTosStatus = () => { + const { user } = useUserStore() + + return useMemo(() => { + const rails: IUserRail[] = user?.rails ?? [] + const bridgeRails = rails.filter((r) => r.rail.provider.code === 'BRIDGE') + const needsBridgeTos = bridgeRails.some((r) => r.status === 'REQUIRES_INFORMATION') + const isBridgeFullyEnabled = bridgeRails.length > 0 && bridgeRails.every((r) => r.status === 'ENABLED') + + return { needsBridgeTos, isBridgeFullyEnabled, bridgeRails } + }, [user?.rails]) +} From 9e996b095b355764a4894c7dbe9749cc03650996 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 19 Feb 2026 22:30:57 +0530 Subject: [PATCH 29/46] fix: listen to additional sumsub sdk methods --- src/components/Kyc/SumsubKycWrapper.tsx | 37 +++++++++++++++++-------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/src/components/Kyc/SumsubKycWrapper.tsx b/src/components/Kyc/SumsubKycWrapper.tsx index 3486f7b52..46fa7cb67 100644 --- a/src/components/Kyc/SumsubKycWrapper.tsx +++ b/src/components/Kyc/SumsubKycWrapper.tsx @@ -87,21 +87,36 @@ export const SumsubKycWrapper = ({ } try { + const handleSubmitted = () => { + console.log('[sumsub] onApplicantSubmitted fired') + stableOnComplete() + } + const handleResubmitted = () => { + console.log('[sumsub] onApplicantResubmitted fired') + stableOnComplete() + } + const handleStatusChanged = (payload: { + reviewStatus?: string + reviewResult?: { reviewAnswer?: string } + }) => { + console.log('[sumsub] onApplicantStatusChanged fired', payload) + // auto-close when sumsub shows success screen + if (payload?.reviewStatus === 'completed' && payload?.reviewResult?.reviewAnswer === 'GREEN') { + stableOnComplete() + } + } + const sdk = window.snsWebSdk .init(accessToken, stableOnRefreshToken) .withConf({ lang: 'en', theme: 'light' }) .withOptions({ addViewportTag: false, adaptIframeHeight: true }) - .on('onApplicantSubmitted', () => stableOnComplete()) - .on('onApplicantResubmitted', () => stableOnComplete()) - .on( - 'onApplicantStatusChanged', - (payload: { reviewStatus?: string; reviewResult?: { reviewAnswer?: string } }) => { - // auto-close when sumsub shows success screen - if (payload?.reviewStatus === 'completed' && payload?.reviewResult?.reviewAnswer === 'GREEN') { - stableOnComplete() - } - } - ) + .on('onApplicantSubmitted', handleSubmitted) + .on('onApplicantResubmitted', handleResubmitted) + .on('onApplicantStatusChanged', handleStatusChanged) + // also listen for idCheck-prefixed events (some sdk versions use these) + .on('idCheck.onApplicantSubmitted', handleSubmitted) + .on('idCheck.onApplicantResubmitted', handleResubmitted) + .on('idCheck.onApplicantStatusChanged', handleStatusChanged) .on('onError', (error: unknown) => { console.error('[sumsub] sdk error', error) stableOnError(error) From 250cea8d8d26269129e428eb2ff3a2db4994f604 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 19 Feb 2026 22:32:52 +0530 Subject: [PATCH 30/46] feat: listen to user rail websocket events --- src/hooks/useWebSocket.ts | 15 ++++++++++++++- src/services/websocket.ts | 15 ++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/hooks/useWebSocket.ts b/src/hooks/useWebSocket.ts index 13b0704fe..94d435cdc 100644 --- a/src/hooks/useWebSocket.ts +++ b/src/hooks/useWebSocket.ts @@ -1,5 +1,5 @@ import { useEffect, useState, useCallback, useRef } from 'react' -import { PeanutWebSocket, getWebSocketInstance, type PendingPerk } from '@/services/websocket' +import { PeanutWebSocket, getWebSocketInstance, type PendingPerk, type RailStatusUpdate } from '@/services/websocket' import { type HistoryEntry } from './useTransactionHistory' type WebSocketStatus = 'connecting' | 'connected' | 'disconnected' | 'error' @@ -13,6 +13,7 @@ interface UseWebSocketOptions { onSumsubKycStatusUpdate?: (status: string, rejectLabels?: string[]) => void onTosUpdate?: (data: { accepted: boolean }) => void onPendingPerk?: (perk: PendingPerk) => void + onRailStatusUpdate?: (data: RailStatusUpdate) => void onConnect?: () => void onDisconnect?: () => void onError?: (error: Event) => void @@ -28,6 +29,7 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => { onSumsubKycStatusUpdate, onTosUpdate, onPendingPerk, + onRailStatusUpdate, onConnect, onDisconnect, onError, @@ -44,6 +46,7 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => { onSumsubKycStatusUpdate, onTosUpdate, onPendingPerk, + onRailStatusUpdate, onConnect, onDisconnect, onError, @@ -58,6 +61,7 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => { onSumsubKycStatusUpdate, onTosUpdate, onPendingPerk, + onRailStatusUpdate, onConnect, onDisconnect, onError, @@ -69,6 +73,7 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => { onSumsubKycStatusUpdate, onTosUpdate, onPendingPerk, + onRailStatusUpdate, onConnect, onDisconnect, onError, @@ -181,6 +186,12 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => { } } + const handleRailStatusUpdate = (data: RailStatusUpdate) => { + if (callbacksRef.current.onRailStatusUpdate) { + callbacksRef.current.onRailStatusUpdate(data) + } + } + // Register event handlers ws.on('connect', handleConnect) ws.on('disconnect', handleDisconnect) @@ -191,6 +202,7 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => { ws.on('sumsub_kyc_status_update', handleSumsubKycStatusUpdate) ws.on('persona_tos_status_update', handleTosUpdate) ws.on('pending_perk', handlePendingPerk) + ws.on('user_rail_status_changed', handleRailStatusUpdate) // Auto-connect if enabled if (autoConnect) { @@ -208,6 +220,7 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => { ws.off('sumsub_kyc_status_update', handleSumsubKycStatusUpdate) ws.off('persona_tos_status_update', handleTosUpdate) ws.off('pending_perk', handlePendingPerk) + ws.off('user_rail_status_changed', handleRailStatusUpdate) } }, [autoConnect, connect, username]) diff --git a/src/services/websocket.ts b/src/services/websocket.ts index 497df472e..8c64ec8e0 100644 --- a/src/services/websocket.ts +++ b/src/services/websocket.ts @@ -2,6 +2,12 @@ import { type HistoryEntry } from '@/hooks/useTransactionHistory' import { type PendingPerk } from '@/services/perks' export type { PendingPerk } +export interface RailStatusUpdate { + railId: string + status: string + provider?: string +} + export type WebSocketMessage = { type: | 'ping' @@ -12,7 +18,8 @@ export type WebSocketMessage = { | 'sumsub_kyc_status_update' | 'persona_tos_status_update' | 'pending_perk' - data?: HistoryEntry | PendingPerk + | 'user_rail_status_changed' + data?: HistoryEntry | PendingPerk | RailStatusUpdate } export class PeanutWebSocket { @@ -144,6 +151,12 @@ export class PeanutWebSocket { } break + case 'user_rail_status_changed': + if (message.data && 'railId' in (message.data as object)) { + this.emit('user_rail_status_changed', message.data) + } + break + default: // Handle other message types if needed this.emit(message.type, message.data) From 40d222f5a4d4c93ab98e4e1cc841daa92e0e07af Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 19 Feb 2026 22:35:23 +0530 Subject: [PATCH 31/46] fix: move PeanutDoesntStoreAnyPersonalInformation out to a separate component --- src/app/(mobile-ui)/qr-pay/page.tsx | 2 +- src/components/Kyc/InitiateMantecaKYCModal.tsx | 2 +- .../Kyc/PeanutDoesntStoreAnyPersonalInformation.tsx | 11 +++++++++++ src/hooks/useSumsubKycFlow.ts | 1 - 4 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 src/components/Kyc/PeanutDoesntStoreAnyPersonalInformation.tsx diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index 9fadf1fbb..92137bdc3 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -2,7 +2,7 @@ import { useSearchParams, useRouter } from 'next/navigation' import { useState, useCallback, useMemo, useEffect, useContext, useRef } from 'react' -import { PeanutDoesntStoreAnyPersonalInformation } from '@/components/Kyc/KycVerificationInProgressModal' +import { PeanutDoesntStoreAnyPersonalInformation } from '@/components/Kyc/PeanutDoesntStoreAnyPersonalInformation' import Card from '@/components/Global/Card' import { Button } from '@/components/0_Bruddle/Button' import { Icon } from '@/components/Global/Icons/Icon' diff --git a/src/components/Kyc/InitiateMantecaKYCModal.tsx b/src/components/Kyc/InitiateMantecaKYCModal.tsx index d72f425cc..6f547e6f3 100644 --- a/src/components/Kyc/InitiateMantecaKYCModal.tsx +++ b/src/components/Kyc/InitiateMantecaKYCModal.tsx @@ -6,7 +6,7 @@ import { type IconName } from '@/components/Global/Icons/Icon' import { useMantecaKycFlow } from '@/hooks/useMantecaKycFlow' import { type CountryData } from '@/components/AddMoney/consts' import { Button } from '@/components/0_Bruddle/Button' -import { PeanutDoesntStoreAnyPersonalInformation } from './KycVerificationInProgressModal' +import { PeanutDoesntStoreAnyPersonalInformation } from '@/components/Kyc/PeanutDoesntStoreAnyPersonalInformation' import { useEffect } from 'react' interface Props { diff --git a/src/components/Kyc/PeanutDoesntStoreAnyPersonalInformation.tsx b/src/components/Kyc/PeanutDoesntStoreAnyPersonalInformation.tsx new file mode 100644 index 000000000..3922f0c00 --- /dev/null +++ b/src/components/Kyc/PeanutDoesntStoreAnyPersonalInformation.tsx @@ -0,0 +1,11 @@ +import { Icon } from '@/components/Global/Icons/Icon' +import { twMerge } from 'tailwind-merge' + +export const PeanutDoesntStoreAnyPersonalInformation = ({ className }: { className?: string }) => { + return ( +
+ + Peanut doesn't store any of your documents +
+ ) +} diff --git a/src/hooks/useSumsubKycFlow.ts b/src/hooks/useSumsubKycFlow.ts index 484e9ccfa..6256d1307 100644 --- a/src/hooks/useSumsubKycFlow.ts +++ b/src/hooks/useSumsubKycFlow.ts @@ -46,7 +46,6 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: prevStatusRef.current = liveKycStatus if (prevStatus !== 'APPROVED' && liveKycStatus === 'APPROVED') { - setIsVerificationProgressModalOpen(false) onKycSuccess?.() } else if (prevStatus !== 'REJECTED' && liveKycStatus === 'REJECTED') { setIsVerificationProgressModalOpen(false) From 2f892cbbeb45626a09a2279fa63f1fa0bcf2df90 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 19 Feb 2026 22:36:37 +0530 Subject: [PATCH 32/46] feat: add useRailStatusTracking hook --- src/hooks/useRailStatusTracking.ts | 162 +++++++++++++++++++++++++++++ src/interfaces/interfaces.ts | 13 +++ 2 files changed, 175 insertions(+) create mode 100644 src/hooks/useRailStatusTracking.ts diff --git a/src/hooks/useRailStatusTracking.ts b/src/hooks/useRailStatusTracking.ts new file mode 100644 index 000000000..2be342ca0 --- /dev/null +++ b/src/hooks/useRailStatusTracking.ts @@ -0,0 +1,162 @@ +import { useState, useCallback, useRef, useEffect, useMemo } from 'react' +import { useUserStore } from '@/redux/hooks' +import { useAuth } from '@/context/authContext' +import { useWebSocket } from '@/hooks/useWebSocket' +import { type IUserRail, type ProviderDisplayStatus, type ProviderStatus } from '@/interfaces' +import { type RailStatusUpdate } from '@/services/websocket' + +interface RailStatusTrackingResult { + providers: ProviderStatus[] + allSettled: boolean + needsBridgeTos: boolean + startTracking: () => void + stopTracking: () => void +} + +const POLL_INTERVAL_MS = 4000 + +// human-readable labels for provider groups +const PROVIDER_LABELS: Record = { + BRIDGE: 'Bank transfers', + MANTECA: 'QR payments', +} + +function deriveProviderDisplayName(providerCode: string, rails: IUserRail[]): string { + const base = PROVIDER_LABELS[providerCode] ?? providerCode + // add country context from rail methods + const countries = [...new Set(rails.map((r) => r.rail.method.country).filter(Boolean))] + if (countries.length > 0) { + return `${base} (${countries.join(', ')})` + } + return base +} + +function deriveStatus(rail: IUserRail): ProviderDisplayStatus { + switch (rail.status) { + case 'ENABLED': + return 'enabled' + case 'REQUIRES_INFORMATION': + case 'REQUIRES_EXTRA_INFORMATION': + return 'requires_tos' + case 'FAILED': + case 'REJECTED': + return 'failed' + case 'PENDING': + default: + return 'setting_up' + } +} + +// pick the "most advanced" status for a provider group +function deriveGroupStatus(rails: IUserRail[]): ProviderDisplayStatus { + const statuses = rails.map(deriveStatus) + // priority: requires_tos > enabled > failed > setting_up + if (statuses.includes('requires_tos')) return 'requires_tos' + if (statuses.includes('enabled')) return 'enabled' + if (statuses.includes('failed')) return 'failed' + return 'setting_up' +} + +export const useRailStatusTracking = (): RailStatusTrackingResult => { + const { user } = useUserStore() + const { fetchUser } = useAuth() + const [isTracking, setIsTracking] = useState(false) + const pollTimerRef = useRef(null) + const isMountedRef = useRef(true) + + // listen for rail status WebSocket events + useWebSocket({ + username: user?.user.username ?? undefined, + autoConnect: isTracking, + onRailStatusUpdate: useCallback( + (_data: RailStatusUpdate) => { + // refetch user to get updated rails from server + if (isTracking) { + fetchUser() + } + }, + [isTracking, fetchUser] + ), + }) + + // derive provider statuses from current rails + const providers = useMemo((): ProviderStatus[] => { + const rails: IUserRail[] = user?.rails ?? [] + if (rails.length === 0) return [] + + // group by provider + const byProvider = new Map() + for (const rail of rails) { + const code = rail.rail.provider.code + const list = byProvider.get(code) ?? [] + list.push(rail) + byProvider.set(code, list) + } + + return Array.from(byProvider.entries()).map(([code, providerRails]) => ({ + providerCode: code, + displayName: deriveProviderDisplayName(code, providerRails), + status: deriveGroupStatus(providerRails), + rails: providerRails, + })) + }, [user?.rails]) + + const allSettled = useMemo(() => { + if (providers.length === 0) return false + return providers.every((p) => p.status !== 'setting_up') + }, [providers]) + + const needsBridgeTos = useMemo(() => { + return providers.some((p) => p.providerCode === 'BRIDGE' && p.status === 'requires_tos') + }, [providers]) + + // stop polling when all settled + useEffect(() => { + if (allSettled && isTracking) { + if (pollTimerRef.current) { + clearInterval(pollTimerRef.current) + pollTimerRef.current = null + } + } + }, [allSettled, isTracking]) + + const startTracking = useCallback(() => { + setIsTracking(true) + + // start polling as fallback + if (pollTimerRef.current) clearInterval(pollTimerRef.current) + pollTimerRef.current = setInterval(() => { + if (isMountedRef.current) { + fetchUser() + } + }, POLL_INTERVAL_MS) + }, [fetchUser]) + + const stopTracking = useCallback(() => { + setIsTracking(false) + if (pollTimerRef.current) { + clearInterval(pollTimerRef.current) + pollTimerRef.current = null + } + }, []) + + // cleanup on unmount + useEffect(() => { + isMountedRef.current = true + return () => { + isMountedRef.current = false + if (pollTimerRef.current) { + clearInterval(pollTimerRef.current) + pollTimerRef.current = null + } + } + }, []) + + return { + providers, + allSettled, + needsBridgeTos, + startTracking, + stopTracking, + } +} diff --git a/src/interfaces/interfaces.ts b/src/interfaces/interfaces.ts index bb84671bc..6b4b0868d 100644 --- a/src/interfaces/interfaces.ts +++ b/src/interfaces/interfaces.ts @@ -3,6 +3,19 @@ import { type SumsubKycStatus } from '@/app/actions/types/sumsub.types' export type RecipientType = 'address' | 'ens' | 'iban' | 'us' | 'username' +// phases for the multi-phase kyc verification modal +export type KycModalPhase = 'verifying' | 'preparing' | 'bridge_tos' | 'complete' + +// per-provider rail status for tracking after kyc approval +export type ProviderDisplayStatus = 'setting_up' | 'requires_tos' | 'enabled' | 'failed' + +export interface ProviderStatus { + providerCode: string + displayName: string + status: ProviderDisplayStatus + rails: IUserRail[] +} + // Moved here from bridge-accounts.utils.ts to avoid circular dependency export type BridgeKycStatus = 'not_started' | 'under_review' | 'approved' | 'rejected' | 'incomplete' From f58ad8dddcf416313ece23f3073bce367b113613 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 19 Feb 2026 22:37:47 +0530 Subject: [PATCH 33/46] fix: update SumsubKycFlow and KycVerificationInProgressModal to handle multi phase kyc verification tackling bridge tos acceptance --- .../Kyc/KycVerificationInProgressModal.tsx | 158 ++++++++--- src/components/Kyc/SumsubKycFlow.tsx | 246 ++++++++++++++++-- 2 files changed, 348 insertions(+), 56 deletions(-) diff --git a/src/components/Kyc/KycVerificationInProgressModal.tsx b/src/components/Kyc/KycVerificationInProgressModal.tsx index 401aa0ba9..c7f23d04c 100644 --- a/src/components/Kyc/KycVerificationInProgressModal.tsx +++ b/src/components/Kyc/KycVerificationInProgressModal.tsx @@ -1,16 +1,34 @@ import { useRouter } from 'next/navigation' import ActionModal from '@/components/Global/ActionModal' -import { Icon, type IconName } from '@/components/Global/Icons/Icon' -import { twMerge } from 'tailwind-merge' +import { type IconName } from '@/components/Global/Icons/Icon' +import { PeanutDoesntStoreAnyPersonalInformation } from '@/components/Kyc/PeanutDoesntStoreAnyPersonalInformation' +import { type KycModalPhase } from '@/interfaces' interface KycVerificationInProgressModalProps { isOpen: boolean onClose: () => void + phase?: KycModalPhase + onAcceptTerms?: () => void + onSkipTerms?: () => void + onContinue?: () => void + tosError?: string | null + isLoadingTos?: boolean + preparingTimedOut?: boolean } -// this modal is shown after the user submits their kyc information. -// it waits for a final status from the websocket before disappearing. -export const KycVerificationInProgressModal = ({ isOpen, onClose }: KycVerificationInProgressModalProps) => { +// multi-phase modal shown during and after kyc verification. +// phase transitions are controlled by the parent orchestrator (SumsubKycFlow). +export const KycVerificationInProgressModal = ({ + isOpen, + onClose, + phase = 'verifying', + onAcceptTerms, + onSkipTerms, + onContinue, + tosError, + isLoadingTos, + preparingTimedOut, +}: KycVerificationInProgressModalProps) => { const router = useRouter() const handleGoHome = () => { @@ -18,42 +36,124 @@ export const KycVerificationInProgressModal = ({ isOpen, onClose }: KycVerificat router.push('/home') } - const descriptionWithInfo = ( -

- This usually takes less than a minute. You can stay here while we finish, or return to the home screen and - we'll notify you when it's done. -

- ) + if (phase === 'verifying') { + return ( + + This usually takes less than a minute. You can stay here while we finish, or return to the home + screen and we'll notify you when it's done. +

+ } + ctas={[ + { + text: 'Go to Home', + onClick: handleGoHome, + variant: 'purple', + className: 'w-full', + shadowSize: '4', + }, + ]} + preventClose + hideModalCloseButton + footer={} + /> + ) + } + + if (phase === 'preparing') { + return ( + + ) + } + + if (phase === 'bridge_tos') { + const description = + tosError || 'One more step: accept terms of service to enable bank transfers in the US, Europe, and Mexico.' + return ( + + ) + } + + // phase === 'complete' return ( } /> ) } - -export const PeanutDoesntStoreAnyPersonalInformation = ({ className }: { className?: string }) => { - return ( -
- - Peanut doesn't store any of your documents -
- ) -} diff --git a/src/components/Kyc/SumsubKycFlow.tsx b/src/components/Kyc/SumsubKycFlow.tsx index adf5a1528..412bfde37 100644 --- a/src/components/Kyc/SumsubKycFlow.tsx +++ b/src/components/Kyc/SumsubKycFlow.tsx @@ -1,12 +1,16 @@ -import { useState, useCallback } from 'react' +import { useState, useCallback, useEffect, useRef } from 'react' import { Button, type ButtonProps } from '@/components/0_Bruddle/Button' import { SumsubKycWrapper } from '@/components/Kyc/SumsubKycWrapper' import { KycVerificationInProgressModal } from '@/components/Kyc/KycVerificationInProgressModal' -import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' +import IframeWrapper from '@/components/Global/IframeWrapper' import { useSumsubKycFlow } from '@/hooks/useSumsubKycFlow' -import { useBridgeTosStatus } from '@/hooks/useBridgeTosStatus' +import { useRailStatusTracking } from '@/hooks/useRailStatusTracking' import { useAuth } from '@/context/authContext' +import { getBridgeTosLink, confirmBridgeTos } from '@/app/actions/users' import { type KYCRegionIntent } from '@/app/actions/types/sumsub.types' +import { type KycModalPhase } from '@/interfaces' + +const PREPARING_TIMEOUT_MS = 30000 interface SumsubKycFlowProps extends ButtonProps { onKycSuccess?: () => void @@ -17,51 +21,232 @@ interface SumsubKycFlowProps extends ButtonProps { /** * entry point for the kyc flow. * renders a button that initiates kyc, the sumsub sdk wrapper modal, - * a verification-in-progress modal, and a bridge ToS step after sumsub approval. + * and a multi-phase verification modal that handles: + * verifying → preparing → bridge_tos (if applicable) → complete */ export const SumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent, ...buttonProps }: SumsubKycFlowProps) => { const { fetchUser } = useAuth() - const [showBridgeTos, setShowBridgeTos] = useState(false) - const { needsBridgeTos } = useBridgeTosStatus() - // intercept onKycSuccess to check for bridge ToS - const handleKycApproved = useCallback(async () => { - // refetch user to get latest rails (submitToProviders may have just run) + // multi-phase modal state + const [modalPhase, setModalPhase] = useState('verifying') + const [forceShowModal, setForceShowModal] = useState(false) + const [preparingTimedOut, setPreparingTimedOut] = useState(false) + const preparingTimerRef = useRef(null) + const isRealtimeFlowRef = useRef(false) + + // bridge ToS state + const [tosLink, setTosLink] = useState(null) + const [showTosIframe, setShowTosIframe] = useState(false) + const [tosError, setTosError] = useState(null) + const [isLoadingTos, setIsLoadingTos] = useState(false) + + // ref for closeVerificationProgressModal (avoids circular dep with completeFlow) + const closeVerificationModalRef = useRef<() => void>(() => {}) + + // rail tracking + const { allSettled, needsBridgeTos, startTracking, stopTracking } = useRailStatusTracking() + + const clearPreparingTimer = useCallback(() => { + if (preparingTimerRef.current) { + clearTimeout(preparingTimerRef.current) + preparingTimerRef.current = null + } + }, []) + + // complete the flow — close everything, call original onKycSuccess + const completeFlow = useCallback(() => { + isRealtimeFlowRef.current = false + setForceShowModal(false) + setModalPhase('verifying') + setPreparingTimedOut(false) + setTosLink(null) + setShowTosIframe(false) + setTosError(null) + clearPreparingTimer() + stopTracking() + closeVerificationModalRef.current() + onKycSuccess?.() + }, [onKycSuccess, clearPreparingTimer, stopTracking]) + + // called by useSumsubKycFlow when sumsub status transitions to APPROVED + const handleSumsubApproved = useCallback(async () => { + // for real-time flow, optimistically show "Identity verified!" while we check rails + if (isRealtimeFlowRef.current) { + setModalPhase('preparing') + setForceShowModal(true) + } + const updatedUser = await fetchUser() const rails = updatedUser?.rails ?? [] + const bridgeNeedsTos = rails.some( (r) => r.rail.provider.code === 'BRIDGE' && r.status === 'REQUIRES_INFORMATION' ) if (bridgeNeedsTos) { - setShowBridgeTos(true) - } else { - onKycSuccess?.() + setModalPhase('bridge_tos') + setForceShowModal(true) + clearPreparingTimer() + return + } + + const anyPending = rails.some((r) => r.status === 'PENDING') + + if (anyPending || (rails.length === 0 && isRealtimeFlowRef.current)) { + // rails still being set up — show preparing and start tracking + setModalPhase('preparing') + setForceShowModal(true) + startTracking() + return } - }, [fetchUser, onKycSuccess]) + + // all settled — done + completeFlow() + }, [fetchUser, startTracking, clearPreparingTimer, completeFlow]) const { isLoading, error, showWrapper, accessToken, - handleInitiateKyc, - handleSdkComplete, + handleInitiateKyc: originalHandleInitiateKyc, + handleSdkComplete: originalHandleSdkComplete, handleClose, refreshToken, isVerificationProgressModalOpen, closeVerificationProgressModal, - } = useSumsubKycFlow({ onKycSuccess: handleKycApproved, onManualClose, regionIntent }) + } = useSumsubKycFlow({ onKycSuccess: handleSumsubApproved, onManualClose, regionIntent }) - const handleTosComplete = useCallback(() => { - setShowBridgeTos(false) - onKycSuccess?.() - }, [onKycSuccess]) + // keep ref in sync + useEffect(() => { + closeVerificationModalRef.current = closeVerificationProgressModal + }, [closeVerificationProgressModal]) - const handleTosSkip = useCallback(() => { - setShowBridgeTos(false) - onKycSuccess?.() - }, [onKycSuccess]) + // wrap handleSdkComplete to track real-time flow + const handleSdkComplete = useCallback(() => { + isRealtimeFlowRef.current = true + originalHandleSdkComplete() + }, [originalHandleSdkComplete]) + + // wrap handleInitiateKyc to reset state for new attempts + const handleInitiateKyc = useCallback( + async (overrideIntent?: KYCRegionIntent) => { + setModalPhase('verifying') + setForceShowModal(false) + setPreparingTimedOut(false) + setTosLink(null) + setShowTosIframe(false) + setTosError(null) + isRealtimeFlowRef.current = false + clearPreparingTimer() + + await originalHandleInitiateKyc(overrideIntent) + }, + [originalHandleInitiateKyc, clearPreparingTimer] + ) + + // 30s timeout for preparing phase + useEffect(() => { + if (modalPhase === 'preparing' && !preparingTimedOut) { + clearPreparingTimer() + preparingTimerRef.current = setTimeout(() => { + setPreparingTimedOut(true) + }, PREPARING_TIMEOUT_MS) + } else { + clearPreparingTimer() + } + }, [modalPhase, preparingTimedOut, clearPreparingTimer]) + + // phase transitions driven by rail tracking + useEffect(() => { + if (modalPhase === 'preparing') { + if (needsBridgeTos) { + setModalPhase('bridge_tos') + clearPreparingTimer() + } else if (allSettled) { + setModalPhase('complete') + clearPreparingTimer() + stopTracking() + } + } else if (modalPhase === 'bridge_tos') { + // after ToS accepted, rails transition to ENABLED + if (allSettled && !needsBridgeTos) { + setModalPhase('complete') + stopTracking() + } + } + }, [modalPhase, needsBridgeTos, allSettled, clearPreparingTimer, stopTracking]) + + // handle "Accept Terms" click in bridge_tos phase + const handleAcceptTerms = useCallback(async () => { + setIsLoadingTos(true) + setTosError(null) + + try { + const response = await getBridgeTosLink() + + if (response.error || !response.data?.tosLink) { + setTosError( + response.error || 'Could not load terms. You can accept them later from your activity feed.' + ) + return + } + + setTosLink(response.data.tosLink) + setShowTosIframe(true) + } catch { + setTosError('Something went wrong. You can accept terms later from your activity feed.') + } finally { + setIsLoadingTos(false) + } + }, []) + + // handle ToS iframe close + const handleTosIframeClose = useCallback( + async (source?: 'manual' | 'completed' | 'tos_accepted') => { + setShowTosIframe(false) + + if (source === 'tos_accepted') { + // confirm with backend + const result = await confirmBridgeTos() + + if (!result.data?.accepted) { + // retry after short delay + await new Promise((resolve) => setTimeout(resolve, 2000)) + await confirmBridgeTos() + } + + // refetch user — the phase-transition effect will handle moving to 'complete' + await fetchUser() + } + // if manual close, stay on bridge_tos phase (user can try again or skip) + }, + [fetchUser] + ) + + // handle "Skip for now" in bridge_tos phase + const handleSkipTerms = useCallback(() => { + completeFlow() + }, [completeFlow]) + + // handle modal close (Go to Home, etc.) + const handleModalClose = useCallback(() => { + isRealtimeFlowRef.current = false + setForceShowModal(false) + clearPreparingTimer() + stopTracking() + closeVerificationProgressModal() + }, [clearPreparingTimer, stopTracking, closeVerificationProgressModal]) + + // cleanup on unmount + useEffect(() => { + return () => { + clearPreparingTimer() + stopTracking() + } + }, [clearPreparingTimer, stopTracking]) + + const isModalOpen = isVerificationProgressModalOpen || forceShowModal return ( <> @@ -80,11 +265,18 @@ export const SumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent, ...bu /> - + {tosLink && } ) } From 8c64be9fec75fe38c25ae7340c182660102950a7 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Fri, 20 Feb 2026 21:41:34 +0530 Subject: [PATCH 34/46] feat: add isTerminalRejection utility and sumsubRejectType to unified kyc status --- src/constants/sumsub-reject-labels.consts.ts | 18 ++++++++++++++++++ src/hooks/useUnifiedKycStatus.ts | 6 ++++++ 2 files changed, 24 insertions(+) diff --git a/src/constants/sumsub-reject-labels.consts.ts b/src/constants/sumsub-reject-labels.consts.ts index 64197c111..ab641bd76 100644 --- a/src/constants/sumsub-reject-labels.consts.ts +++ b/src/constants/sumsub-reject-labels.consts.ts @@ -81,3 +81,21 @@ export const getRejectLabelInfo = (label: string): RejectLabelInfo => { export const hasTerminalRejectLabel = (labels: string[]): boolean => { return labels.some((label) => TERMINAL_REJECT_LABELS.has(label)) } + +const MAX_RETRY_COUNT = 2 + +/** determine if a rejection is terminal (permanent, cannot be retried) */ +export const isTerminalRejection = ({ + rejectType, + failureCount, + rejectLabels, +}: { + rejectType?: 'RETRY' | 'FINAL' | null + failureCount?: number + rejectLabels?: string[] | null +}): boolean => { + if (rejectType === 'FINAL') return true + if (failureCount && failureCount >= MAX_RETRY_COUNT) return true + if (rejectLabels?.length && hasTerminalRejectLabel(rejectLabels)) return true + return false +} diff --git a/src/hooks/useUnifiedKycStatus.ts b/src/hooks/useUnifiedKycStatus.ts index 4b3bc9ff2..bc5b410ae 100644 --- a/src/hooks/useUnifiedKycStatus.ts +++ b/src/hooks/useUnifiedKycStatus.ts @@ -38,6 +38,11 @@ export default function useUnifiedKycStatus() { const sumsubRejectLabels = useMemo(() => sumsubVerification?.rejectLabels ?? null, [sumsubVerification]) + const sumsubRejectType = useMemo( + () => (sumsubVerification?.rejectType as 'RETRY' | 'FINAL' | null) ?? null, + [sumsubVerification] + ) + // region intent used during the sumsub verification (stored in metadata by initiate-kyc) const sumsubVerificationRegionIntent = useMemo( () => (sumsubVerification?.metadata?.regionIntent as string) ?? null, @@ -77,6 +82,7 @@ export default function useUnifiedKycStatus() { isSumsubActionRequired, sumsubStatus, sumsubRejectLabels, + sumsubRejectType, sumsubVerificationRegionIntent, } } From 302df7b6bdc4566f99724be42a9457a9bb8df0df Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Fri, 20 Feb 2026 21:42:00 +0530 Subject: [PATCH 35/46] refactor: extract shared rejectlabelslist component and simplify drawer states --- src/components/Kyc/RejectLabelsList.tsx | 32 ++++++++++++++++++ .../Kyc/states/KycActionRequired.tsx | 31 ++++++++++------- src/components/Kyc/states/KycFailed.tsx | 33 ++++--------------- 3 files changed, 58 insertions(+), 38 deletions(-) create mode 100644 src/components/Kyc/RejectLabelsList.tsx diff --git a/src/components/Kyc/RejectLabelsList.tsx b/src/components/Kyc/RejectLabelsList.tsx new file mode 100644 index 000000000..69bfb63c5 --- /dev/null +++ b/src/components/Kyc/RejectLabelsList.tsx @@ -0,0 +1,32 @@ +import { useMemo } from 'react' +import InfoCard from '@/components/Global/InfoCard' +import { getRejectLabelInfo } from '@/constants/sumsub-reject-labels.consts' + +// renders sumsub reject labels as individual InfoCards, with a generic fallback +// when no labels are provided. shared between drawer states and modals. +export const RejectLabelsList = ({ rejectLabels }: { rejectLabels?: string[] | null }) => { + const labels = rejectLabels?.length ? rejectLabels : null + + const reasons = useMemo(() => { + if (!labels) return null + return labels.map((label) => getRejectLabelInfo(label)) + }, [labels]) + + if (!reasons) { + return ( + + ) + } + + return ( +
+ {reasons.map((reason, i) => ( + + ))} +
+ ) +} diff --git a/src/components/Kyc/states/KycActionRequired.tsx b/src/components/Kyc/states/KycActionRequired.tsx index 508dba7ee..418f28842 100644 --- a/src/components/Kyc/states/KycActionRequired.tsx +++ b/src/components/Kyc/states/KycActionRequired.tsx @@ -1,26 +1,33 @@ -import { Button } from '@/components/0_Bruddle/Button' import { KYCStatusDrawerItem } from '../KYCStatusDrawerItem' -import InfoCard from '@/components/Global/InfoCard' +import { RejectLabelsList } from '../RejectLabelsList' +import { Button } from '@/components/0_Bruddle/Button' +import type { IconName } from '@/components/Global/Icons/Icon' // this component shows the kyc status when sumsub requires additional action from the user. -export const KycActionRequired = ({ onResume, isLoading }: { onResume: () => void; isLoading?: boolean }) => { +// displays specific rejection reasons when available (e.g. bad photo quality, expired doc). +export const KycActionRequired = ({ + onResume, + isLoading, + rejectLabels, +}: { + onResume: () => void + isLoading?: boolean + rejectLabels?: string[] | null +}) => { return ( -
+
- + + +
) diff --git a/src/components/Kyc/states/KycFailed.tsx b/src/components/Kyc/states/KycFailed.tsx index 8365d6608..3088a6b06 100644 --- a/src/components/Kyc/states/KycFailed.tsx +++ b/src/components/Kyc/states/KycFailed.tsx @@ -1,16 +1,15 @@ import { Button } from '@/components/0_Bruddle/Button' import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' import { KYCStatusDrawerItem } from '../KYCStatusDrawerItem' +import { RejectLabelsList } from '../RejectLabelsList' import Card from '@/components/Global/Card' import InfoCard from '@/components/Global/InfoCard' import { useMemo } from 'react' import { formatDate } from '@/utils/general.utils' import { CountryRegionRow } from '../CountryRegionRow' -import { getRejectLabelInfo, hasTerminalRejectLabel } from '@/constants/sumsub-reject-labels.consts' +import { isTerminalRejection } from '@/constants/sumsub-reject-labels.consts' import { useModalsContext } from '@/context/ModalsContext' -const MAX_RETRY_COUNT = 2 - // this component shows the kyc status when it's failed/rejected. // for sumsub: maps reject labels to human-readable reasons, handles terminal vs retryable states. // for bridge: shows raw reason string as before. @@ -49,26 +48,10 @@ export const KycFailed = ({ } }, [bridgeKycRejectedAt]) - // determine if this is a terminal (permanent) rejection - const isTerminal = useMemo(() => { - if (rejectType === 'FINAL') return true - if (failureCount && failureCount >= MAX_RETRY_COUNT) return true - if (rejectLabels?.length && hasTerminalRejectLabel(rejectLabels)) return true - return false - }, [rejectType, failureCount, rejectLabels]) - - // map sumsub labels to human-readable items for InfoCard - const reasonItems = useMemo(() => { - if (!isSumsub || !rejectLabels?.length) return null - return rejectLabels.map((label) => { - const info = getRejectLabelInfo(label) - return ( - - {info.title}: {info.description} - - ) - }) - }, [isSumsub, rejectLabels]) + const isTerminal = useMemo( + () => isTerminalRejection({ rejectType, failureCount, rejectLabels }), + [rejectType, failureCount, rejectLabels] + ) // formatted bridge reason (legacy display) const formattedBridgeReason = useMemo(() => { @@ -94,9 +77,7 @@ export const KycFailed = ({ {!isSumsub && } - {isSumsub && reasonItems && ( - - )} + {isSumsub && } {isTerminal ? (
From 8e5ab8bdc4b460a4ee5bfbf88ea4b4241d15ae93 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Fri, 20 Feb 2026 21:42:14 +0530 Subject: [PATCH 36/46] fix: handle kyc status transitions for non-success terminal states --- src/components/Kyc/SumsubKycFlow.tsx | 9 +++++++++ src/hooks/useSumsubKycFlow.ts | 8 +++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/components/Kyc/SumsubKycFlow.tsx b/src/components/Kyc/SumsubKycFlow.tsx index 412bfde37..9e0cc345b 100644 --- a/src/components/Kyc/SumsubKycFlow.tsx +++ b/src/components/Kyc/SumsubKycFlow.tsx @@ -109,6 +109,7 @@ export const SumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent, ...bu error, showWrapper, accessToken, + liveKycStatus, handleInitiateKyc: originalHandleInitiateKyc, handleSdkComplete: originalHandleSdkComplete, handleClose, @@ -122,6 +123,14 @@ export const SumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent, ...bu closeVerificationModalRef.current = closeVerificationProgressModal }, [closeVerificationProgressModal]) + // refresh user store when kyc status transitions to a non-success state + // so the drawer/status item reads the updated verification record + useEffect(() => { + if (liveKycStatus === 'ACTION_REQUIRED' || liveKycStatus === 'REJECTED') { + fetchUser() + } + }, [liveKycStatus, fetchUser]) + // wrap handleSdkComplete to track real-time flow const handleSdkComplete = useCallback(() => { isRealtimeFlowRef.current = true diff --git a/src/hooks/useSumsubKycFlow.ts b/src/hooks/useSumsubKycFlow.ts index 6256d1307..d52cddc7b 100644 --- a/src/hooks/useSumsubKycFlow.ts +++ b/src/hooks/useSumsubKycFlow.ts @@ -47,7 +47,13 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: if (prevStatus !== 'APPROVED' && liveKycStatus === 'APPROVED') { onKycSuccess?.() - } else if (prevStatus !== 'REJECTED' && liveKycStatus === 'REJECTED') { + } else if ( + liveKycStatus && + liveKycStatus !== prevStatus && + liveKycStatus !== 'APPROVED' && + liveKycStatus !== 'PENDING' + ) { + // close modal for any non-success terminal state (REJECTED, ACTION_REQUIRED, FAILED, etc.) setIsVerificationProgressModalOpen(false) } }, [liveKycStatus, onKycSuccess]) From 2bc70ba9c7e200c54860dce831f5304b7a0b7073 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Fri, 20 Feb 2026 21:42:32 +0530 Subject: [PATCH 37/46] feat: add status-aware modals for regions verification page --- .../Kyc/modals/KycActionRequiredModal.tsx | 44 ++++++++++ .../Kyc/modals/KycProcessingModal.tsx | 27 +++++++ .../Kyc/modals/KycRejectedModal.tsx | 79 ++++++++++++++++++ .../views/RegionsVerification.view.tsx | 81 ++++++++++++++++++- 4 files changed, 228 insertions(+), 3 deletions(-) create mode 100644 src/components/Kyc/modals/KycActionRequiredModal.tsx create mode 100644 src/components/Kyc/modals/KycProcessingModal.tsx create mode 100644 src/components/Kyc/modals/KycRejectedModal.tsx diff --git a/src/components/Kyc/modals/KycActionRequiredModal.tsx b/src/components/Kyc/modals/KycActionRequiredModal.tsx new file mode 100644 index 000000000..707f5ead6 --- /dev/null +++ b/src/components/Kyc/modals/KycActionRequiredModal.tsx @@ -0,0 +1,44 @@ +import ActionModal from '@/components/Global/ActionModal' +import { RejectLabelsList } from '../RejectLabelsList' + +interface KycActionRequiredModalProps { + visible: boolean + onClose: () => void + onResubmit: () => void + isLoading?: boolean + rejectLabels?: string[] | null +} + +// shown when user clicks a locked region while their kyc needs resubmission (soft reject) +export const KycActionRequiredModal = ({ + visible, + onClose, + onResubmit, + isLoading, + rejectLabels, +}: KycActionRequiredModalProps) => { + return ( + + +
+ } + ctas={[ + { + text: isLoading ? 'Loading...' : 'Re-submit verification', + icon: 'retry', + onClick: onResubmit, + disabled: isLoading, + shadowSize: '4', + }, + ]} + /> + ) +} diff --git a/src/components/Kyc/modals/KycProcessingModal.tsx b/src/components/Kyc/modals/KycProcessingModal.tsx new file mode 100644 index 000000000..c904bd12d --- /dev/null +++ b/src/components/Kyc/modals/KycProcessingModal.tsx @@ -0,0 +1,27 @@ +import ActionModal from '@/components/Global/ActionModal' + +interface KycProcessingModalProps { + visible: boolean + onClose: () => void +} + +// shown when user clicks a locked region while their kyc is pending/in review +export const KycProcessingModal = ({ visible, onClose }: KycProcessingModalProps) => { + return ( + + ) +} diff --git a/src/components/Kyc/modals/KycRejectedModal.tsx b/src/components/Kyc/modals/KycRejectedModal.tsx new file mode 100644 index 000000000..9d2aece39 --- /dev/null +++ b/src/components/Kyc/modals/KycRejectedModal.tsx @@ -0,0 +1,79 @@ +import { useMemo } from 'react' +import ActionModal from '@/components/Global/ActionModal' +import InfoCard from '@/components/Global/InfoCard' +import { RejectLabelsList } from '../RejectLabelsList' +import { isTerminalRejection } from '@/constants/sumsub-reject-labels.consts' +import { useModalsContext } from '@/context/ModalsContext' + +interface KycRejectedModalProps { + visible: boolean + onClose: () => void + onRetry: () => void + isLoading?: boolean + rejectLabels?: string[] | null + rejectType?: 'RETRY' | 'FINAL' | null + failureCount?: number +} + +// shown when user clicks a locked region while their kyc is rejected +export const KycRejectedModal = ({ + visible, + onClose, + onRetry, + isLoading, + rejectLabels, + rejectType, + failureCount, +}: KycRejectedModalProps) => { + const { setIsSupportModalOpen } = useModalsContext() + + const isTerminal = useMemo( + () => isTerminalRejection({ rejectType, failureCount, rejectLabels }), + [rejectType, failureCount, rejectLabels] + ) + + return ( + + + {isTerminal && ( + + )} +
+ } + ctas={[ + isTerminal + ? { + text: 'Contact support', + onClick: () => { + onClose() + setIsSupportModalOpen(true) + }, + shadowSize: '4', + } + : { + text: isLoading ? 'Loading...' : 'Retry verification', + icon: 'retry', + onClick: onRetry, + disabled: isLoading, + shadowSize: '4', + }, + ]} + /> + ) +} diff --git a/src/components/Profile/views/RegionsVerification.view.tsx b/src/components/Profile/views/RegionsVerification.view.tsx index aa5707259..1a48238eb 100644 --- a/src/components/Profile/views/RegionsVerification.view.tsx +++ b/src/components/Profile/views/RegionsVerification.view.tsx @@ -8,19 +8,52 @@ import NavHeader from '@/components/Global/NavHeader' import StartVerificationModal from '@/components/IdentityVerification/StartVerificationModal' import { SumsubKycWrapper } from '@/components/Kyc/SumsubKycWrapper' import { KycVerificationInProgressModal } from '@/components/Kyc/KycVerificationInProgressModal' +import { KycProcessingModal } from '@/components/Kyc/modals/KycProcessingModal' +import { KycActionRequiredModal } from '@/components/Kyc/modals/KycActionRequiredModal' +import { KycRejectedModal } from '@/components/Kyc/modals/KycRejectedModal' import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' import { useIdentityVerification, getRegionIntent, type Region } from '@/hooks/useIdentityVerification' +import useUnifiedKycStatus from '@/hooks/useUnifiedKycStatus' import { useSumsubKycFlow } from '@/hooks/useSumsubKycFlow' import { useAuth } from '@/context/authContext' import Image from 'next/image' import { useRouter } from 'next/navigation' -import { useState, useCallback, useRef } from 'react' +import { useState, useCallback, useRef, useMemo } from 'react' import { type KYCRegionIntent } from '@/app/actions/types/sumsub.types' +type ModalVariant = 'start' | 'processing' | 'action_required' | 'rejected' + +// determine which modal to show based on sumsub status and clicked region intent +function getModalVariant( + sumsubStatus: string | null, + clickedRegionIntent: KYCRegionIntent | undefined, + existingRegionIntent: string | null +): ModalVariant { + // no verification or not started → start fresh + if (!sumsubStatus || sumsubStatus === 'NOT_STARTED') return 'start' + + // different region intent → allow new verification + if (existingRegionIntent && clickedRegionIntent && clickedRegionIntent !== existingRegionIntent) return 'start' + + switch (sumsubStatus) { + case 'PENDING': + case 'IN_REVIEW': + return 'processing' + case 'ACTION_REQUIRED': + return 'action_required' + case 'REJECTED': + case 'FAILED': + return 'rejected' + default: + return 'start' + } +} + const RegionsVerification = () => { const router = useRouter() - const { fetchUser } = useAuth() + const { user, fetchUser } = useAuth() const { unlockedRegions, lockedRegions } = useIdentityVerification() + const { sumsubStatus, sumsubRejectLabels, sumsubRejectType, sumsubVerificationRegionIntent } = useUnifiedKycStatus() const [selectedRegion, setSelectedRegion] = useState(null) // keeps the region display stable during modal close animation const displayRegionRef = useRef(null) @@ -29,11 +62,25 @@ const RegionsVerification = () => { // and status checks use the correct template after the confirmation modal closes const [activeRegionIntent, setActiveRegionIntent] = useState(undefined) const [showBridgeTos, setShowBridgeTos] = useState(false) + // skip StartVerificationView when re-submitting (user already consented) + const [autoStartSdk, setAutoStartSdk] = useState(false) + + const sumsubFailureCount = useMemo( + () => + user?.user?.kycVerifications?.filter((v) => v.provider === 'SUMSUB' && v.status === 'REJECTED').length ?? 0, + [user] + ) + + const clickedRegionIntent = selectedRegion ? getRegionIntent(selectedRegion.path) : undefined + const modalVariant = selectedRegion + ? getModalVariant(sumsubStatus, clickedRegionIntent, sumsubVerificationRegionIntent) + : null const handleFinalKycSuccess = useCallback(() => { setSelectedRegion(null) setActiveRegionIntent(undefined) setShowBridgeTos(false) + setAutoStartSdk(false) }, []) // intercept sumsub approval to check for bridge ToS @@ -68,6 +115,7 @@ const RegionsVerification = () => { onManualClose: () => { setSelectedRegion(null) setActiveRegionIntent(undefined) + setAutoStartSdk(false) }, }) @@ -86,6 +134,12 @@ const RegionsVerification = () => { await handleInitiateKyc(intent) }, [handleInitiateKyc, selectedRegion]) + // re-submission: skip StartVerificationView since user already consented + const handleResubmitKyc = useCallback(async () => { + setAutoStartSdk(true) + await handleStartKyc() + }, [handleStartKyc]) + return (
{
+ + + + + + {error &&

{error}

} { onClose={handleSumsubClose} onComplete={handleSdkComplete} onRefreshToken={refreshToken} + autoStart={autoStartSdk} /> Date: Fri, 20 Feb 2026 21:43:02 +0530 Subject: [PATCH 38/46] fix: re-submit verification flow and stop verification button in kyc modal --- src/components/Kyc/KycStatusDrawer.tsx | 20 +++- src/components/Kyc/SumsubKycWrapper.tsx | 127 +++++++++++++----------- 2 files changed, 86 insertions(+), 61 deletions(-) diff --git a/src/components/Kyc/KycStatusDrawer.tsx b/src/components/Kyc/KycStatusDrawer.tsx index e0fe1bea9..762328478 100644 --- a/src/components/Kyc/KycStatusDrawer.tsx +++ b/src/components/Kyc/KycStatusDrawer.tsx @@ -14,6 +14,7 @@ import IFrameWrapper from '@/components/Global/IframeWrapper' import { SumsubKycWrapper } from '@/components/Kyc/SumsubKycWrapper' import { KycVerificationInProgressModal } from '@/components/Kyc/KycVerificationInProgressModal' import { getKycStatusCategory, isKycStatusNotStarted } from '@/constants/kyc.consts' +import { type KYCRegionIntent } from '@/app/actions/types/sumsub.types' interface KycStatusDrawerProps { isOpen: boolean @@ -31,6 +32,10 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus const countryCode = verification ? verification.mantecaGeo || verification.bridgeGeo : null const isBridgeKyc = !verification && !!bridgeKycStatus const provider = verification ? verification.provider : 'BRIDGE' + // derive region intent from sumsub verification metadata so token uses correct level + const sumsubRegionIntent = ( + verification?.provider === 'SUMSUB' ? verification?.metadata?.regionIntent : undefined + ) as KYCRegionIntent | undefined const { handleInitiateKyc: initiateBridgeKyc, @@ -63,7 +68,8 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus isLoading: isSumsubLoading, isVerificationProgressModalOpen: isSumsubProgressModalOpen, closeVerificationProgressModal: closeSumsubProgressModal, - } = useSumsubKycFlow({ onKycSuccess: onClose, onManualClose: onClose }) + error: sumsubError, + } = useSumsubKycFlow({ onKycSuccess: onClose, onManualClose: onClose, regionIntent: sumsubRegionIntent }) const onRetry = async () => { if (provider === 'SUMSUB') { @@ -103,7 +109,13 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus /> ) case 'action_required': - return + return ( + + ) case 'failed': return ( KYC Status {renderContent()} + {sumsubError && provider === 'SUMSUB' && ( +

{sumsubError}

+ )} @@ -146,6 +161,7 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus onClose={handleSumsubClose} onComplete={handleSumsubComplete} onRefreshToken={sumsubRefreshToken} + autoStart /> diff --git a/src/components/Kyc/SumsubKycWrapper.tsx b/src/components/Kyc/SumsubKycWrapper.tsx index 46fa7cb67..8fcf0bc57 100644 --- a/src/components/Kyc/SumsubKycWrapper.tsx +++ b/src/components/Kyc/SumsubKycWrapper.tsx @@ -18,6 +18,8 @@ interface SumsubKycWrapperProps { onComplete: () => void onError?: (error: unknown) => void onRefreshToken: () => Promise + /** skip StartVerificationView and launch SDK immediately (for re-submissions) */ + autoStart?: boolean } export const SumsubKycWrapper = ({ @@ -27,6 +29,7 @@ export const SumsubKycWrapper = ({ onComplete, onError, onRefreshToken, + autoStart, }: SumsubKycWrapperProps) => { const [isVerificationStarted, setIsVerificationStarted] = useState(false) const [sdkLoaded, setSdkLoaded] = useState(false) @@ -142,7 +145,7 @@ export const SumsubKycWrapper = ({ } }, [isVerificationStarted, accessToken, sdkLoaded, stableOnComplete, stableOnError, stableOnRefreshToken]) - // reset state when modal closes + // reset state when modal closes, auto-start on re-submission useEffect(() => { if (!visible) { setIsVerificationStarted(false) @@ -155,8 +158,11 @@ export const SumsubKycWrapper = ({ } sdkInstanceRef.current = null } + } else if (autoStart) { + // skip StartVerificationView on re-submission (user already consented) + setIsVerificationStarted(true) } - }, [visible]) + }, [visible, autoStart]) const modalDetails = useMemo(() => { if (modalVariant === 'trouble') { @@ -204,62 +210,65 @@ export const SumsubKycWrapper = ({ }, [modalVariant, onClose, setIsSupportModalOpen]) return ( - - {!isVerificationStarted ? ( - // start verification view (provider-agnostic, not reusing StartVerificationView which references "Persona") - setIsVerificationStarted(true)} /> - ) : sdkLoadError ? ( - // script failed to load — show user-facing error -
- -

- Failed to load verification. Please check your connection and try again. -

- -
- ) : ( - // SDK container + controls -
-
-
-
- - + <> + + {!isVerificationStarted ? ( + setIsVerificationStarted(true)} + /> + ) : sdkLoadError ? ( +
+ +

+ Failed to load verification. Please check your connection and try again. +

+ +
+ ) : ( +
+
+
+
+ + +
-
- )} + )} +
+ {/* rendered outside the outer Modal to avoid pointer-events-none blocking clicks */} setIsHelpModalOpen(false)} @@ -267,13 +276,13 @@ export const SumsubKycWrapper = ({ description={modalDetails.description} icon={modalDetails.icon} iconContainerClassName={modalDetails.iconContainerClassName} - modalPanelClassName="max-w-full pointer-events-auto" + modalPanelClassName="max-w-full" ctaClassName="grid grid-cols-1 gap-3" contentContainerClassName="px-6 py-6" - modalClassName="!z-[10001] pointer-events-auto" + modalClassName="!z-[10001]" preventClose={true} ctas={modalDetails.ctas} /> - + ) } From bebaeeff8c5491c8114d58f8d65b3ebd896d712c Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Mon, 23 Feb 2026 21:31:45 +0530 Subject: [PATCH 39/46] fix: gate terminal rejection logic by provider and log tos retry failure --- src/components/Kyc/SumsubKycFlow.tsx | 7 +++++-- src/components/Kyc/states/KycFailed.tsx | 5 +++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/components/Kyc/SumsubKycFlow.tsx b/src/components/Kyc/SumsubKycFlow.tsx index 9e0cc345b..e5d01ea85 100644 --- a/src/components/Kyc/SumsubKycFlow.tsx +++ b/src/components/Kyc/SumsubKycFlow.tsx @@ -220,9 +220,12 @@ export const SumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent, ...bu const result = await confirmBridgeTos() if (!result.data?.accepted) { - // retry after short delay + // bridge may not have registered acceptance yet — retry after short delay await new Promise((resolve) => setTimeout(resolve, 2000)) - await confirmBridgeTos() + const retryResult = await confirmBridgeTos() + if (!retryResult.data?.accepted) { + console.warn('[SumsubKycFlow] bridge ToS confirmation failed after retry') + } } // refetch user — the phase-transition effect will handle moving to 'complete' diff --git a/src/components/Kyc/states/KycFailed.tsx b/src/components/Kyc/states/KycFailed.tsx index 3088a6b06..991ff9a3f 100644 --- a/src/components/Kyc/states/KycFailed.tsx +++ b/src/components/Kyc/states/KycFailed.tsx @@ -48,9 +48,10 @@ export const KycFailed = ({ } }, [bridgeKycRejectedAt]) + // only sumsub verifications can be terminal — bridge rejections always allow retry const isTerminal = useMemo( - () => isTerminalRejection({ rejectType, failureCount, rejectLabels }), - [rejectType, failureCount, rejectLabels] + () => (isSumsub ? isTerminalRejection({ rejectType, failureCount, rejectLabels }) : false), + [isSumsub, rejectType, failureCount, rejectLabels] ) // formatted bridge reason (legacy display) From 05029c0f54475cdb3b149af0e0c0ec3cc755d037 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Mon, 23 Feb 2026 21:31:50 +0530 Subject: [PATCH 40/46] docs: update kyc 2.0 testing guide with all implemented test cases --- docs/KYC-2.0-TESTING.md | 248 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 docs/KYC-2.0-TESTING.md diff --git a/docs/KYC-2.0-TESTING.md b/docs/KYC-2.0-TESTING.md new file mode 100644 index 000000000..8b147e9a9 --- /dev/null +++ b/docs/KYC-2.0-TESTING.md @@ -0,0 +1,248 @@ +# KYC 2.0 — Testing Guide + +## Prerequisites + +- Backend (`peanut-api-ts`) running locally on `feat/kyc2.0` +- Frontend (`peanut-ui`) running locally on `feat/kyc2.0-error-retry-ui` +- Sumsub sandbox dashboard access +- A test user account + +## How to simulate statuses + +| Method | How | +|--------|-----| +| **Sumsub sandbox** | Use [test documents](https://docs.sumsub.com/docs/verification-document-templates) to trigger real review results | +| **DB manipulation** | Directly update `user_kyc_verifications` — see SQL snippets per test | +| **WebSocket** | Send manual `sumsub_kyc_status_update` message with `{ status, rejectLabels }` | +| **Sumsub dashboard** | Manually approve/reject applicant — fires real webhook | + +--- + +## Test cases + +### 1. Happy path — standard regions (Europe / North America) + +- [ ] 1a. Open regions & verification → select Europe or North America → `regionIntent: 'STANDARD'` sent to backend +- [ ] 1b. StartVerificationModal appears with region info → click "Start verification" +- [ ] 1c. SumsubKycWrapper opens → StartVerificationView (privacy consent) shown → click "Start" +- [ ] 1d. Complete Sumsub SDK with valid test document → SDK fires `onApplicantSubmitted` +- [ ] 1e. Modal transitions to "Verification in progress" phase (verifying) +- [ ] 1f. Sumsub approves (webhook GREEN) → modal transitions to "preparing" phase +- [ ] 1g. Provider submission completes → Bridge rail → REQUIRES_INFORMATION → modal transitions to "bridge_tos" phase +- [ ] 1h. Accept Bridge ToS in iframe → rails transition to ENABLED → modal shows "complete" phase +- [ ] 1i. Activity feed shows "Verified" green badge, region unlocked + +### 2. Happy path — LATAM + +- [ ] 2a. Open regions & verification → select LATAM (e.g. Argentina) → `regionIntent: 'LATAM'` sent +- [ ] 2b. Complete SDK with questionnaire (tax ID, PEP, FATCA fields appear for LATAM template) +- [ ] 2c. Sumsub approves → modal transitions: verifying → preparing → complete (no bridge_tos phase) +- [ ] 2d. Manteca rail → ENABLED, region unlocked +- [ ] 2e. Backend logs: `submitToProviders` sends Manteca payload with questionnaire data + +### 3. Happy path — foreign user (rest of world) + +- [ ] 3a. Open regions & verification → select "Rest of world" (e.g. India) → `regionIntent: 'STANDARD'` +- [ ] 3b. Complete SDK, get approved → same flow +- [ ] 3c. QR payment access: `useQrKycGate` returns `PROCEED_TO_PAY` (provides tax ID per-payment for Manteca super user) +- [ ] 3d. Bank transfer access: should NOT have Bridge/Manteca rails enabled (no provider submission for foreign users) + +### 4. Multi-phase completion modal + +- [ ] 4a. Phase 1 (verifying): clock icon + "We're verifying your identity" + preventClose +- [ ] 4b. Phase 2 (preparing): "Identity verified!" + "Preparing your account..." spinner + preventClose +- [ ] 4c. Phase 2 timeout: after 30s, shows "This is taking longer than expected" + escape button +- [ ] 4d. Phase 3 (bridge_tos): ToS prompt + Bridge iframe (only for standard regions with Bridge rails) +- [ ] 4e. Phase 4 (complete): "All set!" + "Continue" button +- [ ] 4f. LATAM flow: skips bridge_tos phase entirely (verifying → preparing → complete) +- [ ] 4g. Close during preparing: activity drawer shows per-provider status as fallback + +### 5. Bridge ToS acceptance + +- [ ] 5a. After sumsub APPROVED + Bridge submission → Bridge rail = REQUIRES_INFORMATION +- [ ] 5b. BridgeTosStep shown inline in multi-phase modal with ToS iframe +- [ ] 5c. Accept ToS → backend confirms via Bridge API → rails transition to ENABLED +- [ ] 5d. Skip ToS (close modal) → BridgeTosReminder appears in activity feed +- [ ] 5e. Click BridgeTosReminder → reopens BridgeTosStep modal → accept → rails ENABLED + +### 6. ACTION_REQUIRED state — activity drawer + +DB: `UPDATE user_kyc_verifications SET status = 'ACTION_REQUIRED' WHERE user_id = '' AND provider = 'SUMSUB';` + +- [ ] 6a. Activity feed (`KycStatusItem`) shows "Action needed" subtitle with amber status pill +- [ ] 6b. Open drawer → shows "Action needed" badge, warning InfoCard, reject labels listed +- [ ] 6c. Click "Continue verification" → opens Sumsub SDK with `autoStart` (skips StartVerificationView) +- [ ] 6d. `useQrKycGate` returns `IDENTITY_VERIFICATION_IN_PROGRESS` — user blocked from QR payments + +### 7. ACTION_REQUIRED state — regions page + +DB: same as test 6 + +- [ ] 7a. Click a locked region → KycActionRequiredModal appears (not StartVerificationModal) +- [ ] 7b. Modal shows "Action needed" title, reject labels, "Re-submit verification" button +- [ ] 7c. Click "Re-submit verification" → SumsubKycWrapper opens with `autoStart` (skips consent screen) +- [ ] 7d. User re-submits in SDK → flow continues normally + +### 8. REJECTED — retryable (RETRY) — activity drawer + +DB: `UPDATE user_kyc_verifications SET status = 'REJECTED', reject_type = 'RETRY', reject_labels = '["DOCUMENT_BAD_QUALITY", "SELFIE_MISMATCH"]' WHERE user_id = '' AND provider = 'SUMSUB';` + +- [ ] 8a. Activity feed shows "Rejected" subtitle with red status pill +- [ ] 8b. Open drawer → human-readable reasons: "Poor document quality..." and "Selfie doesn't match..." +- [ ] 8c. "Retry verification" button visible (not terminal) +- [ ] 8d. Click retry → opens Sumsub SDK with `autoStart` and correct `regionIntent` + +### 9. REJECTED — retryable (RETRY) — regions page + +DB: same as test 8 + +- [ ] 9a. Click a locked region → KycRejectedModal appears with amber styling +- [ ] 9b. Title: "Verification unsuccessful", shows reject labels, "Retry verification" button +- [ ] 9c. Click retry → SumsubKycWrapper opens with `autoStart` +- [ ] 9d. User re-submits → flow continues normally + +### 10. REJECTED — terminal (FINAL) — activity drawer + +DB: `UPDATE user_kyc_verifications SET status = 'REJECTED', reject_type = 'FINAL', reject_labels = '["REGULATIONS_VIOLATIONS"]' WHERE user_id = '' AND provider = 'SUMSUB';` + +- [ ] 10a. Open drawer → `isTerminal = true` +- [ ] 10b. Shows "Your verification cannot be retried" InfoCard with lock icon, no retry button +- [ ] 10c. "Contact support" button opens Crisp support modal + +### 11. REJECTED — terminal (FINAL) — regions page + +DB: same as test 10 + +- [ ] 11a. Click a locked region → KycRejectedModal appears with red styling +- [ ] 11b. Title: "Verification failed", lock icon, "Your verification cannot be retried" +- [ ] 11c. "Contact support" button (no retry button) + +### 12. REJECTED — terminal via labels (no rejectType needed) + +DB: `UPDATE user_kyc_verifications SET status = 'REJECTED', reject_type = NULL, reject_labels = '["DOCUMENT_FAKE"]' WHERE user_id = '' AND provider = 'SUMSUB';` + +- [ ] 12a. Both drawer and regions page modal → terminal state triggered by `hasTerminalRejectLabel` +- [ ] 12b. Same locked UI — support contact only, no retry + +### 13. REJECTED — terminal via failure count (2+ rejections) + +DB: insert 2+ rows with `provider = 'SUMSUB'` and `status = 'REJECTED'` for the same user. + +- [ ] 13a. Both drawer and regions page modal → `failureCount >= 2` triggers terminal +- [ ] 13b. Same locked UI — support contact only + +### 14. PENDING / IN_REVIEW — regions page + +DB: `UPDATE user_kyc_verifications SET status = 'PENDING' WHERE user_id = '' AND provider = 'SUMSUB';` + +- [ ] 14a. Click a locked region → KycProcessingModal appears +- [ ] 14b. Shows "Verification in progress" title, "We're reviewing your identity" +- [ ] 14c. Close button works, no retry/resubmit CTA + +### 15. Status-aware modal routing on regions page + +This tests the `getModalVariant()` logic: + +- [ ] 15a. No verification → StartVerificationModal (start fresh) +- [ ] 15b. Status NOT_STARTED → StartVerificationModal +- [ ] 15c. Status PENDING/IN_REVIEW → KycProcessingModal +- [ ] 15d. Status ACTION_REQUIRED → KycActionRequiredModal +- [ ] 15e. Status REJECTED (retryable) → KycRejectedModal with retry +- [ ] 15f. Status REJECTED (terminal) → KycRejectedModal without retry +- [ ] 15g. Different regionIntent than existing → StartVerificationModal (new verification) + +### 16. Stop verification flow + +- [ ] 16a. During SDK verification, click "Stop verification process" button at bottom +- [ ] 16b. Confirmation modal appears: "Stop verification?" with description +- [ ] 16c. Click "Stop verification" → SDK destroyed, modal closes +- [ ] 16d. Click "Continue verifying" → confirmation modal closes, SDK still active +- [ ] 16e. "Having trouble?" button → help modal → "Chat with support" opens Crisp + +### 17. WebSocket real-time updates + +- [ ] 17a. Start KYC, complete SDK → "Verification in progress" modal (verifying phase) +- [ ] 17b. Sumsub dashboard approve → WebSocket fires `sumsub_kyc_status_update` with APPROVED +- [ ] 17c. Modal transitions to preparing phase (not closed immediately) +- [ ] 17d. `user_rail_status_changed` WebSocket fires as rails update +- [ ] 17e. All rails settled → modal transitions to complete phase +- [ ] 17f. REJECTED webhook → progress modal closes, user data refreshed for drawer + +### 18. Resume abandoned flow + +- [ ] 18a. Start KYC, begin SDK, stop verification → SDK destroyed +- [ ] 18b. Re-open KYC later → same applicant reused, SDK picks up where user left off + +### 19. SDK error / script load failure + +- [ ] 19a. Block `static.sumsub.com` in devtools network tab, open KYC, start verification +- [ ] 19b. Error UI: red alert icon, "Failed to load verification..." + Close button +- [ ] 19c. "Having trouble?" button (when SDK loads but hangs) → help modal → "Chat with support" + +### 20. Bridge KYC status (regression check) + +- [ ] 20a. User with `bridgeKycStatus: 'approved'` → activity feed "Approved" green badge, drawer shows completed +- [ ] 20b. User with `bridgeKycStatus: 'under_review'` → "In progress", drawer shows processing +- [ ] 20c. User with `bridgeKycStatus: 'rejected'` → drawer shows raw bridge reason (not mapped labels) + +### 21. QR payment gating (`useQrKycGate`) + +- [ ] 21a. No KYC at all → `REQUIRES_IDENTITY_VERIFICATION` — blocked +- [ ] 21b. Sumsub APPROVED → `PROCEED_TO_PAY` +- [ ] 21c. Manteca ACTIVE → `PROCEED_TO_PAY` +- [ ] 21d. Bridge approved → `PROCEED_TO_PAY` +- [ ] 21e. Sumsub PENDING / IN_REVIEW → `IDENTITY_VERIFICATION_IN_PROGRESS` — blocked +- [ ] 21f. Sumsub ACTION_REQUIRED → `IDENTITY_VERIFICATION_IN_PROGRESS` — blocked +- [ ] 21g. `paymentProcessor === 'SIMPLEFI'` → `PROCEED_TO_PAY` regardless + +### 22. Provider submission (backend-only — check via logs/DB) + +- [ ] 22a. Sumsub approved + PENDING Bridge rail → Bridge payload built, UserRail → `REQUIRES_INFORMATION`, UserRailEvent created +- [ ] 22b. Sumsub approved + PENDING Manteca rail → Manteca payload built, questionnaire data extracted, image uploaded, UserRail → `ENABLED` +- [ ] 22c. Bridge accepts, Manteca API down → Bridge `REQUIRES_INFORMATION`, Manteca stays `PENDING`, Sentry logged +- [ ] 22d. Both providers fail → both rails stay `PENDING`, errors logged +- [ ] 22e. Duplicate webhook → status unchanged → early exit, no duplicate submission +- [ ] 22f. `applicantCreated` webhook → `sumsubApplicantId` linked via `externalUserId` in DB +- [ ] 22g. WebSocket `user_rail_status_changed` fires for each rail status transition + +### 23. Rail status tracking (frontend) + +- [ ] 23a. After APPROVED, `useRailStatusTracking` starts polling (4s interval) +- [ ] 23b. WebSocket `user_rail_status_changed` → immediate status update without waiting for poll +- [ ] 23c. Bridge rail REQUIRES_INFORMATION → provider status = `requires_tos` +- [ ] 23d. Manteca rail ENABLED → provider status = `enabled` +- [ ] 23e. All rails settled → tracking stops automatically +- [ ] 23f. Rail FAILED → provider status = `failed` + +### 24. Re-submit from activity drawer (KycStatusDrawer) + +- [ ] 24a. ACTION_REQUIRED in drawer → click "Continue verification" → Sumsub SDK opens with `autoStart` +- [ ] 24b. Correct `regionIntent` derived from verification metadata (STANDARD vs LATAM) +- [ ] 24c. REJECTED (retryable) in drawer → click "Retry verification" → Sumsub SDK opens with `autoStart` +- [ ] 24d. Error from `useSumsubKycFlow` displayed in drawer below content +- [ ] 24e. REJECTED (terminal) in drawer → no retry button, "Contact support" only + +--- + +## Known blockers + +| Blocker | Impact | Workaround | +|---------|--------|------------| +| No self-healing submission retry | If `submitToProviders()` fails, rails stay PENDING forever | Manually re-trigger via DB or restart. Poller only polls sumsub status, not re-triggers submission. | +| `moderationComment` not in events | Can't show reviewer's human note | Label mapping covers common cases. | +| Bridge ToS not e2e tested | Full flow (create customer → ToS prompt → accept → ENABLED) untested with real Bridge customer | Test with sandbox Bridge account. | +| No ProviderStatusList component | After APPROVED, activity drawer doesn't show per-provider rail status breakdown | Check DB for rail statuses. | + +--- + +## Issues found during testing + +> Add any bugs, weird behavior, or unexpected results here. Include the test case number, what you expected, and what actually happened. + +| # | Test case | Description | Severity | Fixed? | +|---|-----------|-------------|----------|--------| +| | | | | | +| | | | | | +| | | | | | +| | | | | | +| | | | | | From 3c62aef50b99002c2f41ff176405b339b72ecbd9 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Mon, 23 Feb 2026 21:33:38 +0530 Subject: [PATCH 41/46] fix: remove kyc testing guide --- docs/KYC-2.0-TESTING.md | 248 ---------------------------------------- 1 file changed, 248 deletions(-) delete mode 100644 docs/KYC-2.0-TESTING.md diff --git a/docs/KYC-2.0-TESTING.md b/docs/KYC-2.0-TESTING.md deleted file mode 100644 index 8b147e9a9..000000000 --- a/docs/KYC-2.0-TESTING.md +++ /dev/null @@ -1,248 +0,0 @@ -# KYC 2.0 — Testing Guide - -## Prerequisites - -- Backend (`peanut-api-ts`) running locally on `feat/kyc2.0` -- Frontend (`peanut-ui`) running locally on `feat/kyc2.0-error-retry-ui` -- Sumsub sandbox dashboard access -- A test user account - -## How to simulate statuses - -| Method | How | -|--------|-----| -| **Sumsub sandbox** | Use [test documents](https://docs.sumsub.com/docs/verification-document-templates) to trigger real review results | -| **DB manipulation** | Directly update `user_kyc_verifications` — see SQL snippets per test | -| **WebSocket** | Send manual `sumsub_kyc_status_update` message with `{ status, rejectLabels }` | -| **Sumsub dashboard** | Manually approve/reject applicant — fires real webhook | - ---- - -## Test cases - -### 1. Happy path — standard regions (Europe / North America) - -- [ ] 1a. Open regions & verification → select Europe or North America → `regionIntent: 'STANDARD'` sent to backend -- [ ] 1b. StartVerificationModal appears with region info → click "Start verification" -- [ ] 1c. SumsubKycWrapper opens → StartVerificationView (privacy consent) shown → click "Start" -- [ ] 1d. Complete Sumsub SDK with valid test document → SDK fires `onApplicantSubmitted` -- [ ] 1e. Modal transitions to "Verification in progress" phase (verifying) -- [ ] 1f. Sumsub approves (webhook GREEN) → modal transitions to "preparing" phase -- [ ] 1g. Provider submission completes → Bridge rail → REQUIRES_INFORMATION → modal transitions to "bridge_tos" phase -- [ ] 1h. Accept Bridge ToS in iframe → rails transition to ENABLED → modal shows "complete" phase -- [ ] 1i. Activity feed shows "Verified" green badge, region unlocked - -### 2. Happy path — LATAM - -- [ ] 2a. Open regions & verification → select LATAM (e.g. Argentina) → `regionIntent: 'LATAM'` sent -- [ ] 2b. Complete SDK with questionnaire (tax ID, PEP, FATCA fields appear for LATAM template) -- [ ] 2c. Sumsub approves → modal transitions: verifying → preparing → complete (no bridge_tos phase) -- [ ] 2d. Manteca rail → ENABLED, region unlocked -- [ ] 2e. Backend logs: `submitToProviders` sends Manteca payload with questionnaire data - -### 3. Happy path — foreign user (rest of world) - -- [ ] 3a. Open regions & verification → select "Rest of world" (e.g. India) → `regionIntent: 'STANDARD'` -- [ ] 3b. Complete SDK, get approved → same flow -- [ ] 3c. QR payment access: `useQrKycGate` returns `PROCEED_TO_PAY` (provides tax ID per-payment for Manteca super user) -- [ ] 3d. Bank transfer access: should NOT have Bridge/Manteca rails enabled (no provider submission for foreign users) - -### 4. Multi-phase completion modal - -- [ ] 4a. Phase 1 (verifying): clock icon + "We're verifying your identity" + preventClose -- [ ] 4b. Phase 2 (preparing): "Identity verified!" + "Preparing your account..." spinner + preventClose -- [ ] 4c. Phase 2 timeout: after 30s, shows "This is taking longer than expected" + escape button -- [ ] 4d. Phase 3 (bridge_tos): ToS prompt + Bridge iframe (only for standard regions with Bridge rails) -- [ ] 4e. Phase 4 (complete): "All set!" + "Continue" button -- [ ] 4f. LATAM flow: skips bridge_tos phase entirely (verifying → preparing → complete) -- [ ] 4g. Close during preparing: activity drawer shows per-provider status as fallback - -### 5. Bridge ToS acceptance - -- [ ] 5a. After sumsub APPROVED + Bridge submission → Bridge rail = REQUIRES_INFORMATION -- [ ] 5b. BridgeTosStep shown inline in multi-phase modal with ToS iframe -- [ ] 5c. Accept ToS → backend confirms via Bridge API → rails transition to ENABLED -- [ ] 5d. Skip ToS (close modal) → BridgeTosReminder appears in activity feed -- [ ] 5e. Click BridgeTosReminder → reopens BridgeTosStep modal → accept → rails ENABLED - -### 6. ACTION_REQUIRED state — activity drawer - -DB: `UPDATE user_kyc_verifications SET status = 'ACTION_REQUIRED' WHERE user_id = '' AND provider = 'SUMSUB';` - -- [ ] 6a. Activity feed (`KycStatusItem`) shows "Action needed" subtitle with amber status pill -- [ ] 6b. Open drawer → shows "Action needed" badge, warning InfoCard, reject labels listed -- [ ] 6c. Click "Continue verification" → opens Sumsub SDK with `autoStart` (skips StartVerificationView) -- [ ] 6d. `useQrKycGate` returns `IDENTITY_VERIFICATION_IN_PROGRESS` — user blocked from QR payments - -### 7. ACTION_REQUIRED state — regions page - -DB: same as test 6 - -- [ ] 7a. Click a locked region → KycActionRequiredModal appears (not StartVerificationModal) -- [ ] 7b. Modal shows "Action needed" title, reject labels, "Re-submit verification" button -- [ ] 7c. Click "Re-submit verification" → SumsubKycWrapper opens with `autoStart` (skips consent screen) -- [ ] 7d. User re-submits in SDK → flow continues normally - -### 8. REJECTED — retryable (RETRY) — activity drawer - -DB: `UPDATE user_kyc_verifications SET status = 'REJECTED', reject_type = 'RETRY', reject_labels = '["DOCUMENT_BAD_QUALITY", "SELFIE_MISMATCH"]' WHERE user_id = '' AND provider = 'SUMSUB';` - -- [ ] 8a. Activity feed shows "Rejected" subtitle with red status pill -- [ ] 8b. Open drawer → human-readable reasons: "Poor document quality..." and "Selfie doesn't match..." -- [ ] 8c. "Retry verification" button visible (not terminal) -- [ ] 8d. Click retry → opens Sumsub SDK with `autoStart` and correct `regionIntent` - -### 9. REJECTED — retryable (RETRY) — regions page - -DB: same as test 8 - -- [ ] 9a. Click a locked region → KycRejectedModal appears with amber styling -- [ ] 9b. Title: "Verification unsuccessful", shows reject labels, "Retry verification" button -- [ ] 9c. Click retry → SumsubKycWrapper opens with `autoStart` -- [ ] 9d. User re-submits → flow continues normally - -### 10. REJECTED — terminal (FINAL) — activity drawer - -DB: `UPDATE user_kyc_verifications SET status = 'REJECTED', reject_type = 'FINAL', reject_labels = '["REGULATIONS_VIOLATIONS"]' WHERE user_id = '' AND provider = 'SUMSUB';` - -- [ ] 10a. Open drawer → `isTerminal = true` -- [ ] 10b. Shows "Your verification cannot be retried" InfoCard with lock icon, no retry button -- [ ] 10c. "Contact support" button opens Crisp support modal - -### 11. REJECTED — terminal (FINAL) — regions page - -DB: same as test 10 - -- [ ] 11a. Click a locked region → KycRejectedModal appears with red styling -- [ ] 11b. Title: "Verification failed", lock icon, "Your verification cannot be retried" -- [ ] 11c. "Contact support" button (no retry button) - -### 12. REJECTED — terminal via labels (no rejectType needed) - -DB: `UPDATE user_kyc_verifications SET status = 'REJECTED', reject_type = NULL, reject_labels = '["DOCUMENT_FAKE"]' WHERE user_id = '' AND provider = 'SUMSUB';` - -- [ ] 12a. Both drawer and regions page modal → terminal state triggered by `hasTerminalRejectLabel` -- [ ] 12b. Same locked UI — support contact only, no retry - -### 13. REJECTED — terminal via failure count (2+ rejections) - -DB: insert 2+ rows with `provider = 'SUMSUB'` and `status = 'REJECTED'` for the same user. - -- [ ] 13a. Both drawer and regions page modal → `failureCount >= 2` triggers terminal -- [ ] 13b. Same locked UI — support contact only - -### 14. PENDING / IN_REVIEW — regions page - -DB: `UPDATE user_kyc_verifications SET status = 'PENDING' WHERE user_id = '' AND provider = 'SUMSUB';` - -- [ ] 14a. Click a locked region → KycProcessingModal appears -- [ ] 14b. Shows "Verification in progress" title, "We're reviewing your identity" -- [ ] 14c. Close button works, no retry/resubmit CTA - -### 15. Status-aware modal routing on regions page - -This tests the `getModalVariant()` logic: - -- [ ] 15a. No verification → StartVerificationModal (start fresh) -- [ ] 15b. Status NOT_STARTED → StartVerificationModal -- [ ] 15c. Status PENDING/IN_REVIEW → KycProcessingModal -- [ ] 15d. Status ACTION_REQUIRED → KycActionRequiredModal -- [ ] 15e. Status REJECTED (retryable) → KycRejectedModal with retry -- [ ] 15f. Status REJECTED (terminal) → KycRejectedModal without retry -- [ ] 15g. Different regionIntent than existing → StartVerificationModal (new verification) - -### 16. Stop verification flow - -- [ ] 16a. During SDK verification, click "Stop verification process" button at bottom -- [ ] 16b. Confirmation modal appears: "Stop verification?" with description -- [ ] 16c. Click "Stop verification" → SDK destroyed, modal closes -- [ ] 16d. Click "Continue verifying" → confirmation modal closes, SDK still active -- [ ] 16e. "Having trouble?" button → help modal → "Chat with support" opens Crisp - -### 17. WebSocket real-time updates - -- [ ] 17a. Start KYC, complete SDK → "Verification in progress" modal (verifying phase) -- [ ] 17b. Sumsub dashboard approve → WebSocket fires `sumsub_kyc_status_update` with APPROVED -- [ ] 17c. Modal transitions to preparing phase (not closed immediately) -- [ ] 17d. `user_rail_status_changed` WebSocket fires as rails update -- [ ] 17e. All rails settled → modal transitions to complete phase -- [ ] 17f. REJECTED webhook → progress modal closes, user data refreshed for drawer - -### 18. Resume abandoned flow - -- [ ] 18a. Start KYC, begin SDK, stop verification → SDK destroyed -- [ ] 18b. Re-open KYC later → same applicant reused, SDK picks up where user left off - -### 19. SDK error / script load failure - -- [ ] 19a. Block `static.sumsub.com` in devtools network tab, open KYC, start verification -- [ ] 19b. Error UI: red alert icon, "Failed to load verification..." + Close button -- [ ] 19c. "Having trouble?" button (when SDK loads but hangs) → help modal → "Chat with support" - -### 20. Bridge KYC status (regression check) - -- [ ] 20a. User with `bridgeKycStatus: 'approved'` → activity feed "Approved" green badge, drawer shows completed -- [ ] 20b. User with `bridgeKycStatus: 'under_review'` → "In progress", drawer shows processing -- [ ] 20c. User with `bridgeKycStatus: 'rejected'` → drawer shows raw bridge reason (not mapped labels) - -### 21. QR payment gating (`useQrKycGate`) - -- [ ] 21a. No KYC at all → `REQUIRES_IDENTITY_VERIFICATION` — blocked -- [ ] 21b. Sumsub APPROVED → `PROCEED_TO_PAY` -- [ ] 21c. Manteca ACTIVE → `PROCEED_TO_PAY` -- [ ] 21d. Bridge approved → `PROCEED_TO_PAY` -- [ ] 21e. Sumsub PENDING / IN_REVIEW → `IDENTITY_VERIFICATION_IN_PROGRESS` — blocked -- [ ] 21f. Sumsub ACTION_REQUIRED → `IDENTITY_VERIFICATION_IN_PROGRESS` — blocked -- [ ] 21g. `paymentProcessor === 'SIMPLEFI'` → `PROCEED_TO_PAY` regardless - -### 22. Provider submission (backend-only — check via logs/DB) - -- [ ] 22a. Sumsub approved + PENDING Bridge rail → Bridge payload built, UserRail → `REQUIRES_INFORMATION`, UserRailEvent created -- [ ] 22b. Sumsub approved + PENDING Manteca rail → Manteca payload built, questionnaire data extracted, image uploaded, UserRail → `ENABLED` -- [ ] 22c. Bridge accepts, Manteca API down → Bridge `REQUIRES_INFORMATION`, Manteca stays `PENDING`, Sentry logged -- [ ] 22d. Both providers fail → both rails stay `PENDING`, errors logged -- [ ] 22e. Duplicate webhook → status unchanged → early exit, no duplicate submission -- [ ] 22f. `applicantCreated` webhook → `sumsubApplicantId` linked via `externalUserId` in DB -- [ ] 22g. WebSocket `user_rail_status_changed` fires for each rail status transition - -### 23. Rail status tracking (frontend) - -- [ ] 23a. After APPROVED, `useRailStatusTracking` starts polling (4s interval) -- [ ] 23b. WebSocket `user_rail_status_changed` → immediate status update without waiting for poll -- [ ] 23c. Bridge rail REQUIRES_INFORMATION → provider status = `requires_tos` -- [ ] 23d. Manteca rail ENABLED → provider status = `enabled` -- [ ] 23e. All rails settled → tracking stops automatically -- [ ] 23f. Rail FAILED → provider status = `failed` - -### 24. Re-submit from activity drawer (KycStatusDrawer) - -- [ ] 24a. ACTION_REQUIRED in drawer → click "Continue verification" → Sumsub SDK opens with `autoStart` -- [ ] 24b. Correct `regionIntent` derived from verification metadata (STANDARD vs LATAM) -- [ ] 24c. REJECTED (retryable) in drawer → click "Retry verification" → Sumsub SDK opens with `autoStart` -- [ ] 24d. Error from `useSumsubKycFlow` displayed in drawer below content -- [ ] 24e. REJECTED (terminal) in drawer → no retry button, "Contact support" only - ---- - -## Known blockers - -| Blocker | Impact | Workaround | -|---------|--------|------------| -| No self-healing submission retry | If `submitToProviders()` fails, rails stay PENDING forever | Manually re-trigger via DB or restart. Poller only polls sumsub status, not re-triggers submission. | -| `moderationComment` not in events | Can't show reviewer's human note | Label mapping covers common cases. | -| Bridge ToS not e2e tested | Full flow (create customer → ToS prompt → accept → ENABLED) untested with real Bridge customer | Test with sandbox Bridge account. | -| No ProviderStatusList component | After APPROVED, activity drawer doesn't show per-provider rail status breakdown | Check DB for rail statuses. | - ---- - -## Issues found during testing - -> Add any bugs, weird behavior, or unexpected results here. Include the test case number, what you expected, and what actually happened. - -| # | Test case | Description | Severity | Fixed? | -|---|-----------|-------------|----------|--------| -| | | | | | -| | | | | | -| | | | | | -| | | | | | -| | | | | | From 4cda4bbece0d1e28479a27f9b0cfc027b7a706cb Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Tue, 24 Feb 2026 14:38:12 +0530 Subject: [PATCH 42/46] feat: bridge additional document collection UI adds requires_documents display status to rail tracking so bridge rails needing extra documents are distinguished from those needing ToS acceptance. new KycRequiresDocuments component shows human-readable requirement descriptions and a submit button that opens the sumsub SDK with the peanut-additional-docs level. wires into KycStatusDrawer to show the additional docs UI when bridge rails have REQUIRES_EXTRA_INFORMATION status, and extends initiateSumsubKyc to accept a levelName parameter. --- src/app/actions/sumsub.ts | 2 + src/components/Kyc/KycStatusDrawer.tsx | 26 ++++++++++ .../Kyc/states/KycRequiresDocuments.tsx | 47 +++++++++++++++++++ src/constants/bridge-requirements.consts.ts | 41 ++++++++++++++++ src/hooks/useRailStatusTracking.ts | 12 ++++- src/hooks/useSumsubKycFlow.ts | 7 ++- src/interfaces/interfaces.ts | 2 +- 7 files changed, 132 insertions(+), 5 deletions(-) create mode 100644 src/components/Kyc/states/KycRequiresDocuments.tsx create mode 100644 src/constants/bridge-requirements.consts.ts diff --git a/src/app/actions/sumsub.ts b/src/app/actions/sumsub.ts index 39629d9a1..7222004c6 100644 --- a/src/app/actions/sumsub.ts +++ b/src/app/actions/sumsub.ts @@ -10,6 +10,7 @@ const API_KEY = process.env.PEANUT_API_KEY! // initiate kyc flow (using sumsub) and get websdk access token export const initiateSumsubKyc = async (params?: { regionIntent?: KYCRegionIntent + levelName?: string }): Promise<{ data?: InitiateSumsubKycResponse; error?: string }> => { const jwtToken = (await getJWTCookie())?.value @@ -19,6 +20,7 @@ export const initiateSumsubKyc = async (params?: { const body: Record = { regionIntent: params?.regionIntent, + levelName: params?.levelName, } try { diff --git a/src/components/Kyc/KycStatusDrawer.tsx b/src/components/Kyc/KycStatusDrawer.tsx index 762328478..1811689ec 100644 --- a/src/components/Kyc/KycStatusDrawer.tsx +++ b/src/components/Kyc/KycStatusDrawer.tsx @@ -2,6 +2,7 @@ import { KycActionRequired } from './states/KycActionRequired' import { KycCompleted } from './states/KycCompleted' import { KycFailed } from './states/KycFailed' import { KycProcessing } from './states/KycProcessing' +import { KycRequiresDocuments } from './states/KycRequiresDocuments' import { Drawer, DrawerContent, DrawerTitle } from '../Global/Drawer' import { type BridgeKycStatus } from '@/utils/bridge-accounts.utils' import { type IUserKycVerification } from '@/interfaces' @@ -83,6 +84,16 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus const isLoadingKyc = isBridgeLoading || isMantecaLoading || isSumsubLoading + // check if any bridge rail needs additional documents + const bridgeRailsNeedingDocs = (user?.rails ?? []).filter( + (r) => r.status === 'REQUIRES_EXTRA_INFORMATION' && r.rail.provider.code === 'BRIDGE' + ) + const additionalRequirements: string[] = + bridgeRailsNeedingDocs.length > 0 + ? ((bridgeRailsNeedingDocs[0].metadata?.additionalRequirements as string[]) ?? []) + : [] + const needsAdditionalDocs = additionalRequirements.length > 0 + // count sumsub rejections for failure lockout. // counts total REJECTED entries — accurate if backend creates a new row per attempt. // if backend updates in-place (single row), this will be 0 or 1 and the lockout @@ -90,7 +101,22 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus const sumsubFailureCount = user?.user?.kycVerifications?.filter((v) => v.provider === 'SUMSUB' && v.status === 'REJECTED').length ?? 0 + const handleSubmitAdditionalDocs = async () => { + await initiateSumsub(undefined, 'peanut-additional-docs') + } + const renderContent = () => { + // bridge additional document requirement takes priority over verification status + if (needsAdditionalDocs) { + return ( + + ) + } + switch (statusCategory) { case 'processing': return ( diff --git a/src/components/Kyc/states/KycRequiresDocuments.tsx b/src/components/Kyc/states/KycRequiresDocuments.tsx new file mode 100644 index 000000000..9b6a247ef --- /dev/null +++ b/src/components/Kyc/states/KycRequiresDocuments.tsx @@ -0,0 +1,47 @@ +import { KYCStatusDrawerItem } from '../KYCStatusDrawerItem' +import { Button } from '@/components/0_Bruddle/Button' +import { getRequirementLabel } from '@/constants/bridge-requirements.consts' +import type { IconName } from '@/components/Global/Icons/Icon' + +// shows when a payment provider (bridge) needs additional documents from the user. +// displays the specific requirements with human-readable descriptions. +export const KycRequiresDocuments = ({ + requirements, + onSubmitDocuments, + isLoading, +}: { + requirements: string[] + onSubmitDocuments: () => void + isLoading?: boolean +}) => { + return ( +
+ + +
+

+ Your payment provider requires additional verification documents. +

+ {requirements.map((req) => { + const label = getRequirementLabel(req) + return ( +
+

{label.title}

+

{label.description}

+
+ ) + })} +
+ + +
+ ) +} diff --git a/src/constants/bridge-requirements.consts.ts b/src/constants/bridge-requirements.consts.ts new file mode 100644 index 000000000..6e7cb5bef --- /dev/null +++ b/src/constants/bridge-requirements.consts.ts @@ -0,0 +1,41 @@ +interface RequirementLabelInfo { + title: string + description: string +} + +// map of bridge additional_requirements to user-friendly labels +const BRIDGE_REQUIREMENT_LABELS: Record = { + proof_of_address: { + title: 'Proof of Address', + description: + 'Upload a utility bill, bank statement, or government letter showing your current address (dated within 3 months).', + }, + additional_identity_document: { + title: 'Additional Identity Document', + description: 'Upload an additional government-issued ID document.', + }, + proof_of_source_of_funds: { + title: 'Proof of Source of Funds', + description: 'Upload documentation showing the origin of your funds (e.g. pay stub, tax return).', + }, + proof_of_tax_identification: { + title: 'Tax Identification', + description: 'Upload a document showing your tax identification number.', + }, +} + +const FALLBACK_LABEL: RequirementLabelInfo = { + title: 'Additional Document', + description: 'Please provide the requested document.', +} + +/** get human-readable label for a bridge additional requirement */ +export function getRequirementLabel(requirement: string): RequirementLabelInfo { + return BRIDGE_REQUIREMENT_LABELS[requirement] ?? { + // auto-format unknown requirement codes as title case + title: requirement + .replace(/_/g, ' ') + .replace(/\b\w/g, (c) => c.toUpperCase()), + description: FALLBACK_LABEL.description, + } +} diff --git a/src/hooks/useRailStatusTracking.ts b/src/hooks/useRailStatusTracking.ts index 2be342ca0..bc3dbf8e1 100644 --- a/src/hooks/useRailStatusTracking.ts +++ b/src/hooks/useRailStatusTracking.ts @@ -9,6 +9,7 @@ interface RailStatusTrackingResult { providers: ProviderStatus[] allSettled: boolean needsBridgeTos: boolean + needsAdditionalDocs: boolean startTracking: () => void stopTracking: () => void } @@ -35,8 +36,9 @@ function deriveStatus(rail: IUserRail): ProviderDisplayStatus { switch (rail.status) { case 'ENABLED': return 'enabled' - case 'REQUIRES_INFORMATION': case 'REQUIRES_EXTRA_INFORMATION': + return 'requires_documents' + case 'REQUIRES_INFORMATION': return 'requires_tos' case 'FAILED': case 'REJECTED': @@ -50,7 +52,8 @@ function deriveStatus(rail: IUserRail): ProviderDisplayStatus { // pick the "most advanced" status for a provider group function deriveGroupStatus(rails: IUserRail[]): ProviderDisplayStatus { const statuses = rails.map(deriveStatus) - // priority: requires_tos > enabled > failed > setting_up + // priority: requires_documents > requires_tos > enabled > failed > setting_up + if (statuses.includes('requires_documents')) return 'requires_documents' if (statuses.includes('requires_tos')) return 'requires_tos' if (statuses.includes('enabled')) return 'enabled' if (statuses.includes('failed')) return 'failed' @@ -110,6 +113,10 @@ export const useRailStatusTracking = (): RailStatusTrackingResult => { return providers.some((p) => p.providerCode === 'BRIDGE' && p.status === 'requires_tos') }, [providers]) + const needsAdditionalDocs = useMemo(() => { + return providers.some((p) => p.status === 'requires_documents') + }, [providers]) + // stop polling when all settled useEffect(() => { if (allSettled && isTracking) { @@ -156,6 +163,7 @@ export const useRailStatusTracking = (): RailStatusTrackingResult => { providers, allSettled, needsBridgeTos, + needsAdditionalDocs, startTracking, stopTracking, } diff --git a/src/hooks/useSumsubKycFlow.ts b/src/hooks/useSumsubKycFlow.ts index d52cddc7b..f6d5a248f 100644 --- a/src/hooks/useSumsubKycFlow.ts +++ b/src/hooks/useSumsubKycFlow.ts @@ -79,12 +79,15 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: }, [regionIntent]) const handleInitiateKyc = useCallback( - async (overrideIntent?: KYCRegionIntent) => { + async (overrideIntent?: KYCRegionIntent, levelName?: string) => { setIsLoading(true) setError(null) try { - const response = await initiateSumsubKyc({ regionIntent: overrideIntent ?? regionIntent }) + const response = await initiateSumsubKyc({ + regionIntent: overrideIntent ?? regionIntent, + levelName, + }) if (response.error) { setError(response.error) diff --git a/src/interfaces/interfaces.ts b/src/interfaces/interfaces.ts index 6b4b0868d..b201ce45a 100644 --- a/src/interfaces/interfaces.ts +++ b/src/interfaces/interfaces.ts @@ -7,7 +7,7 @@ export type RecipientType = 'address' | 'ens' | 'iban' | 'us' | 'username' export type KycModalPhase = 'verifying' | 'preparing' | 'bridge_tos' | 'complete' // per-provider rail status for tracking after kyc approval -export type ProviderDisplayStatus = 'setting_up' | 'requires_tos' | 'enabled' | 'failed' +export type ProviderDisplayStatus = 'setting_up' | 'requires_tos' | 'requires_documents' | 'enabled' | 'failed' export interface ProviderStatus { providerCode: string From 91d7b1f922f6fbbcc36995cac7833fbb899ec2dc Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Tue, 24 Feb 2026 15:01:06 +0530 Subject: [PATCH 43/46] chore: format --- src/components/Kyc/states/KycRequiresDocuments.tsx | 6 ++---- src/constants/bridge-requirements.consts.ts | 14 +++++++------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/components/Kyc/states/KycRequiresDocuments.tsx b/src/components/Kyc/states/KycRequiresDocuments.tsx index 9b6a247ef..671e26f5f 100644 --- a/src/components/Kyc/states/KycRequiresDocuments.tsx +++ b/src/components/Kyc/states/KycRequiresDocuments.tsx @@ -19,15 +19,13 @@ export const KycRequiresDocuments = ({
-

- Your payment provider requires additional verification documents. -

+

Your payment provider requires additional verification documents.

{requirements.map((req) => { const label = getRequirementLabel(req) return (

{label.title}

-

{label.description}

+

{label.description}

) })} diff --git a/src/constants/bridge-requirements.consts.ts b/src/constants/bridge-requirements.consts.ts index 6e7cb5bef..d50ae2868 100644 --- a/src/constants/bridge-requirements.consts.ts +++ b/src/constants/bridge-requirements.consts.ts @@ -31,11 +31,11 @@ const FALLBACK_LABEL: RequirementLabelInfo = { /** get human-readable label for a bridge additional requirement */ export function getRequirementLabel(requirement: string): RequirementLabelInfo { - return BRIDGE_REQUIREMENT_LABELS[requirement] ?? { - // auto-format unknown requirement codes as title case - title: requirement - .replace(/_/g, ' ') - .replace(/\b\w/g, (c) => c.toUpperCase()), - description: FALLBACK_LABEL.description, - } + return ( + BRIDGE_REQUIREMENT_LABELS[requirement] ?? { + // auto-format unknown requirement codes as title case + title: requirement.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()), + description: FALLBACK_LABEL.description, + } + ) } From bcb698ce6d18ee3cd7371116c9a263f3a6118914 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Tue, 24 Feb 2026 15:28:28 +0530 Subject: [PATCH 44/46] fix: address code review findings - fix icon name: document -> docs, remove unsafe IconName cast - preserve levelName across token refresh via levelNameRef - add explicit additionalRequirements type to IUserRail.metadata - fix needsAdditionalDocs: derive from rail status, not empty requirements - add fallback UI when requirements array is empty --- src/components/Kyc/KycStatusDrawer.tsx | 9 +++--- .../Kyc/states/KycRequiresDocuments.tsx | 28 +++++++++++-------- src/hooks/useSumsubKycFlow.ts | 12 ++++++-- src/interfaces/interfaces.ts | 2 +- 4 files changed, 31 insertions(+), 20 deletions(-) diff --git a/src/components/Kyc/KycStatusDrawer.tsx b/src/components/Kyc/KycStatusDrawer.tsx index 1811689ec..2faef4a66 100644 --- a/src/components/Kyc/KycStatusDrawer.tsx +++ b/src/components/Kyc/KycStatusDrawer.tsx @@ -88,11 +88,10 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus const bridgeRailsNeedingDocs = (user?.rails ?? []).filter( (r) => r.status === 'REQUIRES_EXTRA_INFORMATION' && r.rail.provider.code === 'BRIDGE' ) - const additionalRequirements: string[] = - bridgeRailsNeedingDocs.length > 0 - ? ((bridgeRailsNeedingDocs[0].metadata?.additionalRequirements as string[]) ?? []) - : [] - const needsAdditionalDocs = additionalRequirements.length > 0 + const needsAdditionalDocs = bridgeRailsNeedingDocs.length > 0 + const additionalRequirements: string[] = needsAdditionalDocs + ? (bridgeRailsNeedingDocs[0].metadata?.additionalRequirements ?? []) + : [] // count sumsub rejections for failure lockout. // counts total REJECTED entries — accurate if backend creates a new row per attempt. diff --git a/src/components/Kyc/states/KycRequiresDocuments.tsx b/src/components/Kyc/states/KycRequiresDocuments.tsx index 671e26f5f..4cccf90be 100644 --- a/src/components/Kyc/states/KycRequiresDocuments.tsx +++ b/src/components/Kyc/states/KycRequiresDocuments.tsx @@ -1,7 +1,6 @@ import { KYCStatusDrawerItem } from '../KYCStatusDrawerItem' import { Button } from '@/components/0_Bruddle/Button' import { getRequirementLabel } from '@/constants/bridge-requirements.consts' -import type { IconName } from '@/components/Global/Icons/Icon' // shows when a payment provider (bridge) needs additional documents from the user. // displays the specific requirements with human-readable descriptions. @@ -20,19 +19,26 @@ export const KycRequiresDocuments = ({

Your payment provider requires additional verification documents.

- {requirements.map((req) => { - const label = getRequirementLabel(req) - return ( -
-

{label.title}

-

{label.description}

-
- ) - })} + {requirements.length > 0 ? ( + requirements.map((req) => { + const label = getRequirementLabel(req) + return ( +
+

{label.title}

+

{label.description}

+
+ ) + }) + ) : ( +
+

Additional Document

+

Please provide the requested document.

+
+ )}
From 1b4a9e7e51ed705d0c7fb825449fe2c905df0300 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Tue, 24 Feb 2026 19:48:04 +0530 Subject: [PATCH 46/46] fix: label copy --- src/hooks/useRailStatusTracking.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useRailStatusTracking.ts b/src/hooks/useRailStatusTracking.ts index 2be342ca0..131d65df1 100644 --- a/src/hooks/useRailStatusTracking.ts +++ b/src/hooks/useRailStatusTracking.ts @@ -18,7 +18,7 @@ const POLL_INTERVAL_MS = 4000 // human-readable labels for provider groups const PROVIDER_LABELS: Record = { BRIDGE: 'Bank transfers', - MANTECA: 'QR payments', + MANTECA: 'QR payments and bank transfers', } function deriveProviderDisplayName(providerCode: string, rails: IUserRail[]): string {