SELF-1889: Initiate Sumsub during onboarding error flows (#1662)

* integrate sumsub into error flows

* formatting

* fix test

* format

* clean up

* udpate flows

* agent feedback

* updates

* save wip updates

* clean up design

* updates

* lint

* agent feedback

* formatting

* fix
This commit is contained in:
Justin Hernandez
2026-01-28 20:10:50 -08:00
committed by GitHub
parent 8da076cf0d
commit c7c9985d91
21 changed files with 1006 additions and 38 deletions

View File

@@ -72,6 +72,9 @@ const config = {
new RegExp(
'packages/mobile-sdk-alpha/node_modules/react-native-svg(/|$)',
),
new RegExp(
'packages/mobile-sdk-alpha/node_modules/react-native-webview(/|$)',
),
new RegExp('packages/mobile-sdk-demo/node_modules/react(/|$)'),
new RegExp('packages/mobile-sdk-demo/node_modules/react-dom(/|$)'),
new RegExp('packages/mobile-sdk-demo/node_modules/react-native(/|$)'),

View File

@@ -0,0 +1,27 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { useCallback } from 'react';
import type { InjectedErrorType } from '@/stores/errorInjectionStore';
import { useErrorInjectionStore } from '@/stores/errorInjectionStore';
import { IS_DEV_MODE } from '@/utils/devUtils';
/**
* Hook for checking if a specific error should be injected
* Only active in dev mode
*/
export function useErrorInjection() {
const injectedErrors = useErrorInjectionStore(state => state.injectedErrors);
const shouldInjectError = useCallback(
(errorType: InjectedErrorType): boolean => {
if (!IS_DEV_MODE) return false;
return injectedErrors.includes(errorType);
},
[injectedErrors],
);
return { shouldInjectError };
}

View File

@@ -0,0 +1,127 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { useCallback, useState } from 'react';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { sanitizeErrorMessage } from '@selfxyz/mobile-sdk-alpha';
import { fetchAccessToken, launchSumsub } from '@/integrations/sumsub';
import type { SumsubResult } from '@/integrations/sumsub/types';
import type { RootStackParamList } from '@/navigation';
export type FallbackErrorSource =
| 'mrz_scan_failed'
| 'nfc_scan_failed'
| 'sumsub_initialization'
| 'sumsub_verification';
export interface UseSumsubLauncherOptions {
/**
* Country code for the user's document
*/
countryCode: string;
/**
* Error source to track where the Sumsub launch was initiated from
*/
errorSource: FallbackErrorSource;
/**
* Optional callback to handle successful verification
*/
onSuccess?: (result: SumsubResult) => void | Promise<void>;
/**
* Optional callback to handle user cancellation
*/
onCancel?: () => void | Promise<void>;
/**
* Optional callback to handle verification failure
*/
onError?: (error: unknown, result?: SumsubResult) => void | Promise<void>;
}
/**
* Custom hook for launching Sumsub verification with consistent error handling.
*
* Abstracts the common pattern of:
* 1. Fetching access token
* 2. Launching Sumsub SDK
* 3. Handling errors by navigating to fallback screen
* 4. Managing loading state
*
* @example
* ```tsx
* const { launchSumsubVerification, isLoading } = useSumsubLauncher({
* countryCode: 'US',
* errorSource: 'nfc_scan_failed',
* });
*
* <Button onPress={launchSumsubVerification} disabled={isLoading}>
* {isLoading ? 'Loading...' : 'Try Alternative Verification'}
* </Button>
* ```
*/
export const useSumsubLauncher = (options: UseSumsubLauncherOptions) => {
const { countryCode, errorSource, onSuccess, onCancel, onError } = options;
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const [isLoading, setIsLoading] = useState(false);
const launchSumsubVerification = useCallback(async () => {
setIsLoading(true);
try {
const accessToken = await fetchAccessToken();
const result = await launchSumsub({ accessToken: accessToken.token });
// Handle user cancellation
if (!result.success && result.status === 'Interrupted') {
await onCancel?.();
return;
}
// Handle verification failure
if (!result.success) {
const error = result.errorMsg || result.errorType || 'Unknown error';
const safeError = sanitizeErrorMessage(error);
console.error('Sumsub verification failed:', safeError);
// Call custom error handler if provided, otherwise navigate to fallback screen
if (onError) {
await onError(safeError, result);
} else {
navigation.navigate('RegistrationFallback', {
errorSource,
countryCode,
});
}
return;
}
// Handle success
await onSuccess?.(result);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
const safeError = sanitizeErrorMessage(errorMessage);
console.error('Error launching alternative verification:', safeError);
// Call custom error handler if provided, otherwise navigate to fallback screen
if (onError) {
await onError(safeError);
} else {
navigation.navigate('RegistrationFallback', {
errorSource,
countryCode,
});
}
} finally {
setIsLoading(false);
}
}, [navigation, countryCode, errorSource, onSuccess, onCancel, onError]);
return {
launchSumsubVerification,
isLoading,
};
};

View File

@@ -19,6 +19,7 @@ import DocumentCameraTroubleScreen from '@/screens/documents/scanning/DocumentCa
import DocumentNFCMethodSelectionScreen from '@/screens/documents/scanning/DocumentNFCMethodSelectionScreen';
import DocumentNFCScanScreen from '@/screens/documents/scanning/DocumentNFCScanScreen';
import DocumentNFCTroubleScreen from '@/screens/documents/scanning/DocumentNFCTroubleScreen';
import RegistrationFallbackScreen from '@/screens/documents/scanning/RegistrationFallbackScreen';
import ConfirmBelongingScreen from '@/screens/documents/selection/ConfirmBelongingScreen';
import CountryPickerScreen from '@/screens/documents/selection/CountryPickerScreen';
import DocumentOnboardingScreen from '@/screens/documents/selection/DocumentOnboardingScreen';
@@ -155,6 +156,17 @@ const documentsScreens = {
errorType: 'general',
},
},
RegistrationFallback: {
screen: RegistrationFallbackScreen,
options: {
title: 'REGISTRATION',
headerShown: false,
} as NativeStackNavigationOptions,
initialParams: {
errorSource: 'sumsub_initialization',
countryCode: '',
},
},
};
export default documentsScreens;

View File

@@ -79,6 +79,7 @@ export type RootStackParamList = Omit<
| 'Home'
| 'IDPicker'
| 'IdDetails'
| 'RegistrationFallback'
| 'Loading'
| 'Modal'
| 'MockDataDeepLink'
@@ -127,6 +128,16 @@ export type RootStackParamList = Omit<
errorType: string;
};
// Registration Fallback screens
RegistrationFallback: {
errorSource:
| 'mrz_scan_failed'
| 'nfc_scan_failed'
| 'sumsub_initialization'
| 'sumsub_verification';
countryCode: string;
};
// Account/Recovery screens
AccountRecovery:
| {

View File

@@ -13,9 +13,11 @@ import {
type LogLevel,
type NFCScanContext,
reactNativeScannerAdapter,
sanitizeErrorMessage,
SdkEvents,
SelfClientProvider as SDKSelfClientProvider,
type TrackEventParams,
useMRZStore,
webNFCScannerShim,
type WsConn,
} from '@selfxyz/mobile-sdk-alpha';
@@ -33,7 +35,12 @@ import {
setPassportKeychainErrorCallback,
} from '@/providers/passportDataProvider';
import { trackEvent, trackNfcEvent } from '@/services/analytics';
import {
type InjectedErrorType,
useErrorInjectionStore,
} from '@/stores/errorInjectionStore';
import { useSettingStore } from '@/stores/settingStore';
import { IS_DEV_MODE } from '@/utils/devUtils';
import {
registerModalCallbacks,
unregisterModalCallbacks,
@@ -68,7 +75,20 @@ function navigateIfReady<RouteName extends keyof RootStackParamList>(
}
export const SelfClientProvider = ({ children }: PropsWithChildren) => {
const config = useMemo(() => ({}), []);
const config = useMemo(
() => ({
devConfig: IS_DEV_MODE
? {
shouldTrigger: (errorType: string) => {
return useErrorInjectionStore
.getState()
.shouldTrigger(errorType as InjectedErrorType);
},
}
: undefined,
}),
[],
);
const adapters: Adapters = useMemo(
() => ({
scanner:
@@ -167,6 +187,9 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => {
const appListeners = useMemo(() => {
const { map, addListener } = createListenersMap();
// Track current countryCode for error navigation
let currentCountryCode = '';
addListener(SdkEvents.PROVING_PASSPORT_DATA_NOT_FOUND, () => {
if (navigationRef.isReady()) {
navigationRef.navigate('DocumentDataNotFound');
@@ -261,7 +284,10 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => {
});
addListener(SdkEvents.DOCUMENT_MRZ_READ_FAILURE, () => {
navigateIfReady('DocumentCameraTrouble');
navigateIfReady('RegistrationFallback', {
errorSource: 'mrz_scan_failed',
countryCode: currentCountryCode,
});
});
addListener(SdkEvents.PROVING_AADHAAR_UPLOAD_SUCCESS, () => {
@@ -280,6 +306,9 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => {
countryCode: string;
documentTypes: string[];
}) => {
currentCountryCode = countryCode;
// Store country code early so it's available for Sumsub fallback flows
useMRZStore.getState().update({ countryCode });
navigateIfReady('IDPicker', { countryCode, documentTypes });
},
);
@@ -300,14 +329,68 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => {
}
break;
case 'kyc':
fetchAccessToken()
.then(accessToken => {
launchSumsub({ accessToken: accessToken.token });
})
// TODO: show sumsub error screen
.catch(error => {
console.error('Error launching Sumsub:', error);
});
(async () => {
try {
// Dev-only: Check for injected initialization error
if (
useErrorInjectionStore
.getState()
.shouldTrigger('sumsub_initialization')
) {
console.log('[DEV] Injecting Sumsub initialization error');
throw new Error(
'Injected Sumsub initialization error for testing',
);
}
const accessToken = await fetchAccessToken();
const result = await launchSumsub({
accessToken: accessToken.token,
});
// User cancelled - return silently
if (!result.success && result.status === 'Interrupted') {
return;
}
// Dev-only: Check for injected verification error
const shouldInjectVerificationError = useErrorInjectionStore
.getState()
.shouldTrigger('sumsub_verification');
// Actual error from provider
if (!result.success || shouldInjectVerificationError) {
if (shouldInjectVerificationError) {
console.log('[DEV] Injecting Sumsub verification error');
} else {
const safeError = sanitizeErrorMessage(
result.errorMsg || result.errorType || 'unknown_error',
);
console.error('KYC provider failed:', safeError);
}
// Guard navigation call after async operations
if (navigationRef.isReady()) {
navigationRef.navigate('RegistrationFallback', {
errorSource: 'sumsub_verification',
countryCode,
});
}
}
// success case: provider handles its own success UI
} catch (error) {
const safeInitError = sanitizeErrorMessage(
error instanceof Error ? error.message : String(error),
);
console.error('Error in KYC flow:', safeInitError);
// Guard navigation call after async operations
if (navigationRef.isReady()) {
navigationRef.navigate('RegistrationFallback', {
errorSource: 'sumsub_initialization',
countryCode,
});
}
}
})();
break;
default:
if (countryCode) {

View File

@@ -107,8 +107,8 @@ const LoadingScreen: React.FC<LoadingScreenProps> = ({ route }) => {
} else {
await init(selfClient, 'dsc', true);
}
} catch {
console.error('Error loading selected document:');
} catch (error) {
console.error('Error loading selected document:', error);
await init(selfClient, 'dsc', true);
} finally {
setIsInitializing(false);

View File

@@ -44,6 +44,12 @@ import {
subscribeToTopics,
unsubscribeFromTopics,
} from '@/services/notifications/notificationService';
import type { InjectedErrorType } from '@/stores/errorInjectionStore';
import {
ERROR_GROUPS,
ERROR_LABELS,
useErrorInjectionStore,
} from '@/stores/errorInjectionStore';
import { usePointEventStore } from '@/stores/pointEventStore';
import { useSettingStore } from '@/stores/settingStore';
import { IS_DEV_MODE } from '@/utils/devUtils';
@@ -390,6 +396,152 @@ const LogLevelSelector = ({
);
};
const ErrorInjectionSelector = () => {
const injectedErrors = useErrorInjectionStore(state => state.injectedErrors);
const setInjectedErrors = useErrorInjectionStore(
state => state.setInjectedErrors,
);
const clearAllErrors = useErrorInjectionStore(state => state.clearAllErrors);
const [open, setOpen] = useState(false);
// Single error selection - replace instead of toggle
const selectError = (errorType: InjectedErrorType) => {
// If clicking the same error, clear it; otherwise set the new one
if (injectedErrors.length === 1 && injectedErrors[0] === errorType) {
clearAllErrors();
} else {
setInjectedErrors([errorType]);
}
// Close the sheet after selection
setOpen(false);
};
const currentError = injectedErrors.length > 0 ? injectedErrors[0] : null;
const currentErrorLabel = currentError ? ERROR_LABELS[currentError] : null;
return (
<YStack gap="$2">
<Button
style={{ backgroundColor: 'white' }}
borderColor={slate200}
borderRadius="$2"
height="$5"
padding={0}
onPress={() => setOpen(true)}
>
<XStack
width="100%"
justifyContent="space-between"
paddingVertical="$3"
paddingLeft="$4"
paddingRight="$1.5"
>
<Text fontSize="$5" color={slate500} fontFamily={dinot}>
{currentErrorLabel || 'Select onboarding error to test'}
</Text>
<ChevronDown color={slate500} strokeWidth={2.5} />
</XStack>
</Button>
{currentError && (
<Button
backgroundColor={red500}
borderRadius="$2"
height="$5"
onPress={clearAllErrors}
pressStyle={{
opacity: 0.8,
scale: 0.98,
}}
>
<Text color={white} fontSize="$5" fontFamily={dinot}>
Clear
</Text>
</Button>
)}
<Sheet
modal
open={open}
onOpenChange={setOpen}
snapPoints={[85]}
animation="medium"
dismissOnSnapToBottom
>
<Sheet.Overlay />
<Sheet.Frame
backgroundColor={white}
borderTopLeftRadius="$9"
borderTopRightRadius="$9"
>
<YStack padding="$4">
<XStack
alignItems="center"
justifyContent="space-between"
marginBottom="$4"
>
<Text fontSize="$8" fontFamily={dinot}>
Onboarding Error Testing
</Text>
<Button
onPress={() => setOpen(false)}
padding="$2"
backgroundColor="transparent"
>
<ChevronDown
color={slate500}
strokeWidth={2.5}
style={{ transform: [{ rotate: '180deg' }] }}
/>
</Button>
</XStack>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 100 }}
>
{Object.entries(ERROR_GROUPS).map(([groupName, errors]) => (
<YStack key={groupName} marginBottom="$4">
<Text
fontSize="$6"
fontFamily={dinot}
fontWeight="600"
color={slate800}
marginBottom="$2"
>
{groupName}
</Text>
{errors.map((errorType: InjectedErrorType) => (
<TouchableOpacity
key={errorType}
onPress={() => selectError(errorType)}
>
<XStack
paddingVertical="$3"
paddingHorizontal="$2"
borderBottomWidth={1}
borderBottomColor={slate200}
alignItems="center"
justifyContent="space-between"
>
<Text fontSize="$5" color={slate600} fontFamily={dinot}>
{ERROR_LABELS[errorType]}
</Text>
{currentError === errorType && (
<Check color={slate600} size={20} />
)}
</XStack>
</TouchableOpacity>
))}
</YStack>
))}
</ScrollView>
</YStack>
</Sheet.Frame>
</Sheet>
</YStack>
);
};
const DevSettingsScreen: React.FC<DevSettingsScreenProps> = ({}) => {
const { clearDocumentCatalogForMigrationTesting } = usePassport();
const clearPointEvents = usePointEventStore(state => state.clearEvents);
@@ -779,6 +931,16 @@ const DevSettingsScreen: React.FC<DevSettingsScreenProps> = ({}) => {
/>
</ParameterSection>
{IS_DEV_MODE && (
<ParameterSection
icon={<BugIcon />}
title="Onboarding Error Testing"
description="Test onboarding error flows"
>
<ErrorInjectionSelector />
</ParameterSection>
)}
{Platform.OS === 'android' && (
<ParameterSection
icon={<BugIcon />}

View File

@@ -3,9 +3,11 @@
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React, { useEffect } from 'react';
import { YStack } from 'tamagui';
import { Caption } from '@selfxyz/mobile-sdk-alpha/components';
import { slate500 } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import { Caption, SecondaryButton } from '@selfxyz/mobile-sdk-alpha/components';
import { slate500, slate700 } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import Activity from '@/assets/icons/activity.svg';
import PassportCameraBulb from '@/assets/icons/passport_camera_bulb.svg';
@@ -15,6 +17,7 @@ import Star from '@/assets/icons/star.svg';
import type { TipProps } from '@/components/Tips';
import Tips from '@/components/Tips';
import useHapticNavigation from '@/hooks/useHapticNavigation';
import { useSumsubLauncher } from '@/hooks/useSumsubLauncher';
import SimpleScrolledTitleLayout from '@/layouts/SimpleScrolledTitleLayout';
import { flush as flushAnalytics } from '@/services/analytics';
@@ -48,6 +51,13 @@ const tips: TipProps[] = [
const DocumentCameraTroubleScreen: React.FC = () => {
const go = useHapticNavigation('DocumentCamera', { action: 'cancel' });
const selfClient = useSelfClient();
const { useMRZStore } = selfClient;
const { countryCode } = useMRZStore();
const { launchSumsubVerification, isLoading } = useSumsubLauncher({
countryCode,
errorSource: 'sumsub_initialization',
});
// error screen, flush analytics
useEffect(() => {
@@ -64,10 +74,28 @@ const DocumentCameraTroubleScreen: React.FC = () => {
</Caption>
}
footer={
<Caption size="large" style={{ color: slate500 }}>
Following these steps should help your phone's camera capture the ID
page quickly and clearly!
</Caption>
<YStack gap="$3">
<Caption size="large" style={{ color: slate500 }}>
Following these steps should help your phone's camera capture the ID
page quickly and clearly!
</Caption>
<Caption
size="large"
style={{ color: slate500, marginTop: 12, marginBottom: 8 }}
>
Or try an alternative verification method:
</Caption>
<SecondaryButton
onPress={launchSumsubVerification}
disabled={isLoading}
textColor={slate700}
style={{ marginBottom: 0 }}
>
{isLoading ? 'Loading...' : 'Try Alternative Verification'}
</SecondaryButton>
</YStack>
}
>
<Tips items={tips} />

View File

@@ -54,6 +54,7 @@ import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import passportVerifyAnimation from '@/assets/animations/passport_verify.json';
import NFC_IMAGE from '@/assets/images/nfc.png';
import { logNFCEvent } from '@/config/sentry';
import { useErrorInjection } from '@/hooks/useErrorInjection';
import { useFeedbackAutoHide } from '@/hooks/useFeedbackAutoHide';
import useHapticNavigation from '@/hooks/useHapticNavigation';
import {
@@ -106,8 +107,9 @@ const DocumentNFCScanScreen: React.FC = () => {
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const route = useRoute<DocumentNFCScanRoute>();
const { showModal } = useFeedback();
useFeedback();
useFeedbackAutoHide();
const { shouldInjectError } = useErrorInjection();
const {
passportNumber,
dateOfBirth,
@@ -189,18 +191,12 @@ const DocumentNFCScanScreen: React.FC = () => {
},
{ message: sanitizeErrorMessage(message) },
);
showModal({
titleText: 'NFC Scan Error',
bodyText: message,
buttonText: SUPPORT_FORM_BUTTON_TEXT,
secondaryButtonText: 'Help',
preventDismiss: false,
onButtonPress: openSupportForm,
onSecondaryButtonPress: goToNFCTrouble,
onModalDismiss: () => {},
navigation.navigate('RegistrationFallback', {
errorSource: 'nfc_scan_failed',
countryCode,
});
},
[baseContext, showModal, goToNFCTrouble],
[baseContext, navigation, countryCode],
);
const checkNfcSupport = useCallback(async () => {
@@ -324,6 +320,18 @@ const DocumentNFCScanScreen: React.FC = () => {
}, 30000);
try {
// Dev-only: Check for injected timeout error
if (shouldInjectError('nfc_timeout')) {
console.log('[DEV] Injecting NFC timeout error');
throw new Error('Injected timeout error for testing');
}
// Dev-only: Check for injected module unavailable error
if (shouldInjectError('nfc_module_unavailable')) {
console.log('[DEV] Injecting NFC module unavailable error');
throw new Error('NFC scanning is currently unavailable');
}
const {
canNumber,
useCan,
@@ -376,6 +384,12 @@ const DocumentNFCScanScreen: React.FC = () => {
);
let passportData: PassportData | null = null;
try {
// Dev-only: Check for injected parse failure error
if (shouldInjectError('nfc_parse_failure')) {
console.log('[DEV] Injecting NFC parse failure error');
throw new Error('Failed to parse NFC response');
}
passportData = parseScanResponse(scanResponse);
} catch (e: unknown) {
console.error('Parsing NFC Response Unsuccessful');
@@ -452,6 +466,7 @@ const DocumentNFCScanScreen: React.FC = () => {
navigation,
openErrorModal,
trackEvent,
shouldInjectError,
]);
const navigateToHome = useHapticNavigation('Home', {

View File

@@ -7,13 +7,15 @@ import { View } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { YStack } from 'tamagui';
import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import { Caption, SecondaryButton } from '@selfxyz/mobile-sdk-alpha/components';
import { slate500 } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { slate500, slate700 } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import type { TipProps } from '@/components/Tips';
import Tips from '@/components/Tips';
import { useFeedbackAutoHide } from '@/hooks/useFeedbackAutoHide';
import useHapticNavigation from '@/hooks/useHapticNavigation';
import { useSumsubLauncher } from '@/hooks/useSumsubLauncher';
import SimpleScrolledTitleLayout from '@/layouts/SimpleScrolledTitleLayout';
import { flushAllAnalytics } from '@/services/analytics';
import { openSupportForm, SUPPORT_FORM_BUTTON_TEXT } from '@/services/support';
@@ -50,6 +52,13 @@ const DocumentNFCTroubleScreen: React.FC = () => {
const goToNFCMethodSelection = useHapticNavigation(
'DocumentNFCMethodSelection',
);
const selfClient = useSelfClient();
const { useMRZStore } = selfClient;
const { countryCode } = useMRZStore();
const { launchSumsubVerification, isLoading } = useSumsubLauncher({
countryCode,
errorSource: 'sumsub_initialization',
});
useFeedbackAutoHide();
// error screen, flush analytics
@@ -71,9 +80,24 @@ const DocumentNFCTroubleScreen: React.FC = () => {
secondaryButtonText="Open NFC Options"
onSecondaryButtonPress={goToNFCMethodSelection}
footer={
<SecondaryButton onPress={openSupportForm} style={{ marginBottom: 0 }}>
{SUPPORT_FORM_BUTTON_TEXT}
</SecondaryButton>
<YStack gap="$3">
<SecondaryButton
onPress={openSupportForm}
textColor={slate700}
style={{ marginBottom: 0 }}
>
{SUPPORT_FORM_BUTTON_TEXT}
</SecondaryButton>
<SecondaryButton
onPress={launchSumsubVerification}
disabled={isLoading}
textColor={slate700}
style={{ marginBottom: 0 }}
>
{isLoading ? 'Loading...' : 'Try Alternative Verification'}
</SecondaryButton>
</YStack>
}
>
<YStack

View File

@@ -0,0 +1,326 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React, { useCallback } from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Button, XStack, YStack } from 'tamagui';
import type { RouteProp } from '@react-navigation/native';
import { useNavigation, useRoute } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { HelpCircle, X } from '@tamagui/lucide-icons';
import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import {
BodyText,
PrimaryButton,
SecondaryButton,
} from '@selfxyz/mobile-sdk-alpha/components';
import {
black,
cyan300,
slate100,
slate200,
slate300,
slate500,
white,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import { useSafeBottomPadding } from '@selfxyz/mobile-sdk-alpha/hooks';
import WarningIcon from '@/assets/images/warning.svg';
import { useSumsubLauncher } from '@/hooks/useSumsubLauncher';
import { buttonTap } from '@/integrations/haptics';
import type { RootStackParamList } from '@/navigation';
import { extraYPadding } from '@/utils/styleUtils';
type FallbackErrorSource =
| 'mrz_scan_failed'
| 'nfc_scan_failed'
| 'sumsub_initialization'
| 'sumsub_verification';
type RegistrationFallbackRouteParams = {
errorSource: FallbackErrorSource;
countryCode: string;
};
type RegistrationFallbackRoute = RouteProp<
Record<string, RegistrationFallbackRouteParams>,
string
>;
const getHeaderTitle = (errorSource: FallbackErrorSource): string => {
switch (errorSource) {
case 'mrz_scan_failed':
return 'MRZ SCAN';
case 'nfc_scan_failed':
return 'NFC SCAN';
default:
return 'REGISTRATION';
}
};
const getCurrentStep = (errorSource: FallbackErrorSource): number => {
switch (errorSource) {
case 'mrz_scan_failed':
return 1; // Step 1: MRZ scanning
case 'nfc_scan_failed':
return 2; // Step 2: NFC reading
case 'sumsub_initialization':
case 'sumsub_verification':
return 3; // Step 3: Proving/verification
default:
return 1;
}
};
const getRetryButtonText = (errorSource: FallbackErrorSource): string => {
switch (errorSource) {
case 'mrz_scan_failed':
return 'Try scanning again';
case 'nfc_scan_failed':
return 'Try reading again';
default:
return 'Try again';
}
};
const getErrorMessages = (
errorSource: FallbackErrorSource,
): { title: string; description: string; canRetryOriginal: boolean } => {
switch (errorSource) {
case 'mrz_scan_failed':
return {
title: 'There was a problem scanning your document',
description: 'Make sure the document is clearly visible and try again',
canRetryOriginal: true,
};
case 'nfc_scan_failed':
return {
title: 'There was a problem reading the chip',
description: 'Make sure NFC is enabled and try again',
canRetryOriginal: true,
};
case 'sumsub_initialization':
return {
title: 'Connection Error',
description:
'Unable to connect to verification service. Please check your internet connection and try again.',
canRetryOriginal: false,
};
case 'sumsub_verification':
return {
title: 'Verification Error',
description:
'Something went wrong during the verification process. Please try again.',
canRetryOriginal: false,
};
}
};
const RegistrationFallbackScreen: React.FC = () => {
const insets = useSafeAreaInsets();
const paddingBottom = useSafeBottomPadding(extraYPadding + 35);
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const route = useRoute<RegistrationFallbackRoute>();
const selfClient = useSelfClient();
const { trackEvent, useMRZStore } = selfClient;
const storeCountryCode = useMRZStore(state => state.countryCode);
const errorSource = route.params?.errorSource || 'sumsub_initialization';
// Use country code from route params, or fall back to MRZ store
const countryCode = route.params?.countryCode || storeCountryCode || '';
const headerTitle = getHeaderTitle(errorSource);
const retryButtonText = getRetryButtonText(errorSource);
const currentStep = getCurrentStep(errorSource);
const { title, description, canRetryOriginal } =
getErrorMessages(errorSource);
const { launchSumsubVerification, isLoading: isRetrying } = useSumsubLauncher(
{
countryCode,
errorSource,
onCancel: () => {
navigation.goBack();
},
onError: (_error, _result) => {
// Stay on this screen - user can try again
// Error is already logged in the hook
},
onSuccess: () => {
// Success - provider handles its own success UI
// The screen will be navigated away by the provider's flow
},
},
);
const handleClose = useCallback(() => {
buttonTap();
navigation.goBack();
}, [navigation]);
const handleTryAlternative = useCallback(async () => {
trackEvent('REGISTRATION_FALLBACK_TRY_ALTERNATIVE', { errorSource });
await launchSumsubVerification();
}, [errorSource, launchSumsubVerification, trackEvent]);
const handleRetryOriginal = useCallback(() => {
trackEvent('REGISTRATION_FALLBACK_RETRY_ORIGINAL', { errorSource });
// Navigate back to the appropriate screen based on error source
if (errorSource === 'mrz_scan_failed') {
navigation.navigate('DocumentCamera');
} else if (errorSource === 'nfc_scan_failed') {
navigation.navigate('DocumentNFCScan', {});
} else if (errorSource === 'sumsub_initialization') {
// Go back to ID Picker
navigation.goBack();
}
// TODO: Handle 'sumsub_verification' case - currently falls through without action
// which could leave users stuck when tapping "Try again" after Sumsub verification failure.
// Consider: calling launchSumsubVerification() or navigating to appropriate retry screen.
// Need to determine the correct retry behavior for failed Sumsub verifications.
}, [errorSource, navigation, trackEvent]);
return (
<YStack flex={1} backgroundColor={slate100}>
{/* Header */}
<YStack backgroundColor={slate100}>
<XStack
backgroundColor={slate100}
padding={20}
justifyContent="space-between"
alignItems="center"
paddingTop={Math.max(insets.top, 15) + extraYPadding}
paddingBottom={10}
>
<Button
unstyled
onPress={handleClose}
padding={8}
borderRadius={20}
hitSlop={10}
>
<X size={24} color={black} />
</Button>
<BodyText
style={{
fontSize: 16,
color: black,
fontWeight: '600',
fontFamily: dinot,
}}
>
{headerTitle}
</BodyText>
<Button
unstyled
padding={8}
borderRadius={20}
hitSlop={10}
width={32}
height={32}
justifyContent="center"
alignItems="center"
disabled
>
<HelpCircle size={20} color={black} opacity={0} />
</Button>
</XStack>
{/* Progress Bar */}
<YStack paddingHorizontal={40} paddingBottom={10}>
<XStack gap={3} height={6}>
{[1, 2, 3, 4].map(step => (
<YStack
key={step}
flex={1}
backgroundColor={step === currentStep ? cyan300 : slate300}
borderRadius={10}
/>
))}
</XStack>
</YStack>
</YStack>
{/* Warning Icon */}
<YStack flex={1} paddingHorizontal={20} paddingTop={20}>
<YStack
flex={1}
justifyContent="center"
alignItems="center"
paddingVertical={20}
>
<WarningIcon width={120} height={120} />
</YStack>
</YStack>
{/* Error Message */}
<YStack
paddingHorizontal={20}
paddingTop={20}
alignItems="center"
paddingVertical={25}
>
<BodyText style={{ fontSize: 19, textAlign: 'center', color: black }}>
{title}
</BodyText>
<BodyText
style={{
marginTop: 6,
fontSize: 17,
textAlign: 'center',
color: slate500,
}}
>
{description}
</BodyText>
</YStack>
{/* Top Button - Retry */}
{canRetryOriginal && (
<YStack paddingHorizontal={25} paddingBottom={20}>
<PrimaryButton onPress={handleRetryOriginal} disabled={isRetrying}>
{retryButtonText}
</PrimaryButton>
</YStack>
)}
{/* Bottom Section with Grey Line Separator */}
<YStack
paddingHorizontal={25}
backgroundColor={white}
paddingBottom={paddingBottom}
paddingTop={25}
gap="$3"
borderTopWidth={1}
borderTopColor={slate200}
>
<SecondaryButton onPress={handleTryAlternative} disabled={isRetrying}>
{isRetrying ? 'Loading...' : 'Try a different method'}
</SecondaryButton>
{/* Footer Text */}
<BodyText
style={{
fontSize: 15,
textAlign: 'center',
color: slate500,
fontStyle: 'italic',
marginTop: 8,
}}
>
Registering with alternative methods may take longer to verify your
document.
</BodyText>
</YStack>
</YStack>
);
};
export default RegistrationFallbackScreen;

View File

@@ -0,0 +1,103 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { IS_DEV_MODE } from '@/utils/devUtils';
export type InjectedErrorType =
| 'mrz_invalid_format'
| 'mrz_unknown_error'
| 'nfc_timeout'
| 'nfc_module_unavailable'
| 'nfc_parse_failure'
| 'api_network_error'
| 'api_timeout'
| 'sumsub_initialization'
| 'sumsub_verification';
export const ERROR_GROUPS = {
MRZ: ['mrz_invalid_format', 'mrz_unknown_error'] as InjectedErrorType[],
NFC: [
'nfc_timeout',
'nfc_module_unavailable',
'nfc_parse_failure',
] as InjectedErrorType[],
API: ['api_network_error', 'api_timeout'] as InjectedErrorType[],
Sumsub: [
'sumsub_initialization',
'sumsub_verification',
] as InjectedErrorType[],
};
export const ERROR_LABELS: Record<InjectedErrorType, string> = {
mrz_invalid_format: 'MRZ: Invalid format',
mrz_unknown_error: 'MRZ: Unknown error',
nfc_timeout: 'NFC: Timeout',
nfc_module_unavailable: 'NFC: Module unavailable',
nfc_parse_failure: 'NFC: Parse failure',
api_network_error: 'API: Network error',
api_timeout: 'API: Timeout',
sumsub_initialization: 'Sumsub: Initialization',
sumsub_verification: 'Sumsub: Verification',
};
interface ErrorInjectionState {
injectedErrors: InjectedErrorType[];
// Actions
setInjectedErrors: (errors: InjectedErrorType[]) => void;
toggleError: (error: InjectedErrorType) => void;
clearError: (error: InjectedErrorType) => void;
clearAllErrors: () => void;
shouldTrigger: (error: InjectedErrorType) => boolean;
}
export const useErrorInjectionStore = create<ErrorInjectionState>()(
persist(
(set, get) => ({
injectedErrors: [],
setInjectedErrors: (errors: InjectedErrorType[]) => {
if (!IS_DEV_MODE) return;
set({ injectedErrors: errors });
},
toggleError: (error: InjectedErrorType) => {
if (!IS_DEV_MODE) return;
set(state => {
const hasError = state.injectedErrors.includes(error);
return {
injectedErrors: hasError
? state.injectedErrors.filter(e => e !== error)
: [...state.injectedErrors, error],
};
});
},
clearError: (error: InjectedErrorType) => {
if (!IS_DEV_MODE) return;
set(state => ({
injectedErrors: state.injectedErrors.filter(e => e !== error),
}));
},
clearAllErrors: () => {
if (!IS_DEV_MODE) return;
set({ injectedErrors: [] });
},
shouldTrigger: (error: InjectedErrorType) => {
if (!IS_DEV_MODE) return false;
const state = get();
return state.injectedErrors.includes(error);
},
}),
{
name: 'error-injection-storage',
storage: createJSONStorage(() => AsyncStorage),
},
),
);

View File

@@ -101,6 +101,7 @@ describe('navigation', () => {
'QRCodeViewFinder',
'RecoverWithPhrase',
'Referral',
'RegistrationFallback',
'SaveRecoveryPhrase',
'Settings',
'ShowRecoveryPhrase',

View File

@@ -224,5 +224,7 @@ export function createSelfClient({
useSelfAppStore,
useProtocolStore,
useMRZStore,
// Expose config for internal SDK use
config: cfg,
};
}

View File

@@ -4,7 +4,7 @@
import type { Config } from '../types/public';
export const defaultConfig: Required<Config> = {
export const defaultConfig: Omit<Required<Config>, 'devConfig'> & Pick<Config, 'devConfig'> = {
timeouts: { scanMs: 60000 },
// in future this can be used to enable/disable experimental features
features: {},

View File

@@ -4,11 +4,14 @@
import type { Config } from '../types/public';
export function mergeConfig(base: Required<Config>, override: Config): Required<Config> {
type BaseConfig = Omit<Required<Config>, 'devConfig'> & Pick<Config, 'devConfig'>;
export function mergeConfig(base: BaseConfig, override: Config): BaseConfig {
return {
...base,
...override,
timeouts: { ...base.timeouts, ...(override.timeouts ?? {}) },
features: { ...base.features, ...(override.features ?? {}) },
devConfig: override.devConfig ?? base.devConfig,
};
}

View File

@@ -12,6 +12,9 @@ import { checkScannedInfo, formatDateToYYMMDD } from '../../processing/mrz';
import { SdkEvents } from '../../types/events';
import type { MRZInfo } from '../../types/public';
// Dev-only error injection - uses injected devConfig from SDK context
// No cross-package requires needed
export type { MRZScannerViewProps } from '../../components/MRZScannerView';
export { MRZScannerView } from '../../components/MRZScannerView';
@@ -28,12 +31,25 @@ const calculateScanDurationSeconds = (scanStartTimeRef: RefObject<number>) => {
export function useReadMRZ(scanStartTimeRef: RefObject<number>) {
const selfClient = useSelfClient();
const shouldTrigger = selfClient.config?.devConfig?.shouldTrigger;
return {
onPassportRead: useCallback(
(error: Error | null, result?: MRZInfo) => {
const scanDurationSeconds = calculateScanDurationSeconds(scanStartTimeRef);
// Dev-only: Check for injected unknown error
if (shouldTrigger?.('mrz_unknown_error')) {
console.log('[DEV] Injecting MRZ unknown error');
selfClient.trackEvent(PassportEvents.CAMERA_SCAN_FAILED, {
reason: 'unknown_error',
error: 'Injected error for testing',
duration_seconds: parseFloat(scanDurationSeconds),
});
selfClient.emit(SdkEvents.DOCUMENT_MRZ_READ_FAILURE);
return;
}
if (error) {
console.error(error);
@@ -63,7 +79,16 @@ export function useReadMRZ(scanStartTimeRef: RefObject<number>) {
const formattedDateOfBirth = Platform.OS === 'ios' ? formatDateToYYMMDD(dateOfBirth) : dateOfBirth;
const formattedDateOfExpiry = Platform.OS === 'ios' ? formatDateToYYMMDD(dateOfExpiry) : dateOfExpiry;
if (!checkScannedInfo(documentNumber, formattedDateOfBirth, formattedDateOfExpiry)) {
// Dev-only: Check for injected invalid format error
const shouldInjectInvalidFormat = shouldTrigger?.('mrz_invalid_format') || false;
if (
shouldInjectInvalidFormat ||
!checkScannedInfo(documentNumber, formattedDateOfBirth, formattedDateOfExpiry)
) {
if (shouldInjectInvalidFormat) {
console.log('[DEV] Injecting MRZ invalid format error');
}
selfClient.trackEvent(PassportEvents.CAMERA_SCAN_FAILED, {
reason: 'invalid_format',
passportNumberLength: documentNumber.length,
@@ -90,7 +115,7 @@ export function useReadMRZ(scanStartTimeRef: RefObject<number>) {
selfClient.emit(SdkEvents.DOCUMENT_MRZ_READ_SUCCESS);
},
[selfClient],
[selfClient, shouldTrigger],
),
};
}

View File

@@ -138,5 +138,6 @@ export { parseNFCResponse, scanNFC } from './nfc';
export { reactNativeScannerAdapter } from './adapters/react-native/nfc-scanner';
export { sanitizeErrorMessage } from './utils/utils';
export { useCountries } from './documents/useCountries';
export { useMRZStore } from './stores/mrzStore';
export { webNFCScannerShim } from './adapters/web/shims';

View File

@@ -35,6 +35,18 @@ export interface Config {
* treated as `false` and the SDK will continue using legacy flows.
*/
features?: Record<string, boolean>;
/**
* Optional dev-mode configuration for error injection and testing. Should
* only be provided in development builds. Production builds should omit this.
*/
devConfig?: {
/**
* Callback to check if a specific error type should be injected for testing.
* @param errorType - The type of error to check (e.g., 'mrz_unknown_error')
* @returns true if the error should be injected, false otherwise
*/
shouldTrigger?: (errorType: string) => boolean;
};
}
/**
@@ -375,6 +387,9 @@ export interface SelfClient {
useProtocolStore: ReturnType<typeof create<ProtocolState, []>>;
/** Zustand store hook mirroring {@link MRZState}. */
useMRZStore: ReturnType<typeof create<MRZState, []>>;
/** The merged configuration object passed to createSelfClient. */
config: Config;
}
/** Function returned by {@link SelfClient.on} to detach a listener. */

View File

@@ -8,7 +8,7 @@ import { mergeConfig } from '../src/config/merge';
import type { Config } from '../src/types/public';
describe('mergeConfig', () => {
const baseConfig: Required<Config> = {
const baseConfig: Omit<Required<Config>, 'devConfig'> & Pick<Config, 'devConfig'> = {
timeouts: {
scanMs: 30000,
},