Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 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
82 changes: 69 additions & 13 deletions src/libs/PolicyUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
import {isOffline as isOfflineNetworkStore} from './Network/NetworkStore';
import {formatMemberForList} from './OptionsListUtils';
import type {MemberForList} from './OptionsListUtils';
import {getAccountIDsByLogins, getLoginsByAccountIDs, getPersonalDetailByEmail} from './PersonalDetailsUtils';
import {getAccountIDsByLogins, getLoginByAccountID, getLoginsByAccountIDs, getPersonalDetailByEmail} from './PersonalDetailsUtils';
import {getAllSortedTransactions, getCategory, getTag, getTagArrayFromName} from './TransactionUtils';
import {isPublicDomain} from './ValidationUtils';

Expand All @@ -72,7 +72,7 @@

let allPolicies: OnyxCollection<Policy>;

Onyx.connect({

Check warning on line 75 in src/libs/PolicyUtils.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.COLLECTION.POLICY,
waitForCollectionCallback: true,
callback: (value) => (allPolicies = value),
Expand Down Expand Up @@ -1013,34 +1013,90 @@

function getManagerAccountID(policy: OnyxEntry<Policy>, expenseReport: OnyxEntry<Report> | {ownerAccountID: number}) {
const employeeAccountID = expenseReport?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID;
const employeeLogin = getLoginsByAccountIDs([employeeAccountID]).at(0) ?? '';
const employeeLogin = getLoginByAccountID(employeeAccountID) ?? '';
const defaultApprover = getDefaultApprover(policy);

// For policy using the optional or basic workflow, the manager is the policy default approver.
if (([CONST.POLICY.APPROVAL_MODE.OPTIONAL, CONST.POLICY.APPROVAL_MODE.BASIC] as Array<ValueOf<typeof CONST.POLICY.APPROVAL_MODE>>).includes(getApprovalWorkflow(policy))) {
return getAccountIDsByLogins([defaultApprover]).at(0) ?? -1;
return getPersonalDetailByEmail(defaultApprover)?.accountID ?? CONST.DEFAULT_NUMBER_ID;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve optimistic approver ID fallback

Replacing getAccountIDsByLogins() with getPersonalDetailByEmail() here removes the optimistic-account-ID fallback when an approver login is not yet present in PERSONAL_DETAILS_LIST. In that case this now returns 0, and callers like the submit flow (managerAccountID: getSubmitToAccountID(...)) can send/keep an invalid manager instead of the previous deterministic generated account ID. This regression is triggered whenever policy approver emails are known but their personal details have not been hydrated locally yet.

Useful? React with πŸ‘Β / πŸ‘Ž.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bernhardoj What do you say about this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both optimistic or DEFAULT_NUMBER account ID are wrong.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But we treat CONST.DEFAULT_NUMBER_ID differently in the app. Code might be using this to prevent a few things or to short-circuit things. While a randomly generated ID will still bypass those checks and render the data with that temp Id until we have real one.

}

const employee = policy?.employeeList?.[employeeLogin];
if (!employee && !defaultApprover) {
return -1;
return CONST.DEFAULT_NUMBER_ID;
}

return getAccountIDsByLogins([employee?.submitsTo ?? defaultApprover]).at(0) ?? -1;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same goes for this function. Earlier we were returning -1 as default so we should make sure that everywhere we were using this function does not use -1 for special handling.

return getPersonalDetailByEmail(employee?.submitsTo ?? defaultApprover)?.accountID ?? CONST.DEFAULT_NUMBER_ID;
}

/**
* Returns the accountID to whom the given expenseReport submits reports to in the given Policy.
*/
function getSubmitToAccountID(policy: OnyxEntry<Policy>, expenseReport: OnyxEntry<Report>): number {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can see that we use this function's return value to compare with the ownerAccountID at many places. If we return 0 as the default, that condition will become true for optimistic reports, as it defaults to 0 for ownerAccountIDs. Maybe the reason to use -1 here is to override that.

const ruleApprovers = getRuleApprovers(policy, expenseReport);
const employeeAccountID = expenseReport?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID;
const employeeLogin = getLoginsByAccountIDs([employeeAccountID]).at(0) ?? '';
if (ruleApprovers.length > 0 && ruleApprovers.at(0) === employeeLogin) {
ruleApprovers.shift();
}
if (ruleApprovers.length > 0 && !isSubmitAndClose(policy)) {
return getAccountIDsByLogins([ruleApprovers.at(0) ?? '']).at(0) ?? -1;
const approvalRules = policy?.rules?.approvalRules;

// Skip rule evaluation entirely for "Submit & Close" policies and policies with no approval rules.
if (!isSubmitAndClose(policy) && approvalRules?.length) {
// Pre-build a lookup map of { category: { value β†’ approver }, tag: { value β†’ approver } }
// from the policy's approval rules so that each transaction's category/tag can be resolved in O(1).
const rulesMap: Record<'category' | 'tag', Record<string, string>> = {category: {}, tag: {}};

for (let i = 0; i < approvalRules.length; i++) {
const rule = approvalRules.at(i);
if (!rule) {
continue;
}
for (let j = 0; j < rule.applyWhen.length; j++) {
const applyWhen = rule.applyWhen.at(j);
if (!applyWhen || applyWhen.condition !== CONST.POLICY.RULE_CONDITIONS.MATCHES) {
continue;
}
if (applyWhen.field === CONST.POLICY.FIELDS.CATEGORY || applyWhen.field === CONST.POLICY.FIELDS.TAG) {
rulesMap[applyWhen.field][applyWhen.value] = rule.approver;
}
}
}

if (!isEmptyObject(rulesMap.category) || !isEmptyObject(rulesMap.tag)) {
const allReportTransactions = getAllSortedTransactions(expenseReport?.reportID);

// Skip rule evaluation if the report has no transactions β€” there is nothing to match rules against.
if (allReportTransactions.length) {
const employeeAccountID = expenseReport?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID;
const employeeLogin = getLoginByAccountID(employeeAccountID);

let firstCategoryApprover = '';
let firstTagApprover = '';

for (let i = 0; i < allReportTransactions.length; i++) {
const transaction = allReportTransactions.at(i);
const category = getCategory(transaction);
const categoryApprover = rulesMap.category[category];

// Category approvers take strict priority over tag approvers.
// Break immediately on the first match so we don't keep scanning transactions unnecessarily.
if (categoryApprover && categoryApprover !== employeeLogin) {
firstCategoryApprover = categoryApprover;
break;
}

// Only look for a tag approver if we haven't found one yet β€” no need to re-check on subsequent transactions.
if (!firstTagApprover) {
const tag = getTag(transaction);
const tagApprover = rulesMap.tag[tag];

if (tagApprover && tagApprover !== employeeLogin) {
firstTagApprover = tagApprover;
}
}
}

const ruleApprover = firstCategoryApprover || firstTagApprover;
if (ruleApprover) {
return getPersonalDetailByEmail(ruleApprover)?.accountID ?? CONST.DEFAULT_NUMBER_ID;
}
}
}
}

return getManagerAccountID(policy, expenseReport);
Expand Down
108 changes: 107 additions & 1 deletion tests/perf-test/PolicyUtils.perf-test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
import Onyx from 'react-native-onyx';
import {measureFunction} from 'reassure';
import {getMemberAccountIDsForWorkspace} from '@libs/PolicyUtils';
import {getMemberAccountIDsForWorkspace, getSubmitToAccountID} from '@libs/PolicyUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Policy, Report, Transaction} from '@src/types/onyx';
import createCollection from '../utils/collections/createCollection';
import createRandomPolicy from '../utils/collections/policies';
import createRandomPolicyEmployeeList from '../utils/collections/policyEmployeeList';
import {createRandomReport} from '../utils/collections/reports';
import createRandomTransaction from '../utils/collections/transaction';

describe('PolicyUtils', () => {
afterEach(() => {
return Onyx.clear();
});

describe('getMemberAccountIDsForWorkspace', () => {
test('500 policy members with personal details', async () => {
const policyEmployeeList = createCollection(
Expand All @@ -26,4 +37,99 @@ describe('PolicyUtils', () => {
await measureFunction(() => getMemberAccountIDsForWorkspace(policyEmployeeList));
});
});

describe('getSubmitToAccountID', () => {
test('submit and close policy', async () => {
const policy: Policy = {
...createRandomPolicy(0),
approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL,
rules: {
approvalRules: Array.from(Array(100), () => ({
applyWhen: [
{
condition: CONST.POLICY.RULE_CONDITIONS.MATCHES,
field: CONST.POLICY.FIELDS.CATEGORY,
value: '',
},
],
approver: 'approver@gmail.com',
})),
},
};
const expenseReport: Report = {
...createRandomReport(0, undefined),
type: CONST.REPORT.TYPE.EXPENSE,
};
const transactions = createCollection<Transaction>(
(transaction) => `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`,
(index) => ({...createRandomTransaction(index), reportID: expenseReport.reportID}),
100000,
);
await Onyx.mergeCollection(ONYXKEYS.COLLECTION.TRANSACTION, transactions);
await measureFunction(() => getSubmitToAccountID(policy, expenseReport));
});

describe('not a submit and close policy', () => {
test('policy has category approval rules, but all transactions have no category', async () => {
const category = 'Car';
const policy: Policy = {
...createRandomPolicy(0),
approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC,
rules: {
approvalRules: Array.from(Array(100), () => ({
applyWhen: [
{
condition: CONST.POLICY.RULE_CONDITIONS.MATCHES,
field: CONST.POLICY.FIELDS.CATEGORY,
value: category,
},
],
approver: 'approver@gmail.com',
})),
},
};
const expenseReport: Report = {
...createRandomReport(0, undefined),
type: CONST.REPORT.TYPE.EXPENSE,
};
const transactions = createCollection<Transaction>(
(transaction) => `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`,
(index) => ({...createRandomTransaction(index), reportID: expenseReport.reportID, category: ''}),
10000,
);
await Onyx.mergeCollection(ONYXKEYS.COLLECTION.TRANSACTION, transactions);
await measureFunction(() => getSubmitToAccountID(policy, expenseReport));
});

test('all transactions have category, but no category approval rules', async () => {
const policy: Policy = {
...createRandomPolicy(0),
approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC,
rules: {
approvalRules: Array.from(Array(100), () => ({
applyWhen: [
{
condition: CONST.POLICY.RULE_CONDITIONS.MATCHES,
field: CONST.POLICY.FIELDS.TAX,
value: '',
},
],
approver: 'approver@gmail.com',
})),
},
};
const expenseReport: Report = {
...createRandomReport(0, undefined),
type: CONST.REPORT.TYPE.EXPENSE,
};
const transactions = createCollection<Transaction>(
(transaction) => `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`,
(index) => ({...createRandomTransaction(index), reportID: expenseReport.reportID}),
10000,
);
await Onyx.mergeCollection(ONYXKEYS.COLLECTION.TRANSACTION, transactions);
await measureFunction(() => getSubmitToAccountID(policy, expenseReport));
});
});
});
});
Loading