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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 77 additions & 44 deletions src/components/AddWithdraw/AddWithdrawCountriesList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -269,6 +292,48 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => {
)
}

// shared modals — rendered once regardless of view (form vs list)
const sharedModals = (
<>
<InitiateKycModal
visible={isKycModalOpen}
onClose={() => 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}
/>
<BridgeTosStep
visible={showBridgeTos}
onComplete={() => {
hideTos()
const replay = pendingAfterTosRef.current
pendingAfterTosRef.current = null
if (replay) replay()
else formRef.current?.handleSubmit()
}}
onSkip={hideTos}
/>
<SumsubKycModals flow={sumsubFlow} />
</>
)

if (view === 'form') {
return (
<div className="flex min-h-[inherit] flex-col justify-normal gap-8">
Expand Down Expand Up @@ -307,39 +372,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => {
initialData={{}}
error={null}
/>
<InitiateKycModal
visible={isKycModalOpen}
onClose={() => 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}
/>
<BridgeTosStep
visible={showBridgeTos}
onComplete={() => {
hideTos()
formRef.current?.handleSubmit()
}}
onSkip={hideTos}
/>
<SumsubKycModals flow={sumsubFlow} />
{sharedModals}
</div>
)
}
Expand Down Expand Up @@ -454,7 +487,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => {
isKycApprovedModalOpen={showKycStatusModal}
onClose={() => setShowKycStatusModal(false)}
/>
<SumsubKycModals flow={sumsubFlow} />
{sharedModals}
</div>
)
}
Expand Down
7 changes: 5 additions & 2 deletions src/features/limits/views/LimitsPageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -67,7 +67,10 @@ const LimitsPageView = () => {

{/* locked regions - only render if there are actual locked regions */}
{filteredLockedRegions.length > 0 && (
<LockedRegionsList regions={filteredLockedRegions} isBridgeKycPending={isUserBridgeKycUnderReview} />
<LockedRegionsList
regions={filteredLockedRegions}
isBridgeKycPending={isUserBridgeKycUnderReview || isUserBridgeKycIncomplete}
/>
)}

{/* rest of world - always shown with coming soon */}
Expand Down
14 changes: 14 additions & 0 deletions src/hooks/__tests__/useBridgeTransferReadiness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ function setup({
isSumsubApproved = false,
isBridgeApproved = false,
isBridgeUnderReview = false,
isBridgeIncomplete = false,
} = {}) {
mockTosStatus.mockReturnValue({
needsBridgeTos,
Expand All @@ -59,6 +60,7 @@ function setup({
isUserSumsubKycApproved: isSumsubApproved,
isUserBridgeKycApproved: isBridgeApproved,
isUserBridgeKycUnderReview: isBridgeUnderReview,
isUserBridgeKycIncomplete: isBridgeIncomplete,
isUserMantecaKycApproved: false,
isUserKycApproved: isBridgeApproved,
})
Expand Down Expand Up @@ -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())
Expand All @@ -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', () => {
Expand Down
21 changes: 17 additions & 4 deletions src/hooks/useBridgeTransferReadiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 }
}
Expand Down
6 changes: 4 additions & 2 deletions src/hooks/useHomeCarouselCTAs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -287,7 +288,7 @@ export const useHomeCarouselCTAs = () => {
})
}

if (!hasKycApproval && !isUserBridgeKycUnderReview) {
if (!hasKycApproval && !isUserBridgeKycUnderReview && !isUserBridgeKycIncomplete) {
_carouselCTAs.push({
id: 'kyc-prompt',
title: (
Expand Down Expand Up @@ -317,6 +318,7 @@ export const useHomeCarouselCTAs = () => {
isPushOptedIn,
isUserKycApproved,
isUserBridgeKycUnderReview,
isUserBridgeKycIncomplete,
isUserMantecaKycApproved,
router,
requestPermission,
Expand Down
11 changes: 9 additions & 2 deletions src/hooks/useKycStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,21 @@ 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,
isUserMantecaKycApproved: isMantecaApproved,
isUserSumsubKycApproved: isSumsubApproved,
isUserKycApproved: isKycApproved,
isUserBridgeKycUnderReview: isBridgeUnderReview,
isUserBridgeKycIncomplete: isBridgeIncomplete,
}
}
14 changes: 8 additions & 6 deletions src/hooks/useUnifiedKycStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -76,6 +77,7 @@ export default function useUnifiedKycStatus() {
// bridge
isBridgeApproved,
isBridgeUnderReview,
isBridgeIncomplete,
// manteca
isMantecaApproved,
// sumsub
Expand Down
Loading