diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 6b9de3211728..6bab85b5f864 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -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. @@ -4606,6 +4614,7 @@ export { isApprovedOrSubmittedReportAction, isIOURequestReportAction, isNewerReportAction, + isOlderReportAction, isClosedAction, isConsecutiveActionMadeByPreviousActor, isExportedToIntegrationAction, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 8f689fa18919..a79725f1be3b 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -206,6 +206,7 @@ import { isModifiedExpenseAction, isMoneyRequestAction, isMovedAction, + isOlderReportAction, isPendingRemove, isPolicyChangeLogAction, isReimbursementQueuedAction, @@ -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; } diff --git a/src/libs/actions/IOU/ReportWorkflow.ts b/src/libs/actions/IOU/ReportWorkflow.ts index 5282d1c6498f..dba3f319434f 100644 --- a/src/libs/actions/IOU/ReportWorkflow.ts +++ b/src/libs/actions/IOU/ReportWorkflow.ts @@ -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, @@ -308,20 +308,24 @@ function getIOUReportActionWithBadge( const chatReportActions = getAllReportActionsFromIOU()?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`] ?? {}; let actionBadge: ValueOf | 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}; } /** diff --git a/tests/actions/IOUTest/ReportWorkflowTest.ts b/tests/actions/IOUTest/ReportWorkflowTest.ts index 9f4834e97c72..e39a157d8e97 100644 --- a/tests/actions/IOUTest/ReportWorkflowTest.ts +++ b/tests/actions/IOUTest/ReportWorkflowTest.ts @@ -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'; diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 6478f908203a..c122d467eb38 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -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', () => {