Skip to content
Draft
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
17 changes: 13 additions & 4 deletions src/hooks/useOutstandingReports.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type {OnyxEntry} from 'react-native-onyx';
import {getOutstandingReportsForUser, isSelfDM} from '@libs/ReportUtils';
import {getOutstandingReportsForUser, isReportIneligibleForMoveExpenses, isSelfDM} from '@libs/ReportUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Policy} from '@src/types/onyx';
import type {Policy, Report} from '@src/types/onyx';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import useMappedPolicies from './useMappedPolicies';
import useOnyx from './useOnyx';
Expand All @@ -13,6 +13,7 @@ export default function useOutstandingReports(selectedReportID: string | undefin
const [outstandingReportsByPolicyID] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID);
const [personalPolicyID] = useOnyx(ONYXKEYS.PERSONAL_POLICY_ID);
const [allPoliciesID] = useMappedPolicies(policyIdMapper);
const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY);
const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS);
const [selectedReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${selectedReportID}`);

Expand All @@ -21,6 +22,12 @@ export default function useOutstandingReports(selectedReportID: string | undefin
return [];
}

const filterEligibleReports = (reports: Array<OnyxEntry<Report>>) =>
reports.filter((report) => {
const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`];
return !isReportIneligibleForMoveExpenses(report, policy);
});

if (!selectedPolicyID || selectedPolicyID === personalPolicyID || isSelfDM(selectedReport)) {
const result = [];
for (const policyID of Object.values(allPoliciesID ?? {})) {
Expand All @@ -31,8 +38,10 @@ export default function useOutstandingReports(selectedReportID: string | undefin
const reports = getOutstandingReportsForUser(policyID, ownerAccountID, outstandingReportsByPolicyID[policyID] ?? {}, reportNameValuePairs, isEditing);
result.push(...reports);
}
return result;
return filterEligibleReports(result);
}

return getOutstandingReportsForUser(selectedPolicyID, ownerAccountID, outstandingReportsByPolicyID?.[selectedPolicyID ?? CONST.DEFAULT_NUMBER_ID] ?? {}, reportNameValuePairs, isEditing);
return filterEligibleReports(
getOutstandingReportsForUser(selectedPolicyID, ownerAccountID, outstandingReportsByPolicyID?.[selectedPolicyID ?? CONST.DEFAULT_NUMBER_ID] ?? {}, reportNameValuePairs, isEditing),
);
}
189 changes: 189 additions & 0 deletions tests/unit/hooks/useOutstandingReports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import {act, renderHook, waitFor} from '@testing-library/react-native';
import Onyx from 'react-native-onyx';
import useOutstandingReports from '@hooks/useOutstandingReports';
import initOnyxDerivedValues from '@userActions/OnyxDerived';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Policy, Report} from '@src/types/onyx';
import createRandomPolicy from '../../utils/collections/policies';
import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates';

const POLICY_ID = 'policy1';
const ACCOUNT_ID = 100;

function buildPolicy(overrides: Partial<Policy> = {}): Policy {
return {
...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM),
id: POLICY_ID,
pendingAction: undefined,
...overrides,
};
}

function buildExpenseReport(reportID: string, overrides: Partial<Report> = {}): Report {
return {
reportID,
policyID: POLICY_ID,
ownerAccountID: ACCOUNT_ID,
type: CONST.REPORT.TYPE.EXPENSE,
stateNum: CONST.REPORT.STATE_NUM.OPEN,
statusNum: CONST.REPORT.STATUS_NUM.OPEN,
reportName: `Report ${reportID}`,
...overrides,
};
}

async function setupOnyxData(policy: Policy, reports: Report[], transactions: Array<{transactionID: string; reportID: string; reimbursable: boolean}>) {
await Onyx.merge(ONYXKEYS.SESSION, {accountID: ACCOUNT_ID});
await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, policy);

for (const report of reports) {
// eslint-disable-next-line no-await-in-loop
await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report);
}

for (const txn of transactions) {
// eslint-disable-next-line no-await-in-loop
await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${txn.transactionID}`, txn);
}

await waitForBatchedUpdates();
}

describe('useOutstandingReports', () => {
beforeAll(() => {
Onyx.init({keys: ONYXKEYS});
initOnyxDerivedValues();
return waitForBatchedUpdates();
});

beforeEach(async () => {
await act(async () => {
await Onyx.clear();
await waitForBatchedUpdates();
});
});

afterEach(async () => {
await act(async () => {
await Onyx.clear();
await waitForBatchedUpdates();
});
});

it('returns reports when policy does not have instant submit with no approvers', async () => {
// Given a workspace without instant submit and a report containing a non-reimbursable expense
await act(async () => {
await setupOnyxData(buildPolicy({autoReporting: false}), [buildExpenseReport('report1')], [{transactionID: 'txn1', reportID: 'report1', reimbursable: false}]);
});

// When the hook computes outstanding reports for that workspace
const {result} = renderHook(() => useOutstandingReports(undefined, POLICY_ID, ACCOUNT_ID, false));

// Then the report should be included because the policy doesn't trigger the ineligibility filter
await waitFor(() => {
expect(result.current.length).toBe(1);
expect(result.current.at(0)?.reportID).toBe('report1');
});
});

it('filters out reports with only non-reimbursable transactions when policy has instant submit and submit & close', async () => {
// Given a workspace with instant submit and no approvers, and a report containing only non-reimbursable expenses
await act(async () => {
await setupOnyxData(
buildPolicy({
autoReporting: true,
autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT,
approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL,
}),
[buildExpenseReport('report1')],
[{transactionID: 'txn1', reportID: 'report1', reimbursable: false}],
);
});

// When the hook computes outstanding reports for that workspace
const {result} = renderHook(() => useOutstandingReports(undefined, POLICY_ID, ACCOUNT_ID, false));

// Then the report should be excluded because moving expenses to it would fail server-side with a 403
await waitFor(() => {
expect(result.current.length).toBe(0);
});
});

it('keeps reports with reimbursable transactions even with instant submit and submit & close', async () => {
// Given a workspace with instant submit and no approvers, but a report that has reimbursable expenses
await act(async () => {
await setupOnyxData(
buildPolicy({
autoReporting: true,
autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT,
approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL,
}),
[buildExpenseReport('report1')],
[{transactionID: 'txn1', reportID: 'report1', reimbursable: true}],
);
});

// When the hook computes outstanding reports for that workspace
const {result} = renderHook(() => useOutstandingReports(undefined, POLICY_ID, ACCOUNT_ID, false));

// Then the report should be included because it contains reimbursable transactions that keep the report open
await waitFor(() => {
expect(result.current.length).toBe(1);
expect(result.current.at(0)?.reportID).toBe('report1');
});
});

it('returns empty array when all reports are ineligible', async () => {
// Given a workspace with instant submit and no approvers, where every report has only non-reimbursable expenses
await act(async () => {
await setupOnyxData(
buildPolicy({
autoReporting: true,
autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT,
approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL,
}),
[buildExpenseReport('report1'), buildExpenseReport('report2')],
[
{transactionID: 'txn1', reportID: 'report1', reimbursable: false},
{transactionID: 'txn2', reportID: 'report2', reimbursable: false},
],
);
});

// When the hook computes outstanding reports for that workspace
const {result} = renderHook(() => useOutstandingReports(undefined, POLICY_ID, ACCOUNT_ID, false));

// Then no reports should be returned, which allows the confirmation page to disable the Report field instead of opening a blank page
await waitFor(() => {
expect(result.current.length).toBe(0);
});
});

it('filters only ineligible reports and keeps eligible ones', async () => {
// Given a workspace with instant submit and no approvers, one report with only non-reimbursable expenses and another with reimbursable expenses
await act(async () => {
await setupOnyxData(
buildPolicy({
autoReporting: true,
autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT,
approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL,
}),
[buildExpenseReport('reportIneligible'), buildExpenseReport('reportEligible')],
[
{transactionID: 'txnIneligible', reportID: 'reportIneligible', reimbursable: false},
{transactionID: 'txnEligible', reportID: 'reportEligible', reimbursable: true},
],
);
});

// When the hook computes outstanding reports for that workspace
const {result} = renderHook(() => useOutstandingReports(undefined, POLICY_ID, ACCOUNT_ID, false));

// Then only the eligible report should remain since the ineligible one would cause a 403 if expenses were moved to it
await waitFor(() => {
expect(result.current.length).toBe(1);
expect(result.current.at(0)?.reportID).toBe('reportEligible');
});
});
});
Loading