diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 5576eb64736d..a983ec5acba5 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -265,6 +265,7 @@ const ONYXKEYS = { REPORT_USER_IS_LEAVING_ROOM: 'reportUserIsLeavingRoom_', SECURITY_GROUP: 'securityGroup_', TRANSACTION: 'transactions_', + TRANSACTION_VIOLATIONS: 'transactionViolations_', // Holds temporary transactions used during the creation and edit flow TRANSACTION_DRAFT: 'transactionsDraft_', diff --git a/src/hooks/useViolations.ts b/src/hooks/useViolations.ts new file mode 100644 index 000000000000..3aef5cd9b716 --- /dev/null +++ b/src/hooks/useViolations.ts @@ -0,0 +1,72 @@ +import {useCallback, useMemo} from 'react'; +import {TransactionViolation, ViolationName} from '@src/types/onyx'; + +/** + * Names of Fields where violations can occur + */ +type ViolationField = 'amount' | 'billable' | 'category' | 'comment' | 'date' | 'merchant' | 'receipt' | 'tag' | 'tax'; + +/** + * Map from Violation Names to the field where that violation can occur + */ +const violationFields: Record = { + allTagLevelsRequired: 'tag', + autoReportedRejectedExpense: 'merchant', + billableExpense: 'billable', + cashExpenseWithNoReceipt: 'receipt', + categoryOutOfPolicy: 'category', + conversionSurcharge: 'amount', + customUnitOutOfPolicy: 'merchant', + duplicatedTransaction: 'merchant', + fieldRequired: 'merchant', + futureDate: 'date', + invoiceMarkup: 'amount', + maxAge: 'date', + missingCategory: 'category', + missingComment: 'comment', + missingTag: 'tag', + modifiedAmount: 'amount', + modifiedDate: 'date', + nonExpensiworksExpense: 'merchant', + overAutoApprovalLimit: 'amount', + overCategoryLimit: 'amount', + overLimit: 'amount', + overLimitAttendee: 'amount', + perDayLimit: 'amount', + receiptNotSmartScanned: 'receipt', + receiptRequired: 'receipt', + rter: 'merchant', + smartscanFailed: 'receipt', + someTagLevelsRequired: 'tag', + tagOutOfPolicy: 'tag', + taxAmountChanged: 'tax', + taxOutOfPolicy: 'tax', + taxRateChanged: 'tax', + taxRequired: 'tax', +}; + +type ViolationsMap = Map; + +function useViolations(violations: TransactionViolation[]) { + const violationsByField = useMemo((): ViolationsMap => { + const violationGroups = new Map(); + + for (const violation of violations) { + const field = violationFields[violation.name]; + const existingViolations = violationGroups.get(field) ?? []; + violationGroups.set(field, [...existingViolations, violation]); + } + + return violationGroups ?? new Map(); + }, [violations]); + + const hasViolations = useCallback((field: ViolationField) => Boolean(violationsByField.get(field)?.length), [violationsByField]); + const getViolationsForField = useCallback((field: ViolationField) => violationsByField.get(field) ?? [], [violationsByField]); + + return { + hasViolations, + getViolationsForField, + }; +} + +export default useViolations; diff --git a/src/languages/en.ts b/src/languages/en.ts index 8f772f1260bb..b03e5d228a55 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1979,4 +1979,39 @@ export default { }, copyReferralLink: 'Copy referral link', }, + violations: { + allTagLevelsRequired: 'dummy.violations.allTagLevelsRequired', + autoReportedRejectedExpense: 'dummy.violations.autoReportedRejectedExpense', + billableExpense: 'dummy.violations.billableExpense', + cashExpenseWithNoReceipt: 'dummy.violations.cashExpenseWithNoReceipt', + categoryOutOfPolicy: 'dummy.violations.categoryOutOfPolicy', + conversionSurcharge: 'dummy.violations.conversionSurcharge', + customUnitOutOfPolicy: 'dummy.violations.customUnitOutOfPolicy', + duplicatedTransaction: 'dummy.violations.duplicatedTransaction', + fieldRequired: 'dummy.violations.fieldRequired', + futureDate: 'dummy.violations.futureDate', + invoiceMarkup: 'dummy.violations.invoiceMarkup', + maxAge: 'dummy.violations.maxAge', + missingCategory: 'dummy.violations.missingCategory', + missingComment: 'dummy.violations.missingComment', + missingTag: 'dummy.violations.missingTag', + modifiedAmount: 'dummy.violations.modifiedAmount', + modifiedDate: 'dummy.violations.modifiedDate', + nonExpensiworksExpense: 'dummy.violations.nonExpensiworksExpense', + overAutoApprovalLimit: 'dummy.violations.overAutoApprovalLimit', + overCategoryLimit: 'dummy.violations.overCategoryLimit', + overLimit: 'dummy.violations.overLimit', + overLimitAttendee: 'dummy.violations.overLimitAttendee', + perDayLimit: 'dummy.violations.perDayLimit', + receiptNotSmartScanned: 'dummy.violations.receiptNotSmartScanned', + receiptRequired: 'dummy.violations.receiptRequired', + rter: 'dummy.violations.rter', + smartscanFailed: 'dummy.violations.smartscanFailed', + someTagLevelsRequired: 'dummy.violations.someTagLevelsRequired', + tagOutOfPolicy: 'dummy.violations.tagOutOfPolicy', + taxAmountChanged: 'dummy.violations.taxAmountChanged', + taxOutOfPolicy: 'dummy.violations.taxOutOfPolicy', + taxRateChanged: 'dummy.violations.taxRateChanged', + taxRequired: 'dummy.violations.taxRequired', + }, } satisfies TranslationBase; diff --git a/src/languages/es.ts b/src/languages/es.ts index 3887891299df..c6bc77681b21 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -2464,4 +2464,39 @@ export default { }, copyReferralLink: 'Copiar enlace de invitación', }, + violations: { + allTagLevelsRequired: 'dummy.violations.allTagLevelsRequired', + autoReportedRejectedExpense: 'dummy.violations.autoReportedRejectedExpense', + billableExpense: 'dummy.violations.billableExpense', + cashExpenseWithNoReceipt: 'dummy.violations.cashExpenseWithNoReceipt', + categoryOutOfPolicy: 'dummy.violations.categoryOutOfPolicy', + conversionSurcharge: 'dummy.violations.conversionSurcharge', + customUnitOutOfPolicy: 'dummy.violations.customUnitOutOfPolicy', + duplicatedTransaction: 'dummy.violations.duplicatedTransaction', + fieldRequired: 'dummy.violations.fieldRequired', + futureDate: 'dummy.violations.futureDate', + invoiceMarkup: 'dummy.violations.invoiceMarkup', + maxAge: 'dummy.violations.maxAge', + missingCategory: 'dummy.violations.missingCategory', + missingComment: 'dummy.violations.missingComment', + missingTag: 'dummy.violations.missingTag', + modifiedAmount: 'dummy.violations.modifiedAmount', + modifiedDate: 'dummy.violations.modifiedDate', + nonExpensiworksExpense: 'dummy.violations.nonExpensiworksExpense', + overAutoApprovalLimit: 'dummy.violations.overAutoApprovalLimit', + overCategoryLimit: 'dummy.violations.overCategoryLimit', + overLimit: 'dummy.violations.overLimit', + overLimitAttendee: 'dummy.violations.overLimitAttendee', + perDayLimit: 'dummy.violations.perDayLimit', + receiptNotSmartScanned: 'dummy.violations.receiptNotSmartScanned', + receiptRequired: 'dummy.violations.receiptRequired', + rter: 'dummy.violations.rter', + smartscanFailed: 'dummy.violations.smartscanFailed', + someTagLevelsRequired: 'dummy.violations.someTagLevelsRequired', + tagOutOfPolicy: 'dummy.violations.tagOutOfPolicy', + taxAmountChanged: 'dummy.violations.taxAmountChanged', + taxOutOfPolicy: 'dummy.violations.taxOutOfPolicy', + taxRateChanged: 'dummy.violations.taxRateChanged', + taxRequired: 'dummy.violations.taxRequired', + }, } satisfies EnglishTranslation; diff --git a/src/libs/ViolationsUtils.ts b/src/libs/ViolationsUtils.ts new file mode 100644 index 000000000000..4cfa259c9e78 --- /dev/null +++ b/src/libs/ViolationsUtils.ts @@ -0,0 +1,85 @@ +import reject from 'lodash/reject'; +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {PolicyCategories, PolicyTags, Transaction, TransactionViolation} from '@src/types/onyx'; + +const ViolationsUtils = { + /** + * Checks a transaction for policy violations and returns an object with Onyx method, key and updated transaction + * violations. + */ + getViolationsOnyxData( + transaction: Transaction, + transactionViolations: TransactionViolation[], + policyRequiresTags: boolean, + policyTags: PolicyTags, + policyRequiresCategories: boolean, + policyCategories: PolicyCategories, + ): { + onyxMethod: string; + key: string; + value: TransactionViolation[]; + } { + let newTransactionViolations = [...transactionViolations]; + + if (policyRequiresCategories) { + const hasCategoryOutOfPolicyViolation = transactionViolations.some((violation) => violation.name === 'categoryOutOfPolicy'); + const hasMissingCategoryViolation = transactionViolations.some((violation) => violation.name === 'missingCategory'); + const isCategoryInPolicy = Boolean(policyCategories[transaction.category]?.enabled); + + // Add 'categoryOutOfPolicy' violation if category is not in policy + if (!hasCategoryOutOfPolicyViolation && transaction.category && !isCategoryInPolicy) { + newTransactionViolations.push({name: 'categoryOutOfPolicy', type: 'violation', userMessage: ''}); + } + + // Remove 'categoryOutOfPolicy' violation if category is in policy + if (hasCategoryOutOfPolicyViolation && transaction.category && isCategoryInPolicy) { + newTransactionViolations = reject(newTransactionViolations, {name: 'categoryOutOfPolicy'}); + } + + // Remove 'missingCategory' violation if category is valid according to policy + if (hasMissingCategoryViolation && isCategoryInPolicy) { + newTransactionViolations = reject(newTransactionViolations, {name: 'missingCategory'}); + } + + // Add 'missingCategory' violation if category is required and not set + if (!hasMissingCategoryViolation && policyRequiresCategories && !transaction.category) { + newTransactionViolations.push({name: 'missingCategory', type: 'violation', userMessage: ''}); + } + } + + if (policyRequiresTags) { + const hasTagOutOfPolicyViolation = transactionViolations.some((violation) => violation.name === 'tagOutOfPolicy'); + const hasMissingTagViolation = transactionViolations.some((violation) => violation.name === 'missingTag'); + const isTagInPolicy = Boolean(policyTags[transaction.tag]?.enabled); + + // Add 'tagOutOfPolicy' violation if tag is not in policy + if (!hasTagOutOfPolicyViolation && transaction.tag && !isTagInPolicy) { + newTransactionViolations.push({name: 'tagOutOfPolicy', type: 'violation', userMessage: ''}); + } + + // Remove 'tagOutOfPolicy' violation if tag is in policy + if (hasTagOutOfPolicyViolation && transaction.tag && isTagInPolicy) { + newTransactionViolations = reject(newTransactionViolations, {name: 'tagOutOfPolicy'}); + } + + // Remove 'missingTag' violation if tag is valid according to policy + if (hasMissingTagViolation && isTagInPolicy) { + newTransactionViolations = reject(newTransactionViolations, {name: 'missingTag'}); + } + + // Add 'missingTag violation' if tag is required and not set + if (!hasMissingTagViolation && !transaction.tag && policyRequiresTags) { + newTransactionViolations.push({name: 'missingTag', type: 'violation', userMessage: ''}); + } + } + + return { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}`, + value: newTransactionViolations, + }; + }, +}; + +export default ViolationsUtils; diff --git a/src/types/onyx/PolicyCategory.ts b/src/types/onyx/PolicyCategory.ts index adaf16e1acec..b6dfb7bbab9a 100644 --- a/src/types/onyx/PolicyCategory.ts +++ b/src/types/onyx/PolicyCategory.ts @@ -19,4 +19,6 @@ type PolicyCategory = { origin: string; }; +type PolicyCategories = Record; export default PolicyCategory; +export type {PolicyCategories}; diff --git a/src/types/onyx/TransactionViolation.ts b/src/types/onyx/TransactionViolation.ts new file mode 100644 index 000000000000..f7bc5ea1ee8b --- /dev/null +++ b/src/types/onyx/TransactionViolation.ts @@ -0,0 +1,46 @@ +/** + * Names of transaction violations + */ +type ViolationName = + | 'allTagLevelsRequired' + | 'autoReportedRejectedExpense' + | 'billableExpense' + | 'cashExpenseWithNoReceipt' + | 'categoryOutOfPolicy' + | 'conversionSurcharge' + | 'customUnitOutOfPolicy' + | 'duplicatedTransaction' + | 'fieldRequired' + | 'futureDate' + | 'invoiceMarkup' + | 'maxAge' + | 'missingCategory' + | 'missingComment' + | 'missingTag' + | 'modifiedAmount' + | 'modifiedDate' + | 'nonExpensiworksExpense' + | 'overAutoApprovalLimit' + | 'overCategoryLimit' + | 'overLimit' + | 'overLimitAttendee' + | 'perDayLimit' + | 'receiptNotSmartScanned' + | 'receiptRequired' + | 'rter' + | 'smartscanFailed' + | 'someTagLevelsRequired' + | 'tagOutOfPolicy' + | 'taxAmountChanged' + | 'taxOutOfPolicy' + | 'taxRateChanged' + | 'taxRequired'; + +type TransactionViolation = { + type: string; + name: ViolationName; + userMessage: string; + data?: Record; +}; + +export type {TransactionViolation, ViolationName}; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index e7b9c7661c79..4d4f45442d55 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -21,7 +21,7 @@ import PersonalBankAccount from './PersonalBankAccount'; import PersonalDetails from './PersonalDetails'; import PlaidData from './PlaidData'; import Policy from './Policy'; -import PolicyCategory from './PolicyCategory'; +import PolicyCategory, {PolicyCategories} from './PolicyCategory'; import PolicyMember, {PolicyMembers} from './PolicyMember'; import PolicyTag, {PolicyTags} from './PolicyTag'; import PrivatePersonalDetails from './PrivatePersonalDetails'; @@ -42,6 +42,7 @@ import SecurityGroup from './SecurityGroup'; import Session from './Session'; import Task from './Task'; import Transaction from './Transaction'; +import {TransactionViolation, ViolationName} from './TransactionViolation'; import User from './User'; import UserLocation from './UserLocation'; import UserWallet from './UserWallet'; @@ -80,6 +81,7 @@ export type { PlaidData, Policy, PolicyCategory, + PolicyCategories, PolicyMember, PolicyMembers, PolicyTag, @@ -103,8 +105,10 @@ export type { Session, Task, Transaction, + TransactionViolation, User, UserWallet, + ViolationName, WalletAdditionalDetails, WalletOnfido, WalletStatement, diff --git a/tests/unit/ViolationUtilsTest.js b/tests/unit/ViolationUtilsTest.js new file mode 100644 index 000000000000..cc84c547da2e --- /dev/null +++ b/tests/unit/ViolationUtilsTest.js @@ -0,0 +1,194 @@ +import {beforeEach} from '@jest/globals'; +import Onyx from 'react-native-onyx'; +import ViolationsUtils from '@libs/ViolationsUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; + +const categoryOutOfPolicyViolation = { + name: 'categoryOutOfPolicy', + type: 'violation', + userMessage: '', +}; + +const missingCategoryViolation = { + name: 'missingCategory', + type: 'violation', + userMessage: '', +}; + +const tagOutOfPolicyViolation = { + name: 'tagOutOfPolicy', + type: 'violation', + userMessage: '', +}; + +const missingTagViolation = { + name: 'missingTag', + type: 'violation', + userMessage: '', +}; + +describe('getViolationsOnyxData', () => { + let transaction; + let transactionViolations; + let policyRequiresTags; + let policyTags; + let policyRequiresCategories; + let policyCategories; + + beforeEach(() => { + transaction = {transactionID: '123'}; + transactionViolations = []; + policyRequiresTags = false; + policyTags = {}; + policyRequiresCategories = false; + policyCategories = {}; + }); + + it('should return an object with correct shape and with empty transactionViolations array', () => { + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policyRequiresTags, policyTags, policyRequiresCategories, policyCategories); + + expect(result).toEqual({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}`, + value: transactionViolations, + }); + }); + + it('should handle multiple violations', () => { + transactionViolations = [ + {name: 'duplicatedTransaction', type: 'violation', userMessage: ''}, + {name: 'receiptRequired', type: 'violation', userMessage: ''}, + ]; + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policyRequiresTags, policyTags, policyRequiresCategories, policyCategories); + expect(result.value).toEqual(expect.arrayContaining(transactionViolations)); + }); + + describe('policyRequiresCategories', () => { + beforeEach(() => { + policyRequiresCategories = true; + policyCategories = {Food: {enabled: true}}; + transaction.category = 'Food'; + }); + + it('should add missingCategory violation if no category is included', () => { + transaction.category = null; + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policyRequiresTags, policyTags, policyRequiresCategories, policyCategories); + expect(result.value).toEqual(expect.arrayContaining([missingCategoryViolation, ...transactionViolations])); + }); + + it('should add categoryOutOfPolicy violation when category is not in policy', () => { + transaction.category = 'Bananas'; + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policyRequiresTags, policyTags, policyRequiresCategories, policyCategories); + expect(result.value).toEqual(expect.arrayContaining([categoryOutOfPolicyViolation, ...transactionViolations])); + }); + + it('should not include a categoryOutOfPolicy violation when category is in policy', () => { + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policyRequiresTags, policyTags, policyRequiresCategories, policyCategories); + expect(result.value).not.toContainEqual(categoryOutOfPolicyViolation); + }); + + it('should add categoryOutOfPolicy violation to existing violations if they exist', () => { + transaction.category = 'Bananas'; + transactionViolations = [ + {name: 'duplicatedTransaction', type: 'violation', userMessage: ''}, + {name: 'receiptRequired', type: 'violation', userMessage: ''}, + ]; + + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policyRequiresTags, policyTags, policyRequiresCategories, policyCategories); + + expect(result.value).toEqual(expect.arrayContaining([categoryOutOfPolicyViolation, ...transactionViolations])); + }); + + it('should add missingCategory violation to existing violations if they exist', () => { + transaction.category = undefined; + transactionViolations = [ + {name: 'duplicatedTransaction', type: 'violation', userMessage: ''}, + {name: 'receiptRequired', type: 'violation', userMessage: ''}, + ]; + + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policyRequiresTags, policyTags, policyRequiresCategories, policyCategories); + + expect(result.value).toEqual(expect.arrayContaining([missingCategoryViolation, ...transactionViolations])); + }); + }); + + describe('policy does not require Categories', () => { + beforeEach(() => { + policyRequiresCategories = false; + }); + + it('should not add any violations when categories are not required', () => { + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policyRequiresTags, policyTags, policyRequiresCategories, policyCategories); + + expect(result.value).not.toContainEqual([categoryOutOfPolicyViolation]); + expect(result.value).not.toContainEqual([missingCategoryViolation]); + }); + }); + + describe('policyRequiresTags', () => { + beforeEach(() => { + policyRequiresTags = true; + policyTags = {Lunch: {enabled: true}, Dinner: {enabled: true}}; + transaction.tag = 'Lunch'; + }); + + it("shouldn't update the transactionViolations if the policy requires tags and the transaction has a tag from the policy", () => { + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policyRequiresTags, policyTags, policyRequiresCategories, policyCategories); + + expect(result.value).toEqual(transactionViolations); + }); + + it('should add a missingTag violation if none is provided and policy requires tags', () => { + transaction.tag = undefined; + + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policyRequiresTags, policyTags, policyRequiresCategories, policyCategories); + + expect(result.value).toEqual(expect.arrayContaining([missingTagViolation])); + }); + + it('should add a tagOutOfPolicy violation when policy requires tags and tag is not in the policy', () => { + policyTags = {}; + + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policyRequiresTags, policyTags, policyRequiresCategories, policyCategories); + + expect(result.value).toEqual(expect.arrayContaining([tagOutOfPolicyViolation])); + }); + + it('should add tagOutOfPolicy violation to existing violations if transaction has tag that is not in the policy', () => { + transaction.tag = 'Bananas'; + transactionViolations = [ + {name: 'duplicatedTransaction', type: 'violation', userMessage: ''}, + {name: 'receiptRequired', type: 'violation', userMessage: ''}, + ]; + + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policyRequiresTags, policyTags, policyRequiresCategories, policyCategories); + + expect(result.value).toEqual(expect.arrayContaining([tagOutOfPolicyViolation, ...transactionViolations])); + }); + + it('should add missingTag violation to existing violations if transaction does not have a tag', () => { + transaction.tag = undefined; + transactionViolations = [ + {name: 'duplicatedTransaction', type: 'violation', userMessage: ''}, + {name: 'receiptRequired', type: 'violation', userMessage: ''}, + ]; + + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policyRequiresTags, policyTags, policyRequiresCategories, policyCategories); + + expect(result.value).toEqual(expect.arrayContaining([missingTagViolation, ...transactionViolations])); + }); + }); + + describe('policy does not require Tags', () => { + beforeEach(() => { + policyRequiresTags = false; + }); + + it('should not add any violations when tags are not required', () => { + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policyRequiresTags, policyTags, policyRequiresCategories, policyCategories); + + expect(result.value).not.toContainEqual([tagOutOfPolicyViolation]); + expect(result.value).not.toContainEqual([missingTagViolation]); + }); + }); +});