Skip to content
Merged
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
83 changes: 73 additions & 10 deletions src/libs/PolicyUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
} from '@src/types/onyx';
import type {ErrorFields, PendingAction, PendingFields} from '@src/types/onyx/OnyxCommon';
import type {
ApprovalRule,
ConnectionLastSync,
ConnectionName,
Connections,
Expand Down Expand Up @@ -52,7 +53,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 +73,7 @@

let allPolicies: OnyxCollection<Policy>;

Onyx.connect({

Check warning on line 76 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 @@ -1028,9 +1029,72 @@
return [...new Set([...categoryApprovers, ...tagApprovers])];
}

function getFirstRuleApprover(approvalRules: ApprovalRule[], expenseReport: OnyxEntry<Report>) {
// 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)) {
return '';
}

const allReportTransactions = getAllSortedTransactions(expenseReport?.reportID);

if (!allReportTransactions.length) {
return '';
}

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;
}
}
}

return firstCategoryApprover || firstTagApprover;
}

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.
Expand All @@ -1050,14 +1114,13 @@
* 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;

if (!isSubmitAndClose(policy) && approvalRules?.length) {
const ruleApprover = getFirstRuleApprover(approvalRules, expenseReport);
if (ruleApprover) {
return getAccountIDsByLogins([ruleApprover]).at(0) ?? -1;
}
}

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