diff --git a/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx b/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx index 6de32aa783248..9cb1a160cc65a 100644 --- a/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx +++ b/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx @@ -3,9 +3,11 @@ import {View} from 'react-native'; import Button from '@components/Button'; import ConfirmModal from '@components/ConfirmModal'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import Section from '@components/Section'; import Text from '@components/Text'; +import useConfirmModal from '@hooks/useConfirmModal'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; @@ -36,6 +38,7 @@ import { hasTravelInvoicingSettlementAccount, } from '@libs/TravelInvoicingUtils'; import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; +import {updateGeneralSettings as updatePolicyGeneralSettings} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -57,10 +60,13 @@ function WorkspaceTravelInvoicingSection({policyID}: WorkspaceTravelInvoicingSec const {translate} = useLocalize(); const workspaceAccountID = useWorkspaceAccountID(policyID); + const {showConfirmModal, closeModal} = useConfirmModal(); const [isDisableConfirmModalVisible, setIsDisableConfirmModalVisible] = useState(false); const [isOutstandingBalanceModalVisible, setIsOutstandingBalanceModalVisible] = useState(false); const [isPayBalanceModalVisible, setIsPayBalanceModalVisible] = useState(false); + // Ref to track if the "Update to USD" modal is open + const isCurrencyModalOpen = useRef(false); // Ref to track if we should auto-resume the toggle flow after returning from TravelLegalNamePage const shouldResumeToggleRef = useRef(false); @@ -69,6 +75,7 @@ function WorkspaceTravelInvoicingSection({policyID}: WorkspaceTravelInvoicingSec const [cardSettings] = useOnyx(getTravelInvoicingCardSettingsKey(workspaceAccountID)); const [cardOnWaitlist] = useOnyx(`${ONYXKEYS.COLLECTION.NVP_EXPENSIFY_ON_CARD_WAITLIST}${policyID}`); const [account] = useOnyx(ONYXKEYS.ACCOUNT); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT); const [privatePersonalDetails] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS); @@ -147,32 +154,7 @@ function WorkspaceTravelInvoicingSection({policyID}: WorkspaceTravelInvoicingSec payTravelInvoicingSpend(workspaceAccountID); }; - /** - * Handle toggle change for Central Invoicing. - * When turning ON: - * - If has settlement account: call configureTravelInvoicingForPolicy - * - If no settlement account: navigate to selection (enable happens after selection) - * When turning OFF: show confirmation modal, then call deactivateTravelInvoicing. - */ - const handleToggle = (isEnabled: boolean) => { - // Check if user is on a public domain - Travel Invoicing requires a private domain - if (account?.isFromPublicDomain) { - Navigation.navigate(ROUTES.TRAVEL_PUBLIC_DOMAIN_ERROR.getRoute(policyID, Navigation.getActiveRoute())); - return; - } - - if (!isEnabled) { - // Trying to disable - check for outstanding balance first - if (hasOutstandingTravelBalance(travelSettings)) { - // Show blocker modal with error message - setIsOutstandingBalanceModalVisible(true); - return; - } - // Show confirmation modal before disabling - setIsDisableConfirmModalVisible(true); - return; - } - + const continueToggleFlow = () => { if (areTravelPersonalDetailsMissing(privatePersonalDetails)) { shouldResumeToggleRef.current = true; Navigation.navigate(ROUTES.WORKSPACE_TRAVEL_MISSING_PERSONAL_DETAILS.getRoute(policyID)); @@ -206,11 +188,71 @@ function WorkspaceTravelInvoicingSection({policyID}: WorkspaceTravelInvoicingSec Navigation.navigate(ROUTES.WORKSPACE_TRAVEL_SETTINGS_ACCOUNT.getRoute(policyID)); }; + const promptCurrencyChangeAndStartFlow = async () => { + isCurrencyModalOpen.current = true; + const result = await showConfirmModal({ + title: translate('workspace.moreFeatures.travel.travelInvoicing.centralInvoicingSection.title'), + prompt: translate('workspace.bankAccount.updateCurrencyPrompt'), + confirmText: translate('workspace.bankAccount.updateToUSD'), + cancelText: translate('common.cancel'), + danger: true, + }); + isCurrencyModalOpen.current = false; + if (result.action !== ModalActions.CONFIRM || !policy) { + return; + } + updatePolicyGeneralSettings(policy, policy.name, CONST.CURRENCY.USD); + continueToggleFlow(); + }; + + /** + * Handle toggle change for Central Invoicing. + * When turning ON: + * - If has settlement account: call configureTravelInvoicingForPolicy + * - If no settlement account: navigate to selection (enable happens after selection) + * When turning OFF: show confirmation modal, then call deactivateTravelInvoicing. + */ + const handleToggle = (isEnabled: boolean) => { + // Check if user is on a public domain - Travel Invoicing requires a private domain + if (account?.isFromPublicDomain) { + Navigation.navigate(ROUTES.TRAVEL_PUBLIC_DOMAIN_ERROR.getRoute(policyID, Navigation.getActiveRoute())); + return; + } + + if (!isEnabled) { + // Trying to disable - check for outstanding balance first + if (hasOutstandingTravelBalance(travelSettings)) { + // Show blocker modal with error message + setIsOutstandingBalanceModalVisible(true); + return; + } + // Show confirmation modal before disabling + setIsDisableConfirmModalVisible(true); + return; + } + + if (policy?.outputCurrency !== CONST.CURRENCY.USD) { + promptCurrencyChangeAndStartFlow(); + return; + } + + continueToggleFlow(); + }; + const handleConfirmDisable = () => { setIsDisableConfirmModalVisible(false); deactivateTravelInvoicing(policyID, workspaceAccountID); }; + // Dismiss the "Update to USD" modal check if the currency changes to USD externally (e.g. from another device) + useEffect(() => { + if (policy?.outputCurrency !== CONST.CURRENCY.USD || !isCurrencyModalOpen.current) { + return; + } + closeModal(); + isCurrencyModalOpen.current = false; + }, [policy?.outputCurrency, closeModal]); + // Auto-resume the toggle flow after returning from TravelLegalNamePage // When the user saves their legal name and navigates back, privatePersonalDetails updates // and this effect re-triggers handleToggle(true) to continue the enabling flow diff --git a/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx b/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx index 160d9d4fa613a..eec215681a529 100644 --- a/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx +++ b/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx @@ -8,6 +8,7 @@ import OnyxListItemProvider from '@components/OnyxListItemProvider'; import {payTravelInvoicingSpend} from '@libs/actions/TravelInvoicing'; import {getTravelInvoicingCardSettingsKey} from '@libs/TravelInvoicingUtils'; import WorkspaceTravelInvoicingSection from '@pages/workspace/travel/WorkspaceTravelInvoicingSection'; +import {updateGeneralSettings} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy} from '@src/types/onyx'; @@ -70,6 +71,20 @@ jest.mock('@libs/actions/TravelInvoicing', () => { }; }); +jest.mock('@userActions/Policy/Policy', () => ({ + updateGeneralSettings: jest.fn(), +})); + +const mockShowConfirmModal = jest.fn().mockResolvedValue({action: 'CONFIRM'}); +const mockCloseModal = jest.fn(); + +jest.mock('@hooks/useConfirmModal', () => { + return jest.fn().mockImplementation(() => ({ + showConfirmModal: mockShowConfirmModal, + closeModal: mockCloseModal, + })); +}); + const mockPolicy: Policy = { ...createRandomPolicy(parseInt(POLICY_ID, 10) || 1), type: CONST.POLICY.TYPE.CORPORATE, @@ -459,4 +474,40 @@ describe('WorkspaceTravelInvoicingSection', () => { expect(screen.getByText('Payment of $50.00 is queued and will be processed soon.')).toBeTruthy(); }); }); + + describe('Currency Conversion Prompt', () => { + const cardSettingsKey = getTravelInvoicingCardSettingsKey(WORKSPACE_ACCOUNT_ID); + + it('should prompt to update currency to USD if policy currency is not USD, and call updateGeneralSettings on confirm', async () => { + const mockPolicyGbp = { + ...mockPolicy, + outputCurrency: 'GBP', + name: 'GBP Workspace', + }; + + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, mockPolicyGbp); + await Onyx.merge(cardSettingsKey, { + TRAVEL_US: { + isEnabled: false, + }, + }); + await waitForBatchedUpdatesWithAct(); + }); + + renderWorkspaceTravelInvoicingSection(); + await waitForBatchedUpdatesWithAct(); + + // Fire toggle change to true + const toggleButton = screen.getByRole('switch'); + fireEvent.press(toggleButton); + await waitForBatchedUpdatesWithAct(); + + // The confirm modal should be triggered + expect(mockShowConfirmModal).toHaveBeenCalled(); + + // The updateGeneralSettings function should be called + expect(updateGeneralSettings).toHaveBeenCalledWith(expect.objectContaining({outputCurrency: 'GBP', name: 'GBP Workspace'}), 'GBP Workspace', CONST.CURRENCY.USD); + }); + }); });