From 468faba8ad8358ec4815053cac59e2774dd74a1f Mon Sep 17 00:00:00 2001 From: Ryan Teguh Date: Thu, 5 Mar 2026 14:02:48 +0800 Subject: [PATCH 1/4] fix: Submit button appears briefly after the SmartScan fails --- src/libs/ReportPreviewActionUtils.ts | 10 +++++++++- src/libs/ReportPrimaryActionUtils.ts | 10 ++++++++++ src/libs/ReportSecondaryActionUtils.ts | 10 ++++++++++ src/libs/actions/IOU/index.ts | 5 +++++ 4 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/libs/ReportPreviewActionUtils.ts b/src/libs/ReportPreviewActionUtils.ts index 95990894fb7a1..80840592ed0c0 100644 --- a/src/libs/ReportPreviewActionUtils.ts +++ b/src/libs/ReportPreviewActionUtils.ts @@ -20,7 +20,7 @@ import { isReportApproved, isSettled, } from './ReportUtils'; -import {hasSubmissionBlockingViolations, isPending, isScanning} from './TransactionUtils'; +import {hasMissingSmartscanFields, hasSubmissionBlockingViolations, isPending, isScanning, isScanRequest} from './TransactionUtils'; function canSubmit( report: Report, @@ -47,6 +47,14 @@ function canSubmit( const isAnyReceiptBeingScanned = transactions?.some((transaction) => isScanning(transaction)); + const hasSmartScanFailedWithMissingFields = transactions?.some( + (transaction) => isScanRequest(transaction) && transaction?.receipt?.state === CONST.IOU.RECEIPT_STATE.SCAN_FAILED && hasMissingSmartscanFields(transaction, report), + ); + + if (hasSmartScanFailedWithMissingFields) { + return false; + } + if (transactions?.some((transaction) => hasSubmissionBlockingViolations(transaction, violations, currentUserEmail, currentUserAccountID, report, policy))) { return false; } diff --git a/src/libs/ReportPrimaryActionUtils.ts b/src/libs/ReportPrimaryActionUtils.ts index cf11e20a53aea..6a5b464a4c6fd 100644 --- a/src/libs/ReportPrimaryActionUtils.ts +++ b/src/libs/ReportPrimaryActionUtils.ts @@ -48,12 +48,14 @@ import { import { allHavePendingRTERViolation, getTransactionViolations, + hasMissingSmartscanFields, hasPendingRTERViolation as hasPendingRTERViolationTransactionUtils, hasSubmissionBlockingViolations, isDuplicate, isOnHold as isOnHoldTransactionUtils, isPending, isScanning, + isScanRequest, shouldShowBrokenConnectionViolationForMultipleTransactions, shouldShowBrokenConnectionViolation as shouldShowBrokenConnectionViolationTransactionUtils, } from './TransactionUtils'; @@ -121,6 +123,14 @@ function isSubmitAction( return false; } + const hasSmartScanFailedWithMissingFields = reportTransactions?.some( + (transaction) => isScanRequest(transaction) && transaction?.receipt?.state === CONST.IOU.RECEIPT_STATE.SCAN_FAILED && hasMissingSmartscanFields(transaction, report), + ); + + if (hasSmartScanFailedWithMissingFields) { + return false; + } + if (violations && currentUserEmail && currentUserAccountID !== undefined) { if (reportTransactions.some((transaction) => hasSubmissionBlockingViolations(transaction, violations, currentUserEmail, currentUserAccountID, report, policy))) { return false; diff --git a/src/libs/ReportSecondaryActionUtils.ts b/src/libs/ReportSecondaryActionUtils.ts index fb115669cb11e..7f8a19d540288 100644 --- a/src/libs/ReportSecondaryActionUtils.ts +++ b/src/libs/ReportSecondaryActionUtils.ts @@ -67,6 +67,7 @@ import { import { allHavePendingRTERViolation, getOriginalTransactionWithSplitInfo, + hasMissingSmartscanFields, hasReceipt as hasReceiptTransactionUtils, hasSubmissionBlockingViolations, isDuplicate, @@ -77,6 +78,7 @@ import { isPerDiemRequest as isPerDiemRequestTransactionUtils, isReceiptBeingScanned, isScanning as isScanningTransactionUtils, + isScanRequest, shouldShowBrokenConnectionViolationForMultipleTransactions, } from './TransactionUtils'; @@ -210,6 +212,14 @@ function isSubmitAction({ return false; } + const hasSmartScanFailedWithMissingFields = reportTransactions?.some( + (transaction) => isScanRequest(transaction) && transaction?.receipt?.state === CONST.IOU.RECEIPT_STATE.SCAN_FAILED && hasMissingSmartscanFields(transaction, report), + ); + + if (hasSmartScanFailedWithMissingFields) { + return false; + } + if (violations && currentUserLogin && currentUserAccountID !== undefined) { if (reportTransactions.some((transaction) => hasSubmissionBlockingViolations(transaction, violations, currentUserLogin, currentUserAccountID, report, policy))) { return false; diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index ae71aade3154e..d18d480098801 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -211,6 +211,7 @@ import { getWaypoints, hasAnyTransactionWithoutRTERViolation, hasDuplicateTransactions, + hasMissingSmartscanFields, hasSubmissionBlockingViolations, isCustomUnitRateIDForP2P, isDistanceRequest as isDistanceRequestTransactionUtils, @@ -10502,6 +10503,9 @@ function canSubmitReport( const hasAnySubmissionBlockingViolations = transactions.some((transaction) => hasSubmissionBlockingViolations(transaction, allViolations, currentUserEmailParam, currentUserAccountID, report, policy), ); + const hasSmartScanFailedWithMissingFields = transactions.some( + (transaction) => isScanRequestTransactionUtils(transaction) && transaction?.receipt?.state === CONST.IOU.RECEIPT_STATE.SCAN_FAILED && hasMissingSmartscanFields(transaction, report), + ); return ( isOpenExpenseReport && @@ -10511,6 +10515,7 @@ function canSubmitReport( hasTransactionWithoutRTERViolation && !isReportArchived && !hasAnySubmissionBlockingViolations && + !hasSmartScanFailedWithMissingFields && transactions.length > 0 ); } From 7e6a686c36e9e3b70d63dfd7c441cb75e429364a Mon Sep 17 00:00:00 2001 From: Ryan Teguh Date: Thu, 5 Mar 2026 22:56:16 +0800 Subject: [PATCH 2/4] add test case --- tests/actions/ReportPreviewActionUtilsTest.ts | 45 +++++++++++++++++++ tests/unit/IOUUtilsTest.ts | 33 ++++++++++++++ tests/unit/ReportPrimaryActionUtilsTest.ts | 36 +++++++++++++++ tests/unit/ReportSecondaryActionUtilsTest.ts | 40 +++++++++++++++++ 4 files changed, 154 insertions(+) diff --git a/tests/actions/ReportPreviewActionUtilsTest.ts b/tests/actions/ReportPreviewActionUtilsTest.ts index 4c1b2621ecf74..8adb35182477a 100644 --- a/tests/actions/ReportPreviewActionUtilsTest.ts +++ b/tests/actions/ReportPreviewActionUtilsTest.ts @@ -276,6 +276,51 @@ describe('getReportPreviewAction', () => { ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW); }); + it('canSubmit should return false for expense preview when smartscan failed with missing fields (before violation is written)', async () => { + const TRANSACTION_ID = 'TRANSACTION_ID'; + const report: Report = { + ...createRandomReport(REPORT_ID, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + isWaitingOnBankAccount: false, + }; + + const policy = createRandomPolicy(0); + policy.autoReportingFrequency = CONST.POLICY.AUTO_REPORTING_FREQUENCIES.IMMEDIATE; + policy.type = CONST.POLICY.TYPE.CORPORATE; + if (policy.harvesting) { + policy.harvesting.enabled = false; + } + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + + const transaction = { + transactionID: TRANSACTION_ID, + reportID: `${REPORT_ID}`, + iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + receipt: {state: CONST.IOU.RECEIPT_STATE.SCAN_FAILED}, + merchant: '', + } as unknown as Transaction; + + const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID)); + + await waitForBatchedUpdatesWithAct(); + + expect( + getReportPreviewAction({ + isReportArchived: isReportArchived.current, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + currentUserLogin: CURRENT_USER_EMAIL, + report, + policy, + transactions: [transaction], + bankAccountList: {}, + reportMetadata: undefined, + }), + ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW); + }); + describe('canApprove', () => { it('should return true for report being processed', async () => { const report = { diff --git a/tests/unit/IOUUtilsTest.ts b/tests/unit/IOUUtilsTest.ts index 81066b2fc6458..0412407efa4b1 100644 --- a/tests/unit/IOUUtilsTest.ts +++ b/tests/unit/IOUUtilsTest.ts @@ -594,6 +594,39 @@ describe('canSubmitReport', () => { const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.reportID)); expect(canSubmitReport(report, policy, [], undefined, isReportArchived.current, '', currentUserAccountID)).toBe(false); }); + + it('returns false when SmartScan failed with missing fields before violation is written', async () => { + await Onyx.merge(ONYXKEYS.SESSION, {accountID: currentUserAccountID}); + const policy: Policy = { + ...createRandomPolicy(8), + ownerAccountID: currentUserAccountID, + areRulesEnabled: true, + preventSelfApproval: false, + autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.IMMEDIATE, + harvesting: {enabled: false}, + }; + const report: Report = { + ...createRandomReport(8, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + managerID: currentUserAccountID, + ownerAccountID: currentUserAccountID, + policyID: policy.id, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + + const transaction: Transaction = { + ...createRandomTransaction(1), + reportID: report.reportID, + iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + receipt: {state: CONST.IOU.RECEIPT_STATE.SCAN_FAILED}, + merchant: 'Coffee', + created: '', + amount: 100, + }; + + expect(canSubmitReport(report, policy, [transaction], undefined, false, '', currentUserAccountID)).toBe(false); + }); }); describe('Check valid amount for IOU/Expense request', () => { diff --git a/tests/unit/ReportPrimaryActionUtilsTest.ts b/tests/unit/ReportPrimaryActionUtilsTest.ts index 82b8b953f0341..a227b69dc40b0 100644 --- a/tests/unit/ReportPrimaryActionUtilsTest.ts +++ b/tests/unit/ReportPrimaryActionUtilsTest.ts @@ -931,6 +931,42 @@ describe('getPrimaryAction', () => { ).toBe(''); }); + it('should not return SUBMIT when smartscan failed with missing fields before violation is written', async () => { + const report = { + reportID: REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + } as unknown as Report; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + const policy = { + autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.IMMEDIATE, + }; + const TRANSACTION_ID = 'TRANSACTION_ID'; + const transaction = { + transactionID: TRANSACTION_ID, + reportID: `${REPORT_ID}`, + iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + receipt: {state: CONST.IOU.RECEIPT_STATE.SCAN_FAILED}, + merchant: '', + } as unknown as Transaction; + + expect( + getReportPrimaryAction({ + currentUserLogin: CURRENT_USER_EMAIL, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + report, + chatReport, + reportTransactions: [transaction], + violations: {}, + bankAccountList: {}, + policy: policy as Policy, + isChatReportArchived: false, + }), + ).toBe(''); + }); + it('should return an empty string for invoice report when the chat report is archived', async () => { // Given the invoice data const {policy, convertedInvoiceChat: invoiceChatReport}: InvoiceTestData = InvoiceData; diff --git a/tests/unit/ReportSecondaryActionUtilsTest.ts b/tests/unit/ReportSecondaryActionUtilsTest.ts index 2a03ce4c6df2b..4ae9df9cfef53 100644 --- a/tests/unit/ReportSecondaryActionUtilsTest.ts +++ b/tests/unit/ReportSecondaryActionUtilsTest.ts @@ -425,6 +425,46 @@ describe('getSecondaryAction', () => { expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SUBMIT)).toBe(false); }); + it('should not include SUBMIT option when smartscan failed with missing fields before violation is written', async () => { + const report = { + reportID: REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: EMPLOYEE_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + total: 10, + } as unknown as Report; + const policy = { + autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT, + harvesting: { + enabled: true, + }, + } as unknown as Policy; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + + const TRANSACTION_ID = 'TRANSACTION_ID'; + const transaction = { + transactionID: TRANSACTION_ID, + reportID: `${REPORT_ID}`, + iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + receipt: {state: CONST.IOU.RECEIPT_STATE.SCAN_FAILED}, + merchant: '', + } as unknown as Transaction; + + const result = getSecondaryReportActions({ + currentUserLogin: EMPLOYEE_EMAIL, + currentUserAccountID: EMPLOYEE_ACCOUNT_ID, + report, + chatReport, + reportTransactions: [transaction], + originalTransaction: {} as Transaction, + violations: {}, + bankAccountList: {}, + policy, + }); + expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SUBMIT)).toBe(false); + }); + it('includes APPROVE option for approver and report with duplicates', async () => { const report = { reportID: REPORT_ID, From 640fea25d0203dbbbb0e4792d723497b8005b209 Mon Sep 17 00:00:00 2001 From: Ryan Teguh Date: Fri, 6 Mar 2026 12:20:10 +0800 Subject: [PATCH 3/4] fix: Submit button appears briefly after the SmartScan fails --- src/libs/ReportPreviewActionUtils.ts | 8 ++------ src/libs/ReportPrimaryActionUtils.ts | 9 ++------- src/libs/ReportSecondaryActionUtils.ts | 9 ++------- src/libs/TransactionUtils/index.ts | 10 ++++++++++ src/libs/actions/IOU/index.ts | 8 ++------ 5 files changed, 18 insertions(+), 26 deletions(-) diff --git a/src/libs/ReportPreviewActionUtils.ts b/src/libs/ReportPreviewActionUtils.ts index 12ec3754f8b22..38ade57e848e2 100644 --- a/src/libs/ReportPreviewActionUtils.ts +++ b/src/libs/ReportPreviewActionUtils.ts @@ -20,7 +20,7 @@ import { isReportApproved, isSettled, } from './ReportUtils'; -import {hasMissingSmartscanFields, hasSubmissionBlockingViolations, isPending, isScanning, isScanRequest} from './TransactionUtils'; +import {hasSmartScanFailedWithMissingFields, hasSubmissionBlockingViolations, isPending, isScanning} from './TransactionUtils'; function canSubmit( report: Report, @@ -47,11 +47,7 @@ function canSubmit( const isAnyReceiptBeingScanned = transactions?.some((transaction) => isScanning(transaction)); - const hasSmartScanFailedWithMissingFields = transactions?.some( - (transaction) => isScanRequest(transaction) && transaction?.receipt?.state === CONST.IOU.RECEIPT_STATE.SCAN_FAILED && hasMissingSmartscanFields(transaction, report), - ); - - if (hasSmartScanFailedWithMissingFields) { + if (hasSmartScanFailedWithMissingFields(transactions ?? [], report)) { return false; } diff --git a/src/libs/ReportPrimaryActionUtils.ts b/src/libs/ReportPrimaryActionUtils.ts index ed7362f90f2f3..2cf13ba5e067c 100644 --- a/src/libs/ReportPrimaryActionUtils.ts +++ b/src/libs/ReportPrimaryActionUtils.ts @@ -48,14 +48,13 @@ import { import { allHavePendingRTERViolation, getTransactionViolations, - hasMissingSmartscanFields, hasPendingRTERViolation as hasPendingRTERViolationTransactionUtils, + hasSmartScanFailedWithMissingFields, hasSubmissionBlockingViolations, isDuplicate, isOnHold as isOnHoldTransactionUtils, isPending, isScanning, - isScanRequest, shouldShowBrokenConnectionViolationForMultipleTransactions, shouldShowBrokenConnectionViolation as shouldShowBrokenConnectionViolationTransactionUtils, } from './TransactionUtils'; @@ -123,11 +122,7 @@ function isSubmitAction( return false; } - const hasSmartScanFailedWithMissingFields = reportTransactions?.some( - (transaction) => isScanRequest(transaction) && transaction?.receipt?.state === CONST.IOU.RECEIPT_STATE.SCAN_FAILED && hasMissingSmartscanFields(transaction, report), - ); - - if (hasSmartScanFailedWithMissingFields) { + if (hasSmartScanFailedWithMissingFields(reportTransactions ?? [], report)) { return false; } diff --git a/src/libs/ReportSecondaryActionUtils.ts b/src/libs/ReportSecondaryActionUtils.ts index 7f8a19d540288..127be89f34f26 100644 --- a/src/libs/ReportSecondaryActionUtils.ts +++ b/src/libs/ReportSecondaryActionUtils.ts @@ -67,8 +67,8 @@ import { import { allHavePendingRTERViolation, getOriginalTransactionWithSplitInfo, - hasMissingSmartscanFields, hasReceipt as hasReceiptTransactionUtils, + hasSmartScanFailedWithMissingFields, hasSubmissionBlockingViolations, isDuplicate, isManagedCardTransaction as isManagedCardTransactionTransactionUtils, @@ -78,7 +78,6 @@ import { isPerDiemRequest as isPerDiemRequestTransactionUtils, isReceiptBeingScanned, isScanning as isScanningTransactionUtils, - isScanRequest, shouldShowBrokenConnectionViolationForMultipleTransactions, } from './TransactionUtils'; @@ -212,11 +211,7 @@ function isSubmitAction({ return false; } - const hasSmartScanFailedWithMissingFields = reportTransactions?.some( - (transaction) => isScanRequest(transaction) && transaction?.receipt?.state === CONST.IOU.RECEIPT_STATE.SCAN_FAILED && hasMissingSmartscanFields(transaction, report), - ); - - if (hasSmartScanFailedWithMissingFields) { + if (hasSmartScanFailedWithMissingFields(reportTransactions ?? [], report)) { return false; } diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 53e5e85daffe7..3d7a74509afd6 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -2760,6 +2760,15 @@ function shouldReuseInitialTransaction( return !isMultiScanEnabled || (transactions.length === 1 && (!initialTransaction.receipt?.source || initialTransaction.receipt?.isTestReceipt === true)); } +/** + * Check if the transaction has a smartscan failed with missing fields before violation is written + */ +function hasSmartScanFailedWithMissingFields(transactions: Transaction[], report: OnyxEntry): boolean { + return transactions.some( + (transaction) => isScanRequest(transaction) && transaction?.receipt?.state === CONST.IOU.RECEIPT_STATE.SCAN_FAILED && hasMissingSmartscanFields(transaction, report), + ); +} + export { buildOptimisticTransaction, calculateTaxAmount, @@ -2897,6 +2906,7 @@ export { isTimeRequest, getExpenseTypeTranslationKey, isDistanceTypeRequest, + hasSmartScanFailedWithMissingFields, }; export type {TransactionChanges}; diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index e4adb0ce33d57..81f90a3bf6707 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -211,7 +211,7 @@ import { getWaypoints, hasAnyTransactionWithoutRTERViolation, hasDuplicateTransactions, - hasMissingSmartscanFields, + hasSmartScanFailedWithMissingFields, hasSubmissionBlockingViolations, isCustomUnitRateIDForP2P, isDistanceRequest as isDistanceRequestTransactionUtils, @@ -10542,10 +10542,6 @@ function canSubmitReport( const hasAnySubmissionBlockingViolations = transactions.some((transaction) => hasSubmissionBlockingViolations(transaction, allViolations, currentUserEmailParam, currentUserAccountID, report, policy), ); - const hasSmartScanFailedWithMissingFields = transactions.some( - (transaction) => isScanRequestTransactionUtils(transaction) && transaction?.receipt?.state === CONST.IOU.RECEIPT_STATE.SCAN_FAILED && hasMissingSmartscanFields(transaction, report), - ); - return ( isOpenExpenseReport && (report?.ownerAccountID === currentUserAccountID || report?.managerID === currentUserAccountID || isAdmin) && @@ -10554,7 +10550,7 @@ function canSubmitReport( hasTransactionWithoutRTERViolation && !isReportArchived && !hasAnySubmissionBlockingViolations && - !hasSmartScanFailedWithMissingFields && + !hasSmartScanFailedWithMissingFields(transactions, report) && transactions.length > 0 ); } From 5d8f9a7fed2acf193384b0751ef1327f60c3e4fb Mon Sep 17 00:00:00 2001 From: Ryan Teguh Date: Fri, 6 Mar 2026 12:33:17 +0800 Subject: [PATCH 4/4] minor change --- src/libs/actions/IOU/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 81f90a3bf6707..50c5a7344a36d 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -10542,6 +10542,7 @@ function canSubmitReport( const hasAnySubmissionBlockingViolations = transactions.some((transaction) => hasSubmissionBlockingViolations(transaction, allViolations, currentUserEmailParam, currentUserAccountID, report, policy), ); + return ( isOpenExpenseReport && (report?.ownerAccountID === currentUserAccountID || report?.managerID === currentUserAccountID || isAdmin) &&