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
16 changes: 6 additions & 10 deletions src/libs/PolicyUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1895,27 +1895,23 @@ function getSubmitToAccountID(policy: OnyxEntry<Policy>, expenseReport: OnyxEntr
}

function getSubmitReportManagerAccountID(policy: OnyxEntry<Policy>, expenseReport: OnyxEntry<Report>, submitterLogin: string | undefined): number | undefined {
const ownerAccountID = expenseReport?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID;
const existingManagerID = expenseReport?.managerID;
const approvalRules = policy?.rules?.approvalRules;
const ruleApprover = !isSubmitAndClose(policy) && approvalRules?.length ? getFirstRuleApprover(approvalRules, expenseReport, submitterLogin) : '';
const submitToAccountID = getSubmitToAccountID(policy, expenseReport, submitterLogin);
const isValidSubmitToAccountID = isValidAccountRoute(submitToAccountID);
const isValidExistingManagerID = isValidAccountRoute(existingManagerID ?? CONST.DEFAULT_NUMBER_ID) && existingManagerID !== ownerAccountID;
const hasReliablePolicyRoute =
([CONST.POLICY.APPROVAL_MODE.OPTIONAL, CONST.POLICY.APPROVAL_MODE.BASIC] as Array<ValueOf<typeof CONST.POLICY.APPROVAL_MODE>>).includes(getApprovalWorkflow(policy)) ||
!!ruleApprover ||
!!policy?.employeeList?.[submitterLogin ?? ''];

if (hasReliablePolicyRoute && isValidSubmitToAccountID) {
return submitToAccountID;
if (!hasReliablePolicyRoute) {
return undefined;
}

if (!hasReliablePolicyRoute && isValidExistingManagerID) {
return existingManagerID;
const submitToAccountID = getKnownAccountIDByLogin(getSubmitToEmail(policy, expenseReport, submitterLogin));
if (submitToAccountID === undefined || !isValidAccountRoute(submitToAccountID)) {
return undefined;
}

return isValidSubmitToAccountID ? submitToAccountID : existingManagerID;
return submitToAccountID;
}

/**
Expand Down
9 changes: 7 additions & 2 deletions src/libs/actions/IOU/ReportWorkflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1329,7 +1329,8 @@ function submitReport({
const managerAccountIDFromEmail = trimmedManagerEmail ? getAccountIDForSubmitManagerEmail(trimmedManagerEmail, policy?.employeeList) : undefined;
const resolvedManagerAccountIDFromEmail = managerAccountIDFromPopover ?? managerAccountIDFromEmail;
const submitReportManagerAccountID = getSubmitReportManagerAccountID(policy, expenseReport, submitterLogin);
const managerID = trimmedManagerEmail ? (resolvedManagerAccountIDFromEmail ?? managerIDFromChain ?? expenseReport.managerID) : submitReportManagerAccountID;
const apiManagerAccountID = trimmedManagerEmail ? (resolvedManagerAccountIDFromEmail ?? managerIDFromChain) : submitReportManagerAccountID;
const managerID = apiManagerAccountID ?? expenseReport.managerID;
const optimisticNextStepApproverID = !isSubmitAndClosePolicy && managerID !== undefined && isValidAccountRoute(managerID) ? managerID : undefined;
const isCurrentUserManager = currentUserAccountIDParam === managerID;
const optimisticSubmittedReportAction = buildOptimisticSubmittedReportAction(
Expand Down Expand Up @@ -1576,8 +1577,12 @@ function submitReport({

const parameters: SubmitReportParams = {
reportID: expenseReport.reportID,
managerAccountID: managerID,
reportActionID: optimisticSubmittedReportAction.reportActionID,
...(apiManagerAccountID !== undefined
? {
managerAccountID: apiManagerAccountID,
}
: {}),
...(trimmedManagerEmail
? {
managerEmail: trimmedManagerEmail,
Expand Down
4 changes: 2 additions & 2 deletions src/libs/actions/Search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1431,12 +1431,12 @@ function submitMoneyRequestOnSearch(
const managerIDFromChain = getKnownAccountIDByLogin(getApprovalChain(firstPolicy, firstReport, submitterLogin).at(0));
const managerAccountIDFromEmail = trimmedManagerEmail ? getAccountIDForSubmitManagerEmail(trimmedManagerEmail, firstPolicy?.employeeList) : undefined;
const submitReportManagerAccountID = getSubmitReportManagerAccountID(firstPolicy, firstReport, submitterLogin);
const resolvedManagerAccountID = trimmedManagerEmail ? (managerAccountID ?? managerAccountIDFromEmail ?? managerIDFromChain ?? firstReport.managerID) : submitReportManagerAccountID;
const resolvedManagerAccountID = trimmedManagerEmail ? (managerAccountID ?? managerAccountIDFromEmail ?? managerIDFromChain) : submitReportManagerAccountID;

const parameters: SubmitReportParams = {
reportID: firstReport.reportID,
managerAccountID: resolvedManagerAccountID,
reportActionID: optimisticSubmittedReportAction.reportActionID,
...(resolvedManagerAccountID !== undefined ? {managerAccountID: resolvedManagerAccountID} : {}),
...(trimmedManagerEmail ? {managerEmail: trimmedManagerEmail} : {}),
};

Expand Down
28 changes: 11 additions & 17 deletions tests/actions/IOUTest/ReportWorkflowTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1257,7 +1257,7 @@ describe('actions/IOU/ReportWorkflow', () => {
}
});

it('preserves the existing report manager when policy employee data is missing', async () => {
it('omits the API managerAccountID but keeps the existing report manager optimistically when policy employee data is missing', async () => {
const apiWriteSpy = jest.spyOn(API, 'write').mockImplementation(() => Promise.resolve());
const policyID = '1';
const submitterAccountID = 100;
Expand Down Expand Up @@ -1313,14 +1313,15 @@ describe('actions/IOU/ReportWorkflow', () => {
});

const [, parameters, onyxData] = getRequiredWriteCall(apiWriteSpy.mock.calls);
expect(parameters.managerAccountID).toBe(correctManagerAccountID);
// The client route isn't reliable here, so we let the server route the report by the live workflow.
expect(parameters).not.toHaveProperty('managerAccountID');

const reportKey = `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`;
const optimisticReportUpdate = getRequiredOnyxUpdate(onyxData, 'optimisticData', reportKey, Onyx.METHOD.MERGE, true);
expect(optimisticReportUpdate.value.managerID).toBe(correctManagerAccountID);
});

it('preserves the existing report manager for a retracted report when policy employee data is missing', async () => {
it('omits the API managerAccountID but keeps the existing report manager optimistically for a retracted report when policy employee data is missing', 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());
const policyID = '1';
Expand Down Expand Up @@ -1378,7 +1379,8 @@ describe('actions/IOU/ReportWorkflow', () => {
});

const [, parameters, onyxData] = getRequiredWriteCall(apiWriteSpy.mock.calls);
expect(parameters.managerAccountID).toBe(correctManagerAccountID);
// The client route isn't reliable here, so we let the server route the report by the live workflow.
expect(parameters).not.toHaveProperty('managerAccountID');

const reportKey = `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`;
const optimisticReportUpdate = getRequiredOnyxUpdate(onyxData, 'optimisticData', reportKey, Onyx.METHOD.MERGE, true);
Expand Down Expand Up @@ -1653,11 +1655,7 @@ describe('actions/IOU/ReportWorkflow', () => {
throw new Error('Expected an optimistic next step.');
}
expect(optimisticReportValue.nextStep.actorAccountID).toBe(ruleApproverAccountID);

const reportKeyForNextStep = `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`;
const optimisticReportNextStepUpdate = getRequiredOnyxUpdate(onyxData, 'optimisticData', reportKeyForNextStep, Onyx.METHOD.MERGE, true);
const optimisticReportNextStepUpdateValue = optimisticReportNextStepUpdate.value;
expect(optimisticReportNextStepUpdateValue.nextStep).toEqual({
expect(optimisticReportValue.nextStep).toEqual({
actorAccountID: ruleApproverAccountID,
icon: CONST.NEXT_STEP.ICONS.HOURGLASS,
messageKey: CONST.NEXT_STEP.MESSAGE_KEY.WAITING_TO_APPROVE,
Expand Down Expand Up @@ -2068,7 +2066,7 @@ describe('actions/IOU/ReportWorkflow', () => {
expect(failureReportUpdate.value.managerID).toBe(managerAccountID);
});

it('uses the same submit approver selection from search submit', async () => {
it('omits the API managerAccountID from search submit when policy employee data is missing', async () => {
// eslint-disable-next-line rulesdir/no-multiple-api-calls -- Inspecting API.write calls to verify search submit payload.
const apiWriteSpy = jest.spyOn(API, 'write').mockImplementation(() => Promise.resolve());
const policyID = '1';
Expand Down Expand Up @@ -2110,13 +2108,9 @@ describe('actions/IOU/ReportWorkflow', () => {

submitMoneyRequestOnSearch(1, [report], [policy], submitterEmail, getCurrencyDecimalsLocal);

expect(apiWriteSpy).toHaveBeenCalledWith(
'SubmitReport',
expect.objectContaining({
managerAccountID: correctManagerAccountID,
}),
expect.anything(),
);
// The client route isn't reliable here, so we let the server route the report by the live workflow.
const [, parameters] = getRequiredWriteCall(apiWriteSpy.mock.calls);
expect(parameters).not.toHaveProperty('managerAccountID');
});

it('uses the popover-selected manager email for search submit managerAccountID', async () => {
Expand Down
70 changes: 70 additions & 0 deletions tests/unit/PolicyUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
getPolicyIDFromDomainName,
getRateDisplayValue,
getReimburserEmail,
getSubmitReportManagerAccountID,
getSubmitToAccountID,
getSubmitToEmail,
getTagApproverRule,
Expand Down Expand Up @@ -1081,6 +1082,75 @@ describe('PolicyUtils', () => {
});
});
});
describe('getSubmitReportManagerAccountID', () => {
beforeEach(() => {
wrapOnyxWithWaitForBatchedUpdates(Onyx);
Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails);
});
afterEach(async () => {
await Onyx.clear();
await waitForBatchedUpdatesWithAct();
});
it('should return undefined for an advanced policy where the submitter is missing from the employee list, even if the report has a valid managerID', () => {
const policy: Policy = {
...createRandomPolicy(0),
approver: 'owner@test.com',
owner: 'owner@test.com',
// The submitter isn't in the employee list, which is the case for reports migrated from Expensify Classic.
employeeList: {},
type: CONST.POLICY.TYPE.CORPORATE,
approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED,
};
const expenseReport: Report = {
...createRandomReport(0, undefined),
ownerAccountID: employeeAccountID,
// The stale Classic-era manager stamped on the migrated report.
managerID: categoryApprover1AccountID,
type: CONST.REPORT.TYPE.EXPENSE,
};
expect(getSubmitReportManagerAccountID(policy, expenseReport, employeeEmail)).toBeUndefined();
});
it('should return the known approver accountID when the policy route is reliable', () => {
const policy: Policy = {
...createRandomPolicy(0),
approver: 'owner@test.com',
owner: 'owner@test.com',
employeeList,
type: CONST.POLICY.TYPE.CORPORATE,
approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED,
};
const expenseReport: Report = {
...createRandomReport(0, undefined),
ownerAccountID: employeeAccountID,
managerID: categoryApprover1AccountID,
type: CONST.REPORT.TYPE.EXPENSE,
};
expect(getSubmitReportManagerAccountID(policy, expenseReport, employeeEmail)).toBe(adminAccountID);
});
it('should return undefined instead of a generated accountID when the approver is missing from personal details', () => {
const policy: Policy = {
...createRandomPolicy(0),
approver: 'owner@test.com',
owner: 'owner@test.com',
employeeList: {
[employeeEmail]: {
email: employeeEmail,
role: 'user',
submitsTo: 'unknown@test.com',
},
},
type: CONST.POLICY.TYPE.CORPORATE,
approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED,
};
const expenseReport: Report = {
...createRandomReport(0, undefined),
ownerAccountID: employeeAccountID,
managerID: categoryApprover1AccountID,
type: CONST.REPORT.TYPE.EXPENSE,
};
expect(getSubmitReportManagerAccountID(policy, expenseReport, employeeEmail)).toBeUndefined();
});
});
describe('shouldShowPolicy', () => {
beforeEach(() => {
global.fetch = TestHelper.getGlobalFetchMock();
Expand Down
Loading