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
9 changes: 9 additions & 0 deletions src/libs/ReportActionsUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1629,6 +1629,14 @@ function isNewerReportAction(a: ReportAction, b: ReportAction): boolean {
return a.reportActionID > b.reportActionID;
}

/**
* Returns true if action `a` is older than action `b`.
* This is the inverse of isNewerReportAction.
*/
function isOlderReportAction(a: ReportAction, b: ReportAction): boolean {
return isNewerReportAction(b, a);
}

/**
* The first visible action is the second last action in sortedReportActions which satisfy following conditions:
* 1. That is not pending deletion as pending deletion actions are kept in sortedReportActions in memory.
Expand Down Expand Up @@ -4606,6 +4614,7 @@ export {
isApprovedOrSubmittedReportAction,
isIOURequestReportAction,
isNewerReportAction,
isOlderReportAction,
isClosedAction,
isConsecutiveActionMadeByPreviousActor,
isExportedToIntegrationAction,
Expand Down
26 changes: 17 additions & 9 deletions src/libs/ReportUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ import {
isModifiedExpenseAction,
isMoneyRequestAction,
isMovedAction,
isOlderReportAction,
isPendingRemove,
isPolicyChangeLogAction,
isReimbursementQueuedAction,
Expand Down Expand Up @@ -4280,18 +4281,25 @@ function getReasonAndReportActionThatRequiresAttention(
}

if (isInvoiceRoom(optionOrReport)) {
const reportAction = Object.values(reportActions).find(
(action) =>
action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW &&
action.childReportID &&
hasMissingInvoiceBankAccount(action.childReportID) &&
!isSettled(action.childReportID),
);
let earliestAction: ReportAction | undefined;
for (const action of Object.values(reportActions)) {
if (
action.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW ||
!action.childReportID ||
!hasMissingInvoiceBankAccount(action.childReportID) ||
isSettled(action.childReportID)
) {
continue;
}
if (!earliestAction || isOlderReportAction(action, earliestAction)) {
earliestAction = action;
}
}

return reportAction
return earliestAction
? {
reason: CONST.REQUIRES_ATTENTION_REASONS.HAS_MISSING_INVOICE_BANK_ACCOUNT,
reportAction,
reportAction: earliestAction,
}
: null;
}
Expand Down
20 changes: 12 additions & 8 deletions src/libs/actions/IOU/ReportWorkflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import Navigation from '@libs/Navigation/Navigation';
import {getIsOffline} from '@libs/NetworkState';
import {buildNextStepNew, buildOptimisticNextStep} from '@libs/NextStepUtils';
import {arePaymentsEnabled, getSubmitReportManagerAccountID, hasDynamicExternalWorkflow, isPaidGroupPolicy, isPolicyAdmin, isSubmitAndClose} from '@libs/PolicyUtils';
import {getAllReportActions, getReportActionHtml, getReportActionText, hasPendingDEWApprove, isCreatedAction, isDeletedAction} from '@libs/ReportActionsUtils';
import {getAllReportActions, getReportActionHtml, getReportActionText, hasPendingDEWApprove, isCreatedAction, isDeletedAction, isOlderReportAction} from '@libs/ReportActionsUtils';
import {
buildOptimisticApprovedReportAction,
buildOptimisticChangeApproverReportAction,
Expand Down Expand Up @@ -308,20 +308,24 @@ function getIOUReportActionWithBadge(
const chatReportActions = getAllReportActionsFromIOU()?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`] ?? {};

let actionBadge: ValueOf<typeof CONST.REPORT.ACTION_BADGE> | undefined;
const reportAction = Object.values(chatReportActions).find((action) => {
let earliestAction: ReportAction | undefined;

for (const action of Object.values(chatReportActions)) {
if (action?.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW || isDeletedAction(action)) {
return false;
continue;
}
const iouReport = getReportOrDraftReport(action.childReportID);
const badge = getBadgeFromIOUReport(iouReport, chatReport, policy, reportMetadata, invoiceReceiverPolicy, currentUserLogin, currentUserAccountID);
if (badge) {
if (!badge) {
continue;
}
if (!earliestAction || isOlderReportAction(action, earliestAction)) {
earliestAction = action;
actionBadge = badge;
return true;
}
return false;
});
}

return {reportAction, actionBadge};
return {reportAction: earliestAction, actionBadge};
}

/**
Expand Down
90 changes: 90 additions & 0 deletions tests/actions/IOUTest/ReportWorkflowTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3500,6 +3500,96 @@ describe('actions/IOU/ReportWorkflow', () => {
expect(result.actionBadge).toBeUndefined();
});

it('should return the oldest matching report action when multiple actions have badges', async () => {
const chatReportID = '500';
const olderIouReportID = '501';
const newerIouReportID = '502';
const policyID = '503';

const fakePolicy: Policy = {
...createRandomPolicy(Number(policyID)),
id: policyID,
type: CONST.POLICY.TYPE.TEAM,
approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC,
};

const fakeChatReport: Report = {
...createRandomReport(Number(chatReportID), CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT),
reportID: chatReportID,
policyID,
};

// Two submitted expense reports — both will produce APPROVE badges
const olderIouReport: Report = {
...createRandomReport(Number(olderIouReportID), CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT),
reportID: olderIouReportID,
type: CONST.REPORT.TYPE.EXPENSE,
policyID,
stateNum: CONST.REPORT.STATE_NUM.SUBMITTED,
statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED,
managerID: RORY_ACCOUNT_ID,
};

const newerIouReport: Report = {
...createRandomReport(Number(newerIouReportID), CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT),
reportID: newerIouReportID,
type: CONST.REPORT.TYPE.EXPENSE,
policyID,
stateNum: CONST.REPORT.STATE_NUM.SUBMITTED,
statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED,
managerID: RORY_ACCOUNT_ID,
};

const olderTransaction: Transaction = {
...createRandomTransaction(0),
reportID: olderIouReportID,
amount: 100,
status: CONST.TRANSACTION.STATUS.POSTED,
bank: '',
};

const newerTransaction: Transaction = {
...createRandomTransaction(1),
reportID: newerIouReportID,
amount: 200,
status: CONST.TRANSACTION.STATUS.POSTED,
bank: '',
};

await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy);
await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, fakeChatReport);
await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${olderIouReportID}`, olderIouReport);
await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${newerIouReportID}`, newerIouReport);
await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${olderTransaction.transactionID}`, olderTransaction);
await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${newerTransaction.transactionID}`, newerTransaction);

const olderReportPreview = {
reportActionID: 'older-preview',
actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW,
created: '2024-08-08 18:00:00.000',
childReportID: olderIouReportID,
message: [{type: 'TEXT', text: 'Older report preview'}],
};

const newerReportPreview = {
reportActionID: 'newer-preview',
actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW,
created: '2024-08-08 20:00:00.000',
childReportID: newerIouReportID,
message: [{type: 'TEXT', text: 'Newer report preview'}],
};

await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, {
[newerReportPreview.reportActionID]: newerReportPreview,
[olderReportPreview.reportActionID]: olderReportPreview,
});
await waitForBatchedUpdates();

const result = getIOUReportActionWithBadge(fakeChatReport, fakePolicy, {}, undefined, RORY_EMAIL, RORY_ACCOUNT_ID);
expect(result.reportAction).toMatchObject(olderReportPreview);
expect(result.actionBadge).toBe(CONST.REPORT.ACTION_BADGE.APPROVE);
});

it('should return undefined actionBadge when report is settled', async () => {
const chatReportID = '400';
const iouReportID = '401';
Expand Down
75 changes: 75 additions & 0 deletions tests/unit/ReportUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9299,6 +9299,81 @@ describe('ReportUtils', () => {
// Should only return the task where childManagerAccountID matches the current user
expect(result?.reportAction?.reportActionID).toBe('current-user-task');
});

it('should return the earliest matching report action for invoice rooms with missing bank account', async () => {
const invoiceRoomID = '50000';
const olderChildReportID = '50001';
const newerChildReportID = '50002';
const policyID = '50003';

const invoiceRoom: Report = {
...createInvoiceRoom(Number(invoiceRoomID)),
reportID: invoiceRoomID,
policyID,
};

// Child invoice reports: owned by current user, no bank account on policy, and settled
// Note: hasMissingInvoiceBankAccount requires isSettled=true, but the outer condition
// requires !isSettled, making this path currently unreachable. This test documents the
// current behavior and will catch regressions if the conditions are corrected.
const olderChildReport: Report = {
...createRandomReport(Number(olderChildReportID), undefined),
reportID: olderChildReportID,
type: CONST.REPORT.TYPE.INVOICE,
policyID,
ownerAccountID: currentUserAccountID,
statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED,
};

const newerChildReport: Report = {
...createRandomReport(Number(newerChildReportID), undefined),
reportID: newerChildReportID,
type: CONST.REPORT.TYPE.INVOICE,
policyID,
ownerAccountID: currentUserAccountID,
statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED,
};

const fakePolicy: Policy = {
...createRandomPolicy(Number(policyID)),
id: policyID,
};

await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy);
await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${invoiceRoomID}`, invoiceRoom);
await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${olderChildReportID}`, olderChildReport);
await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${newerChildReportID}`, newerChildReport);

const olderReportPreview: ReportAction = {
...createRandomReportAction(Number(olderChildReportID)),
reportActionID: 'older-invoice-preview',
actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW,
created: '2024-01-01 10:00:00.000',
childReportID: olderChildReportID,
};

const newerReportPreview: ReportAction = {
...createRandomReportAction(Number(newerChildReportID)),
reportActionID: 'newer-invoice-preview',
actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW,
created: '2024-01-02 10:00:00.000',
childReportID: newerChildReportID,
};

await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${invoiceRoomID}`, {
[newerReportPreview.reportActionID]: newerReportPreview,
[olderReportPreview.reportActionID]: olderReportPreview,
});
await waitForBatchedUpdates();

const {result: isReportArchived} = renderHook(() => useReportIsArchived(invoiceRoomID));
const result = getReasonAndReportActionThatRequiresAttention(invoiceRoom, currentUserEmail, currentUserAccountID, undefined, isReportArchived.current);

// Currently returns null because hasMissingInvoiceBankAccount requires isSettled=true
// but the outer condition filters out settled reports with isSettled check.
// When this contradiction is resolved, the result should return the older report action.
expect(result).toBe(null);
});
});

describe('canEditReportDescription', () => {
Expand Down
Loading