Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
90dd3f3
Show role-aware SmartScan failure message to non-submitters
MelvinBot Apr 1, 2026
455f3c9
Merge remote-tracking branch 'origin/main' into claude-scanReceiverMi…
MelvinBot Apr 1, 2026
fbfcb04
Fix: run prettier on PureReportActionItem.tsx to fix import ordering
MelvinBot Apr 1, 2026
02f6d8a
Merge remote-tracking branch 'origin/main' into claude-scanReceiverMi…
MelvinBot Apr 2, 2026
6affd95
Address review: use IOU action for canEdit, convert getRBRMessages to…
MelvinBot Apr 6, 2026
959e0b6
Fix: suppress no-deprecated ESLint error for deprecatedAllSortedRepor…
MelvinBot Apr 6, 2026
d9f3878
Use getReportAction for IOU action lookup in OptionsListUtils and Pur…
MelvinBot Apr 6, 2026
bf3ae44
Merge remote-tracking branch 'origin/main' into claude-scanReceiverMi…
MelvinBot Apr 6, 2026
b507e17
Use wasActionTakenByCurrentUser for canEdit in MoneyRequestReceiptView
MelvinBot Apr 6, 2026
a5ce821
Apply suggested canEdit logic in MoneyRequestReceiptView
MelvinBot Apr 6, 2026
dfd4801
Merge main and resolve conflicts
MelvinBot Apr 14, 2026
a50188c
Fix: Fall back to parentReport's IOU action for RECEIPT_SCAN_FAILED
MelvinBot Apr 14, 2026
571cffb
Merge main and resolve conflicts
MelvinBot Apr 14, 2026
9796ae6
Fix: Apply role-aware smartscan failure message in SimpleMessageContent
MelvinBot Apr 14, 2026
5bcbf60
Remove unused imports getReportAction and wasActionTakenByCurrentUser…
MelvinBot Apr 14, 2026
e6d88cb
Merge main into claude-scanReceiverMissingInfo
MelvinBot Apr 14, 2026
d668df5
Merge remote-tracking branch 'origin/main' into claude-scanReceiverMi…
MelvinBot Apr 22, 2026
f6bb460
Fix post-merge duplicate else-if, restore canEditMoneyRequest, fix RE…
MelvinBot Apr 23, 2026
508d140
Merge remote-tracking branch 'origin/main' into claude-scanReceiverMi…
MelvinBot Apr 24, 2026
5161b2a
Merge remote-tracking branch 'origin/main' into claude-scanReceiverMi…
MelvinBot Apr 28, 2026
b401381
Merge remote-tracking branch 'origin/main' into claude-scanReceiverMi…
MelvinBot Apr 30, 2026
cdfe04d
Fix ESLint and typecheck failures after main merge
MelvinBot Apr 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions src/components/TransactionItemRow/TransactionItemRowRBR.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 (
Expand Down
6 changes: 5 additions & 1 deletion src/libs/OptionsListUtils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import {
getRemovedCardFeedMessage,
getRenamedAction,
getRenamedCardFeedMessage,
getReportAction,
getReportActionActorAccountID,
getReportActionHtml,
getReportActionMessageText,
Expand Down Expand Up @@ -109,6 +110,7 @@ import {
isTaskAction,
isThreadParentMessage,
isUnapprovedAction,
wasActionTakenByCurrentUser,
withDEWRoutedActionsArray,
} from '@libs/ReportActionsUtils';
import {getReportName} from '@libs/ReportNameUtils';
Expand Down Expand Up @@ -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)) {
Expand Down
10 changes: 8 additions & 2 deletions src/libs/ReportNameUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,8 @@ import {
getRemovedCardFeedMessage,
getRenamedAction,
getRenamedCardFeedMessage,
getReportAction,
getReportActionMessage as getReportActionMessageFromActionsUtils,
getReportActionMessageText,
getReportActionText,
getSettlementAccountLockedMessage,
getSubmitsToUpdateMessage,
Expand Down Expand Up @@ -114,6 +114,7 @@ import {
isTagModificationAction,
isTransactionThread,
isUnapprovedAction,
wasActionTakenByCurrentUser,
} from './ReportActionsUtils';
// eslint-disable-next-line import/no-cycle
import {
Expand Down Expand Up @@ -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)) {
Expand Down
38 changes: 26 additions & 12 deletions src/libs/Violations/ViolationsUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -861,6 +874,7 @@ const ViolationsUtils = {
const message = ViolationsUtils.getViolationTranslation({
violation,
translate,
canEdit,
tags,
companyCardPageURL,
connectionLink,
Expand Down
7 changes: 6 additions & 1 deletion src/pages/inbox/report/PureReportActionItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -795,7 +795,12 @@ function PureReportActionItem({
/>
);
} else if (isSimpleMessageAction(action)) {
children = <SimpleMessageContent action={action} />;
children = (
<SimpleMessageContent
action={action}
report={report}
/>
);
} else if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.FORWARDED)) {
const wasAutoForwarded = getOriginalMessage(action)?.automaticAction ?? false;
if (wasAutoForwarded) {
Expand Down
12 changes: 8 additions & 4 deletions src/pages/inbox/report/actionContents/SimpleMessageContent.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from 'react';
import type {OnyxEntry} from 'react-native-onyx';
import useLocalize from '@hooks/useLocalize';
import {
getActionableCard3DSTransactionApprovalMessage,
Expand All @@ -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';
Expand All @@ -21,6 +23,7 @@ import type * as OnyxTypes from '@src/types/onyx';

type SimpleMessageContentProps = {
action: OnyxTypes.ReportAction;
report: OnyxEntry<OnyxTypes.Report>;
};

const SIMPLE_MESSAGE_ACTION_TYPES = new Set<string>([
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -91,8 +94,9 @@ function SimpleMessageContent({action}: SimpleMessageContentProps) {
return <ReportActionItemBasicMessage message={translate('violations.resolvedDuplicates')} />;
}
if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.RECEIPT_SCAN_FAILED)) {
const htmlMessage = getReportActionMessageText(action) || translate('iou.receiptScanningFailed');
return <ReportActionItemBasicMessage message={htmlMessage} />;
// RECEIPT_SCAN_FAILED is submitted by Concierge, so use the IOU action to determine edit permission
const iouAction = getReportAction(report?.parentReportID, report?.parentReportActionID);
return <ReportActionItemBasicMessage message={translate('violations.smartscanFailed', {canEdit: wasActionTakenByCurrentUser(iouAction)})} />;
}
if (isUnapprovedAction(action)) {
Comment on lines -94 to 97

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dmkt9 seems this change has caused a few regressions

#89336
https://github.com/Expensify/Expensify/issues/632549#top

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously, we used to render a message from reportAction_ key - served by server. Now that has changed. I see you reviewed the PR, any idea why? Can we revert that specific change in SimpleMessageContent.tsx?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@grgia It seems that I somehow missed this change.

However, I believe using htmlMessage = getReportActionMessageText(action) was unintentional rather than a deliberate change, because:

  • This change is in commit 23b90d7, which was just a merge of the main branch into a feature branch. Somehow, it still managed to be merged into main. I also have a question for the author here
  • Displaying the smartscanFailed message instead of the message in the action is the expected behavior for the issue we are addressing.

Therefore, I think this change can be considered expected behavior rather than a regression. As for #89336, we can consider it an independent bug.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this commit correct 23b90d7?
That does not seem related to this PR

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is not entirely clear, but I think so. I just tried bisecting between commit 23b90d7 and 4a24621 (latest refactor commit), and it still points back to 23b90d7

return <ReportActionItemBasicMessage message={translate('iou.unapproved')} />;
Expand Down
106 changes: 89 additions & 17 deletions tests/ui/PureReportActionItemTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ComposeProviders components={[OnyxListItemProvider, LocaleContextProvider, HTMLEngineProvider]}>
<OptionsListContextProvider>
<ScreenWrapper testID="test">
<PortalProvider>
<PureReportActionItem
personalPolicyID={undefined}
report={report}
parentReportAction={undefined}
action={action}
displayAsGroup={false}
shouldDisplayNewMarker={false}
index={0}
isFirstVisibleReportAction={false}
/>
</PortalProvider>
</ScreenWrapper>
</OptionsListContextProvider>
</ComposeProviders>,
);
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(
<ComposeProviders components={[OnyxListItemProvider, LocaleContextProvider, HTMLEngineProvider]}>
<OptionsListContextProvider>
<ScreenWrapper testID="test">
<PortalProvider>
<PureReportActionItem
personalPolicyID={undefined}
report={report}
parentReportAction={undefined}
action={action}
displayAsGroup={false}
shouldDisplayNewMarker={false}
index={0}
isFirstVisibleReportAction={false}
/>
</PortalProvider>
</ScreenWrapper>
</OptionsListContextProvider>
</ComposeProviders>,
);
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 () => {
Expand Down
10 changes: 8 additions & 2 deletions tests/unit/ViolationUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading