diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 0aea13ae7b32..0930f5e927dd 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -10,6 +10,7 @@ import ROUTES from '@src/ROUTES'; import INPUT_IDS from '@src/types/form/NetSuiteCustomFieldForm'; import type { OnyxInputOrEntry, + Pages, PersonalDetailsList, Policy, PolicyCategories, @@ -17,6 +18,7 @@ import type { PolicyTagLists, PolicyTags, Report, + ReportActions, TaxRate, Transaction, TravelSettings, @@ -41,6 +43,7 @@ import type { Tenant, } from '@src/types/onyx/Policy'; import type PolicyEmployee from '@src/types/onyx/PolicyEmployee'; +import type ReportAction from '@src/types/onyx/ReportAction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {getBankAccountFromID} from './actions/BankAccounts'; import {hasSynchronizationErrorMessage, isConnectionUnverified} from './actions/connections'; @@ -1244,12 +1247,50 @@ function getSubmitToAccountID(policy: OnyxEntry, expenseReport: OnyxEntr return getManagerAccountID(policy, expenseReport); } -function getSubmitReportManagerAccountID(policy: OnyxEntry, expenseReport: OnyxEntry): number { +function isReportLevelApproverOverrideAction(reportAction: OnyxEntry): boolean { + return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.TAKE_CONTROL || reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REROUTE; +} + +function isSubmittedOrReportLevelApproverOverrideAction(reportAction: OnyxEntry): boolean { + return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.SUBMITTED || isReportLevelApproverOverrideAction(reportAction); +} + +function getActiveReportLevelApproverOverrideStatus(reportActions: ReportActions | undefined, reportActionPages?: Pages): boolean | undefined { + const newestActionPage = reportActionPages?.find((page) => page.at(0) === CONST.PAGINATION_START_ID); + if (!newestActionPage) { + return; + } + + for (const reportActionID of newestActionPage) { + if (reportActionID === CONST.PAGINATION_START_ID || reportActionID === CONST.PAGINATION_END_ID) { + continue; + } + + const reportAction = reportActions?.[reportActionID]; + if (!reportAction) { + return; + } + + if (!isSubmittedOrReportLevelApproverOverrideAction(reportAction)) { + continue; + } + + return isReportLevelApproverOverrideAction(reportAction); + } + + return newestActionPage.includes(CONST.PAGINATION_END_ID) ? false : undefined; +} + +function getSubmitReportManagerAccountID(policy: OnyxEntry, expenseReport: OnyxEntry, reportActions?: ReportActions, reportActionPages?: Pages): number { const existingManagerID = expenseReport?.managerID ?? CONST.DEFAULT_NUMBER_ID; const ownerAccountID = expenseReport?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID; + const employeeLogin = getLoginByAccountID(ownerAccountID) ?? ''; + const hasEmployeeData = !!policy?.employeeList?.[employeeLogin]; + const hasValidExistingManager = existingManagerID > CONST.DEFAULT_NUMBER_ID && existingManagerID !== ownerAccountID; + const activeReportLevelApproverOverride = getActiveReportLevelApproverOverrideStatus(reportActions, reportActionPages); - if (existingManagerID > CONST.DEFAULT_NUMBER_ID && existingManagerID !== ownerAccountID) { - // Existing reports may already have a server-computed or manually changed approver. + if (hasValidExistingManager && (!hasEmployeeData || activeReportLevelApproverOverride !== false)) { + // Preserve known-good report managers when policy employee data is missing, when report action history may be incomplete, or when the report was explicitly rerouted. return existingManagerID; } diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 5bed9598ed7b..8965295d2061 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -20,6 +20,7 @@ import type { CompanyCardFeed, OnyxInputOrEntry, OriginalMessageIOU, + Pages, PersonalDetails, PersonalDetailsList, Policy, @@ -103,6 +104,18 @@ Onyx.connect({ }, }); +let allReportActionPages: OnyxCollection; +Onyx.connectWithoutView({ + key: ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES, + waitForCollectionCallback: true, + callback: (pages) => { + if (!pages) { + return; + } + allReportActionPages = pages; + }, +}); + let allReports: OnyxCollection; Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT, @@ -2008,6 +2021,10 @@ function getAllReportActions(reportID: string | undefined): ReportActions { return allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] ?? {}; } +function getReportActionPages(reportID: string | undefined): Pages { + return allReportActionPages?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES}${reportID}`] ?? []; +} + /** * Check whether a report action is an attachment (a file, such as an image or a zip). * @@ -4557,6 +4574,7 @@ export { getHtmlWithAttachmentID, getActionableMentionWhisperMessage, getAllReportActions, + getReportActionPages, getCombinedReportActions, getDismissedViolationMessageText, getFirstVisibleReportActionID, diff --git a/src/libs/actions/IOU/ReportWorkflow.ts b/src/libs/actions/IOU/ReportWorkflow.ts index 08527107754b..b167ab11067b 100644 --- a/src/libs/actions/IOU/ReportWorkflow.ts +++ b/src/libs/actions/IOU/ReportWorkflow.ts @@ -17,7 +17,7 @@ import Navigation from '@libs/Navigation/Navigation'; import {getIsOffline} from '@libs/NetworkState'; import {buildNextStepNew, buildOptimisticNextStep} from '@libs/NextStepUtils'; import {arePaymentsEnabled, getSubmitReportManagerAccountID, hasDynamicExternalWorkflow, isPaidGroupPolicy, isPolicyAdmin, isSubmitAndClose} from '@libs/PolicyUtils'; -import {getAllReportActions, getReportActionHtml, getReportActionText, hasPendingDEWApprove, isCreatedAction, isDeletedAction} from '@libs/ReportActionsUtils'; +import {getAllReportActions, getReportActionHtml, getReportActionPages, getReportActionText, hasPendingDEWApprove, isCreatedAction, isDeletedAction} from '@libs/ReportActionsUtils'; import { buildOptimisticApprovedReportAction, buildOptimisticChangeApproverReportAction, @@ -1299,7 +1299,7 @@ function submitReport({ isASAPSubmitBetaEnabled, isUnapprove: true, }); - const managerID = getSubmitReportManagerAccountID(policy, expenseReport); + const managerID = getSubmitReportManagerAccountID(policy, expenseReport, getAllReportActions(expenseReport.reportID), getReportActionPages(expenseReport.reportID)); const optimisticData: Array< OnyxUpdate diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index b10a6c9b5699..be57e1d6c4a6 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -30,6 +30,7 @@ import enhanceParameters from '@libs/Network/enhanceParameters'; import {rand64} from '@libs/NumberUtils'; import {getActivePaymentType} from '@libs/PaymentUtils'; import {getSubmitReportManagerAccountID, getValidConnectedIntegration, isDelayedSubmissionEnabled} from '@libs/PolicyUtils'; +import {getAllReportActions, getReportActionPages} from '@libs/ReportActionsUtils'; import type {OptimisticExportIntegrationAction} from '@libs/ReportUtils'; import { buildOptimisticExportIntegrationAction, @@ -661,7 +662,7 @@ function submitMoneyRequestOnSearch(hash: number, reportList: Report[], policy: const report = (reportList.at(0) ?? {}) as Report; const parameters: SubmitReportParams = { reportID: report.reportID, - managerAccountID: getSubmitReportManagerAccountID(policy.at(0), report), + managerAccountID: getSubmitReportManagerAccountID(policy.at(0), report, getAllReportActions(report.reportID), getReportActionPages(report.reportID)), reportActionID: rand64(), }; diff --git a/tests/actions/IOUTest/ReportWorkflowTest.ts b/tests/actions/IOUTest/ReportWorkflowTest.ts index c0af883349ad..13aa4c6615b8 100644 --- a/tests/actions/IOUTest/ReportWorkflowTest.ts +++ b/tests/actions/IOUTest/ReportWorkflowTest.ts @@ -1252,9 +1252,429 @@ describe('actions/IOU/ReportWorkflow', () => { apiWriteSpy.mockRestore(); }); + it('uses the updated policy approver when employee data is available', async () => { + // eslint-disable-next-line rulesdir/no-multiple-api-calls -- Inspecting API.write calls to verify submit payload and optimistic data. + const apiWriteSpy = jest.spyOn(API, 'write').mockImplementation(() => Promise.resolve()); + apiWriteSpy.mockClear(); + const policyID = '1'; + const adminAccountID = 100; + const submitterAccountID = 101; + const previousApproverAccountID = 102; + const adminEmail = 'admin@example.com'; + const submitterEmail = 'submitter@example.com'; + const previousApproverEmail = 'previous-approver@example.com'; + + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, { + [adminAccountID]: {accountID: adminAccountID, login: adminEmail}, + [submitterAccountID]: {accountID: submitterAccountID, login: submitterEmail}, + [previousApproverAccountID]: {accountID: previousApproverAccountID, login: previousApproverEmail}, + }); + + const policy: Policy = { + ...createRandomPolicy(Number(policyID)), + id: policyID, + role: CONST.POLICY.ROLE.ADMIN, + type: CONST.POLICY.TYPE.CORPORATE, + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + approver: adminEmail, + owner: adminEmail, + employeeList: { + [submitterEmail]: { + email: submitterEmail, + submitsTo: adminEmail, + }, + }, + }; + + const expenseReport: Report = { + ...createRandomReport(Number(policyID), undefined), + reportID: '1', + policyID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: submitterAccountID, + managerID: previousApproverAccountID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + total: 1000, + currency: CONST.CURRENCY.USD, + }; + + // Loaded report actions with no explicit override allow the manager to be recomputed from current policy data. + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, { + createdAction: { + reportActionID: 'createdAction', + actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, + actorAccountID: submitterAccountID, + created: '2026-05-01T09:00:00.000Z', + }, + } as ReportActions); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES}${expenseReport.reportID}`, [[CONST.PAGINATION_START_ID, 'createdAction', CONST.PAGINATION_END_ID]]); + await waitForBatchedUpdates(); + + submitReport({ + expenseReport, + policy, + currentUserAccountIDParam: adminAccountID, + currentUserEmailParam: adminEmail, + hasViolations: false, + isASAPSubmitBetaEnabled: false, + expenseReportCurrentNextStepDeprecated: undefined, + userBillingGracePeriodEnds: undefined, + amountOwed: 0, + ownerBillingGracePeriodEnd: undefined, + delegateEmail: undefined, + }); + + const [, parameters, onyxData] = apiWriteSpy.mock.calls.at(-1) as [unknown, {managerAccountID?: number}, OnyxData]; + expect(parameters.managerAccountID).toBe(adminAccountID); + + const optimisticReportUpdate = onyxData.optimisticData?.find((update) => update.key === `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`); + expect((optimisticReportUpdate?.value as Report | undefined)?.managerID).toBe(adminAccountID); + + apiWriteSpy.mockRestore(); + }); + + it('preserves an active report-level approver override when submitting', async () => { + // eslint-disable-next-line rulesdir/no-multiple-api-calls -- Inspecting API.write calls to verify submit payload and optimistic data. + const apiWriteSpy = jest.spyOn(API, 'write').mockImplementation(() => Promise.resolve()); + apiWriteSpy.mockClear(); + const policyID = '1'; + const adminAccountID = 100; + const submitterAccountID = 101; + const policyApproverAccountID = 102; + const manualApproverAccountID = 103; + const adminEmail = 'admin@example.com'; + const submitterEmail = 'submitter@example.com'; + const policyApproverEmail = 'policy-approver@example.com'; + const manualApproverEmail = 'manual-approver@example.com'; + + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, { + [adminAccountID]: {accountID: adminAccountID, login: adminEmail}, + [submitterAccountID]: {accountID: submitterAccountID, login: submitterEmail}, + [policyApproverAccountID]: {accountID: policyApproverAccountID, login: policyApproverEmail}, + [manualApproverAccountID]: {accountID: manualApproverAccountID, login: manualApproverEmail}, + }); + + const policy: Policy = { + ...createRandomPolicy(Number(policyID)), + id: policyID, + role: CONST.POLICY.ROLE.ADMIN, + type: CONST.POLICY.TYPE.CORPORATE, + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + approver: adminEmail, + owner: adminEmail, + employeeList: { + [submitterEmail]: { + email: submitterEmail, + submitsTo: policyApproverEmail, + }, + }, + }; + + const expenseReport: Report = { + ...createRandomReport(Number(policyID), undefined), + reportID: '1', + policyID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: submitterAccountID, + managerID: manualApproverAccountID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + total: 1000, + currency: CONST.CURRENCY.USD, + }; + + // A reroute action after the latest submission marks the report manager as an active explicit override. + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, { + rerouteAction: { + reportActionID: 'rerouteAction', + actionName: CONST.REPORT.ACTIONS.TYPE.REROUTE, + actorAccountID: adminAccountID, + created: '2026-05-01T10:00:00.000Z', + }, + } as ReportActions); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES}${expenseReport.reportID}`, [[CONST.PAGINATION_START_ID, 'rerouteAction', CONST.PAGINATION_END_ID]]); + await waitForBatchedUpdates(); + + submitReport({ + expenseReport, + policy, + currentUserAccountIDParam: adminAccountID, + currentUserEmailParam: adminEmail, + hasViolations: false, + isASAPSubmitBetaEnabled: false, + expenseReportCurrentNextStepDeprecated: undefined, + userBillingGracePeriodEnds: undefined, + amountOwed: 0, + ownerBillingGracePeriodEnd: undefined, + delegateEmail: undefined, + }); + + const [, parameters, onyxData] = apiWriteSpy.mock.calls.at(-1) as [unknown, {managerAccountID?: number}, OnyxData]; + expect(parameters.managerAccountID).toBe(manualApproverAccountID); + + const optimisticReportUpdate = onyxData.optimisticData?.find((update) => update.key === `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`); + expect((optimisticReportUpdate?.value as Report | undefined)?.managerID).toBe(manualApproverAccountID); + + apiWriteSpy.mockRestore(); + }); + + it('preserves the existing report manager when report action history is unavailable', async () => { + // eslint-disable-next-line rulesdir/no-multiple-api-calls -- Inspecting API.write calls to verify submit payload and optimistic data. + const apiWriteSpy = jest.spyOn(API, 'write').mockImplementation(() => Promise.resolve()); + apiWriteSpy.mockClear(); + const policyID = '1'; + const adminAccountID = 100; + const submitterAccountID = 101; + const policyApproverAccountID = 102; + const existingManagerAccountID = 103; + const adminEmail = 'admin@example.com'; + const submitterEmail = 'submitter@example.com'; + const policyApproverEmail = 'policy-approver@example.com'; + const existingManagerEmail = 'existing-manager@example.com'; + + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, { + [adminAccountID]: {accountID: adminAccountID, login: adminEmail}, + [submitterAccountID]: {accountID: submitterAccountID, login: submitterEmail}, + [policyApproverAccountID]: {accountID: policyApproverAccountID, login: policyApproverEmail}, + [existingManagerAccountID]: {accountID: existingManagerAccountID, login: existingManagerEmail}, + }); + + const policy: Policy = { + ...createRandomPolicy(Number(policyID)), + id: policyID, + role: CONST.POLICY.ROLE.ADMIN, + type: CONST.POLICY.TYPE.CORPORATE, + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + approver: adminEmail, + owner: adminEmail, + employeeList: { + [submitterEmail]: { + email: submitterEmail, + submitsTo: policyApproverEmail, + }, + }, + }; + + const expenseReport: Report = { + ...createRandomReport(Number(policyID), undefined), + reportID: '1', + policyID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: submitterAccountID, + managerID: existingManagerAccountID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + total: 1000, + currency: CONST.CURRENCY.USD, + }; + + // Without report actions in cache, preserve a valid manager because an explicit override may be missing locally. + submitReport({ + expenseReport, + policy, + currentUserAccountIDParam: adminAccountID, + currentUserEmailParam: adminEmail, + hasViolations: false, + isASAPSubmitBetaEnabled: false, + expenseReportCurrentNextStepDeprecated: undefined, + userBillingGracePeriodEnds: undefined, + amountOwed: 0, + ownerBillingGracePeriodEnd: undefined, + delegateEmail: undefined, + }); + + const [, parameters, onyxData] = apiWriteSpy.mock.calls.at(-1) as [unknown, {managerAccountID?: number}, OnyxData]; + expect(parameters.managerAccountID).toBe(existingManagerAccountID); + + const optimisticReportUpdate = onyxData.optimisticData?.find((update) => update.key === `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`); + expect((optimisticReportUpdate?.value as Report | undefined)?.managerID).toBe(existingManagerAccountID); + + apiWriteSpy.mockRestore(); + }); + + it('preserves the existing report manager when report action history is partial', async () => { + // eslint-disable-next-line rulesdir/no-multiple-api-calls -- Inspecting API.write calls to verify submit payload and optimistic data. + const apiWriteSpy = jest.spyOn(API, 'write').mockImplementation(() => Promise.resolve()); + apiWriteSpy.mockClear(); + const policyID = '1'; + const adminAccountID = 100; + const submitterAccountID = 101; + const policyApproverAccountID = 102; + const existingManagerAccountID = 103; + const adminEmail = 'admin@example.com'; + const submitterEmail = 'submitter@example.com'; + const policyApproverEmail = 'policy-approver@example.com'; + const existingManagerEmail = 'existing-manager@example.com'; + + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, { + [adminAccountID]: {accountID: adminAccountID, login: adminEmail}, + [submitterAccountID]: {accountID: submitterAccountID, login: submitterEmail}, + [policyApproverAccountID]: {accountID: policyApproverAccountID, login: policyApproverEmail}, + [existingManagerAccountID]: {accountID: existingManagerAccountID, login: existingManagerEmail}, + }); + + const policy: Policy = { + ...createRandomPolicy(Number(policyID)), + id: policyID, + role: CONST.POLICY.ROLE.ADMIN, + type: CONST.POLICY.TYPE.CORPORATE, + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + approver: adminEmail, + owner: adminEmail, + employeeList: { + [submitterEmail]: { + email: submitterEmail, + submitsTo: policyApproverEmail, + }, + }, + }; + + const expenseReport: Report = { + ...createRandomReport(Number(policyID), undefined), + reportID: '1', + policyID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: submitterAccountID, + managerID: existingManagerAccountID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + total: 1000, + currency: CONST.CURRENCY.USD, + }; + + // A newest page without an older boundary can still be missing an older active reroute. + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, { + commentAction: { + reportActionID: 'commentAction', + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + actorAccountID: submitterAccountID, + created: '2026-05-01T12:00:00.000Z', + }, + } as ReportActions); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES}${expenseReport.reportID}`, [[CONST.PAGINATION_START_ID, 'commentAction']]); + await waitForBatchedUpdates(); + + submitReport({ + expenseReport, + policy, + currentUserAccountIDParam: adminAccountID, + currentUserEmailParam: adminEmail, + hasViolations: false, + isASAPSubmitBetaEnabled: false, + expenseReportCurrentNextStepDeprecated: undefined, + userBillingGracePeriodEnds: undefined, + amountOwed: 0, + ownerBillingGracePeriodEnd: undefined, + delegateEmail: undefined, + }); + + const [, parameters, onyxData] = apiWriteSpy.mock.calls.at(-1) as [unknown, {managerAccountID?: number}, OnyxData]; + expect(parameters.managerAccountID).toBe(existingManagerAccountID); + + const optimisticReportUpdate = onyxData.optimisticData?.find((update) => update.key === `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`); + expect((optimisticReportUpdate?.value as Report | undefined)?.managerID).toBe(existingManagerAccountID); + + apiWriteSpy.mockRestore(); + }); + + it('recomputes the approver when a later submitted action invalidates the report-level override', async () => { + // eslint-disable-next-line rulesdir/no-multiple-api-calls -- Inspecting API.write calls to verify submit payload and optimistic data. + const apiWriteSpy = jest.spyOn(API, 'write').mockImplementation(() => Promise.resolve()); + apiWriteSpy.mockClear(); + const policyID = '1'; + const adminAccountID = 100; + const submitterAccountID = 101; + const policyApproverAccountID = 102; + const manualApproverAccountID = 103; + const adminEmail = 'admin@example.com'; + const submitterEmail = 'submitter@example.com'; + const policyApproverEmail = 'policy-approver@example.com'; + const manualApproverEmail = 'manual-approver@example.com'; + + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, { + [adminAccountID]: {accountID: adminAccountID, login: adminEmail}, + [submitterAccountID]: {accountID: submitterAccountID, login: submitterEmail}, + [policyApproverAccountID]: {accountID: policyApproverAccountID, login: policyApproverEmail}, + [manualApproverAccountID]: {accountID: manualApproverAccountID, login: manualApproverEmail}, + }); + + const policy: Policy = { + ...createRandomPolicy(Number(policyID)), + id: policyID, + role: CONST.POLICY.ROLE.ADMIN, + type: CONST.POLICY.TYPE.CORPORATE, + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + approver: adminEmail, + owner: adminEmail, + employeeList: { + [submitterEmail]: { + email: submitterEmail, + submitsTo: policyApproverEmail, + }, + }, + }; + + const expenseReport: Report = { + ...createRandomReport(Number(policyID), undefined), + reportID: '1', + policyID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: submitterAccountID, + managerID: manualApproverAccountID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + total: 1000, + currency: CONST.CURRENCY.USD, + }; + + // A submitted action after reroute means the old explicit override is no longer active. + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, { + rerouteAction: { + reportActionID: 'rerouteAction', + actionName: CONST.REPORT.ACTIONS.TYPE.REROUTE, + actorAccountID: adminAccountID, + created: '2026-05-01T10:00:00.000Z', + }, + submittedAction: { + reportActionID: 'submittedAction', + actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, + actorAccountID: submitterAccountID, + created: '2026-05-01T11:00:00.000Z', + }, + } as ReportActions); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES}${expenseReport.reportID}`, [ + [CONST.PAGINATION_START_ID, 'submittedAction', 'rerouteAction', CONST.PAGINATION_END_ID], + ]); + await waitForBatchedUpdates(); + + submitReport({ + expenseReport, + policy, + currentUserAccountIDParam: adminAccountID, + currentUserEmailParam: adminEmail, + hasViolations: false, + isASAPSubmitBetaEnabled: false, + expenseReportCurrentNextStepDeprecated: undefined, + userBillingGracePeriodEnds: undefined, + amountOwed: 0, + ownerBillingGracePeriodEnd: undefined, + delegateEmail: undefined, + }); + + const [, parameters, onyxData] = apiWriteSpy.mock.calls.at(-1) as [unknown, {managerAccountID?: number}, OnyxData]; + expect(parameters.managerAccountID).toBe(policyApproverAccountID); + + const optimisticReportUpdate = onyxData.optimisticData?.find((update) => update.key === `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`); + expect((optimisticReportUpdate?.value as Report | undefined)?.managerID).toBe(policyApproverAccountID); + + apiWriteSpy.mockRestore(); + }); + it('ignores the existing report manager when it points to the submitter', async () => { // eslint-disable-next-line rulesdir/no-multiple-api-calls -- Inspecting API.write calls to verify submit payload and optimistic data. const apiWriteSpy = jest.spyOn(API, 'write').mockImplementation(() => Promise.resolve()); + apiWriteSpy.mockClear(); const policyID = '1'; const submitterAccountID = 100; const computedManagerAccountID = 101; @@ -1312,7 +1732,7 @@ describe('actions/IOU/ReportWorkflow', () => { }); // Then the API payload and optimistic report update use the configured approver instead of routing to the submitter. - const [, parameters, onyxData] = apiWriteSpy.mock.calls.at(0) as [unknown, {managerAccountID?: number}, OnyxData]; + const [, parameters, onyxData] = apiWriteSpy.mock.calls.at(-1) as [unknown, {managerAccountID?: number}, OnyxData]; expect(parameters.managerAccountID).toBe(computedManagerAccountID); const optimisticReportUpdate = onyxData.optimisticData?.find((update) => update.key === `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`);