diff --git a/src/libs/ModifiedExpenseMessage.ts b/src/libs/ModifiedExpenseMessage.ts index 94519b1c86c86..3c4775b6d8cb8 100644 --- a/src/libs/ModifiedExpenseMessage.ts +++ b/src/libs/ModifiedExpenseMessage.ts @@ -28,6 +28,7 @@ import {getPolicyName, getReportName, getRootParentReport, isPolicyExpenseChat, import {getFormattedAttendees, getTagArrayFromName} from './TransactionUtils'; let allPolicyTags: OnyxCollection = {}; +// eslint-disable-next-line @typescript-eslint/no-deprecated -- Onyx.connectWithoutView is being removed in https://github.com/Expensify/App/issues/66336 Onyx.connectWithoutView({ key: ONYXKEYS.COLLECTION.POLICY_TAGS, waitForCollectionCallback: true, @@ -43,7 +44,8 @@ Onyx.connectWithoutView({ let environmentURL: string; getEnvironmentURL().then((url: string) => (environmentURL = url)); -let currentUserLogin = ''; +let storedCurrentUserLogin = ''; +// eslint-disable-next-line @typescript-eslint/no-deprecated -- Onyx.connectWithoutView is being removed in https://github.com/Expensify/App/issues/66336 Onyx.connectWithoutView({ key: ONYXKEYS.SESSION, callback: (value) => { @@ -51,7 +53,7 @@ Onyx.connectWithoutView({ if (!value) { return; } - currentUserLogin = value?.email ?? ''; + storedCurrentUserLogin = value?.email ?? ''; }, }); @@ -164,7 +166,7 @@ function getForExpenseMovedFromSelfDM(translate: LocalizedTranslate, destination // In NewDot, the "Move report" flow only supports moving expenses from self-DM to: // - A policy expense chat // - A 1:1 DM - const currentUserAccountID = getPersonalDetailByEmail(currentUserLogin)?.accountID; + const currentUserAccountID = getPersonalDetailByEmail(storedCurrentUserLogin)?.accountID; const reportName = isPolicyExpenseChat(rootParentReport) ? getPolicyExpenseChatName({report: rootParentReport}) : buildReportNameFromParticipantNames({report: rootParentReport, currentUserAccountID}); @@ -330,7 +332,7 @@ function getForReportAction({ } else if (reportActionOriginalMessage?.source === CONST.CATEGORY_SOURCE.MCC) { // eslint-disable-next-line @typescript-eslint/no-deprecated const policy = getPolicy(policyID); - const isAdmin = isPolicyAdmin(policy, currentUserLogin); + const isAdmin = isPolicyAdmin(policy, storedCurrentUserLogin); // For admins, create a hyperlink to the workspace rules page if (isAdmin && policy?.id) { @@ -514,6 +516,7 @@ function getForReportActionTemp({ movedFromReport, movedToReport, policyTags, + currentUserLogin, }: { translate: LocalizedTranslate; reportAction: OnyxEntry; @@ -521,6 +524,7 @@ function getForReportActionTemp({ movedFromReport?: OnyxEntry; movedToReport?: OnyxEntry; policyTags: OnyxEntry; + currentUserLogin: string; }): string { if (!isModifiedExpenseAction(reportAction)) { return ''; @@ -693,30 +697,17 @@ function getForReportActionTemp({ const hasModifiedBillable = isReportActionOriginalMessageAnObject && 'oldBillable' in reportActionOriginalMessage && 'billable' in reportActionOriginalMessage; if (hasModifiedBillable) { - buildMessageFragmentForValue( - translate, - reportActionOriginalMessage?.billable ?? '', - reportActionOriginalMessage?.oldBillable ?? '', - translate('iou.expense'), - true, - setFragments, - removalFragments, - changeFragments, - ); + const oldBillable = reportActionOriginalMessage?.oldBillable === 'billable' ? translate('common.billable').toLowerCase() : translate('common.nonBillable').toLowerCase(); + const newBillable = reportActionOriginalMessage?.billable === 'billable' ? translate('common.billable').toLowerCase() : translate('common.nonBillable').toLowerCase(); + buildMessageFragmentForValue(translate, newBillable, oldBillable, translate('iou.expense'), true, setFragments, removalFragments, changeFragments); } const hasModifiedReimbursable = isReportActionOriginalMessageAnObject && 'oldReimbursable' in reportActionOriginalMessage && 'reimbursable' in reportActionOriginalMessage; if (hasModifiedReimbursable) { - buildMessageFragmentForValue( - translate, - reportActionOriginalMessage?.reimbursable ?? '', - reportActionOriginalMessage?.oldReimbursable ?? '', - translate('iou.expense'), - true, - setFragments, - removalFragments, - changeFragments, - ); + const oldReimbursable = + reportActionOriginalMessage?.oldReimbursable === 'reimbursable' ? translate('iou.reimbursable').toLowerCase() : translate('iou.nonReimbursable').toLowerCase(); + const newReimbursable = reportActionOriginalMessage?.reimbursable === 'reimbursable' ? translate('iou.reimbursable').toLowerCase() : translate('iou.nonReimbursable').toLowerCase(); + buildMessageFragmentForValue(translate, newReimbursable, oldReimbursable, translate('iou.expense'), true, setFragments, removalFragments, changeFragments); } const hasModifiedAttendees = isReportActionOriginalMessageAnObject && 'oldAttendees' in reportActionOriginalMessage && 'newAttendees' in reportActionOriginalMessage; @@ -741,6 +732,13 @@ function getForReportActionTemp({ getMessageLine(translate, `\n${translate('iou.removed')}`, removalFragments); if (message === '') { + // If we don't have enough structured information to build a detailed message but we + // know the change was AI-generated, fall back to an AI-attributed generic summary so + // users can still understand that Concierge updated the expense automatically. + if (reportActionOriginalMessage?.aiGenerated) { + return `${translate('iou.changedTheExpense')} ${translate('iou.basedOnAI')}`; + } + return translate('iou.changedTheExpense'); } return `${message.substring(1, message.length)}`; diff --git a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx index e507a4be8d03c..a4bd5dd864697 100644 --- a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx @@ -744,6 +744,7 @@ const ContextMenuActions: ContextMenuAction[] = [ policyTags, translate, harvestReport, + currentUserPersonalDetails, }, ) => { const isReportPreviewAction = isReportPreviewActionReportActionsUtils(reportAction); @@ -769,6 +770,7 @@ const ContextMenuActions: ContextMenuAction[] = [ movedFromReport, movedToReport, policyTags, + currentUserLogin: currentUserPersonalDetails?.email ?? '', }); Clipboard.setString(modifyExpenseMessage); } else if (isReimbursementDeQueuedOrCanceledAction(reportAction)) { diff --git a/src/pages/inbox/report/ReportActionItem.tsx b/src/pages/inbox/report/ReportActionItem.tsx index a8dece8f1384a..eb03284bf1374 100644 --- a/src/pages/inbox/report/ReportActionItem.tsx +++ b/src/pages/inbox/report/ReportActionItem.tsx @@ -7,7 +7,7 @@ import useOnyx from '@hooks/useOnyx'; import useOriginalReportID from '@hooks/useOriginalReportID'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import useReportIsArchived from '@hooks/useReportIsArchived'; -import {getForReportAction, getMovedReportID} from '@libs/ModifiedExpenseMessage'; +import {getForReportActionTemp, getMovedReportID} from '@libs/ModifiedExpenseMessage'; import {getIOUReportIDFromReportActionPreview, getOriginalMessage} from '@libs/ReportActionsUtils'; import { chatIncludesChronosWithID, @@ -99,7 +99,14 @@ function ReportActionItem({ const originalReportID = useOriginalReportID(reportID, action); const originalReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${originalReportID}`]; const isOriginalReportArchived = useReportIsArchived(originalReportID); - const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); + const {accountID: currentUserAccountID, email: currentUserEmail} = useCurrentUserPersonalDetails(); + const {policyForMovingExpensesID} = usePolicyForMovingExpenses(); + // When an expense is moved from a self-DM to a workspace, the report's policyID is temporarily + // set to a fake placeholder (CONST.POLICY.OWNER_EMAIL_FAKE). Looking up POLICY_TAGS with that + // fake ID would return nothing, so we fall back to policyForMovingExpensesID (the actual + // destination workspace) to fetch the correct tag list for display. + const policyIDForTags = report?.policyID === CONST.POLICY.OWNER_EMAIL_FAKE && policyForMovingExpensesID ? policyForMovingExpensesID : report?.policyID; + const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyIDForTags}`, {canBeMissing: true}); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {canBeMissing: true}); const [allTransactionDrafts] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {canBeMissing: true}); const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, {canBeMissing: true}); @@ -109,7 +116,6 @@ function ReportActionItem({ const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]; const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST, {canBeMissing: true}); const [personalPolicyID] = useOnyx(ONYXKEYS.PERSONAL_POLICY_ID, {canBeMissing: true}); - const {policyForMovingExpensesID} = usePolicyForMovingExpenses(); // The app would crash due to subscribing to the entire report collection if parentReportID is an empty string. So we should have a fallback ID here. // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID || undefined}`]; @@ -162,12 +168,14 @@ function ReportActionItem({ action as OnyxEntry>, report, )} - modifiedExpenseMessage={getForReportAction({ + modifiedExpenseMessage={getForReportActionTemp({ + translate, reportAction: action, - policyID: report?.policyID, + policy, movedFromReport, movedToReport, - policyForMovingExpensesID, + policyTags: policyTags ?? CONST.POLICY.DEFAULT_TAG_LIST, + currentUserLogin: currentUserEmail ?? '', })} getTransactionsWithReceipts={getTransactionsWithReceipts} clearError={clearError} diff --git a/tests/unit/ModifiedExpenseMessageTest.ts b/tests/unit/ModifiedExpenseMessageTest.ts index 962e2cbe25923..0f7dcf1778693 100644 --- a/tests/unit/ModifiedExpenseMessageTest.ts +++ b/tests/unit/ModifiedExpenseMessageTest.ts @@ -1,5 +1,5 @@ import {getEnvironmentURL} from '@libs/Environment/Environment'; -import {getForReportAction, getMovedFromOrToReportMessage, getMovedReportID} from '@libs/ModifiedExpenseMessage'; +import {getForReportAction, getForReportActionTemp, getMovedFromOrToReportMessage, getMovedReportID} from '@libs/ModifiedExpenseMessage'; // eslint-disable-next-line no-restricted-syntax -- this is required to allow mocking import * as PolicyUtils from '@libs/PolicyUtils'; // eslint-disable-next-line no-restricted-syntax -- this is required to allow mocking @@ -952,4 +952,372 @@ describe('ModifiedExpenseMessage', () => { }); }); }); + + describe('getForReportActionTemp', () => { + // getForReportActionTemp is a temporary function that takes translate, policy, policyTags, and currentUserLogin as parameters + // instead of using module-level Onyx connections. This allows React components to properly re-render when the underlying data changes. + // These tests mirror the getForReportAction tests to ensure the same behavior. + + describe('when the amount is changed', () => { + const reportAction = { + ...createRandomReportAction(1), + actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE, + originalMessage: { + amount: 1800, + currency: CONST.CURRENCY.USD, + oldAmount: 1255, + oldCurrency: CONST.CURRENCY.USD, + }, + }; + + it('returns the correct text message', () => { + const expectedResult = `changed the amount to $18.00 (previously $12.55)`; + + const result = getForReportActionTemp({ + translate: translateLocal, + reportAction, + policyTags: undefined, + currentUserLogin: 'test@example.com', + }); + + expect(result).toEqual(expectedResult); + }); + }); + + describe('when the amount is changed and the description is removed', () => { + const reportAction = { + ...createRandomReportAction(1), + actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE, + originalMessage: { + amount: 1800, + currency: CONST.CURRENCY.USD, + oldAmount: 1255, + oldCurrency: CONST.CURRENCY.USD, + newComment: '', + oldComment: 'this is for the shuttle', + }, + }; + + it('returns the correct text message', () => { + const expectedResult = 'changed the amount to $18.00 (previously $12.55)\nremoved the description (previously "this is for the shuttle")'; + + const result = getForReportActionTemp({ + translate: translateLocal, + reportAction, + policyTags: undefined, + currentUserLogin: 'test@example.com', + }); + + expect(result).toEqual(expectedResult); + }); + }); + + describe('when the merchant is set', () => { + const reportAction = { + ...createRandomReportAction(1), + actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE, + originalMessage: { + oldMerchant: '', + merchant: 'Big Belly', + }, + }; + + it('returns the correct text message', () => { + const expectedResult = `set the merchant to "Big Belly"`; + + const result = getForReportActionTemp({ + translate: translateLocal, + reportAction, + policyTags: undefined, + currentUserLogin: 'test@example.com', + }); + + expect(result).toEqual(expectedResult); + }); + }); + + describe('when the merchant is removed', () => { + const reportAction = { + ...createRandomReportAction(1), + actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE, + originalMessage: { + merchant: '', + oldMerchant: 'Big Belly', + }, + }; + + it('returns the correct text message', () => { + const expectedResult = `removed the merchant (previously "Big Belly")`; + + const result = getForReportActionTemp({ + translate: translateLocal, + reportAction, + policyTags: undefined, + currentUserLogin: 'test@example.com', + }); + + expect(result).toEqual(expectedResult); + }); + }); + + describe('when the category is changed', () => { + const reportAction = { + ...createRandomReportAction(1), + actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE, + originalMessage: { + category: 'Travel', + oldCategory: 'Food', + } as OriginalMessageModifiedExpense, + }; + + it('returns the correct text message', () => { + const expectedResult = `changed the category to "Travel" (previously "Food")`; + + const result = getForReportActionTemp({ + translate: translateLocal, + reportAction, + policyTags: undefined, + currentUserLogin: 'test@example.com', + }); + + expect(result).toEqual(expectedResult); + }); + }); + + describe('when the category is changed with AI attribution', () => { + const reportAction = { + ...createRandomReportAction(1), + actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE, + originalMessage: { + category: 'Travel', + oldCategory: 'Food', + source: CONST.CATEGORY_SOURCE.AI, + } as OriginalMessageModifiedExpense, + }; + + it('returns the correct text message with AI attribution', () => { + const expectedResult = `changed the category based on past activity to "Travel" (previously "Food")`; + + const result = getForReportActionTemp({ + translate: translateLocal, + reportAction, + policyTags: undefined, + currentUserLogin: 'test@example.com', + }); + + expect(result).toEqual(expectedResult); + }); + }); + + describe('when the category is changed with MCC attribution', () => { + const reportAction = { + ...createRandomReportAction(1), + actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE, + originalMessage: { + category: 'Travel', + oldCategory: 'Food', + source: CONST.CATEGORY_SOURCE.MCC, + } as OriginalMessageModifiedExpense, + }; + + it('returns the correct text message with MCC attribution for non-admin', () => { + const expectedResult = `changed the category based on workspace rule to "Travel" (previously "Food")`; + + const result = getForReportActionTemp({ + translate: translateLocal, + reportAction, + policyTags: undefined, + currentUserLogin: 'test@example.com', + }); + + expect(result).toEqual(expectedResult); + }); + + it('returns the correct workspace rules link for admin', () => { + const mockPolicy: Policy = { + id: 'AbC123XyZ789', + name: 'Test Policy', + role: CONST.POLICY.ROLE.ADMIN, + type: CONST.POLICY.TYPE.TEAM, + owner: 'test@example.com', + outputCurrency: 'USD', + isPolicyExpenseChatEnabled: true, + }; + + jest.spyOn(PolicyUtils, 'isPolicyAdmin').mockReturnValue(true); + + const result = getForReportActionTemp({ + translate: translateLocal, + reportAction, + policy: mockPolicy, + policyTags: undefined, + currentUserLogin: 'test@example.com', + }); + + // Verify the policyID in the URL exactly matches the policy.id (case-preserved) + expect(result).toContain(`workspaces/${mockPolicy.id}/rules`); + expect(result).toContain('href='); + expect(result).toContain('workspace rules'); + }); + }); + + describe('when the distance is changed', () => { + const reportAction = { + ...createRandomReportAction(1), + actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE, + originalMessage: { + oldMerchant: '1.00 mi @ $0.70 / mi', + merchant: '10.00 mi @ $0.70 / mi', + oldAmount: 70, + amount: 700, + oldCurrency: CONST.CURRENCY.USD, + currency: CONST.CURRENCY.USD, + }, + }; + + it('then the message says the distance is changed and shows the new and old merchant and amount', () => { + const expectedResult = `changed the distance to ${reportAction.originalMessage.merchant} (previously ${reportAction.originalMessage.oldMerchant}), which updated the amount to $7.00 (previously $0.70)`; + const result = getForReportActionTemp({ + translate: translateLocal, + reportAction, + policyTags: undefined, + currentUserLogin: 'test@example.com', + }); + expect(result).toEqual(expectedResult); + }); + }); + + describe('when moving an expense', () => { + it('returns the movedFromOrToReportMessage message when provided', () => { + const reportAction = { + ...createRandomReportAction(1), + actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE, + }; + + const expectedResult = 'moved an expense'; + + const movedFromReport = { + ...createRandomReport(1, undefined), + reportName: '', + }; + + const result = getForReportActionTemp({ + translate: translateLocal, + reportAction, + movedFromReport, + policyTags: undefined, + currentUserLogin: 'test@example.com', + }); + expect(result).toEqual(expectedResult); + }); + }); + + describe('when the report action is not a modified expense action', () => { + const reportAction = { + ...createRandomReportAction(1), + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + }; + + it('returns an empty string', () => { + const result = getForReportActionTemp({ + translate: translateLocal, + reportAction, + policyTags: undefined, + currentUserLogin: 'test@example.com', + }); + + expect(result).toEqual(''); + }); + }); + + describe('when there are no changes in the original message', () => { + const reportAction = { + ...createRandomReportAction(1), + actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE, + originalMessage: {}, + }; + + it('returns the generic changed expense message', () => { + const expectedResult = 'changed the expense'; + + const result = getForReportActionTemp({ + translate: translateLocal, + reportAction, + policyTags: undefined, + currentUserLogin: 'test@example.com', + }); + + expect(result).toEqual(expectedResult); + }); + }); + + describe('when the billable field is changed', () => { + const reportAction = { + ...createRandomReportAction(1), + actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE, + originalMessage: { + oldBillable: 'nonBillable', + billable: 'billable', + }, + }; + + it('returns the correct translated text message', () => { + const expectedResult = 'changed the expense to "billable" (previously "non-billable")'; + + const result = getForReportActionTemp({ + translate: translateLocal, + reportAction, + policyTags: undefined, + currentUserLogin: 'test@example.com', + }); + + expect(result).toEqual(expectedResult); + }); + }); + + describe('when the reimbursable field is changed', () => { + const reportAction = { + ...createRandomReportAction(1), + actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE, + originalMessage: { + oldReimbursable: 'reimbursable', + reimbursable: 'nonReimbursable', + }, + }; + + it('returns the correct translated text message', () => { + const expectedResult = 'changed the expense to "non-reimbursable" (previously "reimbursable")'; + + const result = getForReportActionTemp({ + translate: translateLocal, + reportAction, + policyTags: undefined, + currentUserLogin: 'test@example.com', + }); + + expect(result).toEqual(expectedResult); + }); + }); + + describe('when there are no changes but aiGenerated is true', () => { + const reportAction = { + ...createRandomReportAction(1), + actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE, + originalMessage: {aiGenerated: true}, + }; + + it('returns the AI-attributed message', () => { + const expectedResult = 'changed the expense based on past activity'; + + const result = getForReportActionTemp({ + translate: translateLocal, + reportAction, + policyTags: undefined, + currentUserLogin: 'test@example.com', + }); + + expect(result).toEqual(expectedResult); + }); + }); + }); });