diff --git a/.github/workflows/preview.yaml b/.github/workflows/preview.yaml index 32743e007..73f5dd5d4 100644 --- a/.github/workflows/preview.yaml +++ b/.github/workflows/preview.yaml @@ -27,10 +27,10 @@ jobs: - name: Install Vercel CLI run: pnpm add --global vercel@latest - name: Link to Project - run: vercel link --yes --project=peanut-wallet --token=${{ secrets.VERCEL_TOKEN }} + run: vercel link --yes --project=peanut-wallet --scope=squirrellabs --token=${{ secrets.VERCEL_TOKEN }} - name: Pull Vercel Environment Information - run: vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }} + run: vercel pull --yes --environment=preview --scope=squirrellabs --token=${{ secrets.VERCEL_TOKEN }} - name: Build Project Artifacts run: vercel build --target=preview --token=${{ secrets.VERCEL_TOKEN }} - name: Deploy Project Artifacts to Vercel - run: vercel deploy --prebuilt --archive=tgz --target=preview --token=${{ secrets.VERCEL_TOKEN }} + run: vercel deploy --prebuilt --archive=tgz --target=preview --scope=squirrellabs --token=${{ secrets.VERCEL_TOKEN }} diff --git a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx index 775faad2b..557064cb2 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -11,6 +11,7 @@ import { formatAmount } from '@/utils/general.utils' import { countryData } from '@/components/AddMoney/consts' import { useAuth } from '@/context/authContext' import useKycStatus from '@/hooks/useKycStatus' +import useProviderRejectionStatus from '@/hooks/useProviderRejectionStatus' import { useCreateOnramp } from '@/hooks/useCreateOnramp' import { useRouter, useParams, useSearchParams } from 'next/navigation' import { useCallback, useEffect, useMemo, useState } from 'react' @@ -71,7 +72,8 @@ export default function OnrampBankPage() { // regionIntent is NOT passed here to avoid creating a backend record on mount. // intent is passed at call time: handleInitiateKyc('STANDARD') const sumsubFlow = useMultiPhaseKycFlow({ - onKycSuccess: () => { + onKycSuccess: async () => { + await fetchUser() setUrlState({ step: 'inputAmount' }) }, }) @@ -96,7 +98,9 @@ export default function OnrampBankPage() { // uk-specific check const isUK = isUKCountry(selectedCountryPath) - const { isUserKycApproved } = useKycStatus() + const { isUserKycApproved, isUserSumsubKycApproved, isUserBridgeKycApproved, isUserBridgeKycUnderReview } = + useKycStatus() + const { bridge: bridgeRejection } = useProviderRejectionStatus() const { guardWithTos, showBridgeTos, hideTos } = useBridgeTosGuard() useEffect(() => { @@ -194,10 +198,17 @@ export default function OnrampBankPage() { } }, [rawTokenAmount, validateAmount, setError]) + const needsBridgeEnrollment = isUserSumsubKycApproved && !isUserBridgeKycApproved && !isUserBridgeKycUnderReview + const handleAmountContinue = () => { if (!validateAmount(rawTokenAmount)) return - if (!isUserKycApproved) { + if ( + needsBridgeEnrollment || + !isUserKycApproved || + bridgeRejection.state === 'fixable' || + bridgeRejection.state === 'blocked' + ) { setShowKycModal(true) return } @@ -398,10 +409,29 @@ export default function OnrampBankPage() { visible={showKycModal} onClose={() => setShowKycModal(false)} onVerify={async () => { - await sumsubFlow.handleInitiateKyc('STANDARD') + // needsBridgeEnrollment takes priority: user has no bridge customer, + // so rejection state from a stale/deleted customer is irrelevant + if (!needsBridgeEnrollment && bridgeRejection.state === 'fixable') { + await sumsubFlow.handleSelfHealResubmit('BRIDGE') + } else { + await sumsubFlow.handleInitiateKyc( + 'STANDARD', + undefined, + needsBridgeEnrollment || undefined + ) + } setShowKycModal(false) }} isLoading={sumsubFlow.isLoading} + variant={ + needsBridgeEnrollment + ? 'cross_region' + : bridgeRejection.state === 'fixable' || bridgeRejection.state === 'blocked' + ? 'provider_rejection' + : 'default' + } + providerMessage={bridgeRejection.userMessage ?? undefined} + regionName={selectedCountry?.title} /> diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index 8ebeb7e6a..58c7b7624 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -1108,12 +1108,61 @@ export default function QRPayPage() { const needsKycVerification = kycGateState === QrKycState.REQUIRES_IDENTITY_VERIFICATION || kycGateState === QrKycState.IDENTITY_VERIFICATION_IN_PROGRESS + const hasProviderRejection = + kycGateState === QrKycState.PROVIDER_REJECTION_FIXABLE || kycGateState === QrKycState.PROVIDER_REJECTION_BLOCKED // show loading while KYC state is being determined if (isLoadingKycState) { return } + // provider rejection: user is sumsub-approved but manteca rejected + if (hasProviderRejection) { + const isFixable = kycGateState === QrKycState.PROVIDER_REJECTION_FIXABLE + return ( +
+ + router.back()} + title={isFixable ? 'We need an updated document' : 'QR payments are not available'} + description={ + isFixable + ? 'We need an updated document to enable QR payments. Please upload a clearer photo of your ID.' + : 'QR payments are not available for your account. Contact support for help.' + } + icon={ + methodIcon ? ( + Payment method + ) : undefined + } + ctas={[ + isFixable + ? { + text: 'Upload document', + onClick: () => { + saveRedirectUrl() + router.push('/profile/identity-verification') + }, + variant: 'purple' as const, + shadowSize: '4' as const, + icon: 'upload', + } + : { + text: 'Contact support', + onClick: () => { + if (typeof window !== 'undefined' && (window as any).$crisp) { + ;(window as any).$crisp.push(['do', 'chat:open']) + } + }, + variant: 'stroke' as const, + }, + ]} + /> +
+ ) + } + // show KYC screens before any error screens - user needs to verify first if (needsKycVerification) { return ( diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index 25c36cbba..1a3b1ac04 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -30,6 +30,10 @@ import { createOfframp, confirmOfframp } from '@/app/actions/offramp' import { useAuth } from '@/context/authContext' import { useBridgeTosGuard } from '@/hooks/useBridgeTosGuard' import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' +import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' +import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' +import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' +import useKycStatus from '@/hooks/useKycStatus' import ExchangeRate from '@/components/ExchangeRate' import countryCurrencyMappings, { isNonEuroSepaCountry } from '@/constants/countryCurrencyMapping' import { useIdentityVerification } from '@/hooks/useIdentityVerification' @@ -65,6 +69,14 @@ export default function WithdrawBankPage() { const [balanceErrorMessage, setBalanceErrorMessage] = useState(null) const { hasPendingTransactions } = usePendingTransactions() const { isBridgeSupportedCountry } = useIdentityVerification() + const { isUserSumsubKycApproved, isUserBridgeKycApproved } = useKycStatus() + const sumsubFlow = useMultiPhaseKycFlow({ + onKycSuccess: async () => { + await fetchUser() + }, + }) + const [showKycModal, setShowKycModal] = useState(false) + const needsBridgeEnrollment = isUserSumsubKycApproved && !isUserBridgeKycApproved && !user?.user.bridgeCustomerId // validate country is supported for bank withdrawals useEffect(() => { @@ -159,6 +171,11 @@ export default function WithdrawBankPage() { } const handleCreateAndInitiateOfframp = async () => { + if (needsBridgeEnrollment) { + setShowKycModal(true) + return + } + if (guardWithTos()) return setIsLoading(true) @@ -444,6 +461,19 @@ export default function WithdrawBankPage() { }} onSkip={hideTos} /> + + setShowKycModal(false)} + onVerify={async () => { + await sumsubFlow.handleInitiateKyc('STANDARD', undefined, true) + setShowKycModal(false) + }} + isLoading={sumsubFlow.isLoading} + variant="cross_region" + regionName={getCountryFromPath(country)?.title} + /> + ) } diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx index 3a75160e8..118d3eca0 100644 --- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx +++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx @@ -35,6 +35,7 @@ import { SoundPlayer } from '@/components/Global/SoundPlayer' import { useQueryClient } from '@tanstack/react-query' import { captureException } from '@sentry/nextjs' import useKycStatus from '@/hooks/useKycStatus' +import useProviderRejectionStatus from '@/hooks/useProviderRejectionStatus' import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' @@ -92,7 +93,8 @@ export default function MantecaWithdrawFlow() { const { user } = useAuth() const { setIsSupportModalOpen, openSupportWithMessage } = useModalsContext() const queryClient = useQueryClient() - const { isUserMantecaKycApproved } = useKycStatus() + const { isUserMantecaKycApproved, isUserSumsubKycApproved } = useKycStatus() + const { manteca: mantecaRejection } = useProviderRejectionStatus() const { hasPendingTransactions } = usePendingTransactions() // inline sumsub kyc flow for manteca users who need LATAM verification @@ -101,6 +103,7 @@ export default function MantecaWithdrawFlow() { const sumsubFlow = useMultiPhaseKycFlow({}) const [showKycModal, setShowKycModal] = useState(false) const [isRedirectingToOnboarding, setIsRedirectingToOnboarding] = useState(false) + // Get method and country from URL parameters const selectedMethodType = searchParams.get('method') // mercadopago, pix, bank-transfer, etc. const countryFromUrl = searchParams.get('country') // argentina, brazil, etc. @@ -532,10 +535,32 @@ export default function MantecaWithdrawFlow() { visible={showKycModal} onClose={() => setShowKycModal(false)} onVerify={async () => { - await sumsubFlow.handleInitiateKyc('LATAM') + if (mantecaRejection.state === 'blocked') { + // blocked users cannot self-heal — route to support + if (typeof window !== 'undefined' && (window as any).$crisp) { + ;(window as any).$crisp.push(['do', 'chat:open']) + } + setShowKycModal(false) + return + } + const hasRejection = mantecaRejection.state === 'fixable' + if (hasRejection) { + await sumsubFlow.handleSelfHealResubmit('MANTECA') + } else { + await sumsubFlow.handleInitiateKyc('LATAM', undefined, true) + } setShowKycModal(false) }} isLoading={sumsubFlow.isLoading} + variant={ + mantecaRejection.state === 'fixable' || mantecaRejection.state === 'blocked' + ? 'provider_rejection' + : isUserSumsubKycApproved + ? 'cross_region' + : 'default' + } + providerMessage={mantecaRejection.userMessage ?? undefined} + regionName={selectedCountry?.title} /> - {errorMessage && } + {(errorMessage || sumsubFlow.error) && ( + + )} )} @@ -804,7 +831,9 @@ export default function MantecaWithdrawFlow() { > {isLoading ? loadingState : 'Withdraw'} - {errorMessage && } + {(errorMessage || sumsubFlow.error) && ( + + )} )} diff --git a/src/app/actions/sumsub.ts b/src/app/actions/sumsub.ts index 471a21290..4a804b04b 100644 --- a/src/app/actions/sumsub.ts +++ b/src/app/actions/sumsub.ts @@ -22,7 +22,9 @@ export const initiateSumsubKyc = async (params?: { const responseJson = await response.json() if (!response.ok) { - return { error: responseJson.message || responseJson.error || 'Failed to initiate identity verification' } + return { + error: responseJson.userMessage || responseJson.error || 'Failed to initiate identity verification', + } } return { @@ -38,3 +40,43 @@ export const initiateSumsubKyc = async (params?: { return { error: message } } } + +export interface SelfHealResubmissionResponse { + token: string + applicantId: string + actionId: string + externalActionId: string + requiredAction: 'REUPLOAD_ID' | 'REUPLOAD_ADDRESS_PROOF' | 'CONTACT_SUPPORT' + userMessage: string + attempt: number + maxAttempts: number +} + +// initiate self-heal document resubmission for a provider-rejected user +export const initiateSelfHealResubmission = async ( + provider: 'BRIDGE' | 'MANTECA' +): Promise<{ data?: SelfHealResubmissionResponse; error?: string }> => { + try { + const response = await serverFetch('/users/identity/resubmit', { + method: 'POST', + body: JSON.stringify({ provider }), + }) + + const responseJson = await response.json() + + if (!response.ok) { + return { + error: responseJson.userMessage || responseJson.error || 'Failed to initiate document resubmission', + } + } + + if (!responseJson.token || !responseJson.applicantId) { + return { error: 'Invalid response from server' } + } + + return { data: responseJson } + } catch (e: unknown) { + const message = e instanceof Error ? e.message : 'An unexpected error occurred' + return { error: message } + } +} diff --git a/src/components/AddMoney/components/MantecaAddMoney.tsx b/src/components/AddMoney/components/MantecaAddMoney.tsx index c75467f02..283da88b2 100644 --- a/src/components/AddMoney/components/MantecaAddMoney.tsx +++ b/src/components/AddMoney/components/MantecaAddMoney.tsx @@ -11,6 +11,7 @@ import { mantecaApi } from '@/services/manteca' import { parseUnits } from 'viem' import { useQueryClient } from '@tanstack/react-query' import useKycStatus from '@/hooks/useKycStatus' +import useProviderRejectionStatus from '@/hooks/useProviderRejectionStatus' import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' @@ -65,7 +66,8 @@ const MantecaAddMoney: FC = () => { const selectedCountry = useMemo(() => { return countryData.find((country) => country.type === 'country' && country.path === selectedCountryPath) }, [selectedCountryPath]) - const { isUserMantecaKycApproved } = useKycStatus() + const { isUserMantecaKycApproved, isUserSumsubKycApproved } = useKycStatus() + const { manteca: mantecaRejection } = useProviderRejectionStatus() const currencyData = useCurrency(selectedCountry?.currency ?? 'ARS') const { user } = useAuth() @@ -223,10 +225,32 @@ const MantecaAddMoney: FC = () => { visible={showKycModal} onClose={() => setShowKycModal(false)} onVerify={async () => { - await sumsubFlow.handleInitiateKyc('LATAM') + if (mantecaRejection.state === 'blocked') { + // blocked users cannot self-heal — route to support + if (typeof window !== 'undefined' && (window as any).$crisp) { + ;(window as any).$crisp.push(['do', 'chat:open']) + } + setShowKycModal(false) + return + } + const hasRejection = mantecaRejection.state === 'fixable' + if (hasRejection) { + await sumsubFlow.handleSelfHealResubmit('MANTECA') + } else { + await sumsubFlow.handleInitiateKyc('LATAM', undefined, true) + } setShowKycModal(false) }} isLoading={sumsubFlow.isLoading} + variant={ + mantecaRejection.state === 'fixable' || mantecaRejection.state === 'blocked' + ? 'provider_rejection' + : isUserSumsubKycApproved + ? 'cross_region' + : 'default' + } + providerMessage={mantecaRejection.userMessage ?? undefined} + regionName={selectedCountry?.title} /> { setTokenAmount={handleUsdAmountChange} onSubmit={handleAmountSubmit} isLoading={isCreatingDeposit} - error={error} + error={error || sumsubFlow.error} currencyData={currencyData} setCurrencyAmount={handleLocalCurrencyAmountChange} setCurrentDenomination={handleDenominationChange} diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index 42501d3b9..698f9ed64 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -23,11 +23,13 @@ import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType' import { useAppDispatch } from '@/redux/hooks' import { bankFormActions } from '@/redux/slices/bank-form-slice' import useKycStatus from '@/hooks/useKycStatus' +import useProviderRejectionStatus from '@/hooks/useProviderRejectionStatus' import KycVerifiedOrReviewModal from '../Global/KycVerifiedOrReviewModal' import { ActionListCard } from '@/components/ActionListCard' import TokenAndNetworkConfirmationModal from '../Global/TokenAndNetworkConfirmationModal' import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' +import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' interface AddWithdrawCountriesListProps { flow: 'add' | 'withdraw' @@ -66,7 +68,9 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { const formRef = useRef<{ handleSubmit: () => void }>(null) const [isSupportedTokensModalOpen, setIsSupportedTokensModalOpen] = useState(false) - const { isUserKycApproved, isUserBridgeKycUnderReview } = useKycStatus() + const { isUserKycApproved, isUserBridgeKycUnderReview, isUserSumsubKycApproved, isUserBridgeKycApproved } = + useKycStatus() + const { bridge: bridgeRejection } = useProviderRejectionStatus() const [showKycStatusModal, setShowKycStatusModal] = useState(false) // read country from path params (web: /add-money/india) or query params (native: /add-money?country=india) @@ -90,6 +94,22 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { // (the multi-phase flow may have completed but websocket/state not yet propagated) await fetchUser() + // block users with bridge provider rejection + if (bridgeRejection.state === 'fixable') { + await sumsubFlow.handleSelfHealResubmit('BRIDGE') + return {} + } + if (bridgeRejection.state === 'blocked') { + return { error: 'Bank transfers are not available for your account. Please contact support.' } + } + + // JIT bridge enrollment: user is sumsub-approved but no bridge customer yet + // show the KYC modal — enrollment happens when user clicks "Start Verification" + if (isUserSumsubKycApproved && !isUserBridgeKycApproved && !user?.user.bridgeCustomerId) { + setIsKycModalOpen(true) + return {} + } + // 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) { @@ -277,6 +297,17 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { initialData={{}} error={null} /> + setIsKycModalOpen(false)} + onVerify={async () => { + await sumsubFlow.handleInitiateKyc('STANDARD', undefined, true) + setIsKycModalOpen(false) + }} + isLoading={sumsubFlow.isLoading} + variant="cross_region" + regionName={currentCountry?.title} + /> ) diff --git a/src/components/Claim/Link/Initial.view.tsx b/src/components/Claim/Link/Initial.view.tsx index 704182231..42aac0a28 100644 --- a/src/components/Claim/Link/Initial.view.tsx +++ b/src/components/Claim/Link/Initial.view.tsx @@ -31,6 +31,7 @@ import { type IClaimScreenProps } from '../Claim.consts' import SendLinkActionList from '@/components/Claim/Link/SendLinkActionList' import { ClaimBankFlowStep, useClaimBankFlow } from '@/context/ClaimBankFlowContext' import useClaimLink from '../useClaimLink' +import underMaintenanceConfig, { CROSS_CHAIN_DISABLED_MESSAGE } from '@/config/underMaintenance.config' import ActionModal from '@/components/Global/ActionModal' import { Slider } from '@/components/Slider' import { BankFlowManager } from './views/BankFlowManager.view' @@ -326,6 +327,12 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => { // check if cross-chain claiming is needed if (isXChain) { + if (underMaintenanceConfig.disableXchainSend) { + // skip throwing through ErrorHandler — surface the friendly maintenance message directly + setErrorState({ showError: true, errorMessage: CROSS_CHAIN_DISABLED_MESSAGE }) + setLoadingState('Idle') + return + } if (!selectedTokenData?.chainId || !selectedTokenData?.address) { throw new Error('Selected token data is required for cross-chain claims') } diff --git a/src/components/Claim/Link/MantecaFlowManager.tsx b/src/components/Claim/Link/MantecaFlowManager.tsx index 0598ac4b1..6c29bd59e 100644 --- a/src/components/Claim/Link/MantecaFlowManager.tsx +++ b/src/components/Claim/Link/MantecaFlowManager.tsx @@ -10,8 +10,10 @@ import MantecaDetailsStep from './views/MantecaDetailsStep.view' import { MercadoPagoStep } from '@/types/manteca.types' import MantecaReviewStep from './views/MantecaReviewStep' import { Button } from '@/components/0_Bruddle/Button' +import ErrorAlert from '@/components/Global/ErrorAlert' import { useRouter } from 'next/navigation' import useKycStatus from '@/hooks/useKycStatus' +import useProviderRejectionStatus from '@/hooks/useProviderRejectionStatus' import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' @@ -27,7 +29,8 @@ const MantecaFlowManager: FC = ({ claimLinkData, amount const [currentStep, setCurrentStep] = useState(MercadoPagoStep.DETAILS) const router = useRouter() const [destinationAddress, setDestinationAddress] = useState('') - const { isUserMantecaKycApproved } = useKycStatus() + const { isUserMantecaKycApproved, isUserSumsubKycApproved } = useKycStatus() + const { manteca: mantecaRejection } = useProviderRejectionStatus() // inline sumsub kyc flow for manteca users who need LATAM verification // regionIntent is NOT passed here to avoid creating a backend record on mount. @@ -115,15 +118,38 @@ const MantecaFlowManager: FC = ({ claimLinkData, amount /> {renderStepDetails()} + {sumsubFlow.error && } setShowKycModal(false)} onVerify={async () => { - await sumsubFlow.handleInitiateKyc('LATAM') + if (mantecaRejection.state === 'blocked') { + // blocked users cannot self-heal — route to support + if (typeof window !== 'undefined' && (window as any).$crisp) { + ;(window as any).$crisp.push(['do', 'chat:open']) + } + setShowKycModal(false) + return + } + const hasRejection = mantecaRejection.state === 'fixable' + if (hasRejection) { + await sumsubFlow.handleSelfHealResubmit('MANTECA') + } else { + await sumsubFlow.handleInitiateKyc('LATAM', undefined, true) + } setShowKycModal(false) }} isLoading={sumsubFlow.isLoading} + variant={ + mantecaRejection.state === 'fixable' || mantecaRejection.state === 'blocked' + ? 'provider_rejection' + : isUserSumsubKycApproved + ? 'cross_region' + : 'default' + } + providerMessage={mantecaRejection.userMessage ?? undefined} + regionName={selectedCountry?.title} /> diff --git a/src/components/Claim/Link/Onchain/Confirm.view.tsx b/src/components/Claim/Link/Onchain/Confirm.view.tsx index 041b44c40..930ea8752 100644 --- a/src/components/Claim/Link/Onchain/Confirm.view.tsx +++ b/src/components/Claim/Link/Onchain/Confirm.view.tsx @@ -21,6 +21,7 @@ import { sendLinksApi } from '@/services/sendLinks' import { useSearchParams } from 'next/navigation' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' +import underMaintenanceConfig, { CROSS_CHAIN_DISABLED_MESSAGE } from '@/config/underMaintenance.config' export const ConfirmClaimLinkView = ({ onNext, @@ -94,6 +95,12 @@ export const ConfirmClaimLinkView = ({ try { let claimTxHash: string | undefined = '' if (selectedRoute) { + if (underMaintenanceConfig.disableXchainSend) { + // safety net for stale routes — picker normally prevents reaching this view with a route + setErrorState({ showError: true, errorMessage: CROSS_CHAIN_DISABLED_MESSAGE }) + setLoadingState('Idle') + return + } claimTxHash = await claimLinkXchain({ address: recipient ? recipient.address : (address ?? ''), link: claimLinkData.link, diff --git a/src/components/Home/ActivationCTAs.tsx b/src/components/Home/ActivationCTAs.tsx index 62dfee416..e59fae072 100644 --- a/src/components/Home/ActivationCTAs.tsx +++ b/src/components/Home/ActivationCTAs.tsx @@ -6,9 +6,10 @@ import { Icon, type IconName } from '@/components/Global/Icons/Icon' import { useRouter } from 'next/navigation' import { useModalsContext } from '@/context/ModalsContext' import Card from '../Global/Card' -import { useEffect, useRef } from 'react' +import { useEffect, useMemo, useRef } from 'react' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' +import useProviderRejectionStatus from '@/hooks/useProviderRejectionStatus' interface ActivationCTAsProps { activationStep: ActivationStep @@ -65,10 +66,13 @@ const STEPS: Record, StepConfig> = { /** * single activation CTA for non-activated users on the home screen. * shows only the current step the user needs to complete. + * when sumsub is approved but a provider rejected the user, overrides + * the deposit/outbound step with a "complete your setup" message. */ export default function ActivationCTAs({ activationStep, onDismissCard }: ActivationCTAsProps) { const router = useRouter() const { setIsQRScannerOpen } = useModalsContext() + const { hasFixableRejection, hasBlockedRejection, primaryRejection } = useProviderRejectionStatus() const lastTrackedStep = useRef(null) useEffect(() => { @@ -80,9 +84,41 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa } }, [activationStep]) - if (activationStep === 'completed') return null + // provider rejection overrides the step copy when user is past the verify step + // (sumsub approved but provider rejected — deposit/outbound CTAs are useless) + const hasProviderRejection = + activationStep !== 'verify' && activationStep !== 'card' && (hasFixableRejection || hasBlockedRejection) - const step = STEPS[activationStep] + const step: StepConfig | null = useMemo(() => { + if (activationStep === 'completed' && !hasProviderRejection) return null + + if (hasProviderRejection) { + if (hasFixableRejection) { + return { + icon: 'globe-lock', + iconBg: 'bg-primary-1', + title: 'Complete your setup', + description: + primaryRejection?.userMessage || 'We need an updated document before you can add money.', + ctaLabel: 'Upload document', + href: '/profile/identity-verification', + } + } + // blocked + return { + icon: 'globe-lock', + iconBg: 'bg-primary-1', + title: 'Verification issue', + description: 'Contact support for help with your verification.', + ctaLabel: 'Contact support', + href: '', // handled in onClick + } + } + + return STEPS[activationStep as Exclude] + }, [activationStep, hasProviderRejection, hasFixableRejection, primaryRejection]) + + if (!step) return null return ( @@ -99,7 +135,11 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa shadowSize="4" className="mt-2 w-full" onClick={() => { - if (activationStep === 'outbound') { + if (hasProviderRejection && hasBlockedRejection && !hasFixableRejection) { + if (typeof window !== 'undefined' && (window as any).$crisp) { + ;(window as any).$crisp.push(['do', 'chat:open']) + } + } else if (activationStep === 'outbound' && !hasProviderRejection) { setIsQRScannerOpen(true) } else { router.push(step.href) diff --git a/src/components/Kyc/InitiateKycModal.tsx b/src/components/Kyc/InitiateKycModal.tsx index 3d9a8ea55..b7d9ca9f3 100644 --- a/src/components/Kyc/InitiateKycModal.tsx +++ b/src/components/Kyc/InitiateKycModal.tsx @@ -7,32 +7,64 @@ interface InitiateKycModalProps { onClose: () => void onVerify: () => void isLoading?: boolean + /** when set, shows provider-specific messaging instead of generic "verify your identity" */ + variant?: 'default' | 'provider_rejection' | 'cross_region' + providerMessage?: string + /** country name shown in cross_region variant (e.g. "Brazil", "Argentina") */ + regionName?: string } -// confirmation modal shown before starting KYC. -// user must click "Start Verification" to proceed to the sumsub SDK. -export const InitiateKycModal = ({ visible, onClose, onVerify, isLoading }: InitiateKycModalProps) => { +// confirmation modal shown before starting KYC or provider resubmission. +// for fresh KYC: "Verify your identity" +// for provider rejections: "We need extra documents" +// for cross-region: "Your identity is verified, we need a local ID" +export const InitiateKycModal = ({ + visible, + onClose, + onVerify, + isLoading, + variant = 'default', + providerMessage, + regionName, +}: InitiateKycModalProps) => { + const isProviderRejection = variant === 'provider_rejection' + const isCrossRegion = variant === 'cross_region' + + const getDescription = () => { + if (isProviderRejection) return providerMessage || 'Please upload a clearer photo of your ID to continue.' + if (isCrossRegion) { + const region = regionName ? ` from ${regionName}` : '' + return `Your identity is already verified. To enable payments in this region, we need a valid ID${region}.` + } + return 'To continue, you need to complete identity verification. This usually takes just a few minutes.' + } + return ( } + footer={ + isProviderRejection ? undefined : ( + + ) + } /> ) } diff --git a/src/components/Kyc/KycStatusDrawer.tsx b/src/components/Kyc/KycStatusDrawer.tsx index 03b71e432..9cf183328 100644 --- a/src/components/Kyc/KycStatusDrawer.tsx +++ b/src/components/Kyc/KycStatusDrawer.tsx @@ -4,6 +4,7 @@ import { KycFailed } from './states/KycFailed' import { KycNotStarted } from './states/KycNotStarted' import { KycProcessing } from './states/KycProcessing' import { KycRequiresDocuments } from './states/KycRequiresDocuments' +import { KycProviderRejection } from './states/KycProviderRejection' import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' import { Drawer, DrawerContent, DrawerTitle } from '../Global/Drawer' import { type BridgeKycStatus } from '@/utils/bridge-accounts.utils' @@ -13,6 +14,7 @@ import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' import { getKycStatusCategory, isKycStatusNotStarted } from '@/constants/kyc.consts' import { type KYCRegionIntent } from '@/app/actions/types/sumsub.types' import { useCallback } from 'react' +import useProviderRejectionStatus from '@/hooks/useProviderRejectionStatus' interface KycStatusDrawerProps { isOpen: boolean @@ -101,6 +103,9 @@ export const KycStatusDrawer = ({ [closeAndStartKyc] ) + // provider rejection status + const { bridge: bridgeRejection, manteca: mantecaRejection, hasAnyRejection } = useProviderRejectionStatus() + const renderContent = () => { // user initiated kyc but abandoned before submitting — close drawer visually // but keep component mounted so SumsubKycModals persists for the SDK flow @@ -108,6 +113,29 @@ export const KycStatusDrawer = ({ return } + // provider rejection: sumsub approved but bridge/manteca rejected + // show two-level status: identity verified + provider-specific rejection + if (statusCategory === 'completed' && hasAnyRejection) { + const rejection = + bridgeRejection.state === 'fixable' || bridgeRejection.state === 'blocked' + ? bridgeRejection + : mantecaRejection + return ( + { + onKeepMounted?.(true) + onClose() + await sumsubFlow.handleSelfHealResubmit(rejection.provider) + // release keep-mounted if SDK didn't open (error path) + if (!sumsubFlow.showWrapper) { + onKeepMounted?.(false) + } + }} + /> + ) + } + // bridge additional document requirement — but don't mask terminal kyc states if (needsAdditionalDocs && statusCategory !== 'failed' && statusCategory !== 'action_required') { return ( diff --git a/src/components/Kyc/KycStatusItem.tsx b/src/components/Kyc/KycStatusItem.tsx index 80f9de1b4..b5986401b 100644 --- a/src/components/Kyc/KycStatusItem.tsx +++ b/src/components/Kyc/KycStatusItem.tsx @@ -17,6 +17,7 @@ import { isKycStatusNotStarted, isKycStatusActionRequired, } from '@/constants/kyc.consts' +import useProviderRejectionStatus from '@/hooks/useProviderRejectionStatus' // kyc history entry type + type guard — used by HomeHistory and history page export interface KycHistoryEntry { @@ -80,6 +81,9 @@ export const KycStatusItem = ({ [user?.rails] ) + // provider rejection status (bridge/manteca) + const { hasFixableRejection, hasBlockedRejection } = useProviderRejectionStatus() + const isApproved = isKycStatusApproved(kycStatus) const isPending = isKycStatusPending(kycStatus) const isRejected = isKycStatusFailed(kycStatus) @@ -89,6 +93,9 @@ export const KycStatusItem = ({ const isInitiatedButNotStarted = !!verification && isKycStatusNotStarted(kycStatus) const subtitle = useMemo(() => { + // provider rejection takes priority when sumsub is approved + if (isApproved && hasFixableRejection) return 'Action needed' + if (isApproved && hasBlockedRejection) return 'Verification issue' if (hasBridgeDocsNeeded) return 'Action needed' if (isInitiatedButNotStarted) return 'Not completed' if (isActionRequired) return 'Action needed' @@ -96,7 +103,16 @@ export const KycStatusItem = ({ if (isApproved) return 'Verified' if (isRejected) return 'Failed' return 'Unknown' - }, [hasBridgeDocsNeeded, isInitiatedButNotStarted, isActionRequired, isPending, isApproved, isRejected]) + }, [ + hasBridgeDocsNeeded, + isInitiatedButNotStarted, + isActionRequired, + isPending, + isApproved, + isRejected, + hasFixableRejection, + hasBlockedRejection, + ]) // only hide for bridge's default "not_started" state. // if a verification record exists, the user has initiated KYC — show it. @@ -122,9 +138,13 @@ export const KycStatusItem = ({

{subtitle}

void +}) => { + const providerLabel = rejection.provider === 'BRIDGE' ? 'Bank transfers' : 'QR payments' + const isFixable = rejection.state === 'fixable' + + return ( +
+ {/* identity verified status */} + + + {/* provider-specific status */} +
+
+
+ +
+
+

+ {providerLabel}: {isFixable ? 'action needed' : 'unavailable'} +

+

+ {rejection.userMessage || + (isFixable ? 'We need an updated document.' : 'Contact support for help.')} +

+
+
+ + {isFixable && rejection.selfHealAttempt > 0 && ( +

+ Attempt {rejection.selfHealAttempt} of {rejection.maxAttempts} +

+ )} +
+ + {isFixable ? ( + + ) : ( + + )} +
+ ) +} diff --git a/src/components/Profile/views/RegionsVerification.view.tsx b/src/components/Profile/views/RegionsVerification.view.tsx index 9711d0483..427fbaaf2 100644 --- a/src/components/Profile/views/RegionsVerification.view.tsx +++ b/src/components/Profile/views/RegionsVerification.view.tsx @@ -10,8 +10,10 @@ import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' import { KycProcessingModal } from '@/components/Kyc/modals/KycProcessingModal' import { KycActionRequiredModal } from '@/components/Kyc/modals/KycActionRequiredModal' import { KycFailedModal } from '@/components/Kyc/modals/KycFailedModal' +import ActionModal from '@/components/Global/ActionModal' import { useIdentityVerification, getRegionIntent, type Region } from '@/hooks/useIdentityVerification' import useUnifiedKycStatus from '@/hooks/useUnifiedKycStatus' +import useProviderRejectionStatus from '@/hooks/useProviderRejectionStatus' import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' import { useAuth } from '@/context/authContext' import Image from 'next/image' @@ -51,7 +53,9 @@ const RegionsVerification = () => { const router = useRouter() const { user } = useAuth() const { unlockedRegions, lockedRegions } = useIdentityVerification() - const { sumsubStatus, sumsubRejectLabels, sumsubRejectType, sumsubVerificationRegionIntent } = useUnifiedKycStatus() + const { sumsubStatus, sumsubRejectLabels, sumsubRejectType, sumsubVerificationRegionIntent, isSumsubApproved } = + useUnifiedKycStatus() + const { bridge: bridgeRejection, manteca: mantecaRejection, hasAnyRejection } = useProviderRejectionStatus() const [selectedRegion, setSelectedRegion] = useState(null) // keeps the region display stable during modal close animation const displayRegionRef = useRef(null) @@ -69,10 +73,19 @@ const RegionsVerification = () => { ) const clickedRegionIntent = selectedRegion ? getRegionIntent(selectedRegion.path) : undefined - const modalVariant = selectedRegion + const baseModalVariant = selectedRegion ? getModalVariant(sumsubStatus, clickedRegionIntent, sumsubVerificationRegionIntent) : null + // override modal variant when sumsub is approved but a provider rejected the user + // determines which provider is relevant based on the clicked region + const providerRejectionForRegion = clickedRegionIntent === 'LATAM' ? mantecaRejection : bridgeRejection + const hasProviderRejectionForRegion = + !!selectedRegion && + isSumsubApproved && + (providerRejectionForRegion.state === 'fixable' || providerRejectionForRegion.state === 'blocked') + const modalVariant = hasProviderRejectionForRegion ? ('provider_rejection' as const) : baseModalVariant + const handleFinalKycSuccess = useCallback(() => { setSelectedRegion(null) setActiveRegionIntent(undefined) @@ -175,6 +188,47 @@ const RegionsVerification = () => { failureCount={sumsubFailureCount} /> + { + handleModalClose() + flow.handleSelfHealResubmit(providerRejectionForRegion.provider) + }, + variant: 'purple' as const, + shadowSize: '4' as const, + } + : { + text: 'Contact support', + onClick: () => { + if (typeof window !== 'undefined' && (window as any).$crisp) { + ;(window as any).$crisp.push(['do', 'chat:open']) + } + handleModalClose() + }, + variant: 'purple' as const, + shadowSize: '4' as const, + }, + ]} + /> + {flow.error &&

{flow.error}

} diff --git a/src/config/underMaintenance.config.ts b/src/config/underMaintenance.config.ts index a56270cfa..8f6ecc252 100644 --- a/src/config/underMaintenance.config.ts +++ b/src/config/underMaintenance.config.ts @@ -60,4 +60,8 @@ const underMaintenanceConfig: MaintenanceConfig = { disableCardPioneers: false, // set to false to enable the Card Pioneers waitlist feature } +// shared user-facing copy for cross-chain disabled paths — keep wording aligned with TokenSelector banner +export const CROSS_CHAIN_DISABLED_MESSAGE = + 'Cross-chain claims are temporarily unavailable. Try claiming to an external wallet on the same chain as the link, or try again later.' + export default underMaintenanceConfig diff --git a/src/constants/sumsub-reject-labels.consts.ts b/src/constants/sumsub-reject-labels.consts.ts index 7c4fc2850..9df35b914 100644 --- a/src/constants/sumsub-reject-labels.consts.ts +++ b/src/constants/sumsub-reject-labels.consts.ts @@ -322,11 +322,11 @@ export const isTerminalRejection = ({ failureCount, rejectLabels, }: { - rejectType?: 'RETRY' | 'FINAL' | null + rejectType?: 'RETRY' | 'FINAL' | 'PROVIDER_FIXABLE' | 'PROVIDER_FINAL' | null failureCount?: number rejectLabels?: string[] | null }): boolean => { - if (rejectType === 'FINAL') return true + if (rejectType === 'FINAL' || rejectType === 'PROVIDER_FINAL') return true if (failureCount && failureCount >= MAX_RETRY_COUNT) return true if (rejectLabels?.length && hasTerminalRejectLabel(rejectLabels)) return true return false diff --git a/src/content b/src/content index 35cd834a4..0b281d1bd 160000 --- a/src/content +++ b/src/content @@ -1 +1 @@ -Subproject commit 35cd834a4494e11136099b739fadff1aea4c06ce +Subproject commit 0b281d1bd5aa47ca82f04b9f98b5d73e8403ccc7 diff --git a/src/hooks/useMultiPhaseKycFlow.ts b/src/hooks/useMultiPhaseKycFlow.ts index 953ae58ae..64c6e0664 100644 --- a/src/hooks/useMultiPhaseKycFlow.ts +++ b/src/hooks/useMultiPhaseKycFlow.ts @@ -143,6 +143,7 @@ export const useMultiPhaseKycFlow = ({ onKycSuccess, onManualClose, regionIntent accessToken, liveKycStatus, handleInitiateKyc: originalHandleInitiateKyc, + handleSelfHealResubmit, handleSdkComplete: originalHandleSdkComplete, handleClose, refreshToken, @@ -173,7 +174,12 @@ export const useMultiPhaseKycFlow = ({ onKycSuccess, onManualClose, regionIntent posthog.capture(ANALYTICS_EVENTS.KYC_SUBMITTED, { region_intent: regionIntent }) isRealtimeFlowRef.current = true originalHandleSdkComplete() - }, [originalHandleSdkComplete, regionIntent]) + // for action flows (manteca, self-heal), the base status is already APPROVED + // and won't transition — directly start the preparing/tracking phase + if (isActionFlow) { + handleSumsubApproved() + } + }, [originalHandleSdkComplete, handleSumsubApproved, isActionFlow, regionIntent]) // wrap handleInitiateKyc to reset state for new attempts const handleInitiateKyc = useCallback( @@ -321,6 +327,7 @@ export const useMultiPhaseKycFlow = ({ onKycSuccess, onManualClose, regionIntent return { // initiation handleInitiateKyc, + handleSelfHealResubmit, isLoading, error, liveKycStatus, diff --git a/src/hooks/useProviderRejectionStatus.ts b/src/hooks/useProviderRejectionStatus.ts new file mode 100644 index 000000000..f9b560754 --- /dev/null +++ b/src/hooks/useProviderRejectionStatus.ts @@ -0,0 +1,166 @@ +'use client' + +import { useAuth } from '@/context/authContext' +import { useMemo } from 'react' +import type { IUserRail, IUserKycVerification } from '@/interfaces/interfaces' + +export type ProviderRejectionState = 'happy' | 'processing' | 'fixable' | 'blocked' + +export interface ProviderRejectionInfo { + provider: 'BRIDGE' | 'MANTECA' + state: ProviderRejectionState + userMessage: string | null + rejectedRails: IUserRail[] + kycVerification: IUserKycVerification | null + selfHealAttempt: number + maxAttempts: number +} + +const MAX_SELF_HEAL_ATTEMPTS = 3 + +/** + * derives per-provider fixable/blocked/processing state from rails + kycVerifications. + * shared by ActivationCTAs and KycStatusItem (DRY — hugo's comment #11). + */ +export default function useProviderRejectionStatus() { + const { user } = useAuth() + + const rails = user?.rails ?? [] + const kycVerifications = user?.user?.kycVerifications ?? [] + + const getProviderState = useMemo(() => { + return (providerCode: 'BRIDGE' | 'MANTECA'): ProviderRejectionInfo => { + const providerRails = rails.filter((r) => r.rail.provider.code === providerCode) + const rejectedRails = providerRails.filter((r) => r.status === 'REJECTED') + const pendingRails = providerRails.filter((r) => r.status === 'PENDING') + const enabledRails = providerRails.filter((r) => r.status === 'ENABLED') + + // find the most recent kyc verification for this provider + const kycVerification = + kycVerifications + .filter((v) => v.provider === providerCode) + .sort((a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime())[0] ?? + null + + const metadata = (kycVerification?.metadata ?? {}) as Record + const selfHealAttempt = (metadata.selfHealAttempt as number) || 0 + + // no rails for this provider — not submitted + if (providerRails.length === 0) { + return { + provider: providerCode, + state: 'happy', + userMessage: null, + rejectedRails: [], + kycVerification, + selfHealAttempt, + maxAttempts: MAX_SELF_HEAL_ATTEMPTS, + } + } + + // all enabled — happy + if (enabledRails.length > 0 && rejectedRails.length === 0) { + return { + provider: providerCode, + state: 'happy', + userMessage: null, + rejectedRails: [], + kycVerification, + selfHealAttempt, + maxAttempts: MAX_SELF_HEAL_ATTEMPTS, + } + } + + // has rejected rails + if (rejectedRails.length > 0) { + const firstRejectedMetadata = (rejectedRails[0].metadata ?? {}) as Record + const isSelfHealable = firstRejectedMetadata.selfHealable === true + const rejectType = kycVerification?.rejectType + + // check if fixable: selfHealable flag on rail + rejectType + attempt limit + const isFixable = + isSelfHealable && + rejectType !== 'PROVIDER_FINAL' && + rejectType !== 'FINAL' && + selfHealAttempt < MAX_SELF_HEAL_ATTEMPTS + + // extract user-facing message from rejection reasons or endorsement issues + let userMessage: string | null = null + const reasons = firstRejectedMetadata.rejectionReasons + const endorsementIssues = firstRejectedMetadata.endorsementIssues + if (Array.isArray(reasons) && reasons.length > 0) { + // bridge format: { reason: string, developer_reason: string } + // manteca format: { task: string, reason: string } + const first = reasons[0] + userMessage = first?.reason || first?.developer_reason || null + } else if (Array.isArray(endorsementIssues) && endorsementIssues.length > 0) { + // bridge endorsement issues: plain strings like 'government_id_verification_failed' + userMessage = `ID verification failed. Please upload a clearer photo.` + } + + return { + provider: providerCode, + state: isFixable ? 'fixable' : 'blocked', + userMessage, + rejectedRails, + kycVerification, + selfHealAttempt, + maxAttempts: MAX_SELF_HEAL_ATTEMPTS, + } + } + + // has pending rails (submitted but not yet reviewed) + if (pendingRails.length > 0) { + return { + provider: providerCode, + state: 'processing', + userMessage: null, + rejectedRails: [], + kycVerification, + selfHealAttempt, + maxAttempts: MAX_SELF_HEAL_ATTEMPTS, + } + } + + // default: processing (REQUIRES_INFORMATION, REQUIRES_EXTRA_INFORMATION, etc.) + return { + provider: providerCode, + state: 'processing', + userMessage: null, + rejectedRails: [], + kycVerification, + selfHealAttempt, + maxAttempts: MAX_SELF_HEAL_ATTEMPTS, + } + } + }, [rails, kycVerifications]) + + const bridge = useMemo(() => getProviderState('BRIDGE'), [getProviderState]) + const manteca = useMemo(() => getProviderState('MANTECA'), [getProviderState]) + + // overall: has any fixable rejection across providers + const hasFixableRejection = bridge.state === 'fixable' || manteca.state === 'fixable' + const hasBlockedRejection = bridge.state === 'blocked' || manteca.state === 'blocked' + const hasAnyRejection = hasFixableRejection || hasBlockedRejection + + // the provider that needs attention first (fixable takes priority) + const primaryRejection = + bridge.state === 'fixable' + ? bridge + : manteca.state === 'fixable' + ? manteca + : bridge.state === 'blocked' + ? bridge + : manteca.state === 'blocked' + ? manteca + : null + + return { + bridge, + manteca, + hasFixableRejection, + hasBlockedRejection, + hasAnyRejection, + primaryRejection, + } +} diff --git a/src/hooks/useQrKycGate.ts b/src/hooks/useQrKycGate.ts index 747ef0e78..80c31aea2 100644 --- a/src/hooks/useQrKycGate.ts +++ b/src/hooks/useQrKycGate.ts @@ -5,11 +5,15 @@ import { useAuth } from '@/context/authContext' import { MantecaKycStatus } from '@/interfaces' import { isKycStatusApproved, isSumsubStatusInProgress } from '@/constants/kyc.consts' +const MAX_SELF_HEAL_ATTEMPTS = 3 + export enum QrKycState { LOADING = 'loading', PROCEED_TO_PAY = 'proceed_to_pay', REQUIRES_IDENTITY_VERIFICATION = 'requires_identity_verification', IDENTITY_VERIFICATION_IN_PROGRESS = 'identity_verification_in_progress', + PROVIDER_REJECTION_FIXABLE = 'provider_rejection_fixable', + PROVIDER_REJECTION_BLOCKED = 'provider_rejection_blocked', } export interface QrKycGateResult { @@ -61,12 +65,31 @@ 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. + // sumsub approved users can proceed to qr pay, unless manteca rejected them const hasSumsubApproved = currentUser.kycVerifications?.some( (v) => v.provider === 'SUMSUB' && isKycStatusApproved(v.status) ) if (hasSumsubApproved) { + // check if manteca has rejected rails (qr payments use manteca) + const rejectedMantecaRails = (user?.rails ?? []).filter( + (r) => r.rail.provider.code === 'MANTECA' && r.status === 'REJECTED' + ) + if (rejectedMantecaRails.length > 0) { + const railMeta = (rejectedMantecaRails[0].metadata ?? {}) as Record + const mantecaKyc = currentUser.kycVerifications + ?.filter((v) => v.provider === 'MANTECA') + .sort((a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime())[0] + const kycMeta = (mantecaKyc?.metadata ?? {}) as Record + const isFixable = + railMeta.selfHealable === true && + mantecaKyc?.rejectType !== 'PROVIDER_FINAL' && + mantecaKyc?.rejectType !== 'FINAL' && + ((kycMeta.selfHealAttempt as number) || 0) < MAX_SELF_HEAL_ATTEMPTS + setKycGateState( + isFixable ? QrKycState.PROVIDER_REJECTION_FIXABLE : QrKycState.PROVIDER_REJECTION_BLOCKED + ) + return + } setKycGateState(QrKycState.PROCEED_TO_PAY) return } @@ -110,7 +133,7 @@ export function useQrKycGate(paymentProcessor?: 'MANTECA' | 'SIMPLEFI' | null): } setKycGateState(QrKycState.REQUIRES_IDENTITY_VERIFICATION) - }, [user?.user, isFetchingUser, paymentProcessor, fetchUser]) + }, [user?.user, user?.rails, isFetchingUser, paymentProcessor, fetchUser]) useEffect(() => { determineKycGateState() @@ -121,6 +144,8 @@ export function useQrKycGate(paymentProcessor?: 'MANTECA' | 'SIMPLEFI' | null): shouldBlockPay: kycGateState === QrKycState.REQUIRES_IDENTITY_VERIFICATION || kycGateState === QrKycState.IDENTITY_VERIFICATION_IN_PROGRESS || + kycGateState === QrKycState.PROVIDER_REJECTION_FIXABLE || + kycGateState === QrKycState.PROVIDER_REJECTION_BLOCKED || kycGateState === QrKycState.LOADING, } diff --git a/src/hooks/useSumsubKycFlow.ts b/src/hooks/useSumsubKycFlow.ts index e8582c6ed..84b9d6886 100644 --- a/src/hooks/useSumsubKycFlow.ts +++ b/src/hooks/useSumsubKycFlow.ts @@ -2,7 +2,7 @@ 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 { initiateSumsubKyc, initiateSelfHealResubmission } from '@/app/actions/sumsub' import { type KYCRegionIntent, type SumsubKycStatus } from '@/app/actions/types/sumsub.types' import { isCapacitor } from '@/utils/capacitor' @@ -37,6 +37,8 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: // guard: only fire onKycSuccess when the user initiated a kyc flow in this session. // prevents stale websocket events or mount-time fetches from auto-closing the drawer. const userInitiatedRef = useRef(false) + // tracks self-heal provider for token refresh — null when in regular KYC flow + const selfHealProviderRef = useRef<'BRIDGE' | 'MANTECA' | null>(null) useEffect(() => { regionIntentRef.current = regionIntent @@ -130,6 +132,7 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: async (overrideIntent?: KYCRegionIntent, levelName?: string, crossRegion?: boolean) => { userInitiatedRef.current = true initiatingRef.current = true + selfHealProviderRef.current = null setIsLoading(true) setError(null) @@ -247,6 +250,7 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: // called when sdk signals applicant submitted const handleSdkComplete = useCallback(() => { userInitiatedRef.current = true + selfHealProviderRef.current = null setShowWrapper(false) setIsActionFlow(false) setIsVerificationProgressModalOpen(true) @@ -260,8 +264,17 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: }, [onManualClose]) // token refresh function passed to the sdk for when the token expires. - // uses regionIntentRef + levelNameRef so refresh always matches the template used during initiation. + // uses self-heal provider ref when in self-heal mode, otherwise regular KYC endpoint. const refreshToken = useCallback(async (): Promise => { + if (selfHealProviderRef.current) { + const response = await initiateSelfHealResubmission(selfHealProviderRef.current) + if (response.error || !response.data?.token) { + throw new Error(response.error || 'Failed to refresh self-heal token') + } + setAccessToken(response.data.token) + return response.data.token + } + const response = await initiateSumsubKyc({ regionIntent: regionIntentRef.current, levelName: levelNameRef.current, @@ -288,6 +301,39 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: setError(null) }, []) + // initiate self-heal document resubmission: calls the resubmit API + // and opens the sumsub SDK with the action token + const handleSelfHealResubmit = useCallback(async (provider: 'BRIDGE' | 'MANTECA') => { + setIsLoading(true) + setError(null) + userInitiatedRef.current = true + selfHealProviderRef.current = provider + + try { + const response = await initiateSelfHealResubmission(provider) + + if (response.error) { + selfHealProviderRef.current = null + setError(response.error) + return + } + + if (response.data?.token) { + setAccessToken(response.data.token) + setShowWrapper(true) + } else { + selfHealProviderRef.current = null + setError('Could not initiate document resubmission. Please try again.') + } + } catch (e: unknown) { + selfHealProviderRef.current = null + const message = e instanceof Error ? e.message : 'An unexpected error occurred' + setError(message) + } finally { + setIsLoading(false) + } + }, []) + return { isLoading, error, @@ -296,6 +342,7 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: liveKycStatus, rejectLabels, handleInitiateKyc, + handleSelfHealResubmit, handleSdkComplete, handleClose, refreshToken, diff --git a/src/interfaces/interfaces.ts b/src/interfaces/interfaces.ts index 73bb7ce01..5559e6adc 100644 --- a/src/interfaces/interfaces.ts +++ b/src/interfaces/interfaces.ts @@ -176,7 +176,7 @@ export interface IUserKycVerification { providerRawStatus?: string | null sumsubApplicantId?: string | null rejectLabels?: string[] | null - rejectType?: 'RETRY' | 'FINAL' | null + rejectType?: 'RETRY' | 'FINAL' | 'PROVIDER_FIXABLE' | 'PROVIDER_FINAL' | null metadata?: { regionIntent?: string; [key: string]: unknown } | null createdAt: string updatedAt: string