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 e4c0a509f..cade7728c 100644
--- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
+++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
@@ -409,7 +409,9 @@ export default function OnrampBankPage() {
visible={showKycModal}
onClose={() => setShowKycModal(false)}
onVerify={async () => {
- if (gate.kind === 'fixable-rejection') {
+ if (gate.kind === 'restart-identity') {
+ await sumsubFlow.handleRestartIdentity()
+ } else if (gate.kind === 'fixable-rejection') {
await sumsubFlow.handleSelfHealResubmit('BRIDGE')
} else {
await sumsubFlow.handleInitiateKyc(
diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx
index 587de5586..61f1210f1 100644
--- a/src/app/(mobile-ui)/qr-pay/page.tsx
+++ b/src/app/(mobile-ui)/qr-pay/page.tsx
@@ -178,6 +178,15 @@ export default function QRPayPage() {
// the resolver itself (Sumsub-approved + US-restricted → status:enabled
// + operations.pay:enabled, caught by canDo above), so a `blocked`
// status here is a genuine block.
+ //
+ // Country-not-supported is self-fixable: user uploaded a non-AR/BR doc
+ // and can verify again with a different one. Split out for the right CTA.
+ if (blockedRail.reason?.code === 'country_not_supported') {
+ return {
+ kycGateState: QrKycState.PROVIDER_RESTART_IDENTITY,
+ qrKycUserMessage: blockedRail.reason.userMessage ?? null,
+ }
+ }
return {
kycGateState: QrKycState.PROVIDER_REJECTION_BLOCKED,
qrKycUserMessage: blockedRail.reason?.userMessage ?? null,
@@ -921,7 +930,9 @@ export default function QRPayPage() {
kycGateState === QrKycState.REQUIRES_IDENTITY_VERIFICATION ||
kycGateState === QrKycState.IDENTITY_VERIFICATION_IN_PROGRESS
const hasProviderRejection =
- kycGateState === QrKycState.PROVIDER_REJECTION_FIXABLE || kycGateState === QrKycState.PROVIDER_REJECTION_BLOCKED
+ kycGateState === QrKycState.PROVIDER_REJECTION_FIXABLE ||
+ kycGateState === QrKycState.PROVIDER_REJECTION_BLOCKED ||
+ kycGateState === QrKycState.PROVIDER_RESTART_IDENTITY
// show loading while KYC state is being determined
if (isLoadingKycState) {
@@ -931,18 +942,28 @@ export default function QRPayPage() {
// provider rejection: user is sumsub-approved but manteca rejected
if (hasProviderRejection) {
const isFixable = kycGateState === QrKycState.PROVIDER_REJECTION_FIXABLE
+ const isRestartIdentity = kycGateState === QrKycState.PROVIDER_RESTART_IDENTITY
return (
setIsSupportModalOpen(true),
- variant: 'stroke' as const,
- },
+ : isRestartIdentity
+ ? {
+ text: 'Verify with a different document',
+ onClick: () => sumsubFlow.handleRestartIdentity(),
+ variant: 'purple' as const,
+ shadowSize: '4' as const,
+ icon: 'upload',
+ }
+ : {
+ text: 'Contact support',
+ onClick: () => setIsSupportModalOpen(true),
+ variant: 'stroke' as const,
+ },
]}
/>
diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
index 689f65fdd..9bd0f2466 100644
--- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
+++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
@@ -478,7 +478,9 @@ export default function WithdrawBankPage() {
visible={showKycModal}
onClose={() => setShowKycModal(false)}
onVerify={async () => {
- if (gate.kind === 'fixable-rejection') {
+ if (gate.kind === 'restart-identity') {
+ await sumsubFlow.handleRestartIdentity()
+ } else if (gate.kind === 'fixable-rejection') {
await sumsubFlow.handleSelfHealResubmit('BRIDGE')
} else {
await sumsubFlow.handleInitiateKyc(
diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx
index 3fc172839..a9f59907a 100644
--- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx
+++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx
@@ -601,8 +601,9 @@ export default function MantecaWithdrawFlow() {
setShowKycModal(false)
return
}
- const hasRejection = mantecaRejection.state === 'fixable'
- if (hasRejection) {
+ if (mantecaRejection.state === 'restart-identity') {
+ await sumsubFlow.handleRestartIdentity()
+ } else if (mantecaRejection.state === 'fixable') {
await sumsubFlow.handleSelfHealResubmit('MANTECA')
} else {
await sumsubFlow.handleInitiateKyc('LATAM', undefined, true, selectedCountry?.id)
@@ -613,11 +614,13 @@ export default function MantecaWithdrawFlow() {
variant={
mantecaRejection.state === 'blocked'
? 'blocked'
- : mantecaRejection.state === 'fixable'
- ? 'provider_rejection'
- : isUserIdentityVerified
- ? 'cross_region'
- : 'default'
+ : mantecaRejection.state === 'restart-identity'
+ ? 'restart_identity'
+ : mantecaRejection.state === 'fixable'
+ ? 'provider_rejection'
+ : isUserIdentityVerified
+ ? 'cross_region'
+ : 'default'
}
providerMessage={mantecaRejection.userMessage ?? undefined}
regionName={selectedCountry?.title}
diff --git a/src/app/actions/sumsub.ts b/src/app/actions/sumsub.ts
index 1f2b2a73c..a3f72895b 100644
--- a/src/app/actions/sumsub.ts
+++ b/src/app/actions/sumsub.ts
@@ -54,6 +54,36 @@ export interface SelfHealResubmissionResponse {
maxAttempts: number
}
+export interface RestartIdentityResponse {
+ token: string
+ levelName: string
+ applicantId: string
+}
+
+/**
+ * Reset the user's Sumsub IDENTITY step and mint a fresh token. Used as the
+ * "Verify with a different document" CTA on a Manteca rail that's blocked
+ * because the user verified with a non-AR/BR document.
+ */
+export const restartIdentityVerification = async (): Promise<{
+ data?: RestartIdentityResponse
+ error?: string
+}> => {
+ try {
+ const response = await serverFetch('/users/identity/restart', { method: 'POST' })
+ const responseJson = await response.json()
+ if (!response.ok) {
+ return {
+ error: responseJson.userMessage || responseJson.error || 'Failed to restart identity verification',
+ }
+ }
+ return { data: responseJson }
+ } catch (e: unknown) {
+ const message = e instanceof Error ? e.message : 'An unexpected error occurred'
+ return { error: message }
+ }
+}
+
// initiate self-heal document resubmission for a provider-rejected user
export const initiateSelfHealResubmission = async (
provider: 'BRIDGE' | 'MANTECA'
diff --git a/src/components/AddMoney/components/MantecaAddMoney.tsx b/src/components/AddMoney/components/MantecaAddMoney.tsx
index b17ce49a4..e402bf540 100644
--- a/src/components/AddMoney/components/MantecaAddMoney.tsx
+++ b/src/components/AddMoney/components/MantecaAddMoney.tsx
@@ -241,8 +241,9 @@ const MantecaAddMoney: FC = () => {
setShowKycModal(false)
return
}
- const hasRejection = mantecaRejection.state === 'fixable'
- if (hasRejection) {
+ if (mantecaRejection.state === 'restart-identity') {
+ await sumsubFlow.handleRestartIdentity()
+ } else if (mantecaRejection.state === 'fixable') {
await sumsubFlow.handleSelfHealResubmit('MANTECA')
} else {
await sumsubFlow.handleInitiateKyc('LATAM', undefined, true, selectedCountry?.id)
@@ -253,11 +254,13 @@ const MantecaAddMoney: FC = () => {
variant={
mantecaRejection.state === 'blocked'
? 'blocked'
- : mantecaRejection.state === 'fixable'
- ? 'provider_rejection'
- : isUserIdentityVerified
- ? 'cross_region'
- : 'default'
+ : mantecaRejection.state === 'restart-identity'
+ ? 'restart_identity'
+ : mantecaRejection.state === 'fixable'
+ ? 'provider_rejection'
+ : isUserIdentityVerified
+ ? 'cross_region'
+ : 'default'
}
providerMessage={mantecaRejection.userMessage ?? undefined}
regionName={selectedCountry?.title}
diff --git a/src/components/Claim/Link/MantecaFlowManager.tsx b/src/components/Claim/Link/MantecaFlowManager.tsx
index f005926eb..cf81cdc38 100644
--- a/src/components/Claim/Link/MantecaFlowManager.tsx
+++ b/src/components/Claim/Link/MantecaFlowManager.tsx
@@ -16,6 +16,7 @@ import { useCapabilities } from '@/hooks/useCapabilities'
import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow'
import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals'
import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal'
+import { deriveProviderRejection } from '@/utils/provider-rejection.utils'
interface MantecaFlowManagerProps {
claimLinkData: ClaimLinkData
@@ -28,7 +29,7 @@ const MantecaFlowManager: FC = ({ claimLinkData, amount
const [currentStep, setCurrentStep] = useState(MercadoPagoStep.DETAILS)
const router = useRouter()
const [destinationAddress, setDestinationAddress] = useState('')
- const { canDo, isKycApproved, railsForProvider } = useCapabilities()
+ const { canDo, isKycApproved, rails } = useCapabilities()
// MIGRATION-REVIEW: MercadoPago/PIX claim is a `pay` operation over Manteca. Old gate was
// `isUserMantecaKycApproved` (any MANTECA/SUMSUB-mantecaGeo verification approved). Mapped to
@@ -37,18 +38,9 @@ const MantecaFlowManager: FC = ({ claimLinkData, amount
// mantecaGeo counted as approved).
const isMantecaPayEnabled = canDo('pay', { provider: 'manteca' })
- // MIGRATION-REVIEW: old `manteca` rejection state (fixable/blocked + userMessage) derived from
- // raw rail status + self-heal metadata. That eligibility now lives backend-side as the rail
- // status: requires-info → fixable (self-heal), blocked → blocked (support). userMessage ←
- // reason.userMessage. Uses TOP-LEVEL status, so the pool pay-rail (top-level 'enabled') is
- // not treated as a rejection.
- const mantecaRejection = useMemo(() => {
- const mantecaRails = railsForProvider('manteca')
- const fixableRail = mantecaRails.find((rail) => rail.status === 'requires-info')
- const blockedRail = mantecaRails.find((rail) => rail.status === 'blocked')
- const state: 'fixable' | 'blocked' | 'happy' = fixableRail ? 'fixable' : blockedRail ? 'blocked' : 'happy'
- return { state, userMessage: (fixableRail ?? blockedRail)?.reason?.userMessage ?? null }
- }, [railsForProvider])
+ // Use the shared rejection util so the `restart-identity` branch (Manteca
+ // country-ineligibility — user uploaded a non-AR/BR doc) is honored here too.
+ const mantecaRejection = useMemo(() => deriveProviderRejection(rails, 'MANTECA'), [rails])
// inline sumsub kyc flow for manteca users who need LATAM verification
// regionIntent is NOT passed here to avoid creating a backend record on mount.
@@ -150,8 +142,9 @@ const MantecaFlowManager: FC = ({ claimLinkData, amount
setShowKycModal(false)
return
}
- const hasRejection = mantecaRejection.state === 'fixable'
- if (hasRejection) {
+ if (mantecaRejection.state === 'restart-identity') {
+ await sumsubFlow.handleRestartIdentity()
+ } else if (mantecaRejection.state === 'fixable') {
await sumsubFlow.handleSelfHealResubmit('MANTECA')
} else {
await sumsubFlow.handleInitiateKyc('LATAM', undefined, true)
@@ -162,15 +155,17 @@ const MantecaFlowManager: FC = ({ claimLinkData, amount
variant={
mantecaRejection.state === 'blocked'
? 'blocked'
- : mantecaRejection.state === 'fixable'
- ? 'provider_rejection'
- : // MIGRATION-REVIEW: 'cross_region' copy = "you're already verified, just need
- // the regional Manteca uplift". Old gate was `isUserSumsubKycApproved`. Sumsub has
- // no rail in the capability model; any enabled rail implies identity verification
- // was completed at least once, so isKycApproved is the closest faithful proxy.
- isKycApproved
- ? 'cross_region'
- : 'default'
+ : mantecaRejection.state === 'restart-identity'
+ ? 'restart_identity'
+ : mantecaRejection.state === 'fixable'
+ ? 'provider_rejection'
+ : // MIGRATION-REVIEW: 'cross_region' copy = "you're already verified, just need
+ // the regional Manteca uplift". Old gate was `isUserSumsubKycApproved`. Sumsub has
+ // no rail in the capability model; any enabled rail implies identity verification
+ // was completed at least once, so isKycApproved is the closest faithful proxy.
+ isKycApproved
+ ? 'cross_region'
+ : 'default'
}
providerMessage={mantecaRejection.userMessage ?? undefined}
regionName={selectedCountry?.title}
diff --git a/src/components/Claim/Link/views/BankFlowManager.view.tsx b/src/components/Claim/Link/views/BankFlowManager.view.tsx
index 4fdea53ef..ff126ea18 100644
--- a/src/components/Claim/Link/views/BankFlowManager.view.tsx
+++ b/src/components/Claim/Link/views/BankFlowManager.view.tsx
@@ -533,7 +533,9 @@ export const BankFlowManager = (props: IClaimScreenProps) => {
visible={showKycModal}
onClose={() => setShowKycModal(false)}
onVerify={async () => {
- if (gate.kind === 'fixable-rejection') {
+ if (gate.kind === 'restart-identity') {
+ await sumsubFlow.handleRestartIdentity()
+ } else if (gate.kind === 'fixable-rejection') {
await sumsubFlow.handleSelfHealResubmit('BRIDGE')
} else {
await sumsubFlow.handleInitiateKyc(
diff --git a/src/components/Kyc/InitiateKycModal.tsx b/src/components/Kyc/InitiateKycModal.tsx
index ca8cc2d8a..b7e04ef0c 100644
--- a/src/components/Kyc/InitiateKycModal.tsx
+++ b/src/components/Kyc/InitiateKycModal.tsx
@@ -11,17 +11,18 @@ interface InitiateKycModalProps {
/** error message from a failed verify/resubmit attempt */
error?: string | null
/** when set, shows context-specific messaging instead of the generic "unlock" copy */
- variant?: 'default' | 'provider_rejection' | 'blocked' | 'cross_region'
+ variant?: 'default' | 'provider_rejection' | 'blocked' | 'restart_identity' | 'cross_region'
providerMessage?: string
/** country name shown in cross_region variant (e.g. "Brazil", "Argentina") */
regionName?: string
}
// confirmation modal shown before starting identity check or document resubmission.
-// default → "Unlock your account" — verb is "unlock", ID check is the means
+// default → "Unlock your account" — verb is "unlock", ID check is the means
// provider_rejection → "We need extra documents"
-// blocked → "We couldn't unlock this — contact support"
-// cross_region → "Unlock {region}"
+// blocked → "We couldn't unlock this — contact support"
+// restart_identity → "Verify with a different document" (self-fix for country mismatch)
+// cross_region → "Unlock {region}"
export const InitiateKycModal = ({
visible,
onClose,
@@ -35,11 +36,13 @@ export const InitiateKycModal = ({
}: InitiateKycModalProps) => {
const isProviderRejection = variant === 'provider_rejection'
const isBlocked = variant === 'blocked'
+ const isRestartIdentity = variant === 'restart_identity'
const isCrossRegion = variant === 'cross_region'
const getTitle = () => {
if (error) return 'Something went wrong'
if (isBlocked) return 'We couldn’t unlock this'
+ if (isRestartIdentity) return 'Verify with a different document'
if (isProviderRejection) return 'We need extra documents'
if (isCrossRegion) return regionName ? `Unlock ${regionName}` : 'Unlock this region'
return 'Unlock your account'
@@ -48,6 +51,11 @@ export const InitiateKycModal = ({
const getDescription = () => {
if (error) return `${error} Please contact support for assistance.`
if (isBlocked) return providerMessage || "We couldn't confirm your ID. Please contact support for assistance."
+ if (isRestartIdentity)
+ return (
+ providerMessage ||
+ 'This rail needs a document from a supported country. You can verify with a different ID.'
+ )
if (isProviderRejection) return providerMessage || 'Please upload a clearer photo of your ID to continue.'
if (isCrossRegion) {
const region = regionName ? ` from ${regionName}` : ''
@@ -63,6 +71,13 @@ export const InitiateKycModal = ({
onClick: onContactSupport ?? onClose,
}
}
+ if (isRestartIdentity) {
+ return {
+ text: isLoading ? 'Loading...' : 'Verify with a different document',
+ onClick: onVerify,
+ icon: 'upload' as IconName,
+ }
+ }
if (isProviderRejection) {
return {
text: isLoading ? 'Loading...' : 'Upload document',
@@ -93,8 +108,8 @@ export const InitiateKycModal = ({
title={getTitle()}
description={getDescription()}
preventClose
- icon={(error || isBlocked ? 'alert' : 'badge') as IconName}
- iconContainerClassName={isBlocked ? 'bg-yellow-1' : ''}
+ icon={(error || isBlocked || isRestartIdentity ? 'alert' : 'badge') as IconName}
+ iconContainerClassName={isBlocked || isRestartIdentity ? 'bg-yellow-1' : ''}
modalPanelClassName="max-w-full m-2"
ctaClassName="grid grid-cols-1 gap-3"
ctas={[
@@ -109,7 +124,7 @@ export const InitiateKycModal = ({
},
]}
footer={
- isProviderRejection || isBlocked ? undefined : (
+ isProviderRejection || isBlocked || isRestartIdentity ? undefined : (
)
}
diff --git a/src/components/Profile/views/UnlockedRegions.view.tsx b/src/components/Profile/views/UnlockedRegions.view.tsx
index 63070f315..35eff07a1 100644
--- a/src/components/Profile/views/UnlockedRegions.view.tsx
+++ b/src/components/Profile/views/UnlockedRegions.view.tsx
@@ -251,13 +251,18 @@ const UnlockedRegions = () => {
title={
providerRejectionForRegion.state === 'fixable'
? 'We need an updated document'
- : 'Region unavailable'
+ : providerRejectionForRegion.state === 'restart-identity'
+ ? 'Verify with a different document'
+ : 'Region unavailable'
}
description={
providerRejectionForRegion.state === 'fixable'
? providerRejectionForRegion.userMessage ||
'Please upload a clearer photo of your ID to unlock this region.'
- : 'This region is not available for your account. Contact support for help.'
+ : providerRejectionForRegion.state === 'restart-identity'
+ ? providerRejectionForRegion.userMessage ||
+ 'This region needs a document from a supported country. You can verify with a different ID.'
+ : 'This region is not available for your account. Contact support for help.'
}
icon="alert"
iconContainerClassName="bg-yellow-1"
@@ -272,15 +277,25 @@ const UnlockedRegions = () => {
variant: 'purple' as const,
shadowSize: '4' as const,
}
- : {
- text: 'Contact support',
- onClick: () => {
- handleModalClose()
- setIsSupportModalOpen(true)
- },
- variant: 'purple' as const,
- shadowSize: '4' as const,
- },
+ : providerRejectionForRegion.state === 'restart-identity'
+ ? {
+ text: 'Verify with a different document',
+ onClick: () => {
+ handleModalClose()
+ flow.handleRestartIdentity()
+ },
+ variant: 'purple' as const,
+ shadowSize: '4' as const,
+ }
+ : {
+ text: 'Contact support',
+ onClick: () => {
+ handleModalClose()
+ setIsSupportModalOpen(true)
+ },
+ variant: 'purple' as const,
+ shadowSize: '4' as const,
+ },
]}
/>
diff --git a/src/constants/kyc.consts.ts b/src/constants/kyc.consts.ts
index cd7509126..436fc5873 100644
--- a/src/constants/kyc.consts.ts
+++ b/src/constants/kyc.consts.ts
@@ -23,6 +23,12 @@ export enum QrKycState {
IDENTITY_VERIFICATION_IN_PROGRESS = 'identity_verification_in_progress',
PROVIDER_REJECTION_FIXABLE = 'provider_rejection_fixable',
PROVIDER_REJECTION_BLOCKED = 'provider_rejection_blocked',
+ /**
+ * Blocked Manteca rail with a `restart-identity` action — user uploaded a
+ * non-AR/BR document on a Manteca-only flow. Self-fixable by verifying with
+ * a different ID.
+ */
+ PROVIDER_RESTART_IDENTITY = 'provider_restart_identity',
}
// sets of status values by category — single source of truth
diff --git a/src/hooks/useMultiPhaseKycFlow.ts b/src/hooks/useMultiPhaseKycFlow.ts
index f0670dcee..0a16637cb 100644
--- a/src/hooks/useMultiPhaseKycFlow.ts
+++ b/src/hooks/useMultiPhaseKycFlow.ts
@@ -201,6 +201,7 @@ export const useMultiPhaseKycFlow = ({ onKycSuccess, onManualClose, regionIntent
accessToken,
liveKycStatus,
handleInitiateKyc: originalHandleInitiateKyc,
+ handleRestartIdentity,
handleSelfHealResubmit,
handleSdkComplete: originalHandleSdkComplete,
handleClose,
@@ -385,6 +386,7 @@ export const useMultiPhaseKycFlow = ({ onKycSuccess, onManualClose, regionIntent
return {
// initiation
handleInitiateKyc,
+ handleRestartIdentity,
handleSelfHealResubmit,
isLoading,
error,
diff --git a/src/hooks/useSumsubKycFlow.ts b/src/hooks/useSumsubKycFlow.ts
index 2b12c5bc4..29051d83b 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, initiateSelfHealResubmission } from '@/app/actions/sumsub'
+import { initiateSumsubKyc, initiateSelfHealResubmission, restartIdentityVerification } from '@/app/actions/sumsub'
import { type KYCRegionIntent, type SumsubKycStatus } from '@/app/actions/types/sumsub.types'
import { isCapacitor } from '@/utils/capacitor'
@@ -316,6 +316,40 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }:
setError(null)
}, [])
+ // Reset Sumsub IDENTITY step + open the WebSDK with a fresh token. The
+ // user lands back on the document-upload screen so they can verify with a
+ // different ID. Used as the CTA for the `restart-identity` gate state
+ // (Manteca country-ineligibility — uploaded a non-AR/BR document).
+ const handleRestartIdentity = useCallback(async () => {
+ setIsLoading(true)
+ setError(null)
+ userInitiatedRef.current = true
+ // Clear any prior self-heal context so refreshToken (below) doesn't
+ // mistakenly hit the self-heal endpoint after a restart-identity flow
+ // (CodeRabbit caught: stale selfHealProviderRef would route the next
+ // refresh through initiateSelfHealResubmission instead of the regular path).
+ selfHealProviderRef.current = null
+
+ try {
+ const response = await restartIdentityVerification()
+ if (response.error) {
+ setError(response.error)
+ return
+ }
+ if (response.data?.token) {
+ setAccessToken(response.data.token)
+ setShowWrapper(true)
+ } else {
+ setError('Could not restart identity verification. Please try again.')
+ }
+ } catch (e: unknown) {
+ const message = e instanceof Error ? e.message : 'An unexpected error occurred'
+ setError(message)
+ } finally {
+ setIsLoading(false)
+ }
+ }, [])
+
// 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') => {
@@ -357,6 +391,7 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }:
liveKycStatus,
rejectLabels,
handleInitiateKyc,
+ handleRestartIdentity,
handleSelfHealResubmit,
handleSdkComplete,
handleClose,
diff --git a/src/types/capabilities.ts b/src/types/capabilities.ts
index 379ea8ef3..2793f0f48 100644
--- a/src/types/capabilities.ts
+++ b/src/types/capabilities.ts
@@ -82,7 +82,18 @@ export interface RailCapability {
reason?: CapabilityReason
}
-export type NextActionKind = 'sumsub' | 'accept-tos' | 'wait' | 'contact-support'
+/**
+ * Action kinds the FE dispatches on:
+ * - `sumsub` — mint a token for an RFI applicant action
+ * - `accept-tos` — open Bridge's hosted ToS link
+ * - `wait` — backend is processing; no user action needed
+ * - `contact-support` — terminal blocker; open the support drawer
+ * - `restart-identity` — reset the Sumsub IDENTITY step and re-open the
+ * WebSDK so the user can verify with a different
+ * document (used for the country-not-supported CTA
+ * on Manteca-only rails; user has a self-fix path).
+ */
+export type NextActionKind = 'sumsub' | 'accept-tos' | 'wait' | 'contact-support' | 'restart-identity'
export interface NextAction {
key: string // stable id, referenced by RailCapability.blockingActions
diff --git a/src/utils/capability-gate.test.ts b/src/utils/capability-gate.test.ts
index 61c3fb5d9..28e60be53 100644
--- a/src/utils/capability-gate.test.ts
+++ b/src/utils/capability-gate.test.ts
@@ -264,3 +264,54 @@ describe('getKycModalVariant — waiting-on-provider', () => {
expect(getKycModalVariant('waiting-on-provider')).toBe('default')
})
})
+
+describe('deriveGate — restart-identity vs contact-support split for blocked rails', () => {
+ const restartAction: NextAction = {
+ key: 'restart-identity',
+ kind: 'restart-identity',
+ purpose: 'verify-with-different-document',
+ }
+ const supportAction: NextAction = { key: 'contact-support', kind: 'contact-support', purpose: 'kyc-support' }
+
+ test('blocked rail carrying restart-identity action → restart-identity gate (self-fix path)', () => {
+ const rail = bankRail({
+ id: 'manteca.bank_transfer_ar',
+ provider: 'manteca',
+ method: 'BANK_TRANSFER_AR',
+ country: 'AR',
+ currency: 'ARS',
+ status: 'blocked',
+ blockingActions: ['restart-identity'],
+ reason: {
+ code: 'country_not_supported',
+ userMessage: 'This rail only supports documents issued in Argentina or Brazil.',
+ },
+ })
+
+ const gate = deriveGate(state([rail], [restartAction]), 'deposit', { channel: 'bank' })
+
+ expect(gate.kind).toBe('restart-identity')
+ if (gate.kind === 'restart-identity') {
+ expect(gate.userMessage).toMatch(/Argentina or Brazil/)
+ expect(gate.reason?.code).toBe('country_not_supported')
+ }
+ expect(getKycModalVariant(gate.kind)).toBe('restart_identity')
+ expect(getGateUserMessage(gate)).toMatch(/Argentina or Brazil/)
+ })
+
+ test('blocked rail with only contact-support → blocked-rejection (terminal)', () => {
+ const rail = bankRail({
+ status: 'blocked',
+ blockingActions: ['contact-support'],
+ reason: {
+ code: 'country_not_supported',
+ userMessage: 'This rail is not available in Cuba yet.',
+ },
+ })
+
+ const gate = deriveGate(state([rail], [supportAction]), 'deposit', { channel: 'bank' })
+
+ expect(gate.kind).toBe('blocked-rejection')
+ expect(getKycModalVariant(gate.kind)).toBe('blocked')
+ })
+})
diff --git a/src/utils/capability-gate.ts b/src/utils/capability-gate.ts
index f09903cec..829f1ca57 100644
--- a/src/utils/capability-gate.ts
+++ b/src/utils/capability-gate.ts
@@ -44,6 +44,13 @@ export type GateState =
| { kind: 'accept-tos'; tosUrl?: string; userMessage: string | null; reason?: CapabilityReason }
| { kind: 'fixable-rejection'; userMessage: string | null; reason?: CapabilityReason }
| { kind: 'blocked-rejection'; userMessage: string | null; reason?: CapabilityReason }
+ /**
+ * Blocked rail with a self-fix path: re-verify Sumsub IDENTITY with a
+ * different document. Triggered today for Manteca country-ineligibility
+ * (user uploaded a non-AR/BR doc on a flow that only supports those).
+ * CTA opens a fresh Sumsub WebSDK after POST /users/identity/restart.
+ */
+ | { kind: 'restart-identity'; userMessage: string | null; reason?: CapabilityReason }
| { kind: 'needs-identity' }
| { kind: 'needs-enrollment' }
@@ -131,9 +138,19 @@ export function deriveGate(state: CapabilityState, op: RailOperation, scope: Gat
const candidates = filterRailsByScope(state.rails, scope)
const actionByKey = new Map(state.nextActions.map((action) => [action.key, action]))
- // 2. blocked
+ // 2. blocked — split: if the rail carries a `restart-identity` action the
+ // user can self-fix by re-verifying with a different document; otherwise
+ // the only path is contact-support.
const blocked = candidates.find((rail) => rail.status === 'blocked')
if (blocked) {
+ const hasRestart = railActions(blocked, actionByKey).some((action) => action.kind === 'restart-identity')
+ if (hasRestart) {
+ return {
+ kind: 'restart-identity',
+ userMessage: blocked.reason?.userMessage ?? null,
+ reason: blocked.reason,
+ }
+ }
return {
kind: 'blocked-rejection',
userMessage: blocked.reason?.userMessage ?? null,
@@ -213,8 +230,9 @@ export function deriveGate(state: CapabilityState, op: RailOperation, scope: Gat
*/
export function getKycModalVariant(
kind: GateState['kind']
-): 'blocked' | 'provider_rejection' | 'cross_region' | 'default' {
+): 'blocked' | 'provider_rejection' | 'cross_region' | 'restart_identity' | 'default' {
if (kind === 'blocked-rejection') return 'blocked'
+ if (kind === 'restart-identity') return 'restart_identity'
if (kind === 'fixable-rejection') return 'provider_rejection'
if (kind === 'needs-enrollment') return 'cross_region'
return 'default'
@@ -229,6 +247,7 @@ export function getGateUserMessage(gate: GateState): string | undefined {
if (
gate.kind === 'fixable-rejection' ||
gate.kind === 'blocked-rejection' ||
+ gate.kind === 'restart-identity' ||
gate.kind === 'accept-tos' ||
gate.kind === 'waiting-on-provider'
) {
diff --git a/src/utils/provider-rejection.utils.ts b/src/utils/provider-rejection.utils.ts
index eae9c0999..81afefa96 100644
--- a/src/utils/provider-rejection.utils.ts
+++ b/src/utils/provider-rejection.utils.ts
@@ -27,7 +27,16 @@ import { type RailCapability } from '@/types/capabilities'
* into 'happy' because no consumer branches on it (all only check
* `state === 'fixable'` / `'blocked'`).
*/
-export type ProviderRejectionState = 'happy' | 'fixable' | 'blocked'
+/**
+ * Per-provider rejection state.
+ * - happy : nothing pending an action
+ * - fixable : user can re-submit docs via self-heal (`requires-info`)
+ * - restart-identity: blocked + the rail carries a `restart-identity` action
+ * (today: Manteca country-not-supported). Self-fixable
+ * by re-verifying with a different document.
+ * - blocked : terminal — contact support
+ */
+export type ProviderRejectionState = 'happy' | 'fixable' | 'restart-identity' | 'blocked'
export interface ProviderRejectionInfo {
provider: 'BRIDGE' | 'MANTECA'
@@ -40,7 +49,14 @@ const PROVIDER_CODE: Record<'BRIDGE' | 'MANTECA', 'bridge' | 'manteca'> = {
MANTECA: 'manteca',
}
-/** Derive the rejection state for a single provider from the capability rails. */
+/**
+ * Derive the rejection state for a single provider from the capability rails.
+ *
+ * `restart-identity` is detected via the rail's `reason.code` — keeps the
+ * signature backwards-compatible (no nextActions param) and mirrors the
+ * backend resolver's split: country-not-supported on a Manteca rail emits a
+ * `restart-identity` action; on Bridge/Rain it stays `contact-support`.
+ */
export function deriveProviderRejection(
rails: RailCapability[],
provider: 'BRIDGE' | 'MANTECA'
@@ -48,7 +64,14 @@ export function deriveProviderRejection(
const providerRails = rails.filter((rail) => rail.provider === PROVIDER_CODE[provider])
const fixableRail = providerRails.find((rail) => rail.status === 'requires-info')
const blockedRail = providerRails.find((rail) => rail.status === 'blocked')
- const state: ProviderRejectionState = fixableRail ? 'fixable' : blockedRail ? 'blocked' : 'happy'
+ const isRestartIdentity = provider === 'MANTECA' && blockedRail?.reason?.code === 'country_not_supported'
+ const state: ProviderRejectionState = fixableRail
+ ? 'fixable'
+ : isRestartIdentity
+ ? 'restart-identity'
+ : blockedRail
+ ? 'blocked'
+ : 'happy'
return {
provider,
state,