From 2fae030c03867b4ae5896be15b51c34c859bd4b4 Mon Sep 17 00:00:00 2001 From: "Alex Beaman (via MelvinBot)" Date: Thu, 5 Mar 2026 13:53:57 +0000 Subject: [PATCH 1/3] Add Explain link to MOVED_TRANSACTION report actions When Concierge automatically moves a transaction (e.g. due to policy violations during harvesting), the backend now includes a reasoning field in the MOVED_TRANSACTION action. This change renders the Explain link on those actions so users can ask Concierge why their expense was moved. Co-authored-by: Alex Beaman --- .../MovedTransactionAction.tsx | 34 +++++++++++++++---- .../inbox/report/PureReportActionItem.tsx | 2 ++ src/types/onyx/OriginalMessage.ts | 2 ++ 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/components/ReportActionItem/MovedTransactionAction.tsx b/src/components/ReportActionItem/MovedTransactionAction.tsx index da4729aaa595..616b517a7116 100644 --- a/src/components/ReportActionItem/MovedTransactionAction.tsx +++ b/src/components/ReportActionItem/MovedTransactionAction.tsx @@ -1,14 +1,16 @@ import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; import RenderHTML from '@components/RenderHTML'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import Parser from '@libs/Parser'; -import {getOriginalMessage} from '@libs/ReportActionsUtils'; +import {getOriginalMessage, hasReasoning} from '@libs/ReportActionsUtils'; import {getMovedTransactionMessage} from '@libs/ReportUtils'; import ReportActionItemBasicMessage from '@pages/inbox/report/ReportActionItemBasicMessage'; +import ReportActionItemMessageWithExplain from '@pages/inbox/report/ReportActionItemMessageWithExplain'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {ReportAction} from '@src/types/onyx'; +import type {Report, ReportAction} from '@src/types/onyx'; type MovedTransactionActionProps = { /** The moved transaction action data */ @@ -16,9 +18,15 @@ type MovedTransactionActionProps = { /** The element to render when there is no report that the transaction was moved to or from */ emptyHTML: React.JSX.Element; + + /** The child report of the action item */ + childReport: OnyxEntry; + + /** Original report from which the given reportAction is first created */ + originalReport: OnyxEntry; }; -function MovedTransactionAction({action, emptyHTML}: MovedTransactionActionProps) { +function MovedTransactionAction({action, emptyHTML, childReport, originalReport}: MovedTransactionActionProps) { const {translate} = useLocalize(); const movedTransactionOriginalMessage = getOriginalMessage(action); const toReportID = movedTransactionOriginalMessage?.toReportID; @@ -30,9 +38,6 @@ function MovedTransactionAction({action, emptyHTML}: MovedTransactionActionProps const isPendingDelete = fromReport?.pendingFields?.preview === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; // When the transaction is moved from personal space (unreported), fromReportID will be "0" which doesn't exist in allReports const hasFromReport = fromReportID === CONST.REPORT.UNREPORTED_REPORT_ID ? true : !!fromReport; - const htmlContent = isPendingDelete - ? `${Parser.htmlToText(getMovedTransactionMessage(translate, action))}` - : `${getMovedTransactionMessage(translate, action)}`; // When expenses are merged multiple times, the previous fromReportID may reference a deleted report, // making it impossible to retrieve the report name for display @@ -41,6 +46,23 @@ function MovedTransactionAction({action, emptyHTML}: MovedTransactionActionProps return emptyHTML; } + const message = getMovedTransactionMessage(translate, action); + + if (hasReasoning(action)) { + return ( + + ); + } + + const htmlContent = isPendingDelete + ? `${Parser.htmlToText(message)}` + : `${message}`; + return ( diff --git a/src/pages/inbox/report/PureReportActionItem.tsx b/src/pages/inbox/report/PureReportActionItem.tsx index aebec325e978..ceccaa26b1f8 100644 --- a/src/pages/inbox/report/PureReportActionItem.tsx +++ b/src/pages/inbox/report/PureReportActionItem.tsx @@ -1466,6 +1466,8 @@ function PureReportActionItem({ } emptyHTML={emptyHTML} + childReport={childReport} + originalReport={originalReport} /> ); } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.MOVED) { diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index 4b74456f8d9b..2dd6f1a100c9 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -1014,6 +1014,8 @@ type OriginalMessageMovedTransaction = { toReportID?: string; /** ID of the original report */ fromReportID: string; + /** Reasoning for the automated move, used by Concierge Explain feature */ + reasoning?: string; }; /** Model of `moved` report action */ From e1abca43cbb1ef7732f62332a32032249c6f0cd5 Mon Sep 17 00:00:00 2001 From: "Alex Beaman (via MelvinBot)" Date: Thu, 5 Mar 2026 13:58:45 +0000 Subject: [PATCH 2/3] Fix: Apply Prettier formatting to MovedTransactionAction Co-authored-by: Alex Beaman --- src/components/ReportActionItem/MovedTransactionAction.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/components/ReportActionItem/MovedTransactionAction.tsx b/src/components/ReportActionItem/MovedTransactionAction.tsx index 616b517a7116..c8fa0f60446e 100644 --- a/src/components/ReportActionItem/MovedTransactionAction.tsx +++ b/src/components/ReportActionItem/MovedTransactionAction.tsx @@ -59,9 +59,7 @@ function MovedTransactionAction({action, emptyHTML, childReport, originalReport} ); } - const htmlContent = isPendingDelete - ? `${Parser.htmlToText(message)}` - : `${message}`; + const htmlContent = isPendingDelete ? `${Parser.htmlToText(message)}` : `${message}`; return ( From a313014eafa49e7caa832aed6fac239bea0ab47c Mon Sep 17 00:00:00 2001 From: "Alex Beaman (via MelvinBot)" Date: Thu, 5 Mar 2026 15:59:26 +0000 Subject: [PATCH 3/3] Add tests for hasReasoning and MOVED_TRANSACTION Explain link Unit tests for the hasReasoning utility covering: non-empty reasoning, missing reasoning, empty string reasoning, and null/undefined actions. UI tests for MOVED_TRANSACTION actions verifying the Explain link renders when reasoning is present and is absent when reasoning is not present. Co-authored-by: Alex Beaman --- tests/ui/PureReportActionItemTest.tsx | 54 +++++++++++++++++++++++++++ tests/unit/ReportActionsUtilsTest.ts | 52 ++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/tests/ui/PureReportActionItemTest.tsx b/tests/ui/PureReportActionItemTest.tsx index d4f30c5ee5dd..b8d2ba799941 100644 --- a/tests/ui/PureReportActionItemTest.tsx +++ b/tests/ui/PureReportActionItemTest.tsx @@ -580,4 +580,58 @@ describe('PureReportActionItem', () => { expect(openLink).toHaveBeenCalledWith(workspaceRulesUrl, expect.any(String)); }); }); + + describe('MOVED_TRANSACTION action', () => { + const FROM_REPORT_ID = '100'; + const TO_REPORT_ID = '200'; + + beforeEach(async () => { + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${FROM_REPORT_ID}`, { + reportID: FROM_REPORT_ID, + reportName: 'Source Report', + type: CONST.REPORT.TYPE.EXPENSE, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${TO_REPORT_ID}`, { + reportID: TO_REPORT_ID, + reportName: 'Destination Report', + type: CONST.REPORT.TYPE.EXPENSE, + }); + }); + await waitForBatchedUpdatesWithAct(); + }); + + it('renders plain message without Explain link when action has no reasoning', async () => { + const action = createReportAction(CONST.REPORT.ACTIONS.TYPE.MOVED_TRANSACTION, { + fromReportID: FROM_REPORT_ID, + toReportID: TO_REPORT_ID, + }); + + renderItemWithAction(action); + await waitForBatchedUpdatesWithAct(); + + // The moved transaction message should be displayed + expect(screen.getByText(/moved this expense/)).toBeOnTheScreen(); + + // The "Explain" link should NOT be present + expect(screen.queryByText('Explain')).not.toBeOnTheScreen(); + }); + + it('renders Explain link when action has reasoning', async () => { + const action = createReportAction(CONST.REPORT.ACTIONS.TYPE.MOVED_TRANSACTION, { + fromReportID: FROM_REPORT_ID, + toReportID: TO_REPORT_ID, + reasoning: 'This expense violated the max amount rule.', + }); + + renderItemWithAction(action); + await waitForBatchedUpdatesWithAct(); + + // The moved transaction message should be displayed + expect(screen.getByText(/moved this expense/)).toBeOnTheScreen(); + + // The "Explain" link should be present + expect(screen.getByText('Explain')).toBeOnTheScreen(); + }); + }); }); diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index 8d742337016c..c2f39f13fd6a 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -32,6 +32,7 @@ import { getSortedReportActions, getSortedReportActionsForDisplay, getUpdateACHAccountMessage, + hasReasoning, isIOUActionMatchingTransactionList, isNewerReportAction, isReportActionVisibleAsLastAction, @@ -3850,4 +3851,55 @@ describe('ReportActionsUtils', () => { expect(ReportActionsUtils.isOriginalReportDeleted(action, originalReport)).toBe(false); }); }); + + describe('hasReasoning', () => { + it('should return true when the action has a non-empty reasoning field', () => { + const action: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.MOVED_TRANSACTION, + reportActionID: '1', + created: '2025-09-29', + originalMessage: { + fromReportID: '2', + toReportID: '3', + reasoning: 'This expense was moved because it violated the max amount rule.', + }, + }; + + expect(hasReasoning(action)).toBe(true); + }); + + it('should return false when the action has no reasoning field', () => { + const action: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.MOVED_TRANSACTION, + reportActionID: '1', + created: '2025-09-29', + originalMessage: { + fromReportID: '2', + toReportID: '3', + }, + }; + + expect(hasReasoning(action)).toBe(false); + }); + + it('should return false when reasoning is an empty string', () => { + const action: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.MOVED_TRANSACTION, + reportActionID: '1', + created: '2025-09-29', + originalMessage: { + fromReportID: '2', + toReportID: '3', + reasoning: '', + }, + }; + + expect(hasReasoning(action)).toBe(false); + }); + + it('should return false when the action is null or undefined', () => { + expect(hasReasoning(null)).toBe(false); + expect(hasReasoning(undefined)).toBe(false); + }); + }); });