Skip to content
84 changes: 68 additions & 16 deletions src/libs/Violations/ViolationsUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,23 +228,50 @@ const ViolationsUtils = {
const isControlPolicy = policy.type === CONST.POLICY.TYPE.CORPORATE;
const inputDate = new Date(updatedTransaction.modifiedCreated ?? updatedTransaction.created);
const shouldDisplayFutureDateViolation = !isInvoiceTransaction && DateUtils.isFutureDay(inputDate) && isControlPolicy;
const hasReceiptRequiredViolation = transactionViolations.some((violation) => violation.name === 'receiptRequired');
const hasOverLimitViolation = transactionViolations.some((violation) => violation.name === 'overLimit');
const hasReceiptRequiredViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.RECEIPT_REQUIRED && violation.data);
const hasCategoryReceiptRequiredViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.RECEIPT_REQUIRED && !violation.data);
const hasOverLimitViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.OVER_LIMIT);
const hasCategoryOverLimitViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.OVER_CATEGORY_LIMIT);
const hasMissingCommentViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.MISSING_COMMENT);
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const amount = updatedTransaction.modifiedAmount || updatedTransaction.amount;
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const currency = updatedTransaction.modifiedCurrency || updatedTransaction.currency;
const canCalculateAmountViolations = policy.outputCurrency === currency;

const categoryName = updatedTransaction.category;
const categoryMaxAmountNoReceipt = policyCategories[categoryName ?? '']?.maxAmountNoReceipt;
const maxAmountNoReceipt = policy.maxExpenseAmountNoReceipt;

// The category maxExpenseAmountNoReceipt and maxExpenseAmount settings override the respective policy settings.
const shouldShowReceiptRequiredViolation =
canCalculateAmountViolations &&
!isInvoiceTransaction &&
policy.maxExpenseAmountNoReceipt &&
Math.abs(amount) > policy.maxExpenseAmountNoReceipt &&
typeof categoryMaxAmountNoReceipt !== 'number' &&
typeof maxAmountNoReceipt === 'number' &&
Math.abs(amount) > maxAmountNoReceipt &&
!TransactionUtils.hasReceipt(updatedTransaction) &&
isControlPolicy;
const shouldShowCategoryReceiptRequiredViolation =
canCalculateAmountViolations &&
!isInvoiceTransaction &&
typeof categoryMaxAmountNoReceipt === 'number' &&
Math.abs(amount) > categoryMaxAmountNoReceipt &&
!TransactionUtils.hasReceipt(updatedTransaction) &&
isControlPolicy;

const overLimitAmount = policy.maxExpenseAmount;
const categoryOverLimit = policyCategories[categoryName ?? '']?.maxExpenseAmount;
const shouldShowOverLimitViolation =
canCalculateAmountViolations && !isInvoiceTransaction && policy.maxExpenseAmount && Math.abs(amount) > policy.maxExpenseAmount && isControlPolicy;
canCalculateAmountViolations &&
!isInvoiceTransaction &&
typeof categoryOverLimit !== 'number' &&
typeof overLimitAmount === 'number' &&
Math.abs(amount) > overLimitAmount &&
isControlPolicy;
const shouldCategoryShowOverLimitViolation =
canCalculateAmountViolations && !isInvoiceTransaction && typeof categoryOverLimit === 'number' && Math.abs(amount) > categoryOverLimit && isControlPolicy;
const shouldShowMissingComment = !isInvoiceTransaction && policyCategories?.[categoryName ?? '']?.areCommentsRequired && !updatedTransaction.comment?.comment && isControlPolicy;
const hasFutureDateViolation = transactionViolations.some((violation) => violation.name === 'futureDate');
// Add 'futureDate' violation if transaction date is in the future and policy type is corporate
if (!hasFutureDateViolation && shouldDisplayFutureDateViolation) {
Expand All @@ -256,34 +283,59 @@ const ViolationsUtils = {
newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.FUTURE_DATE});
}

if (canCalculateAmountViolations && !hasReceiptRequiredViolation && shouldShowReceiptRequiredViolation) {
if (
canCalculateAmountViolations &&
((hasReceiptRequiredViolation && !shouldShowReceiptRequiredViolation) || (hasCategoryReceiptRequiredViolation && !shouldShowCategoryReceiptRequiredViolation))
) {
newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.RECEIPT_REQUIRED});
}

if (
canCalculateAmountViolations &&
((!hasReceiptRequiredViolation && !!shouldShowReceiptRequiredViolation) || (!hasCategoryReceiptRequiredViolation && shouldShowCategoryReceiptRequiredViolation))
) {
newTransactionViolations.push({
name: CONST.VIOLATIONS.RECEIPT_REQUIRED,
data: {
formattedLimit: CurrencyUtils.convertAmountToDisplayString(policy.maxExpenseAmountNoReceipt, policy.outputCurrency),
},
data:
shouldShowCategoryReceiptRequiredViolation || !policy.maxExpenseAmountNoReceipt
? undefined
: {
formattedLimit: CurrencyUtils.convertAmountToDisplayString(policy.maxExpenseAmountNoReceipt, policy.outputCurrency),
},
type: CONST.VIOLATION_TYPES.VIOLATION,
showInReview: true,
});
}

if (canCalculateAmountViolations && hasReceiptRequiredViolation && !shouldShowReceiptRequiredViolation) {
newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.RECEIPT_REQUIRED});
if (canCalculateAmountViolations && hasOverLimitViolation && !shouldShowOverLimitViolation) {
newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.OVER_LIMIT});
}

if (canCalculateAmountViolations && !hasOverLimitViolation && shouldShowOverLimitViolation) {
if (canCalculateAmountViolations && hasCategoryOverLimitViolation && !shouldCategoryShowOverLimitViolation) {
newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.OVER_CATEGORY_LIMIT});
}

if (canCalculateAmountViolations && ((!hasOverLimitViolation && !!shouldShowOverLimitViolation) || (!hasCategoryOverLimitViolation && shouldCategoryShowOverLimitViolation))) {
newTransactionViolations.push({
name: CONST.VIOLATIONS.OVER_LIMIT,
name: shouldCategoryShowOverLimitViolation ? CONST.VIOLATIONS.OVER_CATEGORY_LIMIT : CONST.VIOLATIONS.OVER_LIMIT,
data: {
formattedLimit: CurrencyUtils.convertAmountToDisplayString(policy.maxExpenseAmount, policy.outputCurrency),
formattedLimit: CurrencyUtils.convertAmountToDisplayString(shouldCategoryShowOverLimitViolation ? categoryOverLimit : policy.maxExpenseAmount, policy.outputCurrency),
},
type: CONST.VIOLATION_TYPES.VIOLATION,
showInReview: true,
});
}

if (canCalculateAmountViolations && hasOverLimitViolation && !shouldShowOverLimitViolation) {
newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.OVER_LIMIT});
if (!hasMissingCommentViolation && shouldShowMissingComment) {
newTransactionViolations.push({
name: CONST.VIOLATIONS.MISSING_COMMENT,
type: CONST.VIOLATION_TYPES.VIOLATION,
showInReview: true,
});
}

if (hasMissingCommentViolation && !shouldShowMissingComment) {
newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.MISSING_COMMENT});
}

return {
Expand Down
70 changes: 63 additions & 7 deletions src/libs/actions/IOU.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ import {buildOptimisticPolicyRecentlyUsedCategories} from './Policy/Category';
import {buildAddMembersToWorkspaceOnyxData, buildUpdateWorkspaceMembersRoleOnyxData} from './Policy/Member';
import {buildOptimisticPolicyRecentlyUsedDestinations} from './Policy/PerDiem';
import {buildOptimisticRecentlyUsedCurrencies, buildPolicyData, generatePolicyID} from './Policy/Policy';
import {buildOptimisticPolicyRecentlyUsedTags} from './Policy/Tag';
import {buildOptimisticPolicyRecentlyUsedTags, getPolicyTagsData} from './Policy/Tag';
import type {GuidedSetupData} from './Report';
import {buildInviteToRoomOnyxData, completeOnboarding, getCurrentUserAccountID, notifyNewAction} from './Report';
import {clearAllRelatedReportActionErrors} from './ReportActions';
Expand Down Expand Up @@ -687,6 +687,13 @@ Onyx.connect({
},
});

let allPolicyCategories: OnyxCollection<OnyxTypes.PolicyCategories> = {};
Onyx.connect({
key: ONYXKEYS.COLLECTION.POLICY_CATEGORIES,
waitForCollectionCallback: true,
callback: (val) => (allPolicyCategories = val),
});

const allPolicies: OnyxCollection<OnyxTypes.Policy> = {};
Onyx.connect({
key: ONYXKEYS.COLLECTION.POLICY,
Expand Down Expand Up @@ -4202,8 +4209,14 @@ function getUpdateMoneyRequestParams(
value: {...iouReport, ...(isTotalIndeterminate && {pendingFields: {total: null}})},
});
}
const hasModifiedComment = 'comment' in transactionChanges;

if (policy && isPaidGroupPolicy(policy) && updatedTransaction && (hasModifiedTag || hasModifiedCategory || hasModifiedDistanceRate || hasModifiedAmount || hasModifiedCreated)) {
if (
policy &&
isPaidGroupPolicy(policy) &&
updatedTransaction &&
(hasModifiedTag || hasModifiedCategory || hasModifiedComment || hasModifiedDistanceRate || hasModifiedAmount || hasModifiedCreated)
) {
const currentTransactionViolations = allTransactionViolations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`] ?? [];
const violationsOnyxData = ViolationsUtils.getViolationsOnyxData(
updatedTransaction,
Expand Down Expand Up @@ -10009,13 +10022,13 @@ function detachReceipt(transactionID: string | undefined) {
return;
}
const transaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`];
const expenseReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`] ?? null;
const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${expenseReport?.policyID}`];
const newTransaction = transaction
? {
...transaction,
filename: '',
receipt: {
source: '',
},
receipt: {},
}
: null;

Expand Down Expand Up @@ -10056,7 +10069,28 @@ function detachReceipt(transactionID: string | undefined) {
},
},
];
const expenseReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`] ?? null;

if (policy && isPaidGroupPolicy(policy) && newTransaction) {
const policyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policy.id}`];
const policyTagList = getPolicyTagsData(policy.id);
const currentTransactionViolations = allTransactionViolations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`] ?? [];
const violationsOnyxData = ViolationsUtils.getViolationsOnyxData(
newTransaction,
currentTransactionViolations,
policy,
policyTagList ?? {},
policyCategories ?? {},
hasDependentTags(policy, policyTagList ?? {}),
isInvoiceReportReportUtils(expenseReport),
);
optimisticData.push(violationsOnyxData);
failureData.push({
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`,
value: currentTransactionViolations,
});
}

const updatedReportAction = buildOptimisticDetachReceipt(expenseReport?.reportID, transactionID, transaction?.merchant);

optimisticData.push({
Expand Down Expand Up @@ -10111,12 +10145,14 @@ function replaceReceipt({transactionID, file, source}: ReplaceReceipt) {
}

const transaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`];
const expenseReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`] ?? null;
const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${expenseReport?.policyID}`];
const oldReceipt = transaction?.receipt ?? {};
const receiptOptimistic = {
source,
state: CONST.IOU.RECEIPT_STATE.OPEN,
};

const newTransaction = transaction && {...transaction, receipt: receiptOptimistic, filename: file.name};
const retryParams = {transactionID, file: undefined, source};
const currentSearchQueryJSON = getCurrentSearchQueryJSON();

Expand Down Expand Up @@ -10162,6 +10198,26 @@ function replaceReceipt({transactionID, file, source}: ReplaceReceipt) {
},
];

if (policy && isPaidGroupPolicy(policy) && newTransaction) {
const policyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policy.id}`];
const policyTagList = getPolicyTagsData(policy.id);
const currentTransactionViolations = allTransactionViolations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`] ?? [];
const violationsOnyxData = ViolationsUtils.getViolationsOnyxData(
newTransaction,
currentTransactionViolations,
policy,
policyTagList ?? {},
policyCategories ?? {},
hasDependentTags(policy, policyTagList ?? {}),
isInvoiceReportReportUtils(expenseReport),
);
optimisticData.push(violationsOnyxData);
failureData.push({
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`,
value: currentTransactionViolations,
});
}
if (currentSearchQueryJSON?.hash) {
optimisticData.push({
onyxMethod: Onyx.METHOD.MERGE,
Expand Down
46 changes: 46 additions & 0 deletions tests/unit/ViolationUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ const receiptRequiredViolation = {
},
};

const categoryReceiptRequiredViolation = {
name: CONST.VIOLATIONS.RECEIPT_REQUIRED,
type: CONST.VIOLATION_TYPES.VIOLATION,
showInReview: true,
data: undefined,
};

const overLimitViolation = {
name: CONST.VIOLATIONS.OVER_LIMIT,
type: CONST.VIOLATION_TYPES.VIOLATION,
Expand All @@ -43,6 +50,21 @@ const overLimitViolation = {
},
};

const categoryOverLimitViolation = {
name: CONST.VIOLATIONS.OVER_CATEGORY_LIMIT,
type: CONST.VIOLATION_TYPES.VIOLATION,
showInReview: true,
data: {
formattedLimit: convertAmountToDisplayString(CONST.POLICY.DEFAULT_MAX_EXPENSE_AMOUNT),
},
};

const categoryMissingCommentViolation = {
name: CONST.VIOLATIONS.MISSING_COMMENT,
type: CONST.VIOLATION_TYPES.VIOLATION,
showInReview: true,
};

const customUnitOutOfPolicyViolation = {
name: CONST.VIOLATIONS.CUSTOM_UNIT_OUT_OF_POLICY,
type: CONST.VIOLATION_TYPES.VIOLATION,
Expand Down Expand Up @@ -210,6 +232,30 @@ describe('getViolationsOnyxData', () => {
});
});

describe('policyCategoryRules', () => {
beforeEach(() => {
policy.type = CONST.POLICY.TYPE.CORPORATE;
policy.outputCurrency = CONST.CURRENCY.USD;
policyCategories = {
Food: {
name: 'Food',
enabled: true,
areCommentsRequired: true,
maxAmountNoReceipt: 0,
maxExpenseAmount: CONST.POLICY.DEFAULT_MAX_EXPENSE_AMOUNT,
},
};
transaction.category = 'Food';
transaction.amount = CONST.POLICY.DEFAULT_MAX_EXPENSE_AMOUNT + 1;
transaction.comment = {comment: ''};
});

it.only('should add category specific violations', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@FitseTLT Same thing happened here with it.only() 😁

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sorry, I'm missing the context here, what happened @tgolen?

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.

We are merging the fix don't worry 👍

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@MarioExpensify sorry, the context comes from another PR were it.only() was accidentally used also. Basically, when you use it.only(), then Jest will ONLY run that one test. It's great for debugging and developing locally, but it's also quite hazardous if it gets merged because it skips all the other tests in this file.

@MarioExpensify MarioExpensify May 29, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thank you!! I wasn't aware of that, will keep an eye for other usages that may show up!

const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policy, policyTags, policyCategories, false, false);
expect(result.value).toEqual(expect.arrayContaining([categoryOverLimitViolation, categoryReceiptRequiredViolation, categoryMissingCommentViolation, ...transactionViolations]));
});
});

describe('policyRequiresCategories', () => {
beforeEach(() => {
policy.requiresCategory = true;
Expand Down