diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 8d4ffd685861..5f962b3f1480 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -800,6 +800,15 @@ function getSortedReportActions(reportActions: ReportAction[] | null, shouldSort return sortedActions; } +/** + * Predicate for IOU actions of type `create`. Used by `getCombinedReportActions` to drop + * preview cards we never want to show (on single-transaction reports normally, and on + * SendMoney reports where a `type=create` can only be a phantom from OpenReport's orphan path). + */ +function isIOUCreateAction(action: ReportAction): boolean { + return isMoneyRequestAction(action) && getOriginalMessage(action)?.type === CONST.IOU.REPORT_ACTION_TYPE.CREATE; +} + /** * Returns a sorted and filtered list of report actions from a report and it's associated child * transaction thread report in order to correctly display reportActions from both reports in the one-transaction report view. @@ -807,8 +816,14 @@ function getSortedReportActions(reportActions: ReportAction[] | null, shouldSort function getCombinedReportActions(reportActions: ReportAction[], transactionThreadReportID: string | null, transactionThreadReportActions: ReportAction[], isSelfDM = false): ReportAction[] { const isSentMoneyReport = reportActions.some((action) => isSentMoneyReportAction(action)); - // We don't want to combine report actions of transaction thread in iou report of send money request because we display the transaction report of send money request as a normal thread - if (!transactionThreadReportID || isSentMoneyReport) { + // SendMoney reports never legitimately have a `type=create` IOU action — drop any that appear, + // since they can only be phantoms synthesized by OpenReport's orphan-thread path. + if (isSentMoneyReport) { + return reportActions.filter((action) => !isIOUCreateAction(action)); + } + + // Nothing to combine when there's no transaction thread. + if (!transactionThreadReportID) { return reportActions; } @@ -829,14 +844,16 @@ function getCombinedReportActions(reportActions: ReportAction[], transactionThre // Filter out request and send money request actions because we don't want to show any preview actions for one transaction reports const filteredReportActions = [...filteredParentReportActions, ...filteredTransactionThreadReportActions].filter((action) => { + if (isIOUCreateAction(action)) { + return false; + } if (!isMoneyRequestAction(action)) { return true; } - const actionType = getOriginalMessage(action)?.type ?? ''; if (isSelfDM) { - return actionType !== CONST.IOU.REPORT_ACTION_TYPE.CREATE; + return true; } - return actionType !== CONST.IOU.REPORT_ACTION_TYPE.CREATE && actionType !== CONST.IOU.REPORT_ACTION_TYPE.TRACK; + return getOriginalMessage(action)?.type !== CONST.IOU.REPORT_ACTION_TYPE.TRACK; }); return getSortedReportActions(filteredReportActions, true); diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index 67b779baf692..a1e8e5b6f0e5 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -1566,6 +1566,76 @@ describe('ReportActionsUtils', () => { }); }); + describe('SendMoney phantom type=create filtering', () => { + // Modern SendMoney pay action: type=pay AND IOUDetails present. This is what makes + // isSentMoneyReportAction() return true and triggers our filter branch. + const sendMoneyPayAction = { + ...mockIOUAction, + reportActionID: 'expense-pay-1', + originalMessage: { + ...mockOriginalMessage, + type: CONST.IOU.TYPE.PAY, + IOUDetails: {amount: 100, comment: '', currency: 'USD'}, + IOUTransactionID: 'txn-1', + paymentType: 'Elsewhere', + }, + }; + + // Phantom IOU create action that OpenReport synthesizes for legacy SendMoney transactions. + const phantomCreateAction = { + ...mockIOUAction, + reportActionID: 'phantom-create-1', + originalMessage: { + ...mockOriginalMessage, + type: CONST.IOU.TYPE.CREATE, + IOUTransactionID: 'txn-1', + }, + }; + + // Plain mark-as-paid-elsewhere pay action: type=pay, NO IOUDetails. Does not trigger the + // SendMoney filter branch, so type=create actions are not dropped from these reports. + const markAsPaidElsewhereAction = { + ...mockIOUAction, + reportActionID: 'mark-paid-1', + originalMessage: { + ...mockOriginalMessage, + type: CONST.IOU.TYPE.PAY, + IOUDetails: undefined, + paymentType: 'Elsewhere', + }, + }; + + // Legitimate IOU create action used to verify it survives when no SendMoney pay is present. + const legitimateCreateAction = { + ...mockIOUAction, + reportActionID: 'create-1', + originalMessage: { + ...mockOriginalMessage, + type: CONST.IOU.TYPE.CREATE, + IOUTransactionID: 'txn-1', + }, + }; + + describe('getCombinedReportActions', () => { + it('drops type=create IOU actions when a SendMoney pay action is present on the report', () => { + const result = getCombinedReportActions([sendMoneyPayAction, phantomCreateAction], 'thread-1', []); + expect(result.some((action) => action.reportActionID === sendMoneyPayAction.reportActionID)).toBe(true); + expect(result.some((action) => action.reportActionID === phantomCreateAction.reportActionID)).toBe(false); + }); + + it('keeps type=create when only a plain pay (no IOUDetails) action is present', () => { + const result = getCombinedReportActions([markAsPaidElsewhereAction, legitimateCreateAction], null, []); + expect(result.some((action) => action.reportActionID === legitimateCreateAction.reportActionID)).toBe(true); + }); + + it('keeps the SendMoney pay action itself (filter only drops type=create)', () => { + const result = getCombinedReportActions([sendMoneyPayAction, phantomCreateAction], 'thread-1', []); + const sendMoneyRendered = result.find((action) => action.reportActionID === sendMoneyPayAction.reportActionID); + expect(sendMoneyRendered).toBeDefined(); + }); + }); + }); + describe('shouldShowAddMissingDetails', () => { it('should return true if personal detail is not completed', () => { const mockPersonalDetail = {