diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index 2fc5ff391..1ee627bb3 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -13,7 +13,7 @@ import { withdrawBankUrl, rewriteMethodPath } from '@/utils/native-routes' import { isCapacitor } from '@/utils/capacitor' import EmptyState from '../Global/EmptyStates/EmptyState' import { useAuth } from '@/context/authContext' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { DynamicBankAccountForm, type IBankAccountDetails } from './DynamicBankAccountForm' import { addBankAccount } from '@/app/actions/users' import { type AddBankAccountPayload } from '@/app/actions/types/users.types' @@ -83,6 +83,9 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { const { setIsSupportModalOpen } = useModalsContext() const [showKycStatusModal, setShowKycStatusModal] = useState(false) + // stores the callback to replay after tos acceptance in the list view + const pendingAfterTosRef = useRef<(() => void) | null>(null) + // close kyc modal when sumsub sdk opens useEffect(() => { if (sumsubFlow.showWrapper) setIsKycModalOpen(false) @@ -101,6 +104,27 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { (country) => country.type === 'country' && country.path === countrySlugFromUrl ) + /** returns true if the user is gated (caller should return early) */ + const checkBridgeGate = useCallback( + (onAfterTos?: () => void): boolean => { + if (gate.type !== 'ready') { + if (gate.type === 'accept_tos') { + pendingAfterTosRef.current = onAfterTos ?? null + guardWithTos() + } else { + setIsKycModalOpen(true) + } + return true + } + if (isUserBridgeKycUnderReview) { + setShowKycStatusModal(true) + return true + } + return false + }, + [gate, isUserBridgeKycUnderReview, guardWithTos] + ) + const handleFormSubmit = async ( payload: AddBankAccountPayload, rawData: IBankAccountDetails @@ -120,6 +144,12 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { return { error: 'gate_blocked', silent: true } } + // bridge kyc still under review — don't initiate a new sumsub flow + if (isUserBridgeKycUnderReview) { + setShowKycStatusModal(true) + return { error: 'gate_blocked', silent: true } + } + // scenario (1): happy path: if the user has already completed kyc, we can add the bank account directly // email and name are now collected by sumsub — no need to check them here if (isUserKycApproved) { @@ -181,10 +211,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { const extraParams = isBankFromSend ? `method=${methodParam}` : undefined router.push(rewriteMethodPath(method.path, extraParams)) } else if (method.id.includes('default-bank-withdraw') || method.id.includes('sepa-instant-withdraw')) { - if (isUserBridgeKycUnderReview) { - setShowKycStatusModal(true) - return - } + if (checkBridgeGate(() => handleWithdrawMethodClick(method))) return // Bridge methods: Set in context and navigate for amount input setSelectedMethod({ @@ -215,11 +242,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { setIsSupportedTokensModalOpen(true) return } - // show kyc status modal if user is kyc under review - if (isUserBridgeKycUnderReview) { - setShowKycStatusModal(true) - return - } + if (checkBridgeGate(() => handleAddMethodClick(method))) return const target = rewriteMethodPath(method.path) // force full navigation in capacitor — router.push to same page with @@ -269,6 +292,48 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { ) } + // shared modals — rendered once regardless of view (form vs list) + const sharedModals = ( + <> + setIsKycModalOpen(false)} + onVerify={async () => { + if (gate.type === 'fixable_rejection') { + await sumsubFlow.handleSelfHealResubmit('BRIDGE') + } else { + await sumsubFlow.handleInitiateKyc( + 'STANDARD', + undefined, + gate.type === 'needs_enrollment' || undefined + ) + } + }} + onContactSupport={() => { + setIsKycModalOpen(false) + setIsSupportModalOpen(true) + }} + isLoading={sumsubFlow.isLoading} + error={sumsubFlow.error} + variant={getKycModalVariant(gate.type)} + providerMessage={getGateProviderMessage(gate)} + regionName={currentCountry?.title} + /> + { + hideTos() + const replay = pendingAfterTosRef.current + pendingAfterTosRef.current = null + if (replay) replay() + else formRef.current?.handleSubmit() + }} + onSkip={hideTos} + /> + + + ) + if (view === 'form') { return (
@@ -307,39 +372,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { initialData={{}} error={null} /> - setIsKycModalOpen(false)} - onVerify={async () => { - if (gate.type === 'fixable_rejection') { - await sumsubFlow.handleSelfHealResubmit('BRIDGE') - } else { - await sumsubFlow.handleInitiateKyc( - 'STANDARD', - undefined, - gate.type === 'needs_enrollment' || undefined - ) - } - }} - onContactSupport={() => { - setIsKycModalOpen(false) - setIsSupportModalOpen(true) - }} - isLoading={sumsubFlow.isLoading} - error={sumsubFlow.error} - variant={getKycModalVariant(gate.type)} - providerMessage={getGateProviderMessage(gate)} - regionName={currentCountry?.title} - /> - { - hideTos() - formRef.current?.handleSubmit() - }} - onSkip={hideTos} - /> - + {sharedModals}
) } @@ -454,7 +487,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { isKycApprovedModalOpen={showKycStatusModal} onClose={() => setShowKycStatusModal(false)} /> - + {sharedModals} ) } diff --git a/src/features/limits/views/LimitsPageView.tsx b/src/features/limits/views/LimitsPageView.tsx index 5052b0831..71b8949cf 100644 --- a/src/features/limits/views/LimitsPageView.tsx +++ b/src/features/limits/views/LimitsPageView.tsx @@ -20,7 +20,7 @@ import { getProviderRoute } from '../utils' const LimitsPageView = () => { const onBack = useSafeBack('/profile', { replace: true }) const { unlockedRegions, lockedRegions } = useIdentityVerification() - const { isUserKycApproved, isUserBridgeKycUnderReview } = useKycStatus() + const { isUserKycApproved, isUserBridgeKycUnderReview, isUserBridgeKycIncomplete } = useKycStatus() const { hasMantecaLimits } = useLimits() // check if user has any kyc at all @@ -67,7 +67,10 @@ const LimitsPageView = () => { {/* locked regions - only render if there are actual locked regions */} {filteredLockedRegions.length > 0 && ( - + )} {/* rest of world - always shown with coming soon */} diff --git a/src/hooks/__tests__/useBridgeTransferReadiness.test.ts b/src/hooks/__tests__/useBridgeTransferReadiness.test.ts index f78ab8564..fad5663e6 100644 --- a/src/hooks/__tests__/useBridgeTransferReadiness.test.ts +++ b/src/hooks/__tests__/useBridgeTransferReadiness.test.ts @@ -41,6 +41,7 @@ function setup({ isSumsubApproved = false, isBridgeApproved = false, isBridgeUnderReview = false, + isBridgeIncomplete = false, } = {}) { mockTosStatus.mockReturnValue({ needsBridgeTos, @@ -59,6 +60,7 @@ function setup({ isUserSumsubKycApproved: isSumsubApproved, isUserBridgeKycApproved: isBridgeApproved, isUserBridgeKycUnderReview: isBridgeUnderReview, + isUserBridgeKycIncomplete: isBridgeIncomplete, isUserMantecaKycApproved: false, isUserKycApproved: isBridgeApproved, }) @@ -105,6 +107,12 @@ describe('useBridgeTransferReadiness', () => { expect(result.current.gate.type).toBe('ready') }) + it('ready when sumsub approved and bridge incomplete (enrollment not needed)', () => { + setup({ isSumsubApproved: true, isBridgeIncomplete: true }) + const { result } = renderHook(() => useBridgeTransferReadiness()) + expect(result.current.gate.type).toBe('ready') + }) + it('ready when sumsub approved and bridge approved', () => { setup({ isSumsubApproved: true, isBridgeApproved: true }) const { result } = renderHook(() => useBridgeTransferReadiness()) @@ -122,6 +130,12 @@ describe('useBridgeTransferReadiness', () => { const { result } = renderHook(() => useBridgeTransferReadiness()) expect(result.current.gate.type).toBe('accept_tos') }) + + it('accept_tos when bridge incomplete and tos needed (main bug scenario)', () => { + setup({ needsBridgeTos: true, isBridgeIncomplete: true, isSumsubApproved: true }) + const { result } = renderHook(() => useBridgeTransferReadiness()) + expect(result.current.gate.type).toBe('accept_tos') + }) }) describe('getKycModalVariant', () => { diff --git a/src/hooks/useBridgeTransferReadiness.ts b/src/hooks/useBridgeTransferReadiness.ts index 6beb079cc..8bed58d17 100644 --- a/src/hooks/useBridgeTransferReadiness.ts +++ b/src/hooks/useBridgeTransferReadiness.ts @@ -25,7 +25,8 @@ export type BridgeGateAction = export function useBridgeTransferReadiness() { const { needsBridgeTos } = useBridgeTosStatus() const { bridge: bridgeRejection } = useProviderRejectionStatus() - const { isUserSumsubKycApproved, isUserBridgeKycApproved, isUserBridgeKycUnderReview } = useKycStatus() + const { isUserSumsubKycApproved, isUserBridgeKycApproved, isUserBridgeKycUnderReview, isUserBridgeKycIncomplete } = + useKycStatus() const gate: BridgeGateAction = useMemo(() => { // 1. hard rejection — contact support (checked first because tos is moot for hard-rejected users) @@ -41,14 +42,26 @@ export function useBridgeTransferReadiness() { return { type: 'fixable_rejection', userMessage: bridgeRejection.userMessage } } - // 4. needs enrollment (sumsub approved but bridge not started/approved) - if (isUserSumsubKycApproved && !isUserBridgeKycApproved && !isUserBridgeKycUnderReview) { + // 4. needs enrollment (sumsub approved but bridge not started/approved/in-progress) + if ( + isUserSumsubKycApproved && + !isUserBridgeKycApproved && + !isUserBridgeKycUnderReview && + !isUserBridgeKycIncomplete + ) { return { type: 'needs_enrollment' } } // 5. ready return { type: 'ready' } - }, [needsBridgeTos, bridgeRejection, isUserSumsubKycApproved, isUserBridgeKycApproved, isUserBridgeKycUnderReview]) + }, [ + needsBridgeTos, + bridgeRejection, + isUserSumsubKycApproved, + isUserBridgeKycApproved, + isUserBridgeKycUnderReview, + isUserBridgeKycIncomplete, + ]) return { gate } } diff --git a/src/hooks/useHomeCarouselCTAs.tsx b/src/hooks/useHomeCarouselCTAs.tsx index 87acbbfa2..4c84b1adc 100644 --- a/src/hooks/useHomeCarouselCTAs.tsx +++ b/src/hooks/useHomeCarouselCTAs.tsx @@ -83,7 +83,8 @@ export const useHomeCarouselCTAs = () => { } = useNotifications() const toast = useToast() const router = useRouter() - const { isUserKycApproved, isUserBridgeKycUnderReview, isUserMantecaKycApproved } = useKycStatus() + const { isUserKycApproved, isUserBridgeKycUnderReview, isUserBridgeKycIncomplete, isUserMantecaKycApproved } = + useKycStatus() const { deviceType } = useDeviceType() const isPwa = usePWAStatus() const { setIsIosPwaInstallModalOpen, openSupportWithMessage } = useModalsContext() @@ -287,7 +288,7 @@ export const useHomeCarouselCTAs = () => { }) } - if (!hasKycApproval && !isUserBridgeKycUnderReview) { + if (!hasKycApproval && !isUserBridgeKycUnderReview && !isUserBridgeKycIncomplete) { _carouselCTAs.push({ id: 'kyc-prompt', title: ( @@ -317,6 +318,7 @@ export const useHomeCarouselCTAs = () => { isPushOptedIn, isUserKycApproved, isUserBridgeKycUnderReview, + isUserBridgeKycIncomplete, isUserMantecaKycApproved, router, requestPermission, diff --git a/src/hooks/useKycStatus.tsx b/src/hooks/useKycStatus.tsx index 36b2909f3..9a10f0db7 100644 --- a/src/hooks/useKycStatus.tsx +++ b/src/hooks/useKycStatus.tsx @@ -7,8 +7,14 @@ import useUnifiedKycStatus from './useUnifiedKycStatus' * existing consumers keep the same api shape. */ export default function useKycStatus() { - const { isBridgeApproved, isMantecaApproved, isSumsubApproved, isKycApproved, isBridgeUnderReview } = - useUnifiedKycStatus() + const { + isBridgeApproved, + isMantecaApproved, + isSumsubApproved, + isKycApproved, + isBridgeUnderReview, + isBridgeIncomplete, + } = useUnifiedKycStatus() return { isUserBridgeKycApproved: isBridgeApproved, @@ -16,5 +22,6 @@ export default function useKycStatus() { isUserSumsubKycApproved: isSumsubApproved, isUserKycApproved: isKycApproved, isUserBridgeKycUnderReview: isBridgeUnderReview, + isUserBridgeKycIncomplete: isBridgeIncomplete, } } diff --git a/src/hooks/useUnifiedKycStatus.ts b/src/hooks/useUnifiedKycStatus.ts index 5b099649c..57e7cebaa 100644 --- a/src/hooks/useUnifiedKycStatus.ts +++ b/src/hooks/useUnifiedKycStatus.ts @@ -55,18 +55,19 @@ export default function useUnifiedKycStatus() { [isBridgeApproved, isMantecaApproved, isSumsubApproved] ) - const isBridgeUnderReview = useMemo( - () => user?.user.bridgeKycStatus === 'under_review' || user?.user.bridgeKycStatus === 'incomplete', - [user] - ) + // bridge is actively reviewing submitted docs + const isBridgeUnderReview = useMemo(() => user?.user.bridgeKycStatus === 'under_review', [user]) + + // user still needs to complete requirements (tos, proof of address, etc.) + const isBridgeIncomplete = useMemo(() => user?.user.bridgeKycStatus === 'incomplete', [user]) const isSumsubActionRequired = useMemo(() => sumsubStatus === 'ACTION_REQUIRED', [sumsubStatus]) const isSumsubInProgress = useMemo(() => isSumsubStatusInProgress(sumsubStatus), [sumsubStatus]) const isKycInProgress = useMemo( - () => isBridgeUnderReview || isSumsubInProgress, - [isBridgeUnderReview, isSumsubInProgress] + () => isBridgeUnderReview || isBridgeIncomplete || isSumsubInProgress, + [isBridgeUnderReview, isBridgeIncomplete, isSumsubInProgress] ) return { @@ -76,6 +77,7 @@ export default function useUnifiedKycStatus() { // bridge isBridgeApproved, isBridgeUnderReview, + isBridgeIncomplete, // manteca isMantecaApproved, // sumsub