diff --git a/src/hooks/useTravelBillingFundID.ts b/src/hooks/useTravelBillingFundID.ts index e6469dae9bf1..03ea3f07b0e2 100644 --- a/src/hooks/useTravelBillingFundID.ts +++ b/src/hooks/useTravelBillingFundID.ts @@ -30,15 +30,28 @@ function findLinkedTravelBillingFundID(cardSettings: OnyxCollection, policyID: string | undefined): boolean { + // A pending settlement account save writes the program without linkedPolicyIDs until the server links the policy. + if (settings?.pendingFields?.paymentBankAccountID) { + return false; + } + const travelSettings = settings?.[CONST.TRAVEL.PROGRAM_TRAVEL_US]; + return !!policyID && !!travelSettings && !isPolicyIDInLinkedExpensifyCardPolicyList(travelSettings.linkedPolicyIDs, policyID); +} + /** * Hook to get the fundID that holds Consolidated Travel Billing for a given policyID. * Like the backend, it prefers a domain whose TRAVEL_US program lists the policy, then the workspace account's TRAVEL_US program, - * independently of the last selected Expensify Card feed. Without either it returns the default fundID. + * independently of the last selected Expensify Card feed. Without either it returns the default fundID, or the workspace account + * when the default fund has a TRAVEL_US program that doesn't list the policy. */ function useTravelBillingFundID(policyID: string | undefined) { const defaultFundID = useDefaultFundID(policyID); const workspaceAccountID = useWorkspaceAccountID(policyID); const [workspaceCardSettings] = useOnyx(getTravelBillingCardSettingsKey(workspaceAccountID)); + const unlinkedTravelBillingSelector = (settings: OnyxEntry) => isTravelBillingUnlinkedFromPolicy(settings, policyID); + const [isDefaultFundTravelBillingUnlinked] = useOnyx(getTravelBillingCardSettingsKey(defaultFundID), {selector: unlinkedTravelBillingSelector}); const linkedFundSelector = (cardSettings: OnyxCollection) => findLinkedTravelBillingFundID(cardSettings, policyID, workspaceAccountID); const [linkedFundID] = useOnyx(ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS, {selector: linkedFundSelector}); @@ -52,6 +65,12 @@ function useTravelBillingFundID(policyID: string | undefined) { return workspaceAccountID; } + // The default fund can be a domain linked to this policy only through its card program, with a travel feed that doesn't list this policy. + // The backend resolves this policy to its workspace account in that case, and reading that feed here would show another feed's spend and settings as this policy's. + if (isDefaultFundTravelBillingUnlinked) { + return workspaceAccountID; + } + return defaultFundID; } diff --git a/tests/ui/WorkspaceTravelBillingMonthlyLimitPageTest.tsx b/tests/ui/WorkspaceTravelBillingMonthlyLimitPageTest.tsx index 9335055097ba..8fa6a307959e 100644 --- a/tests/ui/WorkspaceTravelBillingMonthlyLimitPageTest.tsx +++ b/tests/ui/WorkspaceTravelBillingMonthlyLimitPageTest.tsx @@ -112,10 +112,10 @@ describe('WorkspaceTravelBillingMonthlyLimitPage', () => { goBackSpy = jest.spyOn(Navigation, 'goBack').mockImplementation(() => {}); microtaskQueueSpy = jest.spyOn(Navigation, 'setNavigationActionToMicrotaskQueue').mockImplementation((callback) => callback?.()); - // Seed a current monthly spend limit of $100.00 so reducing to $50.00 triggers the confirmation. + // Seed a travel feed that lists the policy, with a current monthly spend limit of $100.00 so reducing to $50.00 triggers the confirmation. await act(async () => { await Onyx.merge(getTravelBillingCardSettingsKey(FUND_ID), { - [CONST.TRAVEL.PROGRAM_TRAVEL_US]: {monthlySpendLimitPerUser: CURRENT_LIMIT_CENTS}, + [CONST.TRAVEL.PROGRAM_TRAVEL_US]: {linkedPolicyIDs: [POLICY_ID], monthlySpendLimitPerUser: CURRENT_LIMIT_CENTS}, }); await waitForBatchedUpdatesWithAct(); }); diff --git a/tests/unit/hooks/useTravelBillingFundID.test.ts b/tests/unit/hooks/useTravelBillingFundID.test.ts index 05c3639bc0f8..4f62d2929007 100644 --- a/tests/unit/hooks/useTravelBillingFundID.test.ts +++ b/tests/unit/hooks/useTravelBillingFundID.test.ts @@ -12,6 +12,7 @@ import Onyx from 'react-native-onyx'; import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; const policyID = 'CF7BE4573FC925E8'; +const otherPolicyID = 'A1B2C3D4E5F60718'; const domainFundID = 18452096; const workspaceFundID = 22588762; @@ -81,4 +82,97 @@ describe('useTravelBillingFundID', () => { expect(result.current).toBe(workspaceFundID); }); }); + + it('resolves the workspace fund when the domain travel billing feed lists only other workspaces', async () => { + // Given a domain linked to the workspace through its US card program, whose TRAVEL_US program lists only another workspace + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {id: policyID, policyAccountID: workspaceFundID}); + await Onyx.merge(getTravelBillingCardSettingsKey(domainFundID), { + [CONST.COUNTRY.US]: {linkedPolicyIDs: [policyID], paymentBankAccountID: 67890}, + [CONST.TRAVEL.PROGRAM_TRAVEL_US]: {...travelBillingSettings, linkedPolicyIDs: [otherPolicyID]}, + }); + await waitForBatchedUpdates(); + + // When the hook resolves the travel billing fund + const {result} = renderHook(() => useTravelBillingFundID(policyID)); + await waitForBatchedUpdates(); + + // Then it returns the workspace, because the domain's travel feed belongs to the other workspace and the backend sets this one up on its workspace account + await waitFor(() => { + expect(result.current).toBe(workspaceFundID); + }); + }); + + it('resolves the workspace fund when the domain travel billing feed was turned off by its last workspace', async () => { + // Given a domain linked to the workspace through its US card program, whose TRAVEL_US program was deactivated and kept an empty linkedPolicyIDs list + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {id: policyID, policyAccountID: workspaceFundID}); + await Onyx.merge(getTravelBillingCardSettingsKey(domainFundID), { + [CONST.COUNTRY.US]: {linkedPolicyIDs: [policyID], paymentBankAccountID: 67890}, + [CONST.TRAVEL.PROGRAM_TRAVEL_US]: {isEnabled: false, linkedPolicyIDs: [], paymentBankAccountID: 8789966}, + }); + await waitForBatchedUpdates(); + + // When the hook resolves the travel billing fund + const {result} = renderHook(() => useTravelBillingFundID(policyID)); + await waitForBatchedUpdates(); + + // Then it returns the workspace, because the backend treats an empty list as naming no workspaces rather than as a legacy feed + await waitFor(() => { + expect(result.current).toBe(workspaceFundID); + }); + }); + + it('resolves the workspace fund when the domain travel billing feed has no linked policies', async () => { + // Given a domain linked to the workspace through its US card program, with a TRAVEL_US program that has no linkedPolicyIDs + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {id: policyID, policyAccountID: workspaceFundID}); + await Onyx.merge(getTravelBillingCardSettingsKey(domainFundID), { + [CONST.COUNTRY.US]: {linkedPolicyIDs: [policyID], paymentBankAccountID: 67890}, + [CONST.TRAVEL.PROGRAM_TRAVEL_US]: {isEnabled: true, paymentBankAccountID: 8789966}, + }); + await waitForBatchedUpdates(); + + // When the hook resolves the travel billing fund + const {result} = renderHook(() => useTravelBillingFundID(policyID)); + await waitForBatchedUpdates(); + + // Then it returns the workspace, because the backend treats a domain feed without linkedPolicyIDs as linked to no workspace + await waitFor(() => { + expect(result.current).toBe(workspaceFundID); + }); + }); + + it('keeps the default domain fund while the first settlement account save is pending', async () => { + // Given a domain linked to the workspace through its US card program, holding the optimistic TRAVEL_US program written by the first settlement account save + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {id: policyID, policyAccountID: workspaceFundID}); + await Onyx.merge(getTravelBillingCardSettingsKey(domainFundID), { + [CONST.COUNTRY.US]: {linkedPolicyIDs: [policyID], paymentBankAccountID: 67890}, + [CONST.TRAVEL.PROGRAM_TRAVEL_US]: {paymentBankAccountID: 8789966}, + pendingFields: {paymentBankAccountID: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + }); + await waitForBatchedUpdates(); + + // When the hook resolves the travel billing fund + const {result} = renderHook(() => useTravelBillingFundID(policyID)); + await waitForBatchedUpdates(); + + // Then it stays on the domain, because the program has no linkedPolicyIDs only until the server links the policy + await waitFor(() => { + expect(result.current).toBe(domainFundID); + }); + }); + + it('resolves the default domain fund when the domain linked through its card program has no travel billing feed', async () => { + // Given a domain linked to the workspace through its US card program and no TRAVEL_US program anywhere + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {id: policyID, policyAccountID: workspaceFundID}); + await Onyx.merge(getTravelBillingCardSettingsKey(domainFundID), {[CONST.COUNTRY.US]: {linkedPolicyIDs: [policyID], paymentBankAccountID: 67890}}); + await waitForBatchedUpdates(); + + // When the hook resolves the travel billing fund + const {result} = renderHook(() => useTravelBillingFundID(policyID)); + await waitForBatchedUpdates(); + + // Then it keeps returning the default domain fund, so setting up travel billing still starts from the domain + await waitFor(() => { + expect(result.current).toBe(domainFundID); + }); + }); });