From 0d06d5e51aa2ba6c0b7f0891c624229694db1a22 Mon Sep 17 00:00:00 2001 From: FitseTLT Date: Wed, 2 Sep 2026 20:24:41 +0300 Subject: [PATCH 1/9] hide export button on pending export --- src/libs/ReportPreviewActionUtils.ts | 9 +++++++-- src/libs/ReportPrimaryActionUtils.ts | 9 +++++++-- src/libs/ReportUtils.ts | 25 +++++++++++++++++++++++++ src/libs/actions/Report/index.ts | 18 +++++++++++------- src/types/onyx/ReportMetadata.ts | 5 +++++ 5 files changed, 55 insertions(+), 11 deletions(-) diff --git a/src/libs/ReportPreviewActionUtils.ts b/src/libs/ReportPreviewActionUtils.ts index 8ddea54d16a7..e10a1cab9415 100644 --- a/src/libs/ReportPreviewActionUtils.ts +++ b/src/libs/ReportPreviewActionUtils.ts @@ -35,6 +35,7 @@ import { isProcessingReport, isReportApproved, isSettled, + isExportInProgress, } from './ReportUtils'; import {hasOnlyPendingCardTransactions, hasSmartScanFailedWithMissingFields, hasSubmissionBlockingViolations, isPending, isScanning} from './TransactionUtils'; @@ -192,7 +193,11 @@ function canPay( return invoiceReceiverPolicy?.role === CONST.POLICY.ROLE.ADMIN && reimbursableSpend > 0; } -function canExport(report: Report, currentUserLogin: string, policy?: Policy) { +function canExport(report: Report, currentUserLogin: string, policy?: Policy, reportMetadata?: OnyxEntry) { + if (isExportInProgress(report, reportMetadata)) { + return false; + } + const isExpense = isExpenseReport(report); const isExporter = policy ? isPreferredExporter(policy, currentUserLogin) : false; const isReimbursed = isSettled(report); @@ -285,7 +290,7 @@ function getReportPreviewAction({ if (canPay(report, isReportArchived, currentUserAccountID, currentUserLogin, bankAccountList, transactions, policy, invoiceReceiverPolicy)) { return CONST.REPORT.REPORT_PREVIEW_ACTIONS.PAY; } - if (canExport(report, currentUserLogin, policy)) { + if (canExport(report, currentUserLogin, policy, reportMetadata)) { return CONST.REPORT.REPORT_PREVIEW_ACTIONS.EXPORT_TO_ACCOUNTING; } diff --git a/src/libs/ReportPrimaryActionUtils.ts b/src/libs/ReportPrimaryActionUtils.ts index 64776f21d470..3fa654a469d7 100644 --- a/src/libs/ReportPrimaryActionUtils.ts +++ b/src/libs/ReportPrimaryActionUtils.ts @@ -55,6 +55,7 @@ import { isReportApproved as isReportApprovedUtils, isReportManager, isSettled, + isExportInProgress, } from './ReportUtils'; import { allHavePendingRTERViolation, @@ -286,11 +287,15 @@ function isPrimaryPayAction({ return invoiceReceiverPolicy?.role === CONST.POLICY.ROLE.ADMIN && reimbursableSpend > 0; } -function isExportAction(report: Report, currentUserLogin: string, policy?: Policy, reportActions?: ReportAction[]) { +function isExportAction(report: Report, currentUserLogin: string, policy?: Policy, reportActions?: ReportAction[], reportMetadata?: OnyxEntry) { if (!policy) { return false; } + if (isExportInProgress(report, reportMetadata)) { + return false; + } + const connectedIntegration = getValidConnectedIntegration(policy); const isInvoiceReport = isInvoiceReportUtils(report); @@ -572,7 +577,7 @@ function getReportPrimaryAction(params: GetReportPrimaryActionParams): ValueOf | ReportAction[], re return lastSuccessfulExportCreated > lastResetCreated; } +/** + * Whether an export this client started is still running. + * + * There is no client event meaning "the export finished" - the 200 only means the request was accepted, `failureData` + * never runs, and the real outcome arrives later over Pusher - so the state is resolved by comparison at read time + * rather than cleared from a callback. That also means a stale flag can never strand the button. + * + * Both signals live on the report, so this holds in a preview where the report actions are not loaded, and it + * survives a refresh. + */ +function isExportInProgress(report: OnyxEntry, reportMetadata: OnyxEntry): boolean { + const {exportErrorCountAtRequest} = reportMetadata ?? {}; + // Checked against null rather than falsiness, because zero is the usual count for a report that never failed. + if (exportErrorCountAtRequest == null) { + return false; + } + + // Only an export error the report did not already carry belongs to this attempt. Pre-existing ones must not + // resolve it, because a report that already failed to export is exactly the retry case where the button is + // offered again. The entries are keyed by microsecond timestamp, so counting them is what identifies a new one. + const exportErrorCount = Object.values(report?.errorFields?.export ?? {}).filter((error) => error != null).length; + return exportErrorCount === exportErrorCountAtRequest; +} + function hasExportError(reportActions: OnyxEntry | ReportAction[], report?: OnyxEntry) { if (report?.hasExportError) { return true; @@ -14748,6 +14772,7 @@ export { isExported, hasExpensifyGuidesEmails, hasExportError, + isExportInProgress, hasOnlyNonReimbursableTransactions, getReportLastMessage, getReportLastVisibleActionCreated, diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index e1fe3ffb864b..76536446db74 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -6470,9 +6470,13 @@ function setGroupDraft(newGroupDraft: Partial) { function exportToIntegration(reportID: string, connectionName: ConnectionName, policy: OnyxEntry) { const action = buildOptimisticExportIntegrationAction(connectionName, false, getExportLabelForConnection(connectionName, policy)); const optimisticReportActionID = action.reportActionID; - const previousExportedValue = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]?.isExportedToIntegration; + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + // Snapshot the export errors the report already carries, so only a new one counts as this attempt's outcome. + const exportErrorCountAtRequest = Object.values(report?.errorFields?.export ?? {}).filter((error) => error != null).length; - const optimisticData: Array> = [ + // `isExportedToIntegration` is deliberately not updated optimistically because the server doesn't set it false + // when export fails so we track export errors to identify an export failure. + const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, @@ -6482,14 +6486,14 @@ function exportToIntegration(reportID: string, connectionName: ConnectionName, p }, { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, value: { - isExportedToIntegration: true, + exportErrorCountAtRequest, }, }, ]; - const failureData: Array> = [ + const failureData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, @@ -6501,9 +6505,9 @@ function exportToIntegration(reportID: string, connectionName: ConnectionName, p }, { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, value: { - isExportedToIntegration: previousExportedValue, + exportErrorCountAtRequest: null, }, }, ]; diff --git a/src/types/onyx/ReportMetadata.ts b/src/types/onyx/ReportMetadata.ts index d0297a9927b6..a0690f696265 100644 --- a/src/types/onyx/ReportMetadata.ts +++ b/src/types/onyx/ReportMetadata.ts @@ -34,6 +34,11 @@ type ReportMetadata = { /** Transaction IDs that were just submitted/moved to this report and should be highlighted on first load */ pendingNewTransactionIDs?: Record; + + /** How many `errorFields.export` entries the report carried when a manual export was started from this client, + * so if a new export error entry is added it means the export attempt failed. + * */ + exportErrorCountAtRequest?: number | null; }; export default ReportMetadata; From abf881f98823f86a94533cf993bb4ee04cab8499 Mon Sep 17 00:00:00 2001 From: FitseTLT Date: Thu, 3 Sep 2026 15:38:49 +0300 Subject: [PATCH 2/9] minor refactor --- src/libs/ReportUtils.ts | 14 +++++++++++--- src/libs/actions/Report/index.ts | 3 ++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index dccb8676bc90..3a88bcc0b425 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -13506,6 +13506,14 @@ function isExported(reportActions: OnyxEntry | ReportAction[], re return lastSuccessfulExportCreated > lastResetCreated; } +/** + * Count the export errors a report currently carries because it is + * what identifies a new failure against a snapshotted baseline. + */ +function getExportErrorCount(report: OnyxEntry): number { + return Object.values(report?.errorFields?.export ?? {}).filter((error) => error != null).length; +} + /** * Whether an export this client started is still running. * @@ -13525,9 +13533,8 @@ function isExportInProgress(report: OnyxEntry, reportMetadata: OnyxEntry // Only an export error the report did not already carry belongs to this attempt. Pre-existing ones must not // resolve it, because a report that already failed to export is exactly the retry case where the button is - // offered again. The entries are keyed by microsecond timestamp, so counting them is what identifies a new one. - const exportErrorCount = Object.values(report?.errorFields?.export ?? {}).filter((error) => error != null).length; - return exportErrorCount === exportErrorCountAtRequest; + // offered again. + return getExportErrorCount(report) === exportErrorCountAtRequest; } function hasExportError(reportActions: OnyxEntry | ReportAction[], report?: OnyxEntry) { @@ -14772,6 +14779,7 @@ export { isExported, hasExpensifyGuidesEmails, hasExportError, + getExportErrorCount, isExportInProgress, hasOnlyNonReimbursableTransactions, getReportLastMessage, diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 76536446db74..aa8530734444 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -172,6 +172,7 @@ import { isSelfDM, isUploadingAttachmentRemovedFromDraft, isValidReportIDFromPath, + getExportErrorCount, prepareOnboardingOnyxData, replaceLocalAttachmentReferences, } from '@libs/ReportUtils'; @@ -6472,7 +6473,7 @@ function exportToIntegration(reportID: string, connectionName: ConnectionName, p const optimisticReportActionID = action.reportActionID; const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; // Snapshot the export errors the report already carries, so only a new one counts as this attempt's outcome. - const exportErrorCountAtRequest = Object.values(report?.errorFields?.export ?? {}).filter((error) => error != null).length; + const exportErrorCountAtRequest = getExportErrorCount(report); // `isExportedToIntegration` is deliberately not updated optimistically because the server doesn't set it false // when export fails so we track export errors to identify an export failure. From 97cdb7e6eee670615e6219a1d58485e08fe08555 Mon Sep 17 00:00:00 2001 From: FitseTLT Date: Thu, 3 Sep 2026 15:42:12 +0300 Subject: [PATCH 3/9] fix spell --- src/libs/ReportUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 3a88bcc0b425..6c0480c60008 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -13526,7 +13526,7 @@ function getExportErrorCount(report: OnyxEntry): number { */ function isExportInProgress(report: OnyxEntry, reportMetadata: OnyxEntry): boolean { const {exportErrorCountAtRequest} = reportMetadata ?? {}; - // Checked against null rather than falsiness, because zero is the usual count for a report that never failed. + // Checked against null rather than falsy value, because zero is the usual count for a report that never failed. if (exportErrorCountAtRequest == null) { return false; } From 5d260c588c11e9bfce34b3a95453479eb323e1a0 Mon Sep 17 00:00:00 2001 From: FitseTLT Date: Thu, 3 Sep 2026 16:20:51 +0300 Subject: [PATCH 4/9] add test --- src/libs/actions/IOU/ReportWorkflow.ts | 56 ++++++++- tests/actions/ReportPreviewActionUtilsTest.ts | 113 ++++++++++++++++++ 2 files changed, 166 insertions(+), 3 deletions(-) diff --git a/src/libs/actions/IOU/ReportWorkflow.ts b/src/libs/actions/IOU/ReportWorkflow.ts index 63bec6fa12f0..65434075f98b 100644 --- a/src/libs/actions/IOU/ReportWorkflow.ts +++ b/src/libs/actions/IOU/ReportWorkflow.ts @@ -618,6 +618,17 @@ function approveMoneyRequest(params: ApproveMoneyRequestFunctionParams) { }); } + // Approving is how a report re-enters an exportable state, so any snapshot left by an earlier export has to go. + // `canExport` offers the button only while the report is approved, reimbursed or closed, which means clearing here + // also covers a reset the client never saw, such as one driven by the server. + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${expenseReport.reportID}`, + value: { + exportErrorCountAtRequest: null, + }, + }); + const successData: Array> = []; if (!isDEWPolicy) { @@ -897,7 +908,20 @@ function reopenReport( }, }; - const optimisticData: Array> = [optimisticIOUReportData, optimisticReportActionsData]; + const optimisticData: Array> = [ + optimisticIOUReportData, + optimisticReportActionsData, + ]; + + // A reset of the approval state invalidates the previous export, so drop any in-flight export snapshot. Without + // this it survives a successful export and keeps both export buttons hidden once the report is exportable again. + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${expenseReport.reportID}`, + value: { + exportErrorCountAtRequest: null, + }, + }); const successData: Array> = [ { @@ -1058,7 +1082,20 @@ function retractReport( }, }; - const optimisticData: Array> = [optimisticIOUReportData, optimisticReportActionsData]; + const optimisticData: Array> = [ + optimisticIOUReportData, + optimisticReportActionsData, + ]; + + // A reset of the approval state invalidates the previous export, so drop any in-flight export snapshot. Without + // this it survives a successful export and keeps both export buttons hidden once the report is exportable again. + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${expenseReport.reportID}`, + value: { + exportErrorCountAtRequest: null, + }, + }); if (chatReport) { optimisticData.push({ @@ -1219,7 +1256,20 @@ function unapproveExpenseReport( }, }; - const optimisticData: Array> = [optimisticIOUReportData, optimisticReportActionData]; + const optimisticData: Array> = [ + optimisticIOUReportData, + optimisticReportActionData, + ]; + + // A reset of the approval state invalidates the previous export, so drop any in-flight export snapshot. Without + // this it survives a successful export and keeps both export buttons hidden once the report is exportable again. + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${expenseReport.reportID}`, + value: { + exportErrorCountAtRequest: null, + }, + }); const successData: Array> = [ { diff --git a/tests/actions/ReportPreviewActionUtilsTest.ts b/tests/actions/ReportPreviewActionUtilsTest.ts index 6ab3a4fda6b2..95626507bdc6 100644 --- a/tests/actions/ReportPreviewActionUtilsTest.ts +++ b/tests/actions/ReportPreviewActionUtilsTest.ts @@ -1264,6 +1264,119 @@ describe('getReportPreviewAction', () => { ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.EXPORT_TO_ACCOUNTING); }); + it('canExport should return false while an export started from this client is still running', async () => { + const report = { + ...createRandomReport(REPORT_ID, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + statusNum: CONST.REPORT.STATUS_NUM.CLOSED, + isWaitingOnBankAccount: false, + }; + + const policy = createRandomPolicy(0); + policy.type = CONST.POLICY.TYPE.CORPORATE; + policy.connections = {[CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: createMock({})}; + policy.reimbursementChoice = CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + const transaction = createMock({ + reportID: `${REPORT_ID}`, + }); + + const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID)); + await waitForBatchedUpdatesWithAct(); + + // The report carried no export errors when the export was started, so no new one has arrived yet. + expect( + getReportPreviewAction({ + isReportArchived: isReportArchived.current, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + currentUserLogin: CURRENT_USER_EMAIL, + report, + policy, + transactions: [transaction], + bankAccountList: {}, + reportMetadata: {exportErrorCountAtRequest: 0}, + ownerLogin: CURRENT_USER_EMAIL, + }), + ).not.toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.EXPORT_TO_ACCOUNTING); + }); + + it('canExport should return true again once the export fails with a new error', async () => { + const report = { + ...createRandomReport(REPORT_ID, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + statusNum: CONST.REPORT.STATUS_NUM.CLOSED, + isWaitingOnBankAccount: false, + errorFields: {export: {exportError: 'export failed'}}, + }; + + const policy = createRandomPolicy(0); + policy.type = CONST.POLICY.TYPE.CORPORATE; + policy.connections = {[CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: createMock({})}; + policy.reimbursementChoice = CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + const transaction = createMock({ + reportID: `${REPORT_ID}`, + }); + + const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID)); + await waitForBatchedUpdatesWithAct(); + + // One export error has arrived since the request, so this attempt is resolved and the button comes back. + expect( + getReportPreviewAction({ + isReportArchived: isReportArchived.current, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + currentUserLogin: CURRENT_USER_EMAIL, + report, + policy, + transactions: [transaction], + bankAccountList: {}, + reportMetadata: {exportErrorCountAtRequest: 0}, + ownerLogin: CURRENT_USER_EMAIL, + }), + ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.EXPORT_TO_ACCOUNTING); + }); + + it('canExport should stay hidden when the report already carried the same export errors', async () => { + const report = { + ...createRandomReport(REPORT_ID, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + statusNum: CONST.REPORT.STATUS_NUM.CLOSED, + isWaitingOnBankAccount: false, + errorFields: {export: {exportError: 'export failed'}}, + }; + + const policy = createRandomPolicy(0); + policy.type = CONST.POLICY.TYPE.CORPORATE; + policy.connections = {[CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: createMock({})}; + policy.reimbursementChoice = CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + const transaction = createMock({ + reportID: `${REPORT_ID}`, + }); + + const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID)); + await waitForBatchedUpdatesWithAct(); + + // Retrying an export on a report that already failed once: the pre-existing error must not resolve the attempt. + expect( + getReportPreviewAction({ + isReportArchived: isReportArchived.current, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + currentUserLogin: CURRENT_USER_EMAIL, + report, + policy, + transactions: [transaction], + bankAccountList: {}, + reportMetadata: {exportErrorCountAtRequest: 1}, + ownerLogin: CURRENT_USER_EMAIL, + }), + ).not.toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.EXPORT_TO_ACCOUNTING); + }); + describe('DEW (Dynamic External Workflow) submit pending', () => { it('should return VIEW action when DEW submit is pending and report is OPEN', async () => { // Given an open expense report with a corporate policy where DEW submit is pending From aa8c3fe36aec4b2395477cc46a72b7d3748097d8 Mon Sep 17 00:00:00 2001 From: FitseTLT Date: Thu, 15 Oct 2026 18:43:31 +0300 Subject: [PATCH 5/9] update to report actions approach --- .../useReportPreviewActionDecision.ts | 2 + src/libs/ReportPreviewActionUtils.ts | 10 +-- src/libs/ReportPrimaryActionUtils.ts | 2 +- src/libs/ReportUtils.ts | 72 +++++++++++-------- src/libs/actions/IOU/ReportWorkflow.ts | 56 +-------------- src/libs/actions/Report/index.ts | 23 +----- src/types/onyx/ReportMetadata.ts | 5 -- 7 files changed, 57 insertions(+), 113 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/useReportPreviewActionDecision.ts b/src/components/ReportActionItem/MoneyRequestReportPreview/useReportPreviewActionDecision.ts index 4ba05bd28c45..3021b49f2ec0 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/useReportPreviewActionDecision.ts +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/useReportPreviewActionDecision.ts @@ -47,6 +47,7 @@ function useReportPreviewActionDecision({ const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${iouReport?.policyID}`); const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const [iouReportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${iouReportID}`); + const [iouReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`); const [ownerLogin] = usePersonalDetail(iouReport?.ownerAccountID, loginSelector); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); @@ -97,6 +98,7 @@ function useReportPreviewActionDecision({ isDEWSubmitPending, violationsData: transactionViolations, reportMetadata: iouReportMetadata, + reportActions: iouReportActions ? Object.values(iouReportActions) : undefined, ownerLogin, rules, }); diff --git a/src/libs/ReportPreviewActionUtils.ts b/src/libs/ReportPreviewActionUtils.ts index 1ce02a135e1f..5cd9371b74b4 100644 --- a/src/libs/ReportPreviewActionUtils.ts +++ b/src/libs/ReportPreviewActionUtils.ts @@ -1,5 +1,5 @@ import CONST from '@src/CONST'; -import type {BankAccountList, Policy, Report, ReportMetadata, Rule, Transaction, TransactionViolation} from '@src/types/onyx'; +import type {BankAccountList, Policy, Report, ReportAction, ReportMetadata, Rule, Transaction, TransactionViolation} from '@src/types/onyx'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; @@ -200,8 +200,8 @@ function canPay( return invoiceReceiverPolicy?.role === CONST.POLICY.ROLE.ADMIN && reimbursableSpend > 0; } -function canExport(report: Report, currentUserLogin: string, policy?: Policy, reportMetadata?: OnyxEntry) { - if (isExportInProgress(report, reportMetadata)) { +function canExport(report: Report, currentUserLogin: string, policy?: Policy, reportActions?: ReportAction[]) { + if (isExportInProgress(reportActions)) { return false; } @@ -249,6 +249,7 @@ function getReportPreviewAction({ isDEWSubmitPending, violationsData, reportMetadata, + reportActions, ownerLogin, rules, }: { @@ -266,6 +267,7 @@ function getReportPreviewAction({ isDEWSubmitPending?: boolean; violationsData?: OnyxCollection; reportMetadata: OnyxEntry; + reportActions?: ReportAction[]; ownerLogin: string | undefined; rules: OnyxCollection; }): ValueOf { @@ -299,7 +301,7 @@ function getReportPreviewAction({ if (canPay(report, isReportArchived, currentUserAccountID, currentUserLogin, bankAccountList, transactions, policy, invoiceReceiverPolicy)) { return CONST.REPORT.REPORT_PREVIEW_ACTIONS.PAY; } - if (canExport(report, currentUserLogin, policy, reportMetadata)) { + if (canExport(report, currentUserLogin, policy, reportActions)) { return CONST.REPORT.REPORT_PREVIEW_ACTIONS.EXPORT_TO_ACCOUNTING; } diff --git a/src/libs/ReportPrimaryActionUtils.ts b/src/libs/ReportPrimaryActionUtils.ts index b594b5fdba6c..64bb9fe9b2f1 100644 --- a/src/libs/ReportPrimaryActionUtils.ts +++ b/src/libs/ReportPrimaryActionUtils.ts @@ -294,7 +294,7 @@ function isExportAction(report: Report, currentUserLogin: string, policy?: Polic return false; } - if (isExportInProgress(report, reportMetadata)) { + if (isExportInProgress(reportActions)) { return false; } diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 0b31750bc0e8..2d5990093a91 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -14126,6 +14126,16 @@ function getIntegrationNameFromExportMessage(reportActions: OnyxEntry([ + CONST.REPORT.ACTIONS.TYPE.REJECTED_TO_SUBMITTER, + CONST.REPORT.ACTIONS.TYPE.RETRACTED, + CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + CONST.REPORT.ACTIONS.TYPE.ACTION_DELEGATE_SUBMIT, + CONST.REPORT.ACTIONS.TYPE.REOPENED, + CONST.REPORT.ACTIONS.TYPE.UNAPPROVED, +]); + function isExported(reportActions: OnyxEntry | ReportAction[], report?: OnyxEntry): boolean { if (report?.isExportedToIntegration !== undefined) { return report.isExportedToIntegration; @@ -14138,15 +14148,6 @@ function isExported(reportActions: OnyxEntry | ReportAction[], re const reportActionList = Array.isArray(reportActions) ? reportActions : Object.values(reportActions); - // Actions that reset the approval state and invalidate previous exports - const resetApprovalActionTypes = new Set([ - CONST.REPORT.ACTIONS.TYPE.REJECTED_TO_SUBMITTER, - CONST.REPORT.ACTIONS.TYPE.RETRACTED, - CONST.REPORT.ACTIONS.TYPE.SUBMITTED, - CONST.REPORT.ACTIONS.TYPE.ACTION_DELEGATE_SUBMIT, - CONST.REPORT.ACTIONS.TYPE.REOPENED, - CONST.REPORT.ACTIONS.TYPE.UNAPPROVED, - ]); const validExportLabels = new Set(Object.values(CONST.EXPORT_LABELS)); let lastResetCreated = ''; @@ -14173,35 +14174,49 @@ function isExported(reportActions: OnyxEntry | ReportAction[], re return lastSuccessfulExportCreated > lastResetCreated; } -/** - * Count the export errors a report currently carries because it is - * what identifies a new failure against a snapshotted baseline. - */ -function getExportErrorCount(report: OnyxEntry): number { - return Object.values(report?.errorFields?.export ?? {}).filter((error) => error != null).length; -} - /** * Whether an export this client started is still running. * * There is no client event meaning "the export finished" - the 200 only means the request was accepted, `failureData` * never runs, and the real outcome arrives later over Pusher - so the state is resolved by comparison at read time - * rather than cleared from a callback. That also means a stale flag can never strand the button. + * rather than cleared from a callback. Nothing is stored, so no stale flag can strand the button and no other flow + * has to remember to clear one. * - * Both signals live on the report, so this holds in a preview where the report actions are not loaded, and it - * survives a refresh. + * Ordered the same way `isExported` orders an export against the actions that reset it: the attempt's own timestamp + * is read off the action that started it. */ -function isExportInProgress(report: OnyxEntry, reportMetadata: OnyxEntry): boolean { - const {exportErrorCountAtRequest} = reportMetadata ?? {}; - // Checked against null rather than falsy value, because zero is the usual count for a report that never failed. - if (exportErrorCountAtRequest == null) { +function isExportInProgress(reportActions: OnyxEntry | ReportAction[]): boolean { + const reportActionList = Array.isArray(reportActions) ? reportActions : Object.values(reportActions ?? {}); + + // Callers pass an array that is empty until the actions load, so an empty list means "not loaded yet" rather than + // "nothing started" - a report with an export under way always carries the action that started it. + if (reportActionList.length === 0) { return false; } - // Only an export error the report did not already carry belongs to this attempt. Pre-existing ones must not - // resolve it, because a report that already failed to export is exactly the retry case where the button is - // offered again. - return getExportErrorCount(report) === exportErrorCountAtRequest; + let lastExportStartedCreated = ''; + let lastExportResolvedCreated = ''; + + for (const action of reportActionList) { + // `EXPORTINTEGRATION` covers both halves of an export, and only `pendingAction` separates them: pending renders + // "started exporting this report to ...", anything else renders a finished export. See getExportIntegrationActionFragments. + if (isExportIntegrationAction(action)) { + if (action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD && action.created > lastExportStartedCreated) { + lastExportStartedCreated = action.created; + } + continue; + } + + // Every outcome ends the attempt, reconciled ones included, unlike `hasExportError` which only counts failures. + // A pending one is this client's own optimistic action, which says nothing about what the server did. + const isOutcome = isIntegrationMessageAction(action) && !action.pendingAction; + // Resetting the approval state invalidates the export the same way it invalidates a finished one in `isExported`. + if ((isOutcome || resetApprovalActionTypes.has(action.actionName)) && action.created > lastExportResolvedCreated) { + lastExportResolvedCreated = action.created; + } + } + + return !!lastExportStartedCreated && lastExportResolvedCreated < lastExportStartedCreated; } function hasExportError(reportActions: OnyxEntry | ReportAction[], report?: OnyxEntry) { @@ -15551,7 +15566,6 @@ export { isExported, hasExpensifyGuidesEmails, hasExportError, - getExportErrorCount, isExportInProgress, hasOnlyNonReimbursableTransactions, hasSettledZeroReimbursableSpend, diff --git a/src/libs/actions/IOU/ReportWorkflow.ts b/src/libs/actions/IOU/ReportWorkflow.ts index 886f624a97c8..8c092881822e 100644 --- a/src/libs/actions/IOU/ReportWorkflow.ts +++ b/src/libs/actions/IOU/ReportWorkflow.ts @@ -299,17 +299,6 @@ function approveMoneyRequest(params: ApproveMoneyRequestFunctionParams) { }); } - // Approving is how a report re-enters an exportable state, so any snapshot left by an earlier export has to go. - // `canExport` offers the button only while the report is approved, reimbursed or closed, which means clearing here - // also covers a reset the client never saw, such as one driven by the server. - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${expenseReport.reportID}`, - value: { - exportErrorCountAtRequest: null, - }, - }); - const successData: Array> = []; if (!isDEWPolicy) { @@ -592,20 +581,7 @@ function reopenReport( }, }; - const optimisticData: Array> = [ - optimisticIOUReportData, - optimisticReportActionsData, - ]; - - // A reset of the approval state invalidates the previous export, so drop any in-flight export snapshot. Without - // this it survives a successful export and keeps both export buttons hidden once the report is exportable again. - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${expenseReport.reportID}`, - value: { - exportErrorCountAtRequest: null, - }, - }); + const optimisticData: Array> = [optimisticIOUReportData, optimisticReportActionsData]; const successData: Array> = [ { @@ -768,20 +744,7 @@ function retractReport( }, }; - const optimisticData: Array> = [ - optimisticIOUReportData, - optimisticReportActionsData, - ]; - - // A reset of the approval state invalidates the previous export, so drop any in-flight export snapshot. Without - // this it survives a successful export and keeps both export buttons hidden once the report is exportable again. - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${expenseReport.reportID}`, - value: { - exportErrorCountAtRequest: null, - }, - }); + const optimisticData: Array> = [optimisticIOUReportData, optimisticReportActionsData]; if (chatReport) { optimisticData.push({ @@ -944,20 +907,7 @@ function unapproveExpenseReport( }, }; - const optimisticData: Array> = [ - optimisticIOUReportData, - optimisticReportActionData, - ]; - - // A reset of the approval state invalidates the previous export, so drop any in-flight export snapshot. Without - // this it survives a successful export and keeps both export buttons hidden once the report is exportable again. - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${expenseReport.reportID}`, - value: { - exportErrorCountAtRequest: null, - }, - }); + const optimisticData: Array> = [optimisticIOUReportData, optimisticReportActionData]; const successData: Array> = [ { diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 5e08fc6d5028..2dd62a92baf3 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -176,7 +176,6 @@ import { isSelfDM, isUploadingAttachmentRemovedFromDraft, isValidReportIDFromPath, - getExportErrorCount, prepareOnboardingOnyxData, replaceLocalAttachmentReferences, } from '@libs/ReportUtils'; @@ -6756,13 +6755,9 @@ function setGroupDraft(newGroupDraft: Partial) { function exportToIntegration(reportID: string, connectionName: ConnectionName, policy: OnyxEntry) { const action = buildOptimisticExportIntegrationAction(connectionName, false, getExportLabelForConnection(connectionName, policy)); const optimisticReportActionID = action.reportActionID; - const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; - // Snapshot the export errors the report already carries, so only a new one counts as this attempt's outcome. - const exportErrorCountAtRequest = getExportErrorCount(report); - // `isExportedToIntegration` is deliberately not updated optimistically because the server doesn't set it false // when export fails so we track export errors to identify an export failure. - const optimisticData: Array> = [ + const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, @@ -6770,16 +6765,9 @@ function exportToIntegration(reportID: string, connectionName: ConnectionName, p [optimisticReportActionID]: action, }, }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, - value: { - exportErrorCountAtRequest, - }, - }, ]; - const failureData: Array> = [ + const failureData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, @@ -6789,13 +6777,6 @@ function exportToIntegration(reportID: string, connectionName: ConnectionName, p }, }, }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, - value: { - exportErrorCountAtRequest: null, - }, - }, ]; const params = { diff --git a/src/types/onyx/ReportMetadata.ts b/src/types/onyx/ReportMetadata.ts index e1984a7e2325..caac26664c85 100644 --- a/src/types/onyx/ReportMetadata.ts +++ b/src/types/onyx/ReportMetadata.ts @@ -30,11 +30,6 @@ type ReportMetadata = { /** Transaction IDs that were just submitted/moved to this report and should be highlighted on first load */ pendingNewTransactionIDs?: Record; - - /** How many `errorFields.export` entries the report carried when a manual export was started from this client, - * so if a new export error entry is added it means the export attempt failed. - * */ - exportErrorCountAtRequest?: number | null; }; export default ReportMetadata; From 35c65f0ce7a766368121337b753e5de7fc3fdf32 Mon Sep 17 00:00:00 2001 From: FitseTLT Date: Thu, 15 Oct 2026 19:00:23 +0300 Subject: [PATCH 6/9] fix test --- src/libs/ReportUtils.ts | 2 +- tests/actions/ReportPreviewActionUtilsTest.ts | 37 +++++++++++++++---- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 2d5990093a91..b91136d3fe7c 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -14198,7 +14198,7 @@ function isExportInProgress(reportActions: OnyxEntry | ReportActi let lastExportResolvedCreated = ''; for (const action of reportActionList) { - // `EXPORTINTEGRATION` covers both halves of an export, and only `pendingAction` separates them: pending renders + // One action type covers both halves of an export, and only `pendingAction` separates them: a pending one renders // "started exporting this report to ...", anything else renders a finished export. See getExportIntegrationActionFragments. if (isExportIntegrationAction(action)) { if (action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD && action.created > lastExportStartedCreated) { diff --git a/tests/actions/ReportPreviewActionUtilsTest.ts b/tests/actions/ReportPreviewActionUtilsTest.ts index 4fd6269ae2e9..eff341dce1a7 100644 --- a/tests/actions/ReportPreviewActionUtilsTest.ts +++ b/tests/actions/ReportPreviewActionUtilsTest.ts @@ -10,7 +10,7 @@ import {hasOnlyNonReimbursableTransactions} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy, Report, Transaction} from '@src/types/onyx'; +import type {Policy, Report, ReportAction, Transaction} from '@src/types/onyx'; import type {NetSuiteConnection} from '@src/types/onyx/Policy'; import Onyx from 'react-native-onyx'; @@ -1403,6 +1403,21 @@ describe('getReportPreviewAction', () => { ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.EXPORT_TO_ACCOUNTING); }); + // A pending EXPORTED_TO_INTEGRATION action is how the client records "started exporting this report". + const buildExportStartedAction = (created: string) => + createMock({ + actionName: CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_INTEGRATION, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + created, + }); + + // The server's answer to an export attempt, success or failure. + const buildExportOutcomeAction = (created: string) => + createMock({ + actionName: CONST.REPORT.ACTIONS.TYPE.INTEGRATIONS_MESSAGE, + created, + }); + it('canExport should return false while an export started from this client is still running', async () => { const report = { ...createRandomReport(REPORT_ID, undefined), @@ -1424,7 +1439,7 @@ describe('getReportPreviewAction', () => { const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID)); await waitForBatchedUpdatesWithAct(); - // The report carried no export errors when the export was started, so no new one has arrived yet. + // Only the pending "started exporting" action is on the report, so nothing has answered it yet. expect( getReportPreviewAction({ isReportArchived: isReportArchived.current, @@ -1434,8 +1449,10 @@ describe('getReportPreviewAction', () => { policy, transactions: [transaction], bankAccountList: {}, - reportMetadata: {exportErrorCountAtRequest: 0}, + reportMetadata: undefined, + reportActions: [buildExportStartedAction('2026-10-14 10:00:00.000')], ownerLogin: CURRENT_USER_EMAIL, + rules: undefined, }), ).not.toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.EXPORT_TO_ACCOUNTING); }); @@ -1462,7 +1479,7 @@ describe('getReportPreviewAction', () => { const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID)); await waitForBatchedUpdatesWithAct(); - // One export error has arrived since the request, so this attempt is resolved and the button comes back. + // The failure message landed after the export started, so this attempt is resolved and the button comes back. expect( getReportPreviewAction({ isReportArchived: isReportArchived.current, @@ -1472,13 +1489,15 @@ describe('getReportPreviewAction', () => { policy, transactions: [transaction], bankAccountList: {}, - reportMetadata: {exportErrorCountAtRequest: 0}, + reportMetadata: undefined, + reportActions: [buildExportStartedAction('2026-10-14 10:00:00.000'), buildExportOutcomeAction('2026-10-14 10:00:05.000')], ownerLogin: CURRENT_USER_EMAIL, + rules: undefined, }), ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.EXPORT_TO_ACCOUNTING); }); - it('canExport should stay hidden when the report already carried the same export errors', async () => { + it('canExport should stay hidden when only an earlier attempt has an outcome', async () => { const report = { ...createRandomReport(REPORT_ID, undefined), type: CONST.REPORT.TYPE.EXPENSE, @@ -1500,7 +1519,7 @@ describe('getReportPreviewAction', () => { const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID)); await waitForBatchedUpdatesWithAct(); - // Retrying an export on a report that already failed once: the pre-existing error must not resolve the attempt. + // Retrying an export on a report that already failed once: the older failure must not resolve the new attempt. expect( getReportPreviewAction({ isReportArchived: isReportArchived.current, @@ -1510,8 +1529,10 @@ describe('getReportPreviewAction', () => { policy, transactions: [transaction], bankAccountList: {}, - reportMetadata: {exportErrorCountAtRequest: 1}, + reportMetadata: undefined, + reportActions: [buildExportOutcomeAction('2026-10-14 09:00:00.000'), buildExportStartedAction('2026-10-14 10:00:00.000')], ownerLogin: CURRENT_USER_EMAIL, + rules: undefined, }), ).not.toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.EXPORT_TO_ACCOUNTING); }); From e1777fd51e7d771797fa8b0b922f8432ccd67ec2 Mon Sep 17 00:00:00 2001 From: FitseTLT Date: Thu, 15 Oct 2026 19:15:32 +0300 Subject: [PATCH 7/9] minor fixes --- src/libs/ReportPrimaryActionUtils.ts | 4 ++-- src/libs/ReportUtils.ts | 2 -- src/libs/actions/Report/index.ts | 3 +-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/libs/ReportPrimaryActionUtils.ts b/src/libs/ReportPrimaryActionUtils.ts index 64bb9fe9b2f1..8167442c134b 100644 --- a/src/libs/ReportPrimaryActionUtils.ts +++ b/src/libs/ReportPrimaryActionUtils.ts @@ -289,7 +289,7 @@ function isPrimaryPayAction({ return invoiceReceiverPolicy?.role === CONST.POLICY.ROLE.ADMIN && reimbursableSpend > 0; } -function isExportAction(report: Report, currentUserLogin: string, policy?: Policy, reportActions?: ReportAction[], reportMetadata?: OnyxEntry) { +function isExportAction(report: Report, currentUserLogin: string, policy?: Policy, reportActions?: ReportAction[]) { if (!policy) { return false; } @@ -581,7 +581,7 @@ function getReportPrimaryAction(params: GetReportPrimaryActionParams): ValueOf | ReportAction[], re function isExportInProgress(reportActions: OnyxEntry | ReportAction[]): boolean { const reportActionList = Array.isArray(reportActions) ? reportActions : Object.values(reportActions ?? {}); - // Callers pass an array that is empty until the actions load, so an empty list means "not loaded yet" rather than - // "nothing started" - a report with an export under way always carries the action that started it. if (reportActionList.length === 0) { return false; } diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 2dd62a92baf3..673e84a22374 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -6755,8 +6755,7 @@ function setGroupDraft(newGroupDraft: Partial) { function exportToIntegration(reportID: string, connectionName: ConnectionName, policy: OnyxEntry) { const action = buildOptimisticExportIntegrationAction(connectionName, false, getExportLabelForConnection(connectionName, policy)); const optimisticReportActionID = action.reportActionID; - // `isExportedToIntegration` is deliberately not updated optimistically because the server doesn't set it false - // when export fails so we track export errors to identify an export failure. + const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, From 632ca55f16d93a1fe6da9ab0e2b629ec39cc2538 Mon Sep 17 00:00:00 2001 From: FitseTLT Date: Thu, 29 Oct 2026 21:44:50 +0300 Subject: [PATCH 8/9] address comments --- .../useReportPreviewActionDecision.ts | 6 +- src/hooks/useExportActions.ts | 2 +- src/libs/ReportPreviewActionUtils.ts | 13 +-- src/libs/ReportSecondaryActionUtils.ts | 5 +- src/libs/ReportUtils.ts | 7 +- tests/actions/ReportPreviewActionUtilsTest.ts | 105 +----------------- tests/unit/ReportPrimaryActionUtilsTest.ts | 51 +++++++++ tests/unit/ReportUtilsTest.ts | 77 +++++++++++++ 8 files changed, 152 insertions(+), 114 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/useReportPreviewActionDecision.ts b/src/components/ReportActionItem/MoneyRequestReportPreview/useReportPreviewActionDecision.ts index 3021b49f2ec0..5c72e084909c 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/useReportPreviewActionDecision.ts +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/useReportPreviewActionDecision.ts @@ -6,7 +6,7 @@ import useReportIsArchived from '@hooks/useReportIsArchived'; import {getConnectedIntegration, hasDynamicExternalWorkflow} from '@libs/PolicyUtils'; import {hasPendingDEWSubmit} from '@libs/ReportActionsUtils'; import getReportPreviewAction from '@libs/ReportPreviewActionUtils'; -import {canIOUBePaid} from '@libs/ReportUtils'; +import {canIOUBePaid, isExportInProgress} from '@libs/ReportUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import {loginSelector} from '@src/selectors/PersonalDetails'; @@ -47,7 +47,7 @@ function useReportPreviewActionDecision({ const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${iouReport?.policyID}`); const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const [iouReportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${iouReportID}`); - const [iouReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`); + const [isExpenseReportExportInProgress] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, {selector: isExportInProgress}); const [ownerLogin] = usePersonalDetail(iouReport?.ownerAccountID, loginSelector); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); @@ -98,7 +98,7 @@ function useReportPreviewActionDecision({ isDEWSubmitPending, violationsData: transactionViolations, reportMetadata: iouReportMetadata, - reportActions: iouReportActions ? Object.values(iouReportActions) : undefined, + isExportInProgress: isExpenseReportExportInProgress, ownerLogin, rules, }); diff --git a/src/hooks/useExportActions.ts b/src/hooks/useExportActions.ts index ea4cf7dc8e98..f2a52b24dd26 100644 --- a/src/hooks/useExportActions.ts +++ b/src/hooks/useExportActions.ts @@ -250,7 +250,7 @@ function useExportActions({reportID, policy, onPDFModalOpen}: UseExportActionsPa // The accounting export actions (export to integration / mark as exported) the user is allowed to perform on the report const secondaryExportActions: string[] = moneyRequestReport - ? getReportAccountingExportActions(accountID, currentUserLogin ?? '', moneyRequestReport, bankAccountList, policy ?? undefined) + ? getReportAccountingExportActions(accountID, currentUserLogin ?? '', moneyRequestReport, bankAccountList, policy ?? undefined, reportActions) : []; // Assemble the export submenu from its pre-sorted groups (accounting actions, custom templates, default templates), with a divider between each non-empty group diff --git a/src/libs/ReportPreviewActionUtils.ts b/src/libs/ReportPreviewActionUtils.ts index 2af495a027a8..ac96fa171ae9 100644 --- a/src/libs/ReportPreviewActionUtils.ts +++ b/src/libs/ReportPreviewActionUtils.ts @@ -1,5 +1,5 @@ import CONST from '@src/CONST'; -import type {BankAccountList, Policy, Report, ReportAction, ReportMetadata, Rule, Transaction, TransactionViolation} from '@src/types/onyx'; +import type {BankAccountList, Policy, Report, ReportMetadata, Rule, Transaction, TransactionViolation} from '@src/types/onyx'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; @@ -34,7 +34,6 @@ import { isProcessingReport, isReportApproved, isSettled, - isExportInProgress, } from './ReportUtils'; import {hasOnlyPendingCardTransactions, hasSmartScanFailedWithMissingFields, hasSubmissionBlockingViolations, isPending, isScanning} from './TransactionUtils'; @@ -194,8 +193,8 @@ function canPay( return invoiceReceiverPolicy?.role === CONST.POLICY.ROLE.ADMIN && reimbursableSpend > 0; } -function canExport(report: Report, currentUserLogin: string, policy?: Policy, reportActions?: ReportAction[]) { - if (isExportInProgress(reportActions)) { +function canExport(report: Report, currentUserLogin: string, policy?: Policy, isExportInProgress = false) { + if (isExportInProgress) { return false; } @@ -243,7 +242,7 @@ function getReportPreviewAction({ isDEWSubmitPending, violationsData, reportMetadata, - reportActions, + isExportInProgress, ownerLogin, rules, }: { @@ -261,7 +260,7 @@ function getReportPreviewAction({ isDEWSubmitPending?: boolean; violationsData?: OnyxCollection; reportMetadata: OnyxEntry; - reportActions?: ReportAction[]; + isExportInProgress?: boolean; ownerLogin: string | undefined; rules: OnyxCollection; }): ValueOf { @@ -295,7 +294,7 @@ function getReportPreviewAction({ if (canPay(report, isReportArchived, currentUserAccountID, currentUserLogin, bankAccountList, transactions, policy, invoiceReceiverPolicy)) { return CONST.REPORT.REPORT_PREVIEW_ACTIONS.PAY; } - if (canExport(report, currentUserLogin, policy, reportActions)) { + if (canExport(report, currentUserLogin, policy, isExportInProgress)) { return CONST.REPORT.REPORT_PREVIEW_ACTIONS.EXPORT_TO_ACCOUNTING; } diff --git a/src/libs/ReportSecondaryActionUtils.ts b/src/libs/ReportSecondaryActionUtils.ts index 819c3feef6fa..4bb1dd619fb0 100644 --- a/src/libs/ReportSecondaryActionUtils.ts +++ b/src/libs/ReportSecondaryActionUtils.ts @@ -8,6 +8,7 @@ import type { Policy, Report, ReportAction, + ReportActions, ReportMetadata, ReportNameValuePairs, Rule, @@ -75,6 +76,7 @@ import { isClosedReport as isClosedReportUtils, isCurrentUserSubmitter, isExpenseReport as isExpenseReportUtils, + isExportInProgress, isExported as isExportedUtils, isHoldCreator, isInvoiceReport as isInvoiceReportUtils, @@ -1247,9 +1249,10 @@ function getReportAccountingExportActions( report: Report, bankAccountList: OnyxEntry, policy?: Policy, + reportActions?: OnyxEntry | ReportAction[], ): Array> { const options: Array> = []; - if (isExportAction(currentUserLogin, report, policy)) { + if (isExportAction(currentUserLogin, report, policy) && !isExportInProgress(reportActions)) { options.push(CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION); } diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 26c406ee212a..e55a0c3c955a 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -14383,8 +14383,8 @@ function isExported(reportActions: OnyxEntry | ReportAction[], re /** * Whether an export this client started is still running. * - * There is no client event meaning "the export finished" - the 200 only means the request was accepted, `failureData` - * never runs, and the real outcome arrives later over Pusher - so the state is resolved by comparison at read time + * There is no client event meaning "the export finished" - the 200 only means the request was accepted, and the real + * outcome arrives later over Pusher - so the state is resolved by comparison at read time * rather than cleared from a callback. Nothing is stored, so no stale flag can strand the button and no other flow * has to remember to clear one. * @@ -14405,7 +14405,8 @@ function isExportInProgress(reportActions: OnyxEntry | ReportActi // One action type covers both halves of an export, and only `pendingAction` separates them: a pending one renders // "started exporting this report to ...", anything else renders a finished export. See getExportIntegrationActionFragments. if (isExportIntegrationAction(action)) { - if (action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD && action.created > lastExportStartedCreated) { + // A rejected request leaves its errors on the started action, and nothing will ever answer it + if (action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD && isEmptyObject(action.errors) && action.created > lastExportStartedCreated) { lastExportStartedCreated = action.created; } continue; diff --git a/tests/actions/ReportPreviewActionUtilsTest.ts b/tests/actions/ReportPreviewActionUtilsTest.ts index c149fa269a21..4ecb5a0ad946 100644 --- a/tests/actions/ReportPreviewActionUtilsTest.ts +++ b/tests/actions/ReportPreviewActionUtilsTest.ts @@ -10,7 +10,7 @@ import {hasOnlyNonReimbursableTransactions, isPayer} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy, Report, ReportAction, Transaction} from '@src/types/onyx'; +import type {Policy, Report, Transaction} from '@src/types/onyx'; import type {NetSuiteConnection} from '@src/types/onyx/Policy'; import Onyx from 'react-native-onyx'; @@ -1541,108 +1541,13 @@ describe('getReportPreviewAction', () => { ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.EXPORT_TO_ACCOUNTING); }); - // A pending EXPORTED_TO_INTEGRATION action is how the client records "started exporting this report". - const buildExportStartedAction = (created: string) => - createMock({ - actionName: CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_INTEGRATION, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - created, - }); - - // The server's answer to an export attempt, success or failure. - const buildExportOutcomeAction = (created: string) => - createMock({ - actionName: CONST.REPORT.ACTIONS.TYPE.INTEGRATIONS_MESSAGE, - created, - }); - - it('canExport should return false while an export started from this client is still running', async () => { - const report = { - ...createRandomReport(REPORT_ID, undefined), - type: CONST.REPORT.TYPE.EXPENSE, - ownerAccountID: CURRENT_USER_ACCOUNT_ID, - statusNum: CONST.REPORT.STATUS_NUM.CLOSED, - isWaitingOnBankAccount: false, - }; - - const policy = createRandomPolicy(0); - policy.type = CONST.POLICY.TYPE.CORPORATE; - policy.connections = {[CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: createMock({})}; - policy.reimbursementChoice = CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO; - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); - const transaction = createMock({ - reportID: `${REPORT_ID}`, - }); - - const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID)); - await waitForBatchedUpdatesWithAct(); - - // Only the pending "started exporting" action is on the report, so nothing has answered it yet. - expect( - getReportPreviewAction({ - isReportArchived: isReportArchived.current, - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - currentUserLogin: CURRENT_USER_EMAIL, - report, - policy, - transactions: [transaction], - bankAccountList: {}, - reportMetadata: undefined, - reportActions: [buildExportStartedAction('2026-10-14 10:00:00.000')], - ownerLogin: CURRENT_USER_EMAIL, - rules: undefined, - }), - ).not.toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.EXPORT_TO_ACCOUNTING); - }); - - it('canExport should return true again once the export fails with a new error', async () => { - const report = { - ...createRandomReport(REPORT_ID, undefined), - type: CONST.REPORT.TYPE.EXPENSE, - ownerAccountID: CURRENT_USER_ACCOUNT_ID, - statusNum: CONST.REPORT.STATUS_NUM.CLOSED, - isWaitingOnBankAccount: false, - errorFields: {export: {exportError: 'export failed'}}, - }; - - const policy = createRandomPolicy(0); - policy.type = CONST.POLICY.TYPE.CORPORATE; - policy.connections = {[CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: createMock({})}; - policy.reimbursementChoice = CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO; - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); - const transaction = createMock({ - reportID: `${REPORT_ID}`, - }); - - const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID)); - await waitForBatchedUpdatesWithAct(); - - // The failure message landed after the export started, so this attempt is resolved and the button comes back. - expect( - getReportPreviewAction({ - isReportArchived: isReportArchived.current, - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - currentUserLogin: CURRENT_USER_EMAIL, - report, - policy, - transactions: [transaction], - bankAccountList: {}, - reportMetadata: undefined, - reportActions: [buildExportStartedAction('2026-10-14 10:00:00.000'), buildExportOutcomeAction('2026-10-14 10:00:05.000')], - ownerLogin: CURRENT_USER_EMAIL, - rules: undefined, - }), - ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.EXPORT_TO_ACCOUNTING); - }); - - it('canExport should stay hidden when only an earlier attempt has an outcome', async () => { + it('canExport should return false while an export is still running', async () => { const report = { ...createRandomReport(REPORT_ID, undefined), type: CONST.REPORT.TYPE.EXPENSE, ownerAccountID: CURRENT_USER_ACCOUNT_ID, statusNum: CONST.REPORT.STATUS_NUM.CLOSED, isWaitingOnBankAccount: false, - errorFields: {export: {exportError: 'export failed'}}, }; const policy = createRandomPolicy(0); @@ -1657,7 +1562,9 @@ describe('getReportPreviewAction', () => { const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID)); await waitForBatchedUpdatesWithAct(); - // Retrying an export on a report that already failed once: the older failure must not resolve the new attempt. + // Given a finished report that would otherwise offer Export + // When an export of it is still running + // Then the preview must not offer a second export expect( getReportPreviewAction({ isReportArchived: isReportArchived.current, @@ -1668,7 +1575,7 @@ describe('getReportPreviewAction', () => { transactions: [transaction], bankAccountList: {}, reportMetadata: undefined, - reportActions: [buildExportOutcomeAction('2026-10-14 09:00:00.000'), buildExportStartedAction('2026-10-14 10:00:00.000')], + isExportInProgress: true, ownerLogin: CURRENT_USER_EMAIL, rules: undefined, }), diff --git a/tests/unit/ReportPrimaryActionUtilsTest.ts b/tests/unit/ReportPrimaryActionUtilsTest.ts index 4fc31cd29725..a00f0eef1ddc 100644 --- a/tests/unit/ReportPrimaryActionUtilsTest.ts +++ b/tests/unit/ReportPrimaryActionUtilsTest.ts @@ -1515,6 +1515,57 @@ describe('getPrimaryAction', () => { ).toBe(CONST.REPORT.PRIMARY_ACTIONS.EXPORT_TO_ACCOUNTING); }); + it('should not return EXPORT TO ACCOUNTING while an export is still running', async () => { + const report = createMock({ + reportID: REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + statusNum: CONST.REPORT.STATUS_NUM.CLOSED, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + const policy = createMock({ + connections: { + intacct: { + config: { + export: { + exporter: CURRENT_USER_EMAIL, + }, + }, + }, + }, + }); + const transaction = createMock({ + reportID: `${REPORT_ID}`, + }); + const exportStartedAction = createMock({ + actionName: CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_INTEGRATION, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + created: '2026-10-14 10:00:00.000', + }); + + jest.mocked(getValidConnectedIntegration).mockReturnValue('netsuite'); + + // Given a finished report the preferred exporter could export + // When an export of it was started and nothing has answered it yet + // Then the header must not offer a second export + expect( + getReportPrimaryAction({ + rules: undefined, + currentUserLogin: CURRENT_USER_EMAIL, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + report, + ownerLogin: '', + chatReport, + reportTransactions: [transaction], + violations: {}, + bankAccountList: {}, + policy, + reportActions: [exportStartedAction], + isChatReportArchived: false, + }), + ).not.toBe(CONST.REPORT.PRIMARY_ACTIONS.EXPORT_TO_ACCOUNTING); + }); + it('should return EXPORT TO ACCOUNTING for admin who is not the preferred exporter', async () => { const report = createMock({ reportID: REPORT_ID, diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index c020188ba3b8..e55a2ff39b5b 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -194,6 +194,7 @@ import { isCurrentUserTheOnlyParticipant, isDeprecatedGroupDM, isEmptyReport, + isExportInProgress, isGroupPolicyExpenseReport, isHarvestCreatedExpenseReport, isJoinRequestInAdminRoom, @@ -29717,3 +29718,79 @@ describe('hasOutstandingChildRequest', () => { expect(hasOutstandingChildRequest(invoiceChatReport, invoiceReportID, currentUserEmail, currentUserAccountID, undefined, undefined)).toBe(false); }); }); + +describe('isExportInProgress', () => { + // A pending EXPORTED_TO_INTEGRATION action is how the client records "started exporting this report" + const buildExportStartedAction = (created: string, errors?: ReportAction['errors']) => + createMock({ + actionName: CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_INTEGRATION, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + created, + errors, + }); + + // The server's answer to an export attempt, success or failure + const buildExportOutcomeAction = (created: string, pendingAction?: ReportAction['pendingAction']) => + createMock({ + actionName: CONST.REPORT.ACTIONS.TYPE.INTEGRATIONS_MESSAGE, + created, + pendingAction, + }); + + it('returns true while nothing has answered the export', () => { + // Given only the pending action the client added when it started the export + const reportActions = [buildExportStartedAction('2026-10-14 10:00:00.000')]; + + // When checking whether the export is running + // Then it is, because the server has not reported an outcome yet + expect(isExportInProgress(reportActions)).toBe(true); + }); + + it('returns false once an outcome arrives after the export started', () => { + // Given the export was answered five seconds after it started + const reportActions = [buildExportStartedAction('2026-10-14 10:00:00.000'), buildExportOutcomeAction('2026-10-14 10:00:05.000')]; + + // When checking whether the export is running + // Then it is not, so the Export button can come back + expect(isExportInProgress(reportActions)).toBe(false); + }); + + it('returns true when only an earlier attempt has an outcome', () => { + // Given a retry on a report whose previous export already failed + const reportActions = [buildExportOutcomeAction('2026-10-14 09:00:00.000'), buildExportStartedAction('2026-10-14 10:00:00.000')]; + + // When checking whether the export is running + // Then the older outcome must not resolve the new attempt + expect(isExportInProgress(reportActions)).toBe(true); + }); + + it('returns false once a reset action follows the export', () => { + // Given the report was unapproved after the export started + const reportActions = [ + buildExportStartedAction('2026-10-14 10:00:00.000'), + createMock({actionName: CONST.REPORT.ACTIONS.TYPE.UNAPPROVED, created: '2026-10-14 10:00:05.000'}), + ]; + + // When checking whether the export is running + // Then it is not, because resetting the approval invalidates the export the same way it does in isExported + expect(isExportInProgress(reportActions)).toBe(false); + }); + + it('ignores an outcome that is still pending', () => { + // Given an outcome action that is this client's own optimistic write + const reportActions = [buildExportStartedAction('2026-10-14 10:00:00.000'), buildExportOutcomeAction('2026-10-14 10:00:05.000', CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD)]; + + // When checking whether the export is running + // Then it still is, because only the server can say how the export ended + expect(isExportInProgress(reportActions)).toBe(true); + }); + + it('returns false when the export request itself failed', () => { + // Given the request was rejected, so failureData left an error on the started action + const reportActions = [buildExportStartedAction('2026-10-14 10:00:00.000', {[Date.now()]: 'error'})]; + + // When checking whether the export is running + // Then it is not, because no outcome will ever arrive and the button must not stay hidden + expect(isExportInProgress(reportActions)).toBe(false); + }); +}); From 4295c643ccae6da32e3aff5cd2b9224ec3e62735 Mon Sep 17 00:00:00 2001 From: FitseTLT Date: Thu, 29 Oct 2026 23:24:16 +0300 Subject: [PATCH 9/9] address comments --- src/libs/ReportUtils.ts | 11 ++++++----- src/libs/actions/Report/index.ts | 5 +++-- tests/unit/ReportUtilsTest.ts | 24 ++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index e55a0c3c955a..0e62881f8483 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -14404,17 +14404,18 @@ function isExportInProgress(reportActions: OnyxEntry | ReportActi for (const action of reportActionList) { // One action type covers both halves of an export, and only `pendingAction` separates them: a pending one renders // "started exporting this report to ...", anything else renders a finished export. See getExportIntegrationActionFragments. - if (isExportIntegrationAction(action)) { + const isExportIntegration = isExportIntegrationAction(action); + if (isExportIntegration && action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) { // A rejected request leaves its errors on the started action, and nothing will ever answer it - if (action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD && isEmptyObject(action.errors) && action.created > lastExportStartedCreated) { + if (isEmptyObject(action.errors) && action.created > lastExportStartedCreated) { lastExportStartedCreated = action.created; } continue; } - // Every outcome ends the attempt, reconciled ones included, unlike `hasExportError` which only counts failures. - // A pending one is this client's own optimistic action, which says nothing about what the server did. - const isOutcome = isIntegrationMessageAction(action) && !action.pendingAction; + // Every outcome ends the attempt, finished exports and reconciled ones included, unlike `hasExportError` which only + // counts failures. A pending one is this client's own optimistic action, which says nothing about what the server did. + const isOutcome = (isExportIntegration || isIntegrationMessageAction(action)) && !action.pendingAction; // Resetting the approval state invalidates the export the same way it invalidates a finished one in `isExported`. if ((isOutcome || resetApprovalActionTypes.has(action.actionName)) && action.created > lastExportResolvedCreated) { lastExportResolvedCreated = action.created; diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 0a3405f5ea72..4ddd1f7ac493 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -85,7 +85,7 @@ import type {LinkToOptions} from '@libs/Navigation/helpers/linkTo/types'; import Navigation from '@libs/Navigation/Navigation'; import REPORT_LINK_ROUTE_PARAMS from '@libs/Navigation/reportLinkRouteParams'; import enhanceParameters from '@libs/Network/enhanceParameters'; -import {getDBTimeWithSkew, getIsOffline as isOfflineNetwork} from '@libs/NetworkState'; +import {getDBTimeWithSkew, getIsOffline as isOfflineNetwork, getServerAnchoredDBTime} from '@libs/NetworkState'; import {buildOptimisticNextStep} from '@libs/NextStepUtils'; import LocalNotification from '@libs/Notification/LocalNotification'; import {rand64} from '@libs/NumberUtils'; @@ -7041,7 +7041,8 @@ function setGroupDraft(newGroupDraft: Partial) { } function exportToIntegration(reportID: string, connectionName: ConnectionName, policy: OnyxEntry) { - const action = buildOptimisticExportIntegrationAction(connectionName, false, getExportLabelForConnection(connectionName, policy)); + // The server stamps the outcome with its own clock, so the start is too, or a device running ahead would sort the outcome first + const action = {...buildOptimisticExportIntegrationAction(connectionName, false, getExportLabelForConnection(connectionName, policy)), created: getServerAnchoredDBTime()}; const optimisticReportActionID = action.reportActionID; const optimisticData: Array> = [ diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index e55a2ff39b5b..7b489c2ee98f 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -29764,6 +29764,30 @@ describe('isExportInProgress', () => { expect(isExportInProgress(reportActions)).toBe(true); }); + it('returns false once a finished export follows the start', () => { + // Given the server recorded the finished export as its own action instead of clearing the started one + const reportActions = [ + buildExportStartedAction('2026-10-14 10:00:00.000'), + createMock({actionName: CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_INTEGRATION, created: '2026-10-14 10:00:05.000'}), + ]; + + // When checking whether the export is running + // Then it is not, because a successful export sends no failure message and would otherwise keep More → Export hidden + expect(isExportInProgress(reportActions)).toBe(false); + }); + + it('returns true when only an earlier export finished', () => { + // Given a re-export of a report that was already exported successfully + const reportActions = [ + createMock({actionName: CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_INTEGRATION, created: '2026-10-14 09:00:00.000'}), + buildExportStartedAction('2026-10-14 10:00:00.000'), + ]; + + // When checking whether the export is running + // Then the older export must not resolve the new attempt + expect(isExportInProgress(reportActions)).toBe(true); + }); + it('returns false once a reset action follows the export', () => { // Given the report was unapproved after the export started const reportActions = [