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
29 changes: 25 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,24 @@ export default function useOutstandingReports(selectedReportID: string | undefin
return [];
}

// Hide reports the backend will reject as move-expense destinations (instant submit +
// submit-and-close + only non-reimbursable transactions returns a 403 — issue #70423). The
// currently-selected source report is always kept so the picker still renders for in-place
// editing — e.g. the "Remove from report" footer for retracted reports (deploy blocker
// #88424). Optimistic create reports are guarded inside isReportIneligibleForMoveExpenses
// itself to handle the create→move microtask race (deploy blocker #88425).
const filterEligibleReports = (reports: Array<OnyxEntry<Report>>) =>
reports.filter((report) => {
if (!report) {
return false;
}
if (selectedReportID && report.reportID === selectedReportID) {
return true;
}
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 +50,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),
);
}
7 changes: 7 additions & 0 deletions src/libs/ReportUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2731,6 +2731,13 @@ function isReportIneligibleForMoveExpenses(moneyRequestReport: OnyxEntry<Report>
if (isDraftReport(moneyRequestReport?.reportID)) {
return false;
}
// Optimistically created reports go through a transient window where the report exists in
// Onyx but the transaction hasn't been associated yet. During that window
// hasOnlyNonReimbursableTransactions flips from false to true, which would otherwise cause
// the new report to flicker into and out of the picker (deploy blocker #88425).
if (moneyRequestReport?.pendingFields?.createReport === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) {
return false;
Comment on lines +2738 to +2739
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Restrict create-report bypass to truly pending reports

The new early return for pendingFields.createReport === ADD treats every optimistic report as eligible, but failed creates keep that pending field set: createNewReport failure data only sets errorFields.createReport and never clears pendingFields.createReport (src/libs/actions/Report/index.ts:3762-3767). In an instant-submit/no-approver workspace, a failed optimistic report with only non-reimbursable transactions will now stay in the destination list indefinitely and can be selected again even though backend move operations are still rejected. The bypass should be limited to in-flight creates (for example, no create error present) or the pending field must be cleared on failure.

Useful? React with 👍 / 👎.

}
return isInstantSubmitEnabled(policy) && isSubmitAndClose(policy) && hasOnlyNonReimbursableTransactions(moneyRequestReport?.reportID);
}

Expand Down
32 changes: 32 additions & 0 deletions tests/unit/ReportUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8835,6 +8835,38 @@ describe('ReportUtils', () => {

expect(result).toBe(false);
});

// Regression test for deploy blocker https://github.com/Expensify/App/issues/88425.
// Optimistically created reports must not be flagged as ineligible during the brief
// window between report creation and the deferred transaction-move microtask, otherwise
// the new report flickers into and out of the picker.
it('should return false for a report that is still optimistically being created (pendingFields.createReport === ADD)', async () => {
const testPolicy: Policy = {
...createRandomPolicy(3007),
autoReporting: true,
autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT,
approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL,
};
const report: Report = {
...createRandomReport(30008, undefined),
type: CONST.REPORT.TYPE.EXPENSE,
policyID: testPolicy.id,
ownerAccountID: currentUserAccountID,
pendingFields: {createReport: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD},
};
const transaction = {
transactionID: '30008',
reportID: report.reportID,
reimbursable: false,
};

await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report);
await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction);

const result = isReportIneligibleForMoveExpenses(report, testPolicy);

expect(result).toBe(false);
});
});

describe('canDeleteTransaction', () => {
Expand Down
250 changes: 250 additions & 0 deletions tests/unit/hooks/useOutstandingReports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
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,
};
}

function buildInstantSubmitNoApproversPolicy(): Policy {
return buildPolicy({
autoReporting: true,
autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT,
approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL,
});
}

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);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

❌ CONSISTENCY-5 (docs)

The eslint-disable-next-line no-await-in-loop directive lacks an accompanying comment explaining why the rule is being suppressed.

Add a justification comment, for example:

// eslint-disable-next-line no-await-in-loop -- Onyx.merge calls must be sequential to avoid race conditions during test data setup
await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report);

Reviewed at: 6de392f | Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.


await waitForBatchedUpdates();
}

describe('useOutstandingReports', () => {
beforeAll(() => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

❌ CONSISTENCY-5 (docs)

The eslint-disable-next-line no-await-in-loop directive lacks an accompanying comment explaining why the rule is being suppressed.

Add a justification comment, for example:

// eslint-disable-next-line no-await-in-loop -- Onyx.merge calls must be sequential to avoid race conditions during test data setup
await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${txn.transactionID}`, txn);

Reviewed at: 6de392f | Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

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(buildInstantSubmitNoApproversPolicy(), [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 (issue #70423)
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(buildInstantSubmitNoApproversPolicy(), [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 so the confirmation Report field can be disabled', async () => {
// Given a workspace with instant submit and no approvers, where every report has only non-reimbursable expenses
await act(async () => {
await setupOnyxData(
buildInstantSubmitNoApproversPolicy(),
[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, with no selected report (the confirmation page case)
const {result} = renderHook(() => useOutstandingReports(undefined, POLICY_ID, ACCOUNT_ID, false));

// Then no reports should be returned. ReportField uses outstandingReports.length to decide whether the field is interactive,
// so an empty list correctly disables the field instead of opening a blank picker (deploy blocker #81608)
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(
buildInstantSubmitNoApproversPolicy(),
[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');
});
});

// Regression test for deploy blocker https://github.com/Expensify/App/issues/88424.
// The currently selected (source) report must always remain in the outstanding list. Otherwise
// outstandingReports.length === 0 in IOURequestEditReportCommon, which makes
// shouldShowNotFoundPage true and unmounts the SelectionList, hiding the "Remove from report"
// footer that lives inside it.
it('keeps the currently selected source report in the list even when it is otherwise ineligible', async () => {
// Given a retracted report containing only a non-reimbursable expense on a workspace
// with instant submit and no approvers — the same conditions that flag a destination as ineligible
await act(async () => {
await setupOnyxData(buildInstantSubmitNoApproversPolicy(), [buildExpenseReport('sourceReport')], [{transactionID: 'txnSource', reportID: 'sourceReport', reimbursable: false}]);
});

// When the picker is rendered with that report as the selected source
const {result} = renderHook(() => useOutstandingReports('sourceReport', POLICY_ID, ACCOUNT_ID, true));

// Then the source report must still be returned so the picker renders and the "Remove from report" footer remains accessible
await waitFor(() => {
expect(result.current.length).toBe(1);
expect(result.current.at(0)?.reportID).toBe('sourceReport');
});
});

it('still filters out an ineligible non-source report even when a different source report is selected', async () => {
// Given a selected source report and another report that is ineligible as a destination
await act(async () => {
await setupOnyxData(
buildInstantSubmitNoApproversPolicy(),
[buildExpenseReport('sourceReport'), buildExpenseReport('ineligibleDestination')],
[
{transactionID: 'txnSource', reportID: 'sourceReport', reimbursable: true},
{transactionID: 'txnIneligible', reportID: 'ineligibleDestination', reimbursable: false},
],
);
});

// When the hook is invoked with sourceReport as the selected report
const {result} = renderHook(() => useOutstandingReports('sourceReport', POLICY_ID, ACCOUNT_ID, true));

// Then only the source remains; the ineligible destination is filtered out as before
await waitFor(() => {
expect(result.current.length).toBe(1);
expect(result.current.at(0)?.reportID).toBe('sourceReport');
});
});

// Regression test for deploy blocker https://github.com/Expensify/App/issues/88425.
// When "Create report" runs, an optimistic empty report is written to Onyx with
// pendingFields.createReport === ADD, then the transaction is moved to it on a deferred
// microtask. During that gap, hasOnlyNonReimbursableTransactions flips from false (zero
// transactions) to true (one non-reimbursable transaction) and would otherwise cause the
// new report to be filtered out, making it appear briefly and then disappear from the picker.
it('keeps optimistically created reports (pendingFields.createReport === ADD) in the list', async () => {
// Given an optimistically created empty report on a workspace with instant submit and no approvers,
// plus a non-reimbursable transaction that has just been associated with it
await act(async () => {
await setupOnyxData(
buildInstantSubmitNoApproversPolicy(),
[
buildExpenseReport('optimisticReport', {
pendingFields: {createReport: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD},
}),
],
[{transactionID: 'txnOptimistic', reportID: 'optimisticReport', reimbursable: false}],
);
});

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

// Then the optimistic report remains visible until the server confirms creation and clears pendingFields
await waitFor(() => {
expect(result.current.length).toBe(1);
expect(result.current.at(0)?.reportID).toBe('optimisticReport');
});
});
});
Loading