Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion app/(protected)/(tabs)/kyc.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export default function KycNative() {
markStarted,
onVerificationComplete,
onVerificationPending,
onVerificationDeclined,
onVerificationError,
} = useDiditSession();

Expand All @@ -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
Expand Down Expand Up @@ -71,6 +75,7 @@ export default function KycNative() {
initSession,
onVerificationComplete,
onVerificationPending,
onVerificationDeclined,
onVerificationError,
]);

Expand Down
9 changes: 7 additions & 2 deletions app/(protected)/(tabs)/kyc.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export default function KycWeb() {
markStarted,
onVerificationComplete,
onVerificationPending,
onVerificationDeclined,
onVerificationError,
} = useDiditSession();
const hasStartedRef = useRef(false);
Expand All @@ -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();
Expand Down Expand Up @@ -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':
Expand Down Expand Up @@ -120,6 +124,7 @@ export default function KycWeb() {
initSession,
onVerificationComplete,
onVerificationPending,
onVerificationDeclined,
onVerificationError,
]);

Expand Down
34 changes: 32 additions & 2 deletions components/kyc/useDiditSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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();
Expand All @@ -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(() => {
Expand All @@ -170,6 +199,7 @@ export function useDiditSession() {
markStarted,
onVerificationComplete,
onVerificationPending,
onVerificationDeclined,
onVerificationError,
};
}
43 changes: 31 additions & 12 deletions hooks/useCardSteps/kycDisplayHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
BridgeRejectionReason,
CardProvider,
KycStatus,
KycWarning,
RainApplicationStatus,
} from '@/lib/types';

Expand Down Expand Up @@ -79,19 +80,37 @@ const DIDIT_WARNING_DESCRIPTIONS: Record<string, string> = {
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- ');
}

/**
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion hooks/useCardSteps/stepHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
CardProvider,
CardStatus,
KycStatus,
KycWarning,
RainApplicationStatus,
} from '@/lib/types';

Expand All @@ -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[] {
Expand Down
20 changes: 18 additions & 2 deletions lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 */
Expand Down
Loading