diff --git a/src/components/TransactionItemRow/TransactionItemRowRBR.tsx b/src/components/TransactionItemRow/TransactionItemRowRBR.tsx index b22dd57cadc7..8e0826d56690 100644 --- a/src/components/TransactionItemRow/TransactionItemRowRBR.tsx +++ b/src/components/TransactionItemRow/TransactionItemRowRBR.tsx @@ -11,7 +11,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils'; +import {getIOUActionForTransactionID, wasActionTakenByCurrentUser} from '@libs/ReportActionsUtils'; import {isMarkAsCashActionForTransaction} from '@libs/ReportPrimaryActionUtils'; import {isSettled} from '@libs/ReportUtils'; import ViolationsUtils from '@libs/Violations/ViolationsUtils'; @@ -61,23 +61,25 @@ function TransactionItemRowRBRInner({transaction, violations, report, containerS const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${report?.policyID}`); const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); const icons = useMemoizedLazyExpensifyIcons(['DotIndicator']); - const transactionThreadId = reportActions ? getIOUActionForTransactionID(Object.values(reportActions ?? {}), transaction.transactionID)?.childReportID : undefined; + const iouAction = reportActions ? getIOUActionForTransactionID(Object.values(reportActions ?? {}), transaction.transactionID) : undefined; + const transactionThreadId = iouAction?.childReportID; const [transactionThreadActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadId}`); const {login: currentUserLogin} = useCurrentUserPersonalDetails(); const isMarkAsCash = parentReport && currentUserLogin && violations ? isMarkAsCashActionForTransaction(currentUserLogin, parentReport, violations, policy) : false; - const RBRMessages = ViolationsUtils.getRBRMessages( + const canEdit = wasActionTakenByCurrentUser(iouAction); + const RBRMessages = ViolationsUtils.getRBRMessages({ transaction, - isSettled(report) ? [] : (violations ?? []), + transactionViolations: isSettled(report) ? [] : (violations ?? []), translate, missingFieldError, - Object.values(transactionThreadActions ?? {}), - policyTags, + transactionThreadActions: Object.values(transactionThreadActions ?? {}), + tags: policyTags, companyCardPageURL, - undefined, cardList, - isMarkAsCash, - ); + isMarkAsCash: isMarkAsCash || undefined, + canEdit, + }); const hasHTMLTags = HTML_TAG_PATTERN.test(RBRMessages); return ( diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index d02c1632b001..a3c097019317 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -66,6 +66,7 @@ import { getRemovedCardFeedMessage, getRenamedAction, getRenamedCardFeedMessage, + getReportAction, getReportActionActorAccountID, getReportActionHtml, getReportActionMessageText, @@ -109,6 +110,7 @@ import { isTaskAction, isThreadParentMessage, isUnapprovedAction, + wasActionTakenByCurrentUser, withDEWRoutedActionsArray, } from '@libs/ReportActionsUtils'; import {getReportName} from '@libs/ReportNameUtils'; @@ -816,7 +818,9 @@ function getLastMessageTextForReport({ } else if (lastReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_INTEGRATION) { lastMessageTextFromReport = getExportIntegrationLastMessageText(translate, lastReportAction); } else if (lastReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.RECEIPT_SCAN_FAILED) { - lastMessageTextFromReport = getReportActionMessageText(lastReportAction) || translate('iou.receiptScanningFailed'); + // RECEIPT_SCAN_FAILED is submitted by Concierge, so use the IOU action to determine edit permission + const iouAction = getReportAction(report?.parentReportID, report?.parentReportActionID); + lastMessageTextFromReport = translate('violations.smartscanFailed', {canEdit: wasActionTakenByCurrentUser(iouAction)}); } else if (lastReportAction?.actionName && isOldDotReportAction(lastReportAction)) { lastMessageTextFromReport = getMessageOfOldDotReportAction(translate, lastReportAction, false); } else if (isActionableJoinRequest(lastReportAction)) { diff --git a/src/libs/ReportNameUtils.ts b/src/libs/ReportNameUtils.ts index 8a979372588c..202f1b3758f3 100644 --- a/src/libs/ReportNameUtils.ts +++ b/src/libs/ReportNameUtils.ts @@ -70,8 +70,8 @@ import { getRemovedCardFeedMessage, getRenamedAction, getRenamedCardFeedMessage, + getReportAction, getReportActionMessage as getReportActionMessageFromActionsUtils, - getReportActionMessageText, getReportActionText, getSettlementAccountLockedMessage, getSubmitsToUpdateMessage, @@ -114,6 +114,7 @@ import { isTagModificationAction, isTransactionThread, isUnapprovedAction, + wasActionTakenByCurrentUser, } from './ReportActionsUtils'; // eslint-disable-next-line import/no-cycle import { @@ -472,7 +473,12 @@ function computeReportNameBasedOnReportAction( }); } if (parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.RECEIPT_SCAN_FAILED) { - return getReportActionMessageText(parentReportAction) || translate('iou.receiptScanningFailed'); + // RECEIPT_SCAN_FAILED is submitted by Concierge, so use the IOU action to determine edit permission + let iouAction = getReportAction(report?.parentReportID, report?.parentReportActionID); + if (!isActionOfType(iouAction, CONST.REPORT.ACTIONS.TYPE.IOU)) { + iouAction = getReportAction(parentReport?.parentReportID, parentReport?.parentReportActionID); + } + return translate('violations.smartscanFailed', {canEdit: wasActionTakenByCurrentUser(iouAction)}); } if (isReimbursementDeQueuedOrCanceledAction(parentReportAction)) { diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index fe9b3faf5088..b4e3aa77ffc4 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -835,18 +835,31 @@ const ViolationsUtils = { return Number(violation.data?.formattedLimit?.replace(CONST.VIOLATION_LIMIT_REGEX, '')); }, - getRBRMessages( - transaction: Transaction, - transactionViolations: TransactionViolation[], - translate: LocaleContextProps['translate'], - missingFieldError?: string, - transactionThreadActions?: ReportAction[], - tags?: PolicyTagLists, - companyCardPageURL?: string, - connectionLink?: string, - cardList?: CardList, - isMarkAsCash?: boolean, - ): string { + getRBRMessages({ + transaction, + transactionViolations, + translate, + missingFieldError, + transactionThreadActions, + tags, + companyCardPageURL, + connectionLink, + cardList, + isMarkAsCash, + canEdit = true, + }: { + transaction: Transaction; + transactionViolations: TransactionViolation[]; + translate: LocaleContextProps['translate']; + missingFieldError?: string; + transactionThreadActions?: ReportAction[]; + tags?: PolicyTagLists; + companyCardPageURL?: string; + connectionLink?: string; + cardList?: CardList; + isMarkAsCash?: boolean; + canEdit?: boolean; + }): string { const errorMessages = extractErrorMessages(transaction?.errors ?? {}, transactionThreadActions?.filter((e) => !!e.errors) ?? [], translate); const filteredViolations = filterReceiptViolations(transactionViolations); @@ -861,6 +874,7 @@ const ViolationsUtils = { const message = ViolationsUtils.getViolationTranslation({ violation, translate, + canEdit, tags, companyCardPageURL, connectionLink, diff --git a/src/pages/inbox/report/PureReportActionItem.tsx b/src/pages/inbox/report/PureReportActionItem.tsx index 4b60a6695d66..921cf8353744 100644 --- a/src/pages/inbox/report/PureReportActionItem.tsx +++ b/src/pages/inbox/report/PureReportActionItem.tsx @@ -795,7 +795,12 @@ function PureReportActionItem({ /> ); } else if (isSimpleMessageAction(action)) { - children = ; + children = ( + + ); } else if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.FORWARDED)) { const wasAutoForwarded = getOriginalMessage(action)?.automaticAction ?? false; if (wasAutoForwarded) { diff --git a/src/pages/inbox/report/actionContents/SimpleMessageContent.tsx b/src/pages/inbox/report/actionContents/SimpleMessageContent.tsx index 1737daf4c77f..e768dbb1e278 100644 --- a/src/pages/inbox/report/actionContents/SimpleMessageContent.tsx +++ b/src/pages/inbox/report/actionContents/SimpleMessageContent.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; import { getActionableCard3DSTransactionApprovalMessage, @@ -8,11 +9,12 @@ import { getMessageOfOldDotReportAction, getOriginalMessage, getRemovedFromApprovalChainMessage, - getReportActionMessageText, + getReportAction, getReportActionText, isActionOfType, isRejectedAction, isUnapprovedAction, + wasActionTakenByCurrentUser, } from '@libs/ReportActionsUtils'; import {getDeletedTransactionMessage, getPolicyChangeMessage} from '@libs/ReportUtils'; import ReportActionItemBasicMessage from '@pages/inbox/report/ReportActionItemBasicMessage'; @@ -21,6 +23,7 @@ import type * as OnyxTypes from '@src/types/onyx'; type SimpleMessageContentProps = { action: OnyxTypes.ReportAction; + report: OnyxEntry; }; const SIMPLE_MESSAGE_ACTION_TYPES = new Set([ @@ -48,7 +51,7 @@ function isSimpleMessageAction(action: OnyxTypes.ReportAction): boolean { return SIMPLE_MESSAGE_ACTION_TYPES.has(action.actionName) || isUnapprovedAction(action) || isRejectedAction(action); } -function SimpleMessageContent({action}: SimpleMessageContentProps) { +function SimpleMessageContent({action, report}: SimpleMessageContentProps) { const {translate} = useLocalize(); if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED)) { @@ -91,8 +94,9 @@ function SimpleMessageContent({action}: SimpleMessageContentProps) { return ; } if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.RECEIPT_SCAN_FAILED)) { - const htmlMessage = getReportActionMessageText(action) || translate('iou.receiptScanningFailed'); - return ; + // RECEIPT_SCAN_FAILED is submitted by Concierge, so use the IOU action to determine edit permission + const iouAction = getReportAction(report?.parentReportID, report?.parentReportActionID); + return ; } if (isUnapprovedAction(action)) { return ; diff --git a/tests/ui/PureReportActionItemTest.tsx b/tests/ui/PureReportActionItemTest.tsx index ab2f4fe44a67..9f6d060e0f8c 100644 --- a/tests/ui/PureReportActionItemTest.tsx +++ b/tests/ui/PureReportActionItemTest.tsx @@ -902,27 +902,99 @@ describe('PureReportActionItem', () => { expect(screen.getByText(translateLocal(translationKey))).toBeOnTheScreen(); }); - it('RECEIPT_SCAN_FAILED action shows message from action data', async () => { - // Given a RECEIPT_SCAN_FAILED message with a html message from server. - // Then verify server message is rendered. + it('RECEIPT_SCAN_FAILED action shows submitter message when current user is the expense submitter', async () => { + const parentReportID = 'parentReport1'; + const parentReportActionID = 'iouAction1'; + + await act(async () => { + await Onyx.merge(ONYXKEYS.SESSION, {accountID: ACTOR_ACCOUNT_ID}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, { + [parentReportActionID]: { + reportActionID: parentReportActionID, + actorAccountID: ACTOR_ACCOUNT_ID, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + created: '2025-07-12 09:03:17.653', + message: [{type: 'COMMENT', html: '', text: ''}], + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, amount: 100, currency: 'USD'}, + }, + }); + }); + await waitForBatchedUpdatesWithAct(); + + const report = {reportID: 'scanReport1', parentReportID, parentReportActionID}; const action = createReportAction(CONST.REPORT.ACTIONS.TYPE.RECEIPT_SCAN_FAILED, {}); - action.message = [ - { - type: 'COMMENT', - html: "the date couldn't be read from this receipt. Please enter it manually.", - text: "the date couldn't be read from this receipt. Please enter it manually.", - }, - ]; - renderItemWithAction(action); + + render( + + + + + + + + + , + ); await waitForBatchedUpdatesWithAct(); - expect(screen.getByText("the date couldn't be read from this receipt. Please enter it manually.")).toBeOnTheScreen(); - // Given an RECEIPT_SCAN_FAILED with no server side message - // Then verify generic translation phrase is rendered - action.message = [{type: 'COMMENT', html: '', text: ''}]; - renderItemWithAction(action); + expect(screen.getByText(translateLocal('violations.smartscanFailed', {canEdit: true}))).toBeOnTheScreen(); + }); + + it('RECEIPT_SCAN_FAILED action shows non-submitter message when current user is not the expense submitter', async () => { + const parentReportID = 'parentReport2'; + const parentReportActionID = 'iouAction2'; + const OTHER_ACCOUNT_ID = 999999; + + await act(async () => { + await Onyx.merge(ONYXKEYS.SESSION, {accountID: ACTOR_ACCOUNT_ID}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, { + [parentReportActionID]: { + reportActionID: parentReportActionID, + actorAccountID: OTHER_ACCOUNT_ID, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + created: '2025-07-12 09:03:17.653', + message: [{type: 'COMMENT', html: '', text: ''}], + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, amount: 100, currency: 'USD'}, + }, + }); + }); + await waitForBatchedUpdatesWithAct(); + + const report = {reportID: 'scanReport2', parentReportID, parentReportActionID}; + const action = createReportAction(CONST.REPORT.ACTIONS.TYPE.RECEIPT_SCAN_FAILED, {}); + + render( + + + + + + + + + , + ); await waitForBatchedUpdatesWithAct(); - expect(screen.getByText(translateLocal('iou.receiptScanningFailed'))).toBeOnTheScreen(); + + expect(screen.getByText(translateLocal('violations.smartscanFailed', {canEdit: false}))).toBeOnTheScreen(); }); it('HOLD_COMMENT action renders via ReportActionItemBasicMessage', async () => { diff --git a/tests/unit/ViolationUtilsTest.ts b/tests/unit/ViolationUtilsTest.ts index 12cdc011c1cc..a60f414ac5ba 100644 --- a/tests/unit/ViolationUtilsTest.ts +++ b/tests/unit/ViolationUtilsTest.ts @@ -1615,14 +1615,20 @@ describe('getRBRMessages', () => { it('should return all violations and missing field error', () => { const missingFieldError = 'Missing required field'; - const result = ViolationsUtils.getRBRMessages(mockTransaction, mockViolations, translateLocal, missingFieldError, []); + const result = ViolationsUtils.getRBRMessages({ + transaction: mockTransaction, + transactionViolations: mockViolations, + translate: translateLocal, + missingFieldError, + transactionThreadActions: [], + }); const expectedResult = `Missing required field. ${translateLocal('violations.missingCategory')}. ${translateLocal('violations.missingTag')}.`; expect(result).toBe(expectedResult); }); it('should filter out empty strings', () => { - const result = ViolationsUtils.getRBRMessages(mockTransaction, mockViolations, translateLocal, undefined, []); + const result = ViolationsUtils.getRBRMessages({transaction: mockTransaction, transactionViolations: mockViolations, translate: translateLocal, transactionThreadActions: []}); const expectedResult = `${translateLocal('violations.missingCategory')}. ${translateLocal('violations.missingTag')}.`; expect(result).toBe(expectedResult);