From 8812cf06c05de384ad36b0fe25a63f395b0ff486 Mon Sep 17 00:00:00 2001 From: Nabi Ebrahimi Date: Mon, 11 May 2026 19:49:00 +0430 Subject: [PATCH 1/6] fix: prevent submitter from retaining edit access after workflow forwardsTo becomes submitsTo --- src/libs/ReportUtils.ts | 18 +++++++-- tests/unit/ReportUtilsTest.ts | 76 +++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 1480bf4a695c..c7a578f639ac 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -212,6 +212,7 @@ import { isIntegrationMessageAction, isModifiedExpenseAction, isMoneyRequestAction, + isForwardedAction, isMovedAction, isPendingRemove, isPolicyChangeLogAction, @@ -2027,6 +2028,17 @@ function requiresManualSubmission(report: OnyxEntry, policy: OnyxEntry

): boolean { + if (!report?.reportID) { + return false; + } + + const reportActions = Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`] ?? {}); + const lastSubmittedAt = reportActions.filter(isSubmittedAction).reduce((latest, action) => (action.created > latest ? action.created : latest), ''); + + return reportActions.some((action) => isForwardedAction(action) && action.created > lastSubmittedAt); +} + function isAwaitingFirstLevelApproval(report: OnyxEntry): boolean { if (!report) { return false; @@ -2035,7 +2047,7 @@ function isAwaitingFirstLevelApproval(report: OnyxEntry): boolean { // This will be fixed as part of https://github.com/Expensify/Expensify/issues/507850 const submitsToAccountID = getSubmitToAccountID(getPolicy(report.policyID), report); - return isProcessingReport(report) && submitsToAccountID === report.managerID; + return isProcessingReport(report) && submitsToAccountID === report.managerID && !hasReportBeenForwardedSinceLastSubmit(report); } /** @@ -4708,8 +4720,8 @@ function canEditMoneyRequest( } if (reportPolicy?.type === CONST.POLICY.TYPE.CORPORATE && moneyRequestReport && isSubmitted && isCurrentUserSubmitter(moneyRequestReport)) { - const isForwarded = getSubmitToAccountID(reportPolicy, moneyRequestReport) !== moneyRequestReport.managerID; - return !isForwarded; + const hasLivePolicyForwardedManager = getSubmitToAccountID(reportPolicy, moneyRequestReport) !== moneyRequestReport.managerID; + return !hasLivePolicyForwardedManager && !hasReportBeenForwardedSinceLastSubmit(moneyRequestReport); } return !isReportApproved({report: moneyRequestReport}) && !isSettled(moneyRequestReport?.reportID) && !isClosedReport(moneyRequestReport) && isRequestor; diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 6478f908203a..70c1691aed4e 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -55,6 +55,7 @@ import { canDeleteReportAction, canDeleteTransaction, canEditMoneyRequest, + canEditReportPolicy, canEditReportAction, canEditReportDescription, canEditReportTitle, @@ -5525,6 +5526,81 @@ describe('ReportUtils', () => { expect(canEditRequest).toEqual(false); }); + + it('should return false for the submitter after a corporate report was forwarded even when the updated workflow points submitsTo at the current manager', async () => { + const reportID = '89012'; + const transactionID = '89012-transaction'; + const policyID = '89012-policy'; + const forwardedToAccountID = 2; + + const reportPolicy: Policy = { + id: policyID, + name: 'Advanced approval policy', + role: CONST.POLICY.ROLE.USER, + type: CONST.POLICY.TYPE.CORPORATE, + owner: '', + outputCurrency: CONST.CURRENCY.USD, + isPolicyExpenseChatEnabled: false, + employeeList: { + 'lagertha2@vikings.net': { + email: 'lagertha2@vikings.net', + role: CONST.POLICY.ROLE.USER, + submitsTo: 'floki@vikings.net', + }, + }, + }; + const expenseReport: Report = { + ...createExpenseReport(Number(reportID)), + reportID, + policyID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: currentUserAccountID, + managerID: forwardedToAccountID, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + }; + const transaction = { + ...createRandomTransaction(Number(transactionID)), + transactionID, + reportID, + }; + const moneyRequestAction: ReportAction = { + ...createRandomReportAction(89012), + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + actorAccountID: currentUserAccountID, + originalMessage: { + IOUReportID: reportID, + IOUTransactionID: transactionID, + amount: 5000, + currency: CONST.CURRENCY.USD, + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + }, + }; + + await Onyx.multiSet({ + [ONYXKEYS.PERSONAL_DETAILS_LIST]: participantsPersonalDetails, + [ONYXKEYS.SESSION]: {email: currentUserEmail, accountID: currentUserAccountID}, + [`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]: reportPolicy, + [`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]: expenseReport, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`]: { + submitted: { + ...createRandomReportAction(89013), + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + created: '2026-04-21 17:00:00', + }, + forwarded: { + ...createRandomReportAction(89014), + actionName: CONST.REPORT.ACTIONS.TYPE.FORWARDED, + created: '2026-04-21 17:10:00', + }, + }, + }); + await waitForBatchedUpdates(); + + expect(canEditReportPolicy(expenseReport, reportPolicy)).toBe(false); + expect(canEditMoneyRequest(moneyRequestAction, transaction, false, expenseReport, reportPolicy)).toBe(false); + }); }); describe('canEditReportAction', () => { From f5edb034d7e6a487d9f75224bf342dd84f152e2c Mon Sep 17 00:00:00 2001 From: Nabi Date: Tue, 12 May 2026 00:16:40 +0100 Subject: [PATCH 2/6] Fixed --- src/libs/ReportActionsUtils.ts | 1 + src/libs/ReportUtils.ts | 4 ++-- tests/unit/ReportUtilsTest.ts | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index fcfa8f5f52f7..437c80f51afb 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -4668,6 +4668,7 @@ export { isDynamicExternalWorkflowSubmitAction, isMarkAsClosedAction, isApprovedAction, + isForwardedAction, isDynamicExternalWorkflowForwardedAction, isUnapprovedAction, isDynamicExternalWorkflowSubmitFailedAction, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 165b2e13abdc..24971cd331df 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -209,10 +209,10 @@ import { isDynamicExternalWorkflowApproveFailedAction, isDynamicExternalWorkflowSubmitFailedAction, isExportIntegrationAction, + isForwardedAction, isIntegrationMessageAction, isModifiedExpenseAction, isMoneyRequestAction, - isForwardedAction, isMovedAction, isPendingRemove, isPolicyChangeLogAction, @@ -2034,7 +2034,7 @@ function hasReportBeenForwardedSinceLastSubmit(report: OnyxEntry): boole } const reportActions = Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`] ?? {}); - const lastSubmittedAt = reportActions.filter(isSubmittedAction).reduce((latest, action) => (action.created > latest ? action.created : latest), ''); + const lastSubmittedAt = reportActions.filter(isSubmittedAction).reduce((latest, action) => (action.created > latest ? action.created : latest), ''); return reportActions.some((action) => isForwardedAction(action) && action.created > lastSubmittedAt); } diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 70c1691aed4e..9dfc59a8fb42 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -5560,7 +5560,7 @@ describe('ReportUtils', () => { statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, }; const transaction = { - ...createRandomTransaction(Number(transactionID)), + ...createRandomTransaction(89012), transactionID, reportID, }; From 579d8df1ea490f9b3c9a7ccf4ec94d3f5bd9c6e2 Mon Sep 17 00:00:00 2001 From: Nabi Date: Wed, 13 May 2026 06:22:53 +0100 Subject: [PATCH 3/6] Updated tests/unit/ReportUtilsTest.ts to fix both type errors in the forwarded corporate expense test. --- tests/unit/ReportUtilsTest.ts | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 9dfc59a8fb42..10416192ffc7 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -187,7 +187,11 @@ import type { import type {ErrorFields, Errors, OnyxValueWithOfflineFeedback} from '@src/types/onyx/OnyxCommon'; import type {JoinWorkspaceResolution} from '@src/types/onyx/OriginalMessage'; import type {ACHAccount, PolicyReportField} from '@src/types/onyx/Policy'; +import type {ReportActionsCollectionDataSet} from '@src/types/onyx/ReportAction'; import type {Participant, Participants} from '@src/types/onyx/Report'; +import type {ReportCollectionDataSet} from '@src/types/onyx/Report'; +import type {TransactionCollectionDataSet} from '@src/types/onyx/Transaction'; +import type CollectionDataSet from '@src/types/utils/CollectionDataSet'; import {toCollectionDataSet} from '@src/types/utils/CollectionDataSet'; import type IconAsset from '@src/types/utils/IconAsset'; import {actionR14932 as mockIOUAction} from '../../__mocks__/reportData/actions'; @@ -5568,6 +5572,8 @@ describe('ReportUtils', () => { ...createRandomReportAction(89012), actionName: CONST.REPORT.ACTIONS.TYPE.IOU, actorAccountID: currentUserAccountID, + message: [{type: CONST.REPORT.MESSAGE.TYPE.TEXT, text: ''}], + previousMessage: undefined, originalMessage: { IOUReportID: reportID, IOUTransactionID: transactionID, @@ -5577,12 +5583,16 @@ describe('ReportUtils', () => { }, }; - await Onyx.multiSet({ - [ONYXKEYS.PERSONAL_DETAILS_LIST]: participantsPersonalDetails, - [ONYXKEYS.SESSION]: {email: currentUserEmail, accountID: currentUserAccountID}, + const policyCollectionDataSet: CollectionDataSet = { [`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]: reportPolicy, + }; + const reportCollectionDataSet: ReportCollectionDataSet = { [`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]: expenseReport, + }; + const transactionCollectionDataSet: TransactionCollectionDataSet = { [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, + }; + const reportActionsCollectionDataSet: ReportActionsCollectionDataSet = { [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`]: { submitted: { ...createRandomReportAction(89013), @@ -5595,6 +5605,15 @@ describe('ReportUtils', () => { created: '2026-04-21 17:10:00', }, }, + }; + + await Onyx.multiSet({ + [ONYXKEYS.PERSONAL_DETAILS_LIST]: participantsPersonalDetails, + [ONYXKEYS.SESSION]: {email: currentUserEmail, accountID: currentUserAccountID}, + ...policyCollectionDataSet, + ...reportCollectionDataSet, + ...transactionCollectionDataSet, + ...reportActionsCollectionDataSet, }); await waitForBatchedUpdates(); From 4ed1c03b23565c98836f5930c681ef25cc871694 Mon Sep 17 00:00:00 2001 From: Nabi Date: Wed, 13 May 2026 06:25:46 +0100 Subject: [PATCH 4/6] fixed prettier --- tests/unit/ReportUtilsTest.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 10416192ffc7..adfea9b7914e 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -55,9 +55,9 @@ import { canDeleteReportAction, canDeleteTransaction, canEditMoneyRequest, - canEditReportPolicy, canEditReportAction, canEditReportDescription, + canEditReportPolicy, canEditReportTitle, canEditRoomVisibility, canEditWriteCapability, @@ -187,9 +187,9 @@ import type { import type {ErrorFields, Errors, OnyxValueWithOfflineFeedback} from '@src/types/onyx/OnyxCommon'; import type {JoinWorkspaceResolution} from '@src/types/onyx/OriginalMessage'; import type {ACHAccount, PolicyReportField} from '@src/types/onyx/Policy'; -import type {ReportActionsCollectionDataSet} from '@src/types/onyx/ReportAction'; import type {Participant, Participants} from '@src/types/onyx/Report'; import type {ReportCollectionDataSet} from '@src/types/onyx/Report'; +import type {ReportActionsCollectionDataSet} from '@src/types/onyx/ReportAction'; import type {TransactionCollectionDataSet} from '@src/types/onyx/Transaction'; import type CollectionDataSet from '@src/types/utils/CollectionDataSet'; import {toCollectionDataSet} from '@src/types/utils/CollectionDataSet'; From 45c594c13ef07385ae00a9e8609d8bca4b4d94b3 Mon Sep 17 00:00:00 2001 From: Nabi Date: Wed, 13 May 2026 07:10:39 +0100 Subject: [PATCH 5/6] fixed EsLint --- tests/unit/ReportUtilsTest.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index adfea9b7914e..58007dc9eb2b 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -187,8 +187,7 @@ import type { import type {ErrorFields, Errors, OnyxValueWithOfflineFeedback} from '@src/types/onyx/OnyxCommon'; import type {JoinWorkspaceResolution} from '@src/types/onyx/OriginalMessage'; import type {ACHAccount, PolicyReportField} from '@src/types/onyx/Policy'; -import type {Participant, Participants} from '@src/types/onyx/Report'; -import type {ReportCollectionDataSet} from '@src/types/onyx/Report'; +import type {Participant, Participants, ReportCollectionDataSet} from '@src/types/onyx/Report'; import type {ReportActionsCollectionDataSet} from '@src/types/onyx/ReportAction'; import type {TransactionCollectionDataSet} from '@src/types/onyx/Transaction'; import type CollectionDataSet from '@src/types/utils/CollectionDataSet'; From 26b4a2fb2ed0e9b4d6101d9025c28c5b18c5ffe1 Mon Sep 17 00:00:00 2001 From: Nabi Date: Tue, 19 May 2026 15:16:58 +0100 Subject: [PATCH 6/6] Refactor corporate forwarding check by introducing a clearer `isForwarded` condition. --- src/libs/ReportUtils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index fdfe9dce0f32..cacfbb1c5cd1 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -4722,8 +4722,8 @@ function canEditMoneyRequest( } if (reportPolicy?.type === CONST.POLICY.TYPE.CORPORATE && moneyRequestReport && isSubmitted && isCurrentUserSubmitter(moneyRequestReport)) { - const hasLivePolicyForwardedManager = getSubmitToAccountID(reportPolicy, moneyRequestReport) !== moneyRequestReport.managerID; - return !hasLivePolicyForwardedManager && !hasReportBeenForwardedSinceLastSubmit(moneyRequestReport); + const isForwarded = getSubmitToAccountID(reportPolicy, moneyRequestReport) !== moneyRequestReport.managerID || hasReportBeenForwardedSinceLastSubmit(moneyRequestReport); + return !isForwarded; } return !isReportApproved({report: moneyRequestReport}) && !isSettled(moneyRequestReport) && !isClosedReport(moneyRequestReport) && isRequestor;