diff --git a/.github/workflows/preview.yaml b/.github/workflows/preview.yaml
index 635054d4d..80001d63c 100644
--- a/.github/workflows/preview.yaml
+++ b/.github/workflows/preview.yaml
@@ -19,10 +19,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 1ab42b4c4..ae32c5bf6 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 } from 'next/navigation'
import { useCallback, useEffect, useMemo, useState } from 'react'
@@ -93,7 +94,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(() => {
@@ -191,10 +194,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
}
@@ -395,10 +405,25 @@ 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 d642c4def..0b5d31363 100644
--- a/src/app/(mobile-ui)/qr-pay/page.tsx
+++ b/src/app/(mobile-ui)/qr-pay/page.tsx
@@ -1076,12 +1076,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 ? (
+
+ ) : 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 5aa46ddfb..809255d2f 100644
--- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
+++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
@@ -28,6 +28,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'
@@ -61,6 +65,10 @@ export default function WithdrawBankPage() {
const [balanceErrorMessage, setBalanceErrorMessage] = useState(null)
const { hasPendingTransactions } = usePendingTransactions()
const { isBridgeSupportedCountry } = useIdentityVerification()
+ const { isUserSumsubKycApproved, isUserBridgeKycApproved } = useKycStatus()
+ const sumsubFlow = useMultiPhaseKycFlow({})
+ const [showKycModal, setShowKycModal] = useState(false)
+ const needsBridgeEnrollment = isUserSumsubKycApproved && !isUserBridgeKycApproved && !user?.user.bridgeCustomerId
// validate country is supported for bank withdrawals
useEffect(() => {
@@ -155,6 +163,11 @@ export default function WithdrawBankPage() {
}
const handleCreateAndInitiateOfframp = async () => {
+ if (needsBridgeEnrollment) {
+ setShowKycModal(true)
+ return
+ }
+
if (guardWithTos()) return
setIsLoading(true)
@@ -428,6 +441,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 471a00a39..0d9a47e99 100644
--- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx
+++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx
@@ -34,6 +34,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'
@@ -90,7 +91,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
@@ -99,6 +101,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.
@@ -530,10 +533,24 @@ export default function MantecaWithdrawFlow() {
visible={showKycModal}
onClose={() => setShowKycModal(false)}
onVerify={async () => {
- await sumsubFlow.handleInitiateKyc('LATAM')
+ 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) && }
)}
@@ -802,7 +819,7 @@ 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 6e1a2eea0..aefc3796d 100644
--- a/src/app/actions/sumsub.ts
+++ b/src/app/actions/sumsub.ts
@@ -39,7 +39,12 @@ 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 {
@@ -55,3 +60,50 @@ 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 }> => {
+ const jwtToken = (await getJWTCookie())?.value
+
+ if (!jwtToken) {
+ return { error: 'Authentication required' }
+ }
+
+ try {
+ const response = await fetchWithSentry(`${PEANUT_API_URL}/users/identity/resubmit`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${jwtToken}`,
+ 'api-key': API_KEY,
+ },
+ body: JSON.stringify({ provider }),
+ })
+
+ const responseJson = await response.json()
+
+ if (!response.ok) {
+ return {
+ error: responseJson.userMessage || responseJson.error || 'Failed to initiate document resubmission',
+ }
+ }
+
+ 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 e12390235..ac03ac285 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'
@@ -63,7 +64,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()
@@ -221,10 +223,24 @@ const MantecaAddMoney: FC = () => {
visible={showKycModal}
onClose={() => setShowKycModal(false)}
onVerify={async () => {
- await sumsubFlow.handleInitiateKyc('LATAM')
+ 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 6daf53151..8c02b22f3 100644
--- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx
+++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx
@@ -21,11 +21,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'
@@ -64,7 +66,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)
const countryPathParts = Array.isArray(params.country) ? params.country : [params.country]
@@ -83,6 +87,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) {
@@ -265,6 +285,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 4a94b1123..e915f23d9 100644
--- a/src/components/Claim/Link/Initial.view.tsx
+++ b/src/components/Claim/Link/Initial.view.tsx
@@ -30,6 +30,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'
@@ -322,6 +323,12 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => {
// check if cross-chain claiming is needed
if (isXChain) {
+ if (underMaintenanceConfig.disableSquidSend) {
+ // 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..563cf038a 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,30 @@ const MantecaFlowManager: FC = ({ claimLinkData, amount
/>
{renderStepDetails()}
+ {sumsubFlow.error && }
setShowKycModal(false)}
onVerify={async () => {
- await sumsubFlow.handleInitiateKyc('LATAM')
+ 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 f71f5d43f..e0d57a125 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,
@@ -97,6 +98,12 @@ export const ConfirmClaimLinkView = ({
try {
let claimTxHash: string | undefined = ''
if (selectedRoute) {
+ if (underMaintenanceConfig.disableSquidSend) {
+ // 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 f8fd156ba..08b609899 100644
--- a/src/components/Home/ActivationCTAs.tsx
+++ b/src/components/Home/ActivationCTAs.tsx
@@ -6,18 +6,24 @@ 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
}
-const STEPS: Record<
- Exclude,
- { icon: IconName; title: string; description: string; ctaLabel: string; href: string }
-> = {
+interface StepConfig {
+ icon: IconName
+ title: string
+ description: string
+ ctaLabel: string
+ href: string
+}
+
+const STEPS: Record, StepConfig> = {
verify: {
icon: 'globe-lock',
title: 'Verify to get started',
@@ -44,10 +50,13 @@ const STEPS: Record<
/**
* 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 }: ActivationCTAsProps) {
const router = useRouter()
const { setIsQRScannerOpen } = useModalsContext()
+ const { hasFixableRejection, hasBlockedRejection, primaryRejection } = useProviderRejectionStatus()
const lastTrackedStep = useRef(null)
useEffect(() => {
@@ -59,9 +68,38 @@ export default function ActivationCTAs({ activationStep }: ActivationCTAsProps)
}
}, [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' && (hasFixableRejection || hasBlockedRejection)
+
+ const step: StepConfig | null = useMemo(() => {
+ if (activationStep === 'completed' && !hasProviderRejection) return null
+
+ if (hasProviderRejection) {
+ if (hasFixableRejection) {
+ return {
+ icon: 'globe-lock',
+ 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',
+ 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])
- const step = STEPS[activationStep]
+ if (!step) return null
return (
@@ -78,7 +116,11 @@ export default function ActivationCTAs({ activationStep }: ActivationCTAsProps)
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 70e56cb11..e09b6cb7c 100644
--- a/src/config/underMaintenance.config.ts
+++ b/src/config/underMaintenance.config.ts
@@ -55,9 +55,13 @@ const underMaintenanceConfig: MaintenanceConfig = {
enableFullMaintenance: false, // set to true to redirect all pages to /maintenance
enableMaintenanceBanner: false, // set to true to show maintenance banner on all pages
disabledPaymentProviders: [], // set to ['MANTECA'] to disable Manteca QR payments
- disableSquidWithdraw: false, // set to true to disable cross-chain withdrawals (only allows USDC on Arbitrum)
- disableSquidSend: false, // set to true to disable cross-chain sends (claim, request payments - only allows USDC on Arbitrum)
+ disableSquidWithdraw: true, // set to true to disable cross-chain withdrawals (only allows USDC on Arbitrum)
+ disableSquidSend: true, // set to true to disable cross-chain sends (claim, request payments - only allows USDC on Arbitrum)
disableCardPioneers: true, // 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 46b6b1ae4..0b281d1bd 160000
--- a/src/content
+++ b/src/content
@@ -1 +1 @@
-Subproject commit 46b6b1ae40047c8674a34fff90b9371b6fdf3aa0
+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..014b94461
--- /dev/null
+++ b/src/hooks/useProviderRejectionStatus.ts
@@ -0,0 +1,163 @@
+'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' && 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..607435876 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,30 @@ 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' &&
+ ((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
}
@@ -121,6 +143,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 d92709a2e..ff915c1a4 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'
interface UseSumsubKycFlowOptions {
@@ -36,6 +36,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
@@ -129,6 +131,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)
@@ -206,6 +209,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)
@@ -219,8 +223,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,
@@ -247,6 +260,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,
@@ -255,6 +301,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 6bc66a79a..cd5e95a73 100644
--- a/src/interfaces/interfaces.ts
+++ b/src/interfaces/interfaces.ts
@@ -254,7 +254,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