diff --git a/app/(protected)/(tabs)/kyc.native.tsx b/app/(protected)/(tabs)/kyc.native.tsx index bf70592d..46fb42de 100644 --- a/app/(protected)/(tabs)/kyc.native.tsx +++ b/app/(protected)/(tabs)/kyc.native.tsx @@ -17,6 +17,7 @@ export default function KycNative() { markStarted, onVerificationComplete, onVerificationPending, + onVerificationDeclined, onVerificationError, } = useDiditSession(); @@ -40,7 +41,10 @@ export default function KycNative() { if (result.session.status === VerificationStatus.Approved) { onVerificationComplete(); } else if (result.session.status === VerificationStatus.Declined) { - onVerificationError('Your identity verification was declined.'); + // Redirect to /card/activate so the user sees specific Didit warnings + // (DOCUMENT_EXPIRED, DATE_OF_BIRTH_NOT_DETECTED, ...) instead of a + // generic declined screen with a retry button that loops. + onVerificationDeclined(); } else { // 'Pending', 'In Review', etc. — redirect back to activate page // so user sees "Under Review" state instead of blank page @@ -71,6 +75,7 @@ export default function KycNative() { initSession, onVerificationComplete, onVerificationPending, + onVerificationDeclined, onVerificationError, ]); diff --git a/app/(protected)/(tabs)/kyc.tsx b/app/(protected)/(tabs)/kyc.tsx index e7e1e2c5..3ecbbd9a 100644 --- a/app/(protected)/(tabs)/kyc.tsx +++ b/app/(protected)/(tabs)/kyc.tsx @@ -16,6 +16,7 @@ export default function KycWeb() { markStarted, onVerificationComplete, onVerificationPending, + onVerificationDeclined, onVerificationError, } = useDiditSession(); const hasStartedRef = useRef(false); @@ -41,7 +42,10 @@ export default function KycWeb() { if (result.session?.status === 'Approved') { onVerificationComplete(); } else if (result.session?.status === 'Declined') { - onVerificationError('Your identity verification was declined.'); + // Redirect to /card/activate so the user sees specific Didit warnings + // (DOCUMENT_EXPIRED, DATE_OF_BIRTH_NOT_DETECTED, ...) instead of a + // generic "declined" screen with a retry button that loops. + onVerificationDeclined(); } else { // 'Pending' shows up here for manual-review sessions. onVerificationPending(); @@ -82,7 +86,7 @@ export default function KycWeb() { break; case 'Declined': hasStartedRef.current = false; - onVerificationError('Your identity verification was declined.'); + onVerificationDeclined(); break; case 'Expired': case 'Kyc Expired': @@ -120,6 +124,7 @@ export default function KycWeb() { initSession, onVerificationComplete, onVerificationPending, + onVerificationDeclined, onVerificationError, ]); diff --git a/components/kyc/useDiditSession.ts b/components/kyc/useDiditSession.ts index 7ddab5c0..c4da1e6c 100644 --- a/components/kyc/useDiditSession.ts +++ b/components/kyc/useDiditSession.ts @@ -118,6 +118,29 @@ export function useDiditSession() { redirectBasedOnKycStatus(KycStatus.UNDER_REVIEW); }, [redirectBasedOnKycStatus]); + /** + * Didit terminal Declined: ID failed validation (e.g. expired doc, missing DOB, blocklist). + * Bounce back to /card/activate?kycStatus=rejected so the step-1 description renders the + * specific warnings (formatted via DIDIT_WARNING_DESCRIPTIONS / short_description) and the + * user clicks "Retry KYC" — which spins up a fresh Didit session via initSession. Without + * this redirect the user gets stuck on /kyc with a generic error and a "Try again" button + * that loops the same broken document. + */ + const onVerificationDeclined = useCallback(() => { + Toast.show({ + type: 'error', + text1: 'Verification declined', + text2: 'Review the details and try again with a valid document.', + props: { badgeText: '' }, + }); + redirectBasedOnKycStatus(KycStatus.REJECTED); + }, [redirectBasedOnKycStatus]); + + /** + * Hard failure (network error, session creation failed, SDK reported `failed`). Stays on + * /kyc and shows the error UI with a Try-again button — distinct from Declined, which is a + * KYC outcome we want surfaced on /card/activate alongside the warnings. + */ const onVerificationError = useCallback((message: string) => { Toast.show({ type: 'error', @@ -146,7 +169,7 @@ export function useDiditSession() { onVerificationPending(); } else if (status.kycStatus === KycStatus.REJECTED || status.status === 'Declined') { clearInterval(interval); - onVerificationError('Your identity verification was declined. Please try again.'); + onVerificationDeclined(); } else if (status.kycStatus === KycStatus.APPROVED || status.status === 'Approved') { clearInterval(interval); onVerificationComplete(); @@ -157,7 +180,13 @@ export function useDiditSession() { }, POLL_INTERVAL_MS); return () => clearInterval(interval); - }, [session.phase, onVerificationComplete, onVerificationError, onVerificationPending]); + }, [ + session.phase, + onVerificationComplete, + onVerificationDeclined, + onVerificationError, + onVerificationPending, + ]); // Auto-init on mount useEffect(() => { @@ -170,6 +199,7 @@ export function useDiditSession() { markStarted, onVerificationComplete, onVerificationPending, + onVerificationDeclined, onVerificationError, }; } diff --git a/hooks/useCardSteps/kycDisplayHelpers.ts b/hooks/useCardSteps/kycDisplayHelpers.ts index 6eacb0ca..0a00db9c 100644 --- a/hooks/useCardSteps/kycDisplayHelpers.ts +++ b/hooks/useCardSteps/kycDisplayHelpers.ts @@ -5,6 +5,7 @@ import { BridgeRejectionReason, CardProvider, KycStatus, + KycWarning, RainApplicationStatus, } from '@/lib/types'; @@ -79,19 +80,37 @@ const DIDIT_WARNING_DESCRIPTIONS: Record = { INVALID_DATE: 'A date on the document is invalid', }; -function formatDiditWarning(tag: string): string { - return ( - DIDIT_WARNING_DESCRIPTIONS[tag] ?? - tag - .replace(/_/g, ' ') - .toLowerCase() - .replace(/^\w/, (c) => c.toUpperCase()) - ); +/** Convert a SCREAMING_SNAKE_CASE tag into a Title-Cased phrase. */ +function formatRiskTag(tag: string): string { + return tag + .replace(/_/g, ' ') + .toLowerCase() + .replace(/^\w/, c => c.toUpperCase()); +} + +/** + * Pick the best display text for a single warning: + * 1. Our DIDIT_WARNING_DESCRIPTIONS override (when we want friendlier wording than Didit's) + * 2. Didit's `short_description` (always set for documented warnings) + * 3. Didit's `long_description` (rare fallback if a partial payload arrives) + * 4. The risk tag formatted into Title Case + */ +function formatDiditWarning(warning: KycWarning): string { + const risk = warning.risk ?? ''; + if (risk && DIDIT_WARNING_DESCRIPTIONS[risk]) { + return DIDIT_WARNING_DESCRIPTIONS[risk]; + } + if (warning.short_description) return warning.short_description; + if (warning.long_description) return warning.long_description; + return risk ? formatRiskTag(risk) : ''; } -function formatKycWarnings(warnings: string[]): string { - if (warnings.length === 0) return ''; - return warnings.map(formatDiditWarning).join('\n- '); +function formatKycWarnings(warnings: KycWarning[]): string { + if (!warnings || warnings.length === 0) return ''; + return warnings + .map(formatDiditWarning) + .filter(line => line.length > 0) + .join('\n- '); } /** @@ -178,7 +197,7 @@ export function getStepDescription( cardIssuer?: CardProvider | null; rainApplicationStatus?: RainApplicationStatus | null; kycStatus?: KycStatus | null; - kycWarnings?: string[] | null; + kycWarnings?: KycWarning[] | null; }, ): string { // Only use Rain description for recognized Rain application statuses diff --git a/hooks/useCardSteps/stepHelpers.ts b/hooks/useCardSteps/stepHelpers.ts index 026fbc7a..4993a5f3 100644 --- a/hooks/useCardSteps/stepHelpers.ts +++ b/hooks/useCardSteps/stepHelpers.ts @@ -9,6 +9,7 @@ import { CardProvider, CardStatus, KycStatus, + KycWarning, RainApplicationStatus, } from '@/lib/types'; @@ -31,7 +32,7 @@ export function buildCardSteps( cardIssuer?: CardProvider | null; rainApplicationStatus?: RainApplicationStatus | null; kycStatus?: KycStatus | null; - kycWarnings?: string[] | null; + kycWarnings?: KycWarning[] | null; handleRainKYCPress?: () => void; }, ): Step[] { diff --git a/lib/types.ts b/lib/types.ts index 89ce50eb..750085ca 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -435,6 +435,22 @@ export interface CardDetailsResponseDto extends CardResponse { provider?: CardProvider; } +/** + * A single warning entry surfaced for a user's KYC. Mirrors Didit's per-block warning shape: + * `risk` is the tag (DOCUMENT_EXPIRED, DATE_OF_BIRTH_NOT_DETECTED, ...) — same key space as + * DIDIT_WARNING_DESCRIPTIONS overrides; `short_description` / `long_description` are Didit's + * pre-formatted user-facing copy. Backend also synthesises one of these (with + * `risk: 'CARD_ACTIVATION_FAILED'`) when Rain rejects the forwarded application. + */ +export interface KycWarning { + risk: string; + log_type?: string; + short_description?: string; + long_description?: string; + feature?: string; + node_id?: string; +} + export interface CardStatusResponse { status?: CardStatus; activationBlocked?: boolean; @@ -444,8 +460,8 @@ export interface CardStatusResponse { provider?: CardProvider; /** Internal KYC status (covers Didit rejection before Rain is reached) */ kycStatus?: KycStatus; - /** Warning tags or reasons from Didit verification (e.g. DOCUMENT_EXPIRED). */ - kycWarnings?: string[]; + /** Warning entries from Didit verification (e.g. DOCUMENT_EXPIRED) and Rain forward failures. */ + kycWarnings?: KycWarning[]; /** Rain KYC: application status from Rain */ rainApplicationStatus?: RainApplicationStatus; /** Rain: link for needsVerification redirect */