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,