Skip to content

Commit 72483d1

Browse files
authored
Merge pull request Expensify#86896 from Expensify/claude-scanReceiverMissingInfo
Show role-aware SmartScan failure message to non-submitters
2 parents 7a7b159 + cdfe04d commit 72483d1

8 files changed

Lines changed: 161 additions & 48 deletions

File tree

src/components/TransactionItemRow/TransactionItemRowRBR.tsx

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import useLocalize from '@hooks/useLocalize';
1111
import useOnyx from '@hooks/useOnyx';
1212
import useTheme from '@hooks/useTheme';
1313
import useThemeStyles from '@hooks/useThemeStyles';
14-
import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils';
14+
import {getIOUActionForTransactionID, wasActionTakenByCurrentUser} from '@libs/ReportActionsUtils';
1515
import {isMarkAsCashActionForTransaction} from '@libs/ReportPrimaryActionUtils';
1616
import {isSettled} from '@libs/ReportUtils';
1717
import ViolationsUtils from '@libs/Violations/ViolationsUtils';
@@ -61,23 +61,25 @@ function TransactionItemRowRBRInner({transaction, violations, report, containerS
6161
const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${report?.policyID}`);
6262
const [cardList] = useOnyx(ONYXKEYS.CARD_LIST);
6363
const icons = useMemoizedLazyExpensifyIcons(['DotIndicator']);
64-
const transactionThreadId = reportActions ? getIOUActionForTransactionID(Object.values(reportActions ?? {}), transaction.transactionID)?.childReportID : undefined;
64+
const iouAction = reportActions ? getIOUActionForTransactionID(Object.values(reportActions ?? {}), transaction.transactionID) : undefined;
65+
const transactionThreadId = iouAction?.childReportID;
6566
const [transactionThreadActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadId}`);
6667
const {login: currentUserLogin} = useCurrentUserPersonalDetails();
6768
const isMarkAsCash = parentReport && currentUserLogin && violations ? isMarkAsCashActionForTransaction(currentUserLogin, parentReport, violations, policy) : false;
6869

69-
const RBRMessages = ViolationsUtils.getRBRMessages(
70+
const canEdit = wasActionTakenByCurrentUser(iouAction);
71+
const RBRMessages = ViolationsUtils.getRBRMessages({
7072
transaction,
71-
isSettled(report) ? [] : (violations ?? []),
73+
transactionViolations: isSettled(report) ? [] : (violations ?? []),
7274
translate,
7375
missingFieldError,
74-
Object.values(transactionThreadActions ?? {}),
75-
policyTags,
76+
transactionThreadActions: Object.values(transactionThreadActions ?? {}),
77+
tags: policyTags,
7678
companyCardPageURL,
77-
undefined,
7879
cardList,
79-
isMarkAsCash,
80-
);
80+
isMarkAsCash: isMarkAsCash || undefined,
81+
canEdit,
82+
});
8183
const hasHTMLTags = HTML_TAG_PATTERN.test(RBRMessages);
8284

8385
return (

src/libs/OptionsListUtils/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import {
6666
getRemovedCardFeedMessage,
6767
getRenamedAction,
6868
getRenamedCardFeedMessage,
69+
getReportAction,
6970
getReportActionActorAccountID,
7071
getReportActionHtml,
7172
getReportActionMessageText,
@@ -109,6 +110,7 @@ import {
109110
isTaskAction,
110111
isThreadParentMessage,
111112
isUnapprovedAction,
113+
wasActionTakenByCurrentUser,
112114
withDEWRoutedActionsArray,
113115
} from '@libs/ReportActionsUtils';
114116
import {getReportName} from '@libs/ReportNameUtils';
@@ -816,7 +818,9 @@ function getLastMessageTextForReport({
816818
} else if (lastReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_INTEGRATION) {
817819
lastMessageTextFromReport = getExportIntegrationLastMessageText(translate, lastReportAction);
818820
} else if (lastReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.RECEIPT_SCAN_FAILED) {
819-
lastMessageTextFromReport = getReportActionMessageText(lastReportAction) || translate('iou.receiptScanningFailed');
821+
// RECEIPT_SCAN_FAILED is submitted by Concierge, so use the IOU action to determine edit permission
822+
const iouAction = getReportAction(report?.parentReportID, report?.parentReportActionID);
823+
lastMessageTextFromReport = translate('violations.smartscanFailed', {canEdit: wasActionTakenByCurrentUser(iouAction)});
820824
} else if (lastReportAction?.actionName && isOldDotReportAction(lastReportAction)) {
821825
lastMessageTextFromReport = getMessageOfOldDotReportAction(translate, lastReportAction, false);
822826
} else if (isActionableJoinRequest(lastReportAction)) {

src/libs/ReportNameUtils.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,8 @@ import {
7070
getRemovedCardFeedMessage,
7171
getRenamedAction,
7272
getRenamedCardFeedMessage,
73+
getReportAction,
7374
getReportActionMessage as getReportActionMessageFromActionsUtils,
74-
getReportActionMessageText,
7575
getReportActionText,
7676
getSettlementAccountLockedMessage,
7777
getSubmitsToUpdateMessage,
@@ -114,6 +114,7 @@ import {
114114
isTagModificationAction,
115115
isTransactionThread,
116116
isUnapprovedAction,
117+
wasActionTakenByCurrentUser,
117118
} from './ReportActionsUtils';
118119
// eslint-disable-next-line import/no-cycle
119120
import {
@@ -472,7 +473,12 @@ function computeReportNameBasedOnReportAction(
472473
});
473474
}
474475
if (parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.RECEIPT_SCAN_FAILED) {
475-
return getReportActionMessageText(parentReportAction) || translate('iou.receiptScanningFailed');
476+
// RECEIPT_SCAN_FAILED is submitted by Concierge, so use the IOU action to determine edit permission
477+
let iouAction = getReportAction(report?.parentReportID, report?.parentReportActionID);
478+
if (!isActionOfType(iouAction, CONST.REPORT.ACTIONS.TYPE.IOU)) {
479+
iouAction = getReportAction(parentReport?.parentReportID, parentReport?.parentReportActionID);
480+
}
481+
return translate('violations.smartscanFailed', {canEdit: wasActionTakenByCurrentUser(iouAction)});
476482
}
477483

478484
if (isReimbursementDeQueuedOrCanceledAction(parentReportAction)) {

src/libs/Violations/ViolationsUtils.ts

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -835,18 +835,31 @@ const ViolationsUtils = {
835835
return Number(violation.data?.formattedLimit?.replace(CONST.VIOLATION_LIMIT_REGEX, ''));
836836
},
837837

838-
getRBRMessages(
839-
transaction: Transaction,
840-
transactionViolations: TransactionViolation[],
841-
translate: LocaleContextProps['translate'],
842-
missingFieldError?: string,
843-
transactionThreadActions?: ReportAction[],
844-
tags?: PolicyTagLists,
845-
companyCardPageURL?: string,
846-
connectionLink?: string,
847-
cardList?: CardList,
848-
isMarkAsCash?: boolean,
849-
): string {
838+
getRBRMessages({
839+
transaction,
840+
transactionViolations,
841+
translate,
842+
missingFieldError,
843+
transactionThreadActions,
844+
tags,
845+
companyCardPageURL,
846+
connectionLink,
847+
cardList,
848+
isMarkAsCash,
849+
canEdit = true,
850+
}: {
851+
transaction: Transaction;
852+
transactionViolations: TransactionViolation[];
853+
translate: LocaleContextProps['translate'];
854+
missingFieldError?: string;
855+
transactionThreadActions?: ReportAction[];
856+
tags?: PolicyTagLists;
857+
companyCardPageURL?: string;
858+
connectionLink?: string;
859+
cardList?: CardList;
860+
isMarkAsCash?: boolean;
861+
canEdit?: boolean;
862+
}): string {
850863
const errorMessages = extractErrorMessages(transaction?.errors ?? {}, transactionThreadActions?.filter((e) => !!e.errors) ?? [], translate);
851864
const filteredViolations = filterReceiptViolations(transactionViolations);
852865

@@ -861,6 +874,7 @@ const ViolationsUtils = {
861874
const message = ViolationsUtils.getViolationTranslation({
862875
violation,
863876
translate,
877+
canEdit,
864878
tags,
865879
companyCardPageURL,
866880
connectionLink,

src/pages/inbox/report/PureReportActionItem.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -795,7 +795,12 @@ function PureReportActionItem({
795795
/>
796796
);
797797
} else if (isSimpleMessageAction(action)) {
798-
children = <SimpleMessageContent action={action} />;
798+
children = (
799+
<SimpleMessageContent
800+
action={action}
801+
report={report}
802+
/>
803+
);
799804
} else if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.FORWARDED)) {
800805
const wasAutoForwarded = getOriginalMessage(action)?.automaticAction ?? false;
801806
if (wasAutoForwarded) {

src/pages/inbox/report/actionContents/SimpleMessageContent.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React from 'react';
2+
import type {OnyxEntry} from 'react-native-onyx';
23
import useLocalize from '@hooks/useLocalize';
34
import {
45
getActionableCard3DSTransactionApprovalMessage,
@@ -8,11 +9,12 @@ import {
89
getMessageOfOldDotReportAction,
910
getOriginalMessage,
1011
getRemovedFromApprovalChainMessage,
11-
getReportActionMessageText,
12+
getReportAction,
1213
getReportActionText,
1314
isActionOfType,
1415
isRejectedAction,
1516
isUnapprovedAction,
17+
wasActionTakenByCurrentUser,
1618
} from '@libs/ReportActionsUtils';
1719
import {getDeletedTransactionMessage, getPolicyChangeMessage} from '@libs/ReportUtils';
1820
import ReportActionItemBasicMessage from '@pages/inbox/report/ReportActionItemBasicMessage';
@@ -21,6 +23,7 @@ import type * as OnyxTypes from '@src/types/onyx';
2123

2224
type SimpleMessageContentProps = {
2325
action: OnyxTypes.ReportAction;
26+
report: OnyxEntry<OnyxTypes.Report>;
2427
};
2528

2629
const SIMPLE_MESSAGE_ACTION_TYPES = new Set<string>([
@@ -48,7 +51,7 @@ function isSimpleMessageAction(action: OnyxTypes.ReportAction): boolean {
4851
return SIMPLE_MESSAGE_ACTION_TYPES.has(action.actionName) || isUnapprovedAction(action) || isRejectedAction(action);
4952
}
5053

51-
function SimpleMessageContent({action}: SimpleMessageContentProps) {
54+
function SimpleMessageContent({action, report}: SimpleMessageContentProps) {
5255
const {translate} = useLocalize();
5356

5457
if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED)) {
@@ -91,8 +94,9 @@ function SimpleMessageContent({action}: SimpleMessageContentProps) {
9194
return <ReportActionItemBasicMessage message={translate('violations.resolvedDuplicates')} />;
9295
}
9396
if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.RECEIPT_SCAN_FAILED)) {
94-
const htmlMessage = getReportActionMessageText(action) || translate('iou.receiptScanningFailed');
95-
return <ReportActionItemBasicMessage message={htmlMessage} />;
97+
// RECEIPT_SCAN_FAILED is submitted by Concierge, so use the IOU action to determine edit permission
98+
const iouAction = getReportAction(report?.parentReportID, report?.parentReportActionID);
99+
return <ReportActionItemBasicMessage message={translate('violations.smartscanFailed', {canEdit: wasActionTakenByCurrentUser(iouAction)})} />;
96100
}
97101
if (isUnapprovedAction(action)) {
98102
return <ReportActionItemBasicMessage message={translate('iou.unapproved')} />;

tests/ui/PureReportActionItemTest.tsx

Lines changed: 89 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -902,27 +902,99 @@ describe('PureReportActionItem', () => {
902902
expect(screen.getByText(translateLocal(translationKey))).toBeOnTheScreen();
903903
});
904904

905-
it('RECEIPT_SCAN_FAILED action shows message from action data', async () => {
906-
// Given a RECEIPT_SCAN_FAILED message with a html message from server.
907-
// Then verify server message is rendered.
905+
it('RECEIPT_SCAN_FAILED action shows submitter message when current user is the expense submitter', async () => {
906+
const parentReportID = 'parentReport1';
907+
const parentReportActionID = 'iouAction1';
908+
909+
await act(async () => {
910+
await Onyx.merge(ONYXKEYS.SESSION, {accountID: ACTOR_ACCOUNT_ID});
911+
await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, {
912+
[parentReportActionID]: {
913+
reportActionID: parentReportActionID,
914+
actorAccountID: ACTOR_ACCOUNT_ID,
915+
actionName: CONST.REPORT.ACTIONS.TYPE.IOU,
916+
created: '2025-07-12 09:03:17.653',
917+
message: [{type: 'COMMENT', html: '', text: ''}],
918+
originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, amount: 100, currency: 'USD'},
919+
},
920+
});
921+
});
922+
await waitForBatchedUpdatesWithAct();
923+
924+
const report = {reportID: 'scanReport1', parentReportID, parentReportActionID};
908925
const action = createReportAction(CONST.REPORT.ACTIONS.TYPE.RECEIPT_SCAN_FAILED, {});
909-
action.message = [
910-
{
911-
type: 'COMMENT',
912-
html: "the date couldn't be read from this receipt. Please enter it manually.",
913-
text: "the date couldn't be read from this receipt. Please enter it manually.",
914-
},
915-
];
916-
renderItemWithAction(action);
926+
927+
render(
928+
<ComposeProviders components={[OnyxListItemProvider, LocaleContextProvider, HTMLEngineProvider]}>
929+
<OptionsListContextProvider>
930+
<ScreenWrapper testID="test">
931+
<PortalProvider>
932+
<PureReportActionItem
933+
personalPolicyID={undefined}
934+
report={report}
935+
parentReportAction={undefined}
936+
action={action}
937+
displayAsGroup={false}
938+
shouldDisplayNewMarker={false}
939+
index={0}
940+
isFirstVisibleReportAction={false}
941+
/>
942+
</PortalProvider>
943+
</ScreenWrapper>
944+
</OptionsListContextProvider>
945+
</ComposeProviders>,
946+
);
917947
await waitForBatchedUpdatesWithAct();
918-
expect(screen.getByText("the date couldn't be read from this receipt. Please enter it manually.")).toBeOnTheScreen();
919948

920-
// Given an RECEIPT_SCAN_FAILED with no server side message
921-
// Then verify generic translation phrase is rendered
922-
action.message = [{type: 'COMMENT', html: '', text: ''}];
923-
renderItemWithAction(action);
949+
expect(screen.getByText(translateLocal('violations.smartscanFailed', {canEdit: true}))).toBeOnTheScreen();
950+
});
951+
952+
it('RECEIPT_SCAN_FAILED action shows non-submitter message when current user is not the expense submitter', async () => {
953+
const parentReportID = 'parentReport2';
954+
const parentReportActionID = 'iouAction2';
955+
const OTHER_ACCOUNT_ID = 999999;
956+
957+
await act(async () => {
958+
await Onyx.merge(ONYXKEYS.SESSION, {accountID: ACTOR_ACCOUNT_ID});
959+
await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, {
960+
[parentReportActionID]: {
961+
reportActionID: parentReportActionID,
962+
actorAccountID: OTHER_ACCOUNT_ID,
963+
actionName: CONST.REPORT.ACTIONS.TYPE.IOU,
964+
created: '2025-07-12 09:03:17.653',
965+
message: [{type: 'COMMENT', html: '', text: ''}],
966+
originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, amount: 100, currency: 'USD'},
967+
},
968+
});
969+
});
970+
await waitForBatchedUpdatesWithAct();
971+
972+
const report = {reportID: 'scanReport2', parentReportID, parentReportActionID};
973+
const action = createReportAction(CONST.REPORT.ACTIONS.TYPE.RECEIPT_SCAN_FAILED, {});
974+
975+
render(
976+
<ComposeProviders components={[OnyxListItemProvider, LocaleContextProvider, HTMLEngineProvider]}>
977+
<OptionsListContextProvider>
978+
<ScreenWrapper testID="test">
979+
<PortalProvider>
980+
<PureReportActionItem
981+
personalPolicyID={undefined}
982+
report={report}
983+
parentReportAction={undefined}
984+
action={action}
985+
displayAsGroup={false}
986+
shouldDisplayNewMarker={false}
987+
index={0}
988+
isFirstVisibleReportAction={false}
989+
/>
990+
</PortalProvider>
991+
</ScreenWrapper>
992+
</OptionsListContextProvider>
993+
</ComposeProviders>,
994+
);
924995
await waitForBatchedUpdatesWithAct();
925-
expect(screen.getByText(translateLocal('iou.receiptScanningFailed'))).toBeOnTheScreen();
996+
997+
expect(screen.getByText(translateLocal('violations.smartscanFailed', {canEdit: false}))).toBeOnTheScreen();
926998
});
927999

9281000
it('HOLD_COMMENT action renders via ReportActionItemBasicMessage', async () => {

tests/unit/ViolationUtilsTest.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1615,14 +1615,20 @@ describe('getRBRMessages', () => {
16151615

16161616
it('should return all violations and missing field error', () => {
16171617
const missingFieldError = 'Missing required field';
1618-
const result = ViolationsUtils.getRBRMessages(mockTransaction, mockViolations, translateLocal, missingFieldError, []);
1618+
const result = ViolationsUtils.getRBRMessages({
1619+
transaction: mockTransaction,
1620+
transactionViolations: mockViolations,
1621+
translate: translateLocal,
1622+
missingFieldError,
1623+
transactionThreadActions: [],
1624+
});
16191625
const expectedResult = `Missing required field. ${translateLocal('violations.missingCategory')}. ${translateLocal('violations.missingTag')}.`;
16201626

16211627
expect(result).toBe(expectedResult);
16221628
});
16231629

16241630
it('should filter out empty strings', () => {
1625-
const result = ViolationsUtils.getRBRMessages(mockTransaction, mockViolations, translateLocal, undefined, []);
1631+
const result = ViolationsUtils.getRBRMessages({transaction: mockTransaction, transactionViolations: mockViolations, translate: translateLocal, transactionThreadActions: []});
16261632
const expectedResult = `${translateLocal('violations.missingCategory')}. ${translateLocal('violations.missingTag')}.`;
16271633

16281634
expect(result).toBe(expectedResult);

0 commit comments

Comments
 (0)