From f128015d94a64f94d40fe7413b5d0f9e03cd783a Mon Sep 17 00:00:00 2001 From: Ben Limpich Date: Fri, 19 Jun 2026 09:30:18 -0700 Subject: [PATCH 1/6] Add setTravelProvisioningErrorMessage action to surface travel provisioning errors --- src/libs/actions/Travel.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/libs/actions/Travel.ts b/src/libs/actions/Travel.ts index 73d992f1f05f..dd27f29b4a9b 100644 --- a/src/libs/actions/Travel.ts +++ b/src/libs/actions/Travel.ts @@ -3,7 +3,7 @@ import Onyx from 'react-native-onyx'; import * as API from '@libs/API'; import type {AcceptSpotnanaTermsParams} from '@libs/API/parameters'; import {SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; -import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; +import {getMicroSecondOnyxErrorWithMessage, getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; @@ -95,4 +95,12 @@ function cleanupTravelProvisioningSession() { Onyx.merge(ONYXKEYS.TRAVEL_PROVISIONING, null); } -export {acceptSpotnanaTerms, cleanupTravelProvisioningSession, requestTravelAccess, setTravelProvisioningNextStep}; +function setTravelProvisioningErrorMessage(message: string) { + Onyx.merge(ONYXKEYS.TRAVEL_PROVISIONING, { + isLoading: false, + error: null, + errors: getMicroSecondOnyxErrorWithMessage(message), + }); +} + +export {acceptSpotnanaTerms, cleanupTravelProvisioningSession, requestTravelAccess, setTravelProvisioningErrorMessage, setTravelProvisioningNextStep}; From 04ff68b0120a27284b2aed8a1f0a7627afacfda6 Mon Sep 17 00:00:00 2001 From: Ben Limpich Date: Fri, 19 Jun 2026 09:30:19 -0700 Subject: [PATCH 2/6] Surface travel provisioning errors on the terms screen instead of failing silently --- src/CONST/index.ts | 1 + src/pages/Travel/DynamicTravelTerms.tsx | 16 +++++++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 6af591bed322..5ca93103e3ec 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7822,6 +7822,7 @@ const CONST = { PROVISIONING: { ERROR_PERMISSION_DENIED: 'permissionDenied', ERROR_ADDITIONAL_VERIFICATION_REQUIRED: 'additionalVerificationRequired', + ERROR_MISSING_WORKSPACE_ADDRESS: 'missingWorkspaceAddress', }, UPDATE_OPERATION_TYPE: { BOOKING_TICKETED: 'BOOKING_TICKETED', diff --git a/src/pages/Travel/DynamicTravelTerms.tsx b/src/pages/Travel/DynamicTravelTerms.tsx index 6f34bc148fc2..603c440f086a 100644 --- a/src/pages/Travel/DynamicTravelTerms.tsx +++ b/src/pages/Travel/DynamicTravelTerms.tsx @@ -22,7 +22,7 @@ import usePermissions from '@hooks/usePermissions'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import {addComment} from '@libs/actions/Report'; -import {acceptSpotnanaTerms, cleanupTravelProvisioningSession} from '@libs/actions/Travel'; +import {acceptSpotnanaTerms, cleanupTravelProvisioningSession, setTravelProvisioningErrorMessage} from '@libs/actions/Travel'; import {getLatestErrorMessage} from '@libs/ErrorUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; @@ -55,7 +55,7 @@ function DynamicTravelTerms({route}: TravelTermsPageProps) { const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const backPath = useDynamicBackPath(DYNAMIC_ROUTES.TRAVEL_TCS.path); - const errorMessage = travelProvisioning?.errors && !travelProvisioning?.error ? getLatestErrorMessage(travelProvisioning) : ''; + const errorMessage = getLatestErrorMessage(travelProvisioning); const isLoading = travelProvisioning?.isLoading; const domain = route.params.domain === CONST.TRAVEL.DEFAULT_DOMAIN ? undefined : route.params.domain; const policyID = route.params.policyID; @@ -95,6 +95,8 @@ function DynamicTravelTerms({route}: TravelTermsPageProps) { // Handle verification required error - show modal and reject to close Safari window if open if (errorCode === CONST.TRAVEL.PROVISIONING.ERROR_ADDITIONAL_VERIFICATION_REQUIRED) { + // The modal communicates the error, so clear the provisioning error to avoid also showing it inline behind the modal + cleanupTravelProvisioningSession(); showConfirmModal({ title: translate('travel.verifyCompany.title'), titleStyles: styles.textHeadlineH1, @@ -117,8 +119,16 @@ function DynamicTravelTerms({route}: TravelTermsPageProps) { return Promise.reject(new Error('Verification required')); } - // Handle general API failure + // Missing workspace address - send the user to the address screen so they can add one and continue + if (errorCode === CONST.TRAVEL.PROVISIONING.ERROR_MISSING_WORKSPACE_ADDRESS) { + cleanupTravelProvisioningSession(); + Navigation.navigate(ROUTES.TRAVEL_WORKSPACE_ADDRESS.getRoute(route.params.domain ?? CONST.TRAVEL.DEFAULT_DOMAIN, policyID, Navigation.getActiveRoute())); + return Promise.reject(new Error('Missing workspace address')); + } + + // Any other backend failure - surface the message inline instead of failing silently if (response?.jsonCode !== 200) { + setTravelProvisioningErrorMessage(response?.message ?? translate('travel.errorMessage')); return Promise.reject(new Error('Request failed')); } From cd2e86b4cf63868185b28bae1b3089e760d76236 Mon Sep 17 00:00:00 2001 From: Ben Limpich Date: Fri, 19 Jun 2026 09:30:19 -0700 Subject: [PATCH 3/6] Add unit test for setTravelProvisioningErrorMessage --- tests/unit/TravelTest.ts | 55 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 tests/unit/TravelTest.ts diff --git a/tests/unit/TravelTest.ts b/tests/unit/TravelTest.ts new file mode 100644 index 000000000000..a78f93bc3b7e --- /dev/null +++ b/tests/unit/TravelTest.ts @@ -0,0 +1,55 @@ +import Onyx from 'react-native-onyx'; +import {setTravelProvisioningErrorMessage} from '@libs/actions/Travel'; +import {getLatestErrorMessage} from '@libs/ErrorUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {TravelProvisioning} from '@src/types/onyx'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; +import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates'; + +function getTravelProvisioningFromOnyx(): Promise { + return new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.TRAVEL_PROVISIONING, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value ?? undefined); + }, + }); + }); +} + +describe('Travel', () => { + beforeAll(() => + Onyx.init({ + keys: ONYXKEYS, + }), + ); + + beforeEach(() => { + wrapOnyxWithWaitForBatchedUpdates(Onyx); + return waitForBatchedUpdates(); + }); + + afterEach(() => { + Onyx.clear(); + }); + + describe('setTravelProvisioningErrorMessage', () => { + it('surfaces the backend error message so the terms screen can display it', async () => { + // Given a provisioning session that is loading and carries a stale backend error code + await Onyx.merge(ONYXKEYS.TRAVEL_PROVISIONING, {isLoading: true, error: 'missingWorkspaceAddress'}); + await waitForBatchedUpdates(); + + // When an unhandled backend failure surfaces its message + const message = 'Please add a workspace address to enable Expensify Travel.'; + setTravelProvisioningErrorMessage(message); + await waitForBatchedUpdates(); + + // Then the message is stored where the screen reads it, loading is cleared, and the stale code is gone + const travelProvisioning = await getTravelProvisioningFromOnyx(); + expect(travelProvisioning?.isLoading).toBe(false); + expect(travelProvisioning?.error).toBeUndefined(); + expect(getLatestErrorMessage(travelProvisioning)).toBe(message); + }); + }); +}); From 7d220d3a803ca69d6e1adc5da85275c1803170e0 Mon Sep 17 00:00:00 2001 From: Ben Limpich Date: Fri, 19 Jun 2026 11:00:13 -0700 Subject: [PATCH 4/6] Stop navigating on missing workspace address and show backend error inline --- src/CONST/index.ts | 1 - src/pages/Travel/DynamicTravelTerms.tsx | 12 ++---------- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 5ca93103e3ec..6af591bed322 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7822,7 +7822,6 @@ const CONST = { PROVISIONING: { ERROR_PERMISSION_DENIED: 'permissionDenied', ERROR_ADDITIONAL_VERIFICATION_REQUIRED: 'additionalVerificationRequired', - ERROR_MISSING_WORKSPACE_ADDRESS: 'missingWorkspaceAddress', }, UPDATE_OPERATION_TYPE: { BOOKING_TICKETED: 'BOOKING_TICKETED', diff --git a/src/pages/Travel/DynamicTravelTerms.tsx b/src/pages/Travel/DynamicTravelTerms.tsx index 603c440f086a..5b212763abcd 100644 --- a/src/pages/Travel/DynamicTravelTerms.tsx +++ b/src/pages/Travel/DynamicTravelTerms.tsx @@ -22,7 +22,7 @@ import usePermissions from '@hooks/usePermissions'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import {addComment} from '@libs/actions/Report'; -import {acceptSpotnanaTerms, cleanupTravelProvisioningSession, setTravelProvisioningErrorMessage} from '@libs/actions/Travel'; +import {acceptSpotnanaTerms, cleanupTravelProvisioningSession} from '@libs/actions/Travel'; import {getLatestErrorMessage} from '@libs/ErrorUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; @@ -119,16 +119,8 @@ function DynamicTravelTerms({route}: TravelTermsPageProps) { return Promise.reject(new Error('Verification required')); } - // Missing workspace address - send the user to the address screen so they can add one and continue - if (errorCode === CONST.TRAVEL.PROVISIONING.ERROR_MISSING_WORKSPACE_ADDRESS) { - cleanupTravelProvisioningSession(); - Navigation.navigate(ROUTES.TRAVEL_WORKSPACE_ADDRESS.getRoute(route.params.domain ?? CONST.TRAVEL.DEFAULT_DOMAIN, policyID, Navigation.getActiveRoute())); - return Promise.reject(new Error('Missing workspace address')); - } - - // Any other backend failure - surface the message inline instead of failing silently + // Any other backend failure surfaces its error inline via the travelProvisioning Onyx key set by the backend if (response?.jsonCode !== 200) { - setTravelProvisioningErrorMessage(response?.message ?? translate('travel.errorMessage')); return Promise.reject(new Error('Request failed')); } From 432f92c069035d7c12754ef2351940fe49a3f112 Mon Sep 17 00:00:00 2001 From: Ben Limpich Date: Fri, 19 Jun 2026 11:00:14 -0700 Subject: [PATCH 5/6] Remove frontend travel provisioning error setter and generic failure message --- src/libs/actions/Travel.ts | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/libs/actions/Travel.ts b/src/libs/actions/Travel.ts index dd27f29b4a9b..578bdd44a683 100644 --- a/src/libs/actions/Travel.ts +++ b/src/libs/actions/Travel.ts @@ -3,7 +3,6 @@ import Onyx from 'react-native-onyx'; import * as API from '@libs/API'; import type {AcceptSpotnanaTermsParams} from '@libs/API/parameters'; import {SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; -import {getMicroSecondOnyxErrorWithMessage, getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; @@ -61,7 +60,6 @@ function acceptSpotnanaTerms(domain?: string, policyID?: string) { key: ONYXKEYS.TRAVEL_PROVISIONING, value: { isLoading: false, - errors: getMicroSecondOnyxErrorWithTranslationKey('travel.errorMessage'), }, }, ]; @@ -95,12 +93,4 @@ function cleanupTravelProvisioningSession() { Onyx.merge(ONYXKEYS.TRAVEL_PROVISIONING, null); } -function setTravelProvisioningErrorMessage(message: string) { - Onyx.merge(ONYXKEYS.TRAVEL_PROVISIONING, { - isLoading: false, - error: null, - errors: getMicroSecondOnyxErrorWithMessage(message), - }); -} - -export {acceptSpotnanaTerms, cleanupTravelProvisioningSession, requestTravelAccess, setTravelProvisioningErrorMessage, setTravelProvisioningNextStep}; +export {acceptSpotnanaTerms, cleanupTravelProvisioningSession, requestTravelAccess, setTravelProvisioningNextStep}; From 90a0fc7a4486d99ff10a2a5a0d44ead3e403e5ed Mon Sep 17 00:00:00 2001 From: Ben Limpich Date: Fri, 19 Jun 2026 11:00:14 -0700 Subject: [PATCH 6/6] Remove unit test for removed setTravelProvisioningErrorMessage action --- tests/unit/TravelTest.ts | 55 ---------------------------------------- 1 file changed, 55 deletions(-) delete mode 100644 tests/unit/TravelTest.ts diff --git a/tests/unit/TravelTest.ts b/tests/unit/TravelTest.ts deleted file mode 100644 index a78f93bc3b7e..000000000000 --- a/tests/unit/TravelTest.ts +++ /dev/null @@ -1,55 +0,0 @@ -import Onyx from 'react-native-onyx'; -import {setTravelProvisioningErrorMessage} from '@libs/actions/Travel'; -import {getLatestErrorMessage} from '@libs/ErrorUtils'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {TravelProvisioning} from '@src/types/onyx'; -import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; -import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates'; - -function getTravelProvisioningFromOnyx(): Promise { - return new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.TRAVEL_PROVISIONING, - callback: (value) => { - Onyx.disconnect(connection); - resolve(value ?? undefined); - }, - }); - }); -} - -describe('Travel', () => { - beforeAll(() => - Onyx.init({ - keys: ONYXKEYS, - }), - ); - - beforeEach(() => { - wrapOnyxWithWaitForBatchedUpdates(Onyx); - return waitForBatchedUpdates(); - }); - - afterEach(() => { - Onyx.clear(); - }); - - describe('setTravelProvisioningErrorMessage', () => { - it('surfaces the backend error message so the terms screen can display it', async () => { - // Given a provisioning session that is loading and carries a stale backend error code - await Onyx.merge(ONYXKEYS.TRAVEL_PROVISIONING, {isLoading: true, error: 'missingWorkspaceAddress'}); - await waitForBatchedUpdates(); - - // When an unhandled backend failure surfaces its message - const message = 'Please add a workspace address to enable Expensify Travel.'; - setTravelProvisioningErrorMessage(message); - await waitForBatchedUpdates(); - - // Then the message is stored where the screen reads it, loading is cleared, and the stale code is gone - const travelProvisioning = await getTravelProvisioningFromOnyx(); - expect(travelProvisioning?.isLoading).toBe(false); - expect(travelProvisioning?.error).toBeUndefined(); - expect(getLatestErrorMessage(travelProvisioning)).toBe(message); - }); - }); -});