Skip to content
Open
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
21 changes: 20 additions & 1 deletion src/hooks/useTravelBillingFundID.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,28 @@ function findLinkedTravelBillingFundID(cardSettings: OnyxCollection<ExpensifyCar
return linkedEntry ? getFundIdFromSettingsKey(linkedEntry[0]) : undefined;
}

/** True when the settings have a TRAVEL_US program whose linkedPolicyIDs leaves out the policy, or that has no list at all. */
function isTravelBillingUnlinkedFromPolicy(settings: OnyxEntry<ExpensifyCardSettings>, 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<ExpensifyCardSettings>) => isTravelBillingUnlinkedFromPolicy(settings, policyID);
const [isDefaultFundTravelBillingUnlinked] = useOnyx(getTravelBillingCardSettingsKey(defaultFundID), {selector: unlinkedTravelBillingSelector});

const linkedFundSelector = (cardSettings: OnyxCollection<ExpensifyCardSettings>) => findLinkedTravelBillingFundID(cardSettings, policyID, workspaceAccountID);
const [linkedFundID] = useOnyx(ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS, {selector: linkedFundSelector});
Expand All @@ -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;
}

Expand Down
4 changes: 2 additions & 2 deletions tests/ui/WorkspaceTravelBillingMonthlyLimitPageTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
94 changes: 94 additions & 0 deletions tests/unit/hooks/useTravelBillingFundID.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
});
});
});
Loading