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
94 changes: 68 additions & 26 deletions src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand All @@ -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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can extend the useConfirmModal hook via context to have a new property that tells about the visibility of the modal instead of using this ref.

// Ref to track if we should auto-resume the toggle flow after returning from TravelLegalNamePage
const shouldResumeToggleRef = useRef(false);

Expand All @@ -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);
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions tests/ui/WorkspaceTravelInvoicingSectionTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
});
});
});
Loading