Skip to content

Commit 4a6c1b3

Browse files
authored
Merge pull request Expensify#88708 from Expensify/lucien/fix-submitFlow-approver
[Payment due @Krishna2323] Fix submit report approver selection from stale policy data
2 parents e25ee77 + 0c0bdca commit 4a6c1b3

4 files changed

Lines changed: 155 additions & 5 deletions

File tree

src/libs/PolicyUtils.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1242,6 +1242,19 @@ function getSubmitToAccountID(policy: OnyxEntry<Policy>, expenseReport: OnyxEntr
12421242
return getManagerAccountID(policy, expenseReport);
12431243
}
12441244

1245+
function getSubmitReportManagerAccountID(policy: OnyxEntry<Policy>, expenseReport: OnyxEntry<Report>): number {
1246+
const existingManagerID = expenseReport?.managerID ?? CONST.DEFAULT_NUMBER_ID;
1247+
const ownerAccountID = expenseReport?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID;
1248+
1249+
if (existingManagerID > CONST.DEFAULT_NUMBER_ID && existingManagerID !== ownerAccountID) {
1250+
// Existing reports may already have a server-computed or manually changed approver.
1251+
return existingManagerID;
1252+
}
1253+
1254+
const submitToAccountID = getSubmitToAccountID(policy, expenseReport);
1255+
return submitToAccountID > CONST.DEFAULT_NUMBER_ID ? submitToAccountID : existingManagerID;
1256+
}
1257+
12451258
function getManagerAccountEmail(policy: OnyxEntry<Policy>, expenseReport: OnyxEntry<Report>): string {
12461259
const managerAccountID = getManagerAccountID(policy, expenseReport);
12471260
return getLoginsByAccountIDs([managerAccountID]).at(0) ?? '';
@@ -2257,6 +2270,7 @@ export {
22572270
getDefaultChatEnabledPolicy,
22582271
getForwardsToAccount,
22592272
getSubmitToAccountID,
2273+
getSubmitReportManagerAccountID,
22602274
getAllTaxRatesNamesAndKeys as getAllTaxRates,
22612275
getAllTaxRatesNamesAndValues,
22622276
getTagNamesFromTagsLists,

src/libs/actions/IOU/ReportWorkflow.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import Navigation from '@libs/Navigation/Navigation';
1717
import {getIsOffline} from '@libs/NetworkState';
1818
// eslint-disable-next-line @typescript-eslint/no-deprecated
1919
import {buildNextStepNew, buildOptimisticNextStep} from '@libs/NextStepUtils';
20-
import {arePaymentsEnabled, getSubmitToAccountID, hasDynamicExternalWorkflow, isPaidGroupPolicy, isPolicyAdmin, isSubmitAndClose} from '@libs/PolicyUtils';
20+
import {arePaymentsEnabled, getSubmitReportManagerAccountID, hasDynamicExternalWorkflow, isPaidGroupPolicy, isPolicyAdmin, isSubmitAndClose} from '@libs/PolicyUtils';
2121
import {getAllReportActions, getReportActionHtml, getReportActionText, hasPendingDEWApprove, isCreatedAction, isDeletedAction} from '@libs/ReportActionsUtils';
2222
import {
2323
buildOptimisticApprovedReportAction,
@@ -1296,8 +1296,7 @@ function submitReport({
12961296
isASAPSubmitBetaEnabled,
12971297
isUnapprove: true,
12981298
});
1299-
const submitToAccountID = getSubmitToAccountID(policy, expenseReport);
1300-
const managerID = submitToAccountID > 0 ? submitToAccountID : expenseReport.managerID;
1299+
const managerID = getSubmitReportManagerAccountID(policy, expenseReport);
13011300

13021301
const optimisticData: Array<
13031302
OnyxUpdate<typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS | typeof ONYXKEYS.COLLECTION.REPORT | typeof ONYXKEYS.COLLECTION.NEXT_STEP | typeof ONYXKEYS.COLLECTION.REPORT_METADATA>

src/libs/actions/Search.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import type {SearchFullscreenNavigatorParamList} from '@libs/Navigation/types';
2929
import enhanceParameters from '@libs/Network/enhanceParameters';
3030
import {rand64} from '@libs/NumberUtils';
3131
import {getActivePaymentType} from '@libs/PaymentUtils';
32-
import {getSubmitToAccountID, getValidConnectedIntegration, isDelayedSubmissionEnabled} from '@libs/PolicyUtils';
32+
import {getSubmitReportManagerAccountID, getValidConnectedIntegration, isDelayedSubmissionEnabled} from '@libs/PolicyUtils';
3333
import type {OptimisticExportIntegrationAction} from '@libs/ReportUtils';
3434
import {
3535
buildOptimisticExportIntegrationAction,
@@ -660,7 +660,7 @@ function submitMoneyRequestOnSearch(hash: number, reportList: Report[], policy:
660660
const report = (reportList.at(0) ?? {}) as Report;
661661
const parameters: SubmitReportParams = {
662662
reportID: report.reportID,
663-
managerAccountID: getSubmitToAccountID(policy.at(0), report) ?? report?.managerID,
663+
managerAccountID: getSubmitReportManagerAccountID(policy.at(0), report),
664664
reportActionID: rand64(),
665665
};
666666

tests/actions/IOUTest/ReportWorkflowTest.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1187,6 +1187,142 @@ describe('actions/IOU/ReportWorkflow', () => {
11871187
expect(Navigation.navigate).not.toHaveBeenCalledWith(ROUTES.RESTRICTED_ACTION.getRoute(policyID));
11881188
}
11891189
});
1190+
1191+
it('preserves the existing report manager when cached policy data resolves to a different approver', async () => {
1192+
// eslint-disable-next-line rulesdir/no-multiple-api-calls -- Inspecting API.write calls to verify submit payload and optimistic data.
1193+
const apiWriteSpy = jest.spyOn(API, 'write').mockImplementation(() => Promise.resolve());
1194+
const policyID = '1';
1195+
const submitterAccountID = 100;
1196+
const correctManagerAccountID = 101;
1197+
const defaultApproverAccountID = 102;
1198+
const submitterEmail = 'submitter@example.com';
1199+
const correctManagerEmail = 'correct-manager@example.com';
1200+
const defaultApproverEmail = 'default-approver@example.com';
1201+
1202+
await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, {
1203+
[submitterAccountID]: {accountID: submitterAccountID, login: submitterEmail},
1204+
[correctManagerAccountID]: {accountID: correctManagerAccountID, login: correctManagerEmail},
1205+
[defaultApproverAccountID]: {accountID: defaultApproverAccountID, login: defaultApproverEmail},
1206+
});
1207+
1208+
// Given cached policy data resolves to the default approver.
1209+
const policy: Policy = {
1210+
...createRandomPolicy(Number(policyID)),
1211+
id: policyID,
1212+
type: CONST.POLICY.TYPE.CORPORATE,
1213+
approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED,
1214+
approver: defaultApproverEmail,
1215+
owner: defaultApproverEmail,
1216+
employeeList: {},
1217+
};
1218+
1219+
// And the report already has the correct manager.
1220+
const expenseReport: Report = {
1221+
...createRandomReport(Number(policyID), undefined),
1222+
reportID: '1',
1223+
policyID,
1224+
type: CONST.REPORT.TYPE.EXPENSE,
1225+
ownerAccountID: submitterAccountID,
1226+
managerID: correctManagerAccountID,
1227+
stateNum: CONST.REPORT.STATE_NUM.OPEN,
1228+
statusNum: CONST.REPORT.STATUS_NUM.OPEN,
1229+
total: 1000,
1230+
currency: CONST.CURRENCY.USD,
1231+
};
1232+
1233+
// When submitting the report.
1234+
submitReport({
1235+
expenseReport,
1236+
policy,
1237+
currentUserAccountIDParam: submitterAccountID,
1238+
currentUserEmailParam: submitterEmail,
1239+
hasViolations: false,
1240+
isASAPSubmitBetaEnabled: false,
1241+
expenseReportCurrentNextStepDeprecated: undefined,
1242+
userBillingGracePeriodEnds: undefined,
1243+
amountOwed: 0,
1244+
ownerBillingGracePeriodEnd: undefined,
1245+
delegateEmail: undefined,
1246+
});
1247+
1248+
// Then the API payload and optimistic report update preserve the existing manager.
1249+
const [, parameters, onyxData] = apiWriteSpy.mock.calls.at(0) as [unknown, {managerAccountID?: number}, OnyxData<typeof ONYXKEYS.COLLECTION.REPORT>];
1250+
expect(parameters.managerAccountID).toBe(correctManagerAccountID);
1251+
1252+
const optimisticReportUpdate = onyxData.optimisticData?.find((update) => update.key === `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`);
1253+
expect((optimisticReportUpdate?.value as Report | undefined)?.managerID).toBe(correctManagerAccountID);
1254+
1255+
apiWriteSpy.mockRestore();
1256+
});
1257+
1258+
it('ignores the existing report manager when it points to the submitter', async () => {
1259+
// eslint-disable-next-line rulesdir/no-multiple-api-calls -- Inspecting API.write calls to verify submit payload and optimistic data.
1260+
const apiWriteSpy = jest.spyOn(API, 'write').mockImplementation(() => Promise.resolve());
1261+
const policyID = '1';
1262+
const submitterAccountID = 100;
1263+
const computedManagerAccountID = 101;
1264+
const submitterEmail = 'submitter@example.com';
1265+
const computedManagerEmail = 'computed-manager@example.com';
1266+
1267+
await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, {
1268+
[submitterAccountID]: {accountID: submitterAccountID, login: submitterEmail},
1269+
[computedManagerAccountID]: {accountID: computedManagerAccountID, login: computedManagerEmail},
1270+
});
1271+
1272+
// Given cached policy data resolves to the configured approver.
1273+
const policy: Policy = {
1274+
...createRandomPolicy(Number(policyID)),
1275+
id: policyID,
1276+
type: CONST.POLICY.TYPE.CORPORATE,
1277+
approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED,
1278+
approver: computedManagerEmail,
1279+
owner: computedManagerEmail,
1280+
employeeList: {
1281+
[submitterEmail]: {
1282+
email: submitterEmail,
1283+
submitsTo: computedManagerEmail,
1284+
},
1285+
},
1286+
};
1287+
1288+
// And the draft report manager is still the submitter.
1289+
const expenseReport: Report = {
1290+
...createRandomReport(Number(policyID), undefined),
1291+
reportID: '1',
1292+
policyID,
1293+
type: CONST.REPORT.TYPE.EXPENSE,
1294+
ownerAccountID: submitterAccountID,
1295+
managerID: submitterAccountID,
1296+
stateNum: CONST.REPORT.STATE_NUM.OPEN,
1297+
statusNum: CONST.REPORT.STATUS_NUM.OPEN,
1298+
total: 1000,
1299+
currency: CONST.CURRENCY.USD,
1300+
};
1301+
1302+
// When submitting the report.
1303+
submitReport({
1304+
expenseReport,
1305+
policy,
1306+
currentUserAccountIDParam: submitterAccountID,
1307+
currentUserEmailParam: submitterEmail,
1308+
hasViolations: false,
1309+
isASAPSubmitBetaEnabled: false,
1310+
expenseReportCurrentNextStepDeprecated: undefined,
1311+
userBillingGracePeriodEnds: undefined,
1312+
amountOwed: 0,
1313+
ownerBillingGracePeriodEnd: undefined,
1314+
delegateEmail: undefined,
1315+
});
1316+
1317+
// Then the API payload and optimistic report update use the configured approver instead of routing to the submitter.
1318+
const [, parameters, onyxData] = apiWriteSpy.mock.calls.at(0) as [unknown, {managerAccountID?: number}, OnyxData<typeof ONYXKEYS.COLLECTION.REPORT>];
1319+
expect(parameters.managerAccountID).toBe(computedManagerAccountID);
1320+
1321+
const optimisticReportUpdate = onyxData.optimisticData?.find((update) => update.key === `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`);
1322+
expect((optimisticReportUpdate?.value as Report | undefined)?.managerID).toBe(computedManagerAccountID);
1323+
1324+
apiWriteSpy.mockRestore();
1325+
});
11901326
});
11911327

11921328
describe('delegateAccountID forwarding', () => {
@@ -1195,6 +1331,7 @@ describe('actions/IOU/ReportWorkflow', () => {
11951331

11961332
beforeEach(async () => {
11971333
jest.clearAllMocks();
1334+
// eslint-disable-next-line rulesdir/no-multiple-api-calls -- Inspecting API.write calls to verify optimistic data.
11981335
jest.spyOn(API, 'write');
11991336
await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {
12001337
[DELEGATE_ACCOUNT_ID]: {

0 commit comments

Comments
 (0)