Skip to content
27 changes: 22 additions & 5 deletions src/libs/ReportActionsUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -800,15 +800,30 @@ 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.
*/
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;
}

Expand All @@ -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);
Expand Down
70 changes: 70 additions & 0 deletions tests/unit/ReportActionsUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading