From 8a57a761085f773e89a4dba26fcea3e692907bfa Mon Sep 17 00:00:00 2001 From: Gustavo Cortez Date: Thu, 20 Aug 2026 16:26:15 -0300 Subject: [PATCH 1/2] KYC: Fix - Surface an error when identity verification cannot start --- src/store/sumsub/sumsub.effects.spec.ts | 82 ++++++++++++++++++++++++- src/store/sumsub/sumsub.effects.ts | 42 ++++++++++++- 2 files changed, 121 insertions(+), 3 deletions(-) diff --git a/src/store/sumsub/sumsub.effects.spec.ts b/src/store/sumsub/sumsub.effects.spec.ts index e0ab2ceef..a1d0157bf 100644 --- a/src/store/sumsub/sumsub.effects.spec.ts +++ b/src/store/sumsub/sumsub.effects.spec.ts @@ -7,6 +7,7 @@ import {Network} from '../../constants'; import {startKycVerification, startGetKycStatus} from './sumsub.effects'; import {SumSubApi} from '../../api/sumsub'; import {launchSumSubSdk} from '../../lib/sumsub'; +import {ongoingProcessManager} from '../../managers/OngoingProcessManager'; // --------------------------------------------------------------------------- // Mocks @@ -32,9 +33,20 @@ jest.mock('../../lib/sumsub', () => ({ launchSumSubSdk: jest.fn(), })); +jest.mock('../../managers/OngoingProcessManager', () => ({ + ongoingProcessManager: { + show: jest.fn(), + hide: jest.fn(), + }, +})); + const mockFetchAccessToken = SumSubApi.fetchAccessToken as jest.Mock; const mockFetchKycStatus = SumSubApi.fetchKycStatus as jest.Mock; const mockLaunchSumSubSdk = launchSumSubSdk as jest.Mock; +const mockOngoingProcess = ongoingProcessManager as unknown as { + show: jest.Mock; + hide: jest.Mock; +}; // --------------------------------------------------------------------------- // Helper: build a store seeded with a logged-in user @@ -95,6 +107,17 @@ describe('startKycVerification — auth guard', () => { expect(mockFetchAccessToken).not.toHaveBeenCalled(); expect(mockLaunchSumSubSdk).not.toHaveBeenCalled(); }); + + it('tells the user to log in instead of failing silently', async () => { + const store = configureTestStore({ + APP: {network: Network.mainnet}, + BITPAY_ID: {user: {}, apiToken: {[Network.mainnet]: API_TOKEN}}, + }); + + await store.dispatch(startKycVerification()); + + expect(store.getState().APP.showBottomNotificationModal).toBe(true); + }); }); // --------------------------------------------------------------------------- @@ -236,7 +259,15 @@ describe('startKycVerification — failure handling', () => { expect(mockLaunchSumSubSdk).not.toHaveBeenCalled(); expect(store.getState().SUMSUB.kyc[Network.mainnet]).toBeNull(); - expect(store.getState().APP.showBottomNotificationModal).not.toBe(true); + }); + + it('surfaces a message when the backend returns no token', async () => { + const store = makeLoggedInStore(); + mockFetchAccessToken.mockResolvedValue(null); + + await store.dispatch(startKycVerification()); + + expect(store.getState().APP.showBottomNotificationModal).toBe(true); }); it('resolves when fetching the access token fails', async () => { @@ -249,6 +280,55 @@ describe('startKycVerification — failure handling', () => { expect(mockLaunchSumSubSdk).not.toHaveBeenCalled(); expect(store.getState().SUMSUB.kyc[Network.mainnet]).toBeNull(); }); + + it('surfaces the backend reason when the token mint throws', async () => { + const store = makeLoggedInStore(); + mockFetchAccessToken.mockRejectedValue( + new Error('Region not supported for identity verification'), + ); + + await store.dispatch(startKycVerification()); + + expect(store.getState().APP.showBottomNotificationModal).toBe(true); + expect( + store.getState().APP.bottomNotificationModalConfig?.message, + ).toContain('Region not supported'); + }); +}); + +// --------------------------------------------------------------------------- +// Loading indicator around the token mint +// --------------------------------------------------------------------------- +describe('startKycVerification — spinner', () => { + it('shows and hides the spinner around the token mint', async () => { + const store = makeLoggedInStore(); + + await store.dispatch(startKycVerification()); + + expect(mockOngoingProcess.show).toHaveBeenCalledWith('GENERAL_AWAITING'); + expect(mockOngoingProcess.hide).toHaveBeenCalledTimes(1); + }); + + it('hides the spinner even when the token mint throws', async () => { + const store = makeLoggedInStore(); + mockFetchAccessToken.mockRejectedValue(new Error('token endpoint down')); + + await store.dispatch(startKycVerification()); + + expect(mockOngoingProcess.hide).toHaveBeenCalledTimes(1); + }); + + it('hides the spinner before the SDK is launched', async () => { + const store = makeLoggedInStore(); + mockLaunchSumSubSdk.mockImplementation(async () => { + expect(mockOngoingProcess.hide).toHaveBeenCalledTimes(1); + return {success: true, status: 'Approved'}; + }); + + await store.dispatch(startKycVerification()); + + expect(mockLaunchSumSubSdk).toHaveBeenCalled(); + }); }); // --------------------------------------------------------------------------- diff --git a/src/store/sumsub/sumsub.effects.ts b/src/store/sumsub/sumsub.effects.ts index dbe67f916..b687704e1 100644 --- a/src/store/sumsub/sumsub.effects.ts +++ b/src/store/sumsub/sumsub.effects.ts @@ -1,3 +1,4 @@ +import {t} from 'i18next'; import {Effect} from '../index'; import {SumSubApi} from '../../api/sumsub'; import {launchSumSubSdk} from '../../lib/sumsub'; @@ -7,6 +8,10 @@ import {KycInfo} from './sumsub.reducer'; import {showBottomNotificationModal} from '../app/app.actions'; import {CustomErrorMessage} from '../../navigation/wallet/components/ErrorMessages'; import {deriveKycUiState} from './sumsub.selectors'; +import {ongoingProcessManager} from '../../managers/OngoingProcessManager'; +import {sleep} from '../../utils/helper-methods'; + +const MODAL_HANDOFF_DELAY = 600; // Fetches the backend KYC object and stores it verbatim. No-op when logged out. export const startGetKycStatus = @@ -57,6 +62,15 @@ export const startKycVerification = dispatch( LogActions.error('[SumSub] Cannot start KYC — user not logged in'), ); + dispatch( + showBottomNotificationModal( + CustomErrorMessage({ + errMsg: t( + 'Please log in to your BitPay account to verify your identity.', + ), + }), + ), + ); return; } @@ -64,15 +78,31 @@ export const startKycVerification = SumSubApi.fetchAccessToken(apiToken); try { - const accessToken = await getAccessToken(); + ongoingProcessManager.show('GENERAL_AWAITING'); + let accessToken: string | null; + try { + accessToken = await getAccessToken(); + } finally { + ongoingProcessManager.hide(); + } - // Null token → not eligible; not an error, just don't launch the SDK. if (!accessToken) { dispatch( LogActions.info( '[SumSub] No access token returned — KYC not available for this user.', ), ); + await sleep(MODAL_HANDOFF_DELAY); + dispatch( + showBottomNotificationModal( + CustomErrorMessage({ + title: t('Verification unavailable'), + errMsg: t( + "Identity verification isn't available for your account at this time. Please contact support if you need help.", + ), + }), + ), + ); return; } @@ -114,5 +144,13 @@ export const startKycVerification = } catch (err) { const msg = err instanceof Error ? err.message : JSON.stringify(err); dispatch(LogActions.error(`[SumSub] SDK error: ${msg}`)); + await sleep(MODAL_HANDOFF_DELAY); + dispatch( + showBottomNotificationModal( + CustomErrorMessage({ + errMsg: msg || t('The verification process encountered an error.'), + }), + ), + ); } }; From a1c2a5871c1c673415a89e4edc16a96059a82335 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 27 Aug 2026 09:24:48 -0300 Subject: [PATCH 2/2] Friendly message if error occurs Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/store/sumsub/sumsub.effects.spec.ts | 8 ++++---- src/store/sumsub/sumsub.effects.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/store/sumsub/sumsub.effects.spec.ts b/src/store/sumsub/sumsub.effects.spec.ts index a1d0157bf..a1dc60c18 100644 --- a/src/store/sumsub/sumsub.effects.spec.ts +++ b/src/store/sumsub/sumsub.effects.spec.ts @@ -281,7 +281,7 @@ describe('startKycVerification — failure handling', () => { expect(store.getState().SUMSUB.kyc[Network.mainnet]).toBeNull(); }); - it('surfaces the backend reason when the token mint throws', async () => { + it('never leaks the raw server error into the modal', async () => { const store = makeLoggedInStore(); mockFetchAccessToken.mockRejectedValue( new Error('Region not supported for identity verification'), @@ -289,10 +289,10 @@ describe('startKycVerification — failure handling', () => { await store.dispatch(startKycVerification()); + const {message} = store.getState().APP.bottomNotificationModalConfig!; expect(store.getState().APP.showBottomNotificationModal).toBe(true); - expect( - store.getState().APP.bottomNotificationModalConfig?.message, - ).toContain('Region not supported'); + expect(message).toBe('The verification process encountered an error.'); + expect(message).not.toContain('Region not supported'); }); }); diff --git a/src/store/sumsub/sumsub.effects.ts b/src/store/sumsub/sumsub.effects.ts index b687704e1..254fe3b37 100644 --- a/src/store/sumsub/sumsub.effects.ts +++ b/src/store/sumsub/sumsub.effects.ts @@ -148,7 +148,7 @@ export const startKycVerification = dispatch( showBottomNotificationModal( CustomErrorMessage({ - errMsg: msg || t('The verification process encountered an error.'), + errMsg: t('The verification process encountered an error.'), }), ), );