Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 [isExpenseReportExportInProgress] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, {selector: isExportInProgress});
const [ownerLogin] = usePersonalDetail(iouReport?.ownerAccountID, loginSelector);
const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE);

Expand Down Expand Up @@ -97,6 +98,7 @@ function useReportPreviewActionDecision({
isDEWSubmitPending,
violationsData: transactionViolations,
reportMetadata: iouReportMetadata,
isExportInProgress: isExpenseReportExportInProgress,
ownerLogin,
rules,
});
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/useExportActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions src/libs/ReportPreviewActionUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,11 @@ function canPay(
return invoiceReceiverPolicy?.role === CONST.POLICY.ROLE.ADMIN && reimbursableSpend > 0;
}

function canExport(report: Report, currentUserLogin: string, policy?: Policy, transactions?: Transaction[]) {
function canExport(report: Report, currentUserLogin: string, policy?: Policy, transactions?: Transaction[], isExportInProgress = false) {
if (isExportInProgress) {
return false;
}

const isExpense = isExpenseReport(report);
const isExporter = policy ? isPreferredExporter(policy, currentUserLogin) : false;
const isReimbursed = isSettled(report);
Expand Down Expand Up @@ -246,6 +250,7 @@ function getReportPreviewAction({
isDEWSubmitPending,
violationsData,
reportMetadata,
isExportInProgress,
ownerLogin,
rules,
}: {
Expand All @@ -263,6 +268,7 @@ function getReportPreviewAction({
isDEWSubmitPending?: boolean;
violationsData?: OnyxCollection<TransactionViolation[]>;
reportMetadata: OnyxEntry<ReportMetadata>;
isExportInProgress?: boolean;
ownerLogin: string | undefined;
rules: OnyxCollection<Rule>;
}): ValueOf<typeof CONST.REPORT.REPORT_PREVIEW_ACTIONS> {
Expand Down Expand Up @@ -296,7 +302,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, transactions)) {
if (canExport(report, currentUserLogin, policy, transactions, isExportInProgress)) {
return CONST.REPORT.REPORT_PREVIEW_ACTIONS.EXPORT_TO_ACCOUNTING;
}

Expand Down
5 changes: 5 additions & 0 deletions src/libs/ReportPrimaryActionUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {
isReportApproved as isReportApprovedUtils,
isReportManager,
isSettled,
isExportInProgress,
} from './ReportUtils';
import {
allHavePendingRTERViolation,
Expand Down Expand Up @@ -301,6 +302,10 @@ function isExportAction(report: Report, currentUserLogin: string, policy?: Polic
return false;
}

if (isExportInProgress(reportActions)) {
return false;
}

const connectedIntegration = getValidConnectedIntegration(policy);
const isInvoiceReport = isInvoiceReportUtils(report);

Expand Down
4 changes: 3 additions & 1 deletion src/libs/ReportSecondaryActionUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ import {
isClosedReport as isClosedReportUtils,
isCurrentUserSubmitter,
isExpenseReport as isExpenseReportUtils,
isExportInProgress,
isExported as isExportedUtils,
isHoldCreator,
isInvoiceReport as isInvoiceReportUtils,
Expand Down Expand Up @@ -1248,9 +1249,10 @@ function getReportAccountingExportActions(
report: Report,
bankAccountList: OnyxEntry<BankAccountList>,
policy?: Policy,
reportActions?: OnyxEntry<ReportActions> | ReportAction[],
): Array<ValueOf<typeof CONST.REPORT.EXPORT_OPTIONS>> {
const options: Array<ValueOf<typeof CONST.REPORT.EXPORT_OPTIONS>> = [];
if (isExportAction(currentUserLogin, report, policy)) {
if (isExportAction(currentUserLogin, report, policy) && !isExportInProgress(reportActions)) {
options.push(CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION);
}

Expand Down
46 changes: 46 additions & 0 deletions src/libs/ReportUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14525,6 +14525,51 @@ function isExported(reportActions: OnyxEntry<ReportActions> | 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, 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.
*
* 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(reportActions: OnyxEntry<ReportActions> | ReportAction[]): boolean {
const reportActionList = Array.isArray(reportActions) ? reportActions : Object.values(reportActions ?? {});

if (reportActionList.length === 0) {
return false;
}

let lastExportStartedCreated = '';
let lastExportResolvedCreated = '';

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.
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 (isEmptyObject(action.errors) && action.created > lastExportStartedCreated) {
lastExportStartedCreated = action.created;
}
continue;
}

// 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 || RESET_APPROVAL_ACTION_TYPES.has(action.actionName)) && action.created > lastExportResolvedCreated) {
lastExportResolvedCreated = action.created;
}
}

return !!lastExportStartedCreated && lastExportResolvedCreated < lastExportStartedCreated;
}

function hasExportError(reportActions: OnyxEntry<ReportActions> | ReportAction[], report?: OnyxEntry<Report>) {
if (report?.hasExportError) {
return true;
Expand Down Expand Up @@ -15902,6 +15947,7 @@ export {
isQueuedForExport,
hasExpensifyGuidesEmails,
hasExportError,
isExportInProgress,
hasOnlyNonReimbursableTransactions,
isCardOnlyReport,
hasSettledZeroReimbursableSpend,
Expand Down
24 changes: 5 additions & 19 deletions src/libs/actions/Report/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -7060,28 +7060,21 @@ function setGroupDraft(newGroupDraft: Partial<NewGroupChatDraft>) {
}

function exportToIntegration(reportID: string, connectionName: ConnectionName, policy: OnyxEntry<Policy>) {
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 previousExportedValue = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]?.isExportedToIntegration;

const optimisticData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS | typeof ONYXKEYS.COLLECTION.REPORT>> = [
const optimisticData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS>> = [
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
value: {
[optimisticReportActionID]: action,
},
},
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
value: {
isExportedToIntegration: true,
},
},
];

const failureData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS | typeof ONYXKEYS.COLLECTION.REPORT>> = [
const failureData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS>> = [
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
Expand All @@ -7091,13 +7084,6 @@ function exportToIntegration(reportID: string, connectionName: ConnectionName, p
},
},
},
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
value: {
isExportedToIntegration: previousExportedValue,
},
},
];

const params = {
Expand Down
41 changes: 41 additions & 0 deletions tests/actions/ReportPreviewActionUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1541,6 +1541,47 @@ describe('getReportPreviewAction', () => {
).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.EXPORT_TO_ACCOUNTING);
});

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,
};

const policy = createRandomPolicy(0);
policy.type = CONST.POLICY.TYPE.CORPORATE;
policy.connections = {[CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: createMock<NetSuiteConnection>({})};
policy.reimbursementChoice = CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO;
await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report);
const transaction = createMock<Transaction>({
reportID: `${REPORT_ID}`,
});

const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID));
await waitForBatchedUpdatesWithAct();

// 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,
currentUserAccountID: CURRENT_USER_ACCOUNT_ID,
currentUserLogin: CURRENT_USER_EMAIL,
report,
policy,
transactions: [transaction],
bankAccountList: {},
reportMetadata: undefined,
isExportInProgress: true,
ownerLogin: CURRENT_USER_EMAIL,
rules: undefined,
}),
).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
Expand Down
51 changes: 51 additions & 0 deletions tests/unit/ReportPrimaryActionUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Report>({
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<Policy>({
connections: {
intacct: {
config: {
export: {
exporter: CURRENT_USER_EMAIL,
},
},
},
},
});
const transaction = createMock<Transaction>({
reportID: `${REPORT_ID}`,
});
const exportStartedAction = createMock<ReportAction>({
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<Report>({
reportID: REPORT_ID,
Expand Down
Loading
Loading