diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 78ed00e3ff41..7dcb704aa5f0 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -52,6 +52,7 @@ import {getBrokenConnectionUrlToFixPersonalCard, getCompanyCardDescription} from import {getDecodedLeafCategoryName, isCategoryMissing} from '@libs/CategoryUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import {insertTagIntoTransactionTagsString} from '@libs/IOUUtils'; import {getRateFromMerchant} from '@libs/MergeTransactionUtils'; import {isBillableEnabledOnPolicy, isSingleTransactionReport} from '@libs/MoneyRequestReportUtils'; import {hasEnabledOptions} from '@libs/OptionsListUtils'; @@ -68,7 +69,6 @@ import { hasVendorFeature, isAttendeeTrackingEnabled, isGroupPolicyByType, - isMultiLevelTags, isPerDiemEnabled, isPolicyAccessible, isTaxTrackingEnabled, @@ -393,15 +393,34 @@ function MoneyRequestView({ const canEditAmount = !isGPSDistanceRequest && isEditable && - (canEditFieldOfMoneyRequest({reportAction: parentReportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.AMOUNT, isChatReportArchived, transaction}) || + (canEditFieldOfMoneyRequest({ + reportAction: parentReportAction, + fieldToEdit: CONST.EDIT_REQUEST_FIELD.AMOUNT, + isChatReportArchived, + transaction, + }) || (shouldShowSplitIndicator && isSplitAvailable)); const canEditMerchant = isEditable && - canEditFieldOfMoneyRequest({reportAction: parentReportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.MERCHANT, isChatReportArchived, transaction, report: moneyRequestReport, policy}); + canEditFieldOfMoneyRequest({ + reportAction: parentReportAction, + fieldToEdit: CONST.EDIT_REQUEST_FIELD.MERCHANT, + isChatReportArchived, + transaction, + report: moneyRequestReport, + policy, + }); const canEditDate = isEditable && - canEditFieldOfMoneyRequest({reportAction: parentReportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.DATE, isChatReportArchived, transaction, report: moneyRequestReport, policy}); + canEditFieldOfMoneyRequest({ + reportAction: parentReportAction, + fieldToEdit: CONST.EDIT_REQUEST_FIELD.DATE, + isChatReportArchived, + transaction, + report: moneyRequestReport, + policy, + }); const canEditDistanceOrRate = isPolicyAccessible(policy, currentUserEmailParam) || isTrackExpense || isP2PDistanceRequest; @@ -463,11 +482,11 @@ function MoneyRequestView({ // transactionTag can be an empty string // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const shouldShowTag = (isPolicyExpenseChat || isExpenseUnreported) && (transactionTag || (canEdit && hasEnabledTags(policyTagLists))); - // Surface a delete confirmation (like tax) when the value is stale and there's nothing valid to select, instead - // of navigating to edit. Categories need at least one, so they only hit this when disabled; tags can be fully - // emptied, so also cover "no enabled tags remain". Scoped to single-level tag lists. + // Surface a delete confirmation (like tax) when the value is stale and there's nothing valid to select, instead of + // navigating to edit. Categories need at least one, so they only hit this when disabled; tags can be fully emptied, + // so also cover "no enabled tags remain". Applies to multi-level tags too - deleting clears the whole tag value. const shouldShowCategoryDisabledAlert = !policy?.areCategoriesEnabled && !!category; - const shouldShowTagDisabledAlert = (!policy?.areTagsEnabled || !hasEnabledTags(policyTagLists)) && !!transactionTag && !isMultiLevelTags(policyTagList); + const shouldShowTagDisabledAlert = (!policy?.areTagsEnabled || !hasEnabledTags(policyTagLists)) && !!transactionTag; const shouldShowBillable = (isPolicyExpenseChat || isExpenseUnreported) && (!!transactionBillable || isBillableEnabledOnPolicy(policy) || !!updatedTransaction?.billable); const isCurrentTransactionReimbursableDifferentFromPolicyDefault = policy?.defaultReimbursable !== undefined && !!(updatedTransaction?.reimbursable ?? transactionReimbursable) !== policy.defaultReimbursable; @@ -593,7 +612,9 @@ function MoneyRequestView({ const renderGoogleMerchantSearchLink = () => ( { @@ -602,7 +623,11 @@ function MoneyRequestView({ }} style={[styles.flexRow, styles.alignItemsCenter, styles.mt1, styles.alignSelfStart]} > - {translate('common.googleThisMerchant', {merchant: originalMerchantForGoogleSearch})} + + {translate('common.googleThisMerchant', { + merchant: originalMerchantForGoogleSearch, + })} + { + const showTagDisabledAlert = (tagListIndex: number) => { const transactionID = transaction?.transactionID; if (!transactionID) { return; @@ -855,11 +880,13 @@ function MoneyRequestView({ return; } + // Clear only the pressed level so the other levels of a multi-level tag are kept. + const updatedTag = insertTagIntoTransactionTagsString(transactionTag ?? '', '', tagListIndex, policy?.hasMultipleTagLists ?? false); updateMoneyRequestTag({ transactionID, transactionThreadReport, parentReport, - tag: '', + tag: updatedTag, policy, policyTagList, policyRecentlyUsedTags: undefined, @@ -1061,7 +1088,7 @@ function MoneyRequestView({ return; } if (shouldShowTagDisabledAlert) { - showTagDisabledAlert(); + showTagDisabledAlert(index); return; } Navigation.navigate( diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index bc3d0681883e..0d19f04f7e1a 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -210,13 +210,12 @@ function getTagViolationForIndependentTags(policyTagList: PolicyTagLists, transa let hasInvalidTag = false; for (let i = 0; i < policyTagKeys.length; i++) { const selectedTag = selectedTags.at(i); - const tags = policyTagList[policyTagKeys[i]].tags; - const listHasEnabledTags = hasEnabledTags(tags); - if (!listHasEnabledTags) { + if (!selectedTag) { continue; } - const isTagInPolicy = !!selectedTag && !!tags[selectedTag]?.enabled; - if (!isTagInPolicy && selectedTag) { + const tags = policyTagList[policyTagKeys[i]].tags; + const isTagInPolicy = !!tags[selectedTag]?.enabled; + if (!isTagInPolicy) { newTransactionViolations.push({ name: CONST.VIOLATIONS.TAG_OUT_OF_POLICY, type: CONST.VIOLATION_TYPES.VIOLATION, @@ -226,6 +225,10 @@ function getTagViolationForIndependentTags(policyTagList: PolicyTagLists, transa }, }); hasInvalidTag = true; + // The backend emits a single tagOutOfPolicy for the whole multi-level tag (labeled with the first + // out-of-policy level), so stop here to match it - flagging every level would flash extra errors on + // the other level rows that vanish once the backend response syncs. + break; } } if (!hasInvalidTag) { @@ -362,7 +365,7 @@ function getIsViolationFixed(violationError: string, params: ViolationFixParams) return !taxCode; } const matchingTaxRate = policyTaxRates[taxCode]; - if (!matchingTaxRate) { + if (!matchingTaxRate || matchingTaxRate.isDisabled) { return false; } // If taxValue is provided, check that it matches the policy tax rate. If taxValue is not provided, just check that the tax code exists in the policy. @@ -544,8 +547,9 @@ const ViolationsUtils = { newTransactionViolations = reject(newTransactionViolations, {name: 'missingCategory'}); } - // Add 'missingCategory' violation if category is required and not set - if (!hasMissingCategoryViolation && policyRequiresCategories && !categoryKey && !isSelfDM) { + // Add 'missingCategory' violation when categories are required and none is set. isCategoryMissing also + // covers the 'Uncategorized'/'none' sentinel, mirroring the categoryOutOfPolicy check above. + if (!hasMissingCategoryViolation && !!policy.requiresCategory && isCategoryMissing(categoryKey) && !isSelfDM) { newTransactionViolations.push({name: 'missingCategory', type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true}); } } else if (transactionViolations.some((violation) => violation.name === 'missingCategory')) { @@ -650,7 +654,11 @@ const ViolationsUtils = { const isPerDiemRequest = TransactionUtils.isPerDiemRequest(updatedTransaction); const isTimeRequest = TransactionUtils.isTimeRequest(updatedTransaction); const isPolicyTrackTaxEnabled = isTaxTrackingEnabled(true, policy, isDistanceRequest, isPerDiemRequest, isTimeRequest); - const isTaxInPolicy = Object.keys(policy.taxRates?.taxes ?? {}).some((key) => key === updatedTransaction.taxCode); + + // A disabled tax rate keeps its key (just `isDisabled: true`) but isn't valid, so it stays out of policy. A + // key-only check would treat it as in-policy and drop the violation on any unrelated recompute (e.g. tag delete). + const taxRate = updatedTransaction.taxCode ? policy.taxRates?.taxes?.[updatedTransaction.taxCode] : undefined; + const isTaxRateValid = !!taxRate && !taxRate.isDisabled; const amount = hasValidModifiedAmount(updatedTransaction) ? Number(updatedTransaction.modifiedAmount) : updatedTransaction.amount; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing @@ -874,7 +882,7 @@ const ViolationsUtils = { // When tax tracking is enabled, only a non-empty tax code that isn't a current policy rate is out of policy. // A transaction with no tax code (e.g. its tax was deleted) must not be flagged. - const shouldAddTaxOutOfPolicy = !isTimeRequest && !isPerDiemRequest && (isPolicyTrackTaxEnabled ? !!updatedTransaction.taxCode && !isTaxInPolicy : hasTransactionTaxData); + const shouldAddTaxOutOfPolicy = !isTimeRequest && !isPerDiemRequest && (isPolicyTrackTaxEnabled ? !!updatedTransaction.taxCode && !isTaxRateValid : hasTransactionTaxData); if (!hasTaxOutOfPolicyViolation && shouldAddTaxOutOfPolicy) { newTransactionViolations.push({name: CONST.VIOLATIONS.TAX_OUT_OF_POLICY, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true}); diff --git a/src/libs/actions/Policy/Tag.ts b/src/libs/actions/Policy/Tag.ts index 5339d4554208..1ae17655b865 100644 --- a/src/libs/actions/Policy/Tag.ts +++ b/src/libs/actions/Policy/Tag.ts @@ -751,6 +751,21 @@ function renamePolicyTag(policyData: PolicyData, policyTag: {oldName: string; ne API.write(WRITE_COMMANDS.RENAME_POLICY_TAG, parameters, onyxData); } +// Builds a POLICY_TAGS update that sets every tag in the given lists to `enabled`. +function buildPolicyTagsEnabledData(policyTags: PolicyTagLists, enabled: boolean): Record> { + return Object.fromEntries( + Object.entries(policyTags).map(([listName, tagList]): [string, Partial] => [ + listName, + {tags: Object.fromEntries(Object.entries(tagList.tags).map(([tagName, tag]): [string, PolicyTag] => [tagName, {...tag, enabled}]))}, + ]), + ); +} + +// Builds a POLICY_TAGS update that restores every list's tags to their current state (used as failure data). +function buildPolicyTagsRestoreData(policyTags: PolicyTagLists): Record> { + return Object.fromEntries(Object.entries(policyTags).map(([listName, tagList]): [string, Partial] => [listName, {tags: tagList.tags}])); +} + function enablePolicyTags(policyData: PolicyData, enabled: boolean) { const policyID = policyData.policy?.id; const policyOptimisticData = { @@ -793,6 +808,11 @@ function enablePolicyTags(policyData: PolicyData, enabled: boolean) { ], }; + // BE flags only the first tag level out of policy when the Tags feature is toggled, so scope the optimistic + // enable/disable to that list to mirror it. + const firstTagList = PolicyUtils.getTagLists(policyData.tags).at(0); + const firstTagListData: PolicyTagLists = firstTagList ? {[firstTagList.name]: firstTagList} : {}; + if (Object.keys(policyData.tags).length === 0) { const defaultTagList: PolicyTagLists = { Tag: { @@ -814,24 +834,7 @@ function enablePolicyTags(policyData: PolicyData, enabled: boolean) { }); pushTransactionViolationsOnyxData(onyxData, policyData, policyOptimisticData, {}, defaultTagList); } else if (!enabled) { - const policyTag = PolicyUtils.getTagLists(policyData.tags).at(0); - - if (!policyTag) { - return; - } - - const policyTagsOptimisticData: Record> = { - [policyTag.name]: { - tags: Object.fromEntries( - Object.keys(policyTag.tags).map((tagName) => [ - tagName, - { - enabled: false, - }, - ]), - ), - } as Partial, - }; + const policyTagsOptimisticData = buildPolicyTagsEnabledData(firstTagListData, false); onyxData.optimisticData?.push( { @@ -847,10 +850,30 @@ function enablePolicyTags(policyData: PolicyData, enabled: boolean) { }, }, ); + onyxData.failureData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: buildPolicyTagsRestoreData(firstTagListData), + }); const autoSelections = pushTransactionAutoSelectionsOnyxData(onyxData, policyData, {...policyOptimisticData, requiresTag: false}, {}, policyTagsOptimisticData); pushTransactionViolationsOnyxData(onyxData, policyData, {...policyOptimisticData, requiresTag: false}, {}, policyTagsOptimisticData, autoSelections); + } else if (firstTagList && Object.keys(firstTagList.tags).length > 0) { + const policyTagsOptimisticData = buildPolicyTagsEnabledData(firstTagListData, true); + + onyxData.optimisticData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: policyTagsOptimisticData, + }); + onyxData.failureData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: buildPolicyTagsRestoreData(firstTagListData), + }); + + pushTransactionViolationsOnyxData(onyxData, policyData, policyOptimisticData, {}, policyTagsOptimisticData); } else { pushTransactionViolationsOnyxData(onyxData, policyData, policyOptimisticData); } diff --git a/tests/actions/PolicyTagTest.ts b/tests/actions/PolicyTagTest.ts index 1f78103dab38..959e879f5081 100644 --- a/tests/actions/PolicyTagTest.ts +++ b/tests/actions/PolicyTagTest.ts @@ -1930,6 +1930,121 @@ describe('actions/Policy', () => { expect(policyData.current.policy?.pendingFields).toBeDefined(); }); + it('should re-enable the tags it previously disabled when enabling the feature again', async () => { + // Given a policy whose tags were turned off when the Tags feature was disabled + const fakePolicy = createRandomPolicy(0); + fakePolicy.areTagsEnabled = false; + + const tagListName = 'Tag'; + const fakePolicyTags = createRandomPolicyTags(tagListName, 2); + const existingTags = fakePolicyTags[tagListName]?.tags ?? {}; + for (const tagName of Object.keys(existingTags)) { + existingTags[tagName].enabled = false; + } + + mockFetch.pause(); + + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${fakePolicy.id}`, fakePolicyTags); + await waitForBatchedUpdates(); + + const {result: policyData, rerender} = renderHook(() => usePolicyData(fakePolicy.id), {wrapper: OnyxListItemProvider}); + + // When re-enabling the feature + enablePolicyTags(policyData.current, true); + await waitForBatchedUpdates(); + + rerender(fakePolicy.id); + + // Then the feature is on and the tags are restored to enabled, so a stale tagOutOfPolicy clears optimistically + expect(policyData.current.policy?.areTagsEnabled).toBe(true); + for (const tagName of Object.keys(existingTags)) { + expect(policyData.current?.tags?.[tagListName]?.tags[tagName]?.enabled).toBe(true); + } + + mockFetch.resume(); + await waitForBatchedUpdates(); + }); + + it('should disable only the first level when disabling a multi-level tag policy', async () => { + // Given a multi-level tag policy with two enabled levels (Department is the first level, Region the second) + const fakePolicy = createRandomPolicy(0); + fakePolicy.areTagsEnabled = true; + + const multiLevelTags: PolicyTagLists = { + ...createRandomPolicyTags('Department', 2), + ...createRandomPolicyTags('Region', 2), + }; + multiLevelTags.Region.orderWeight = 1; + + mockFetch.pause(); + + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${fakePolicy.id}`, multiLevelTags); + await waitForBatchedUpdates(); + + const {result: policyData, rerender} = renderHook(() => usePolicyData(fakePolicy.id), {wrapper: OnyxListItemProvider}); + + // When disabling the feature + enablePolicyTags(policyData.current, false); + await waitForBatchedUpdates(); + + rerender(fakePolicy.id); + + // Then only the first level's tags are disabled; deeper levels stay enabled (BE flags only the first level) + for (const tagName of Object.keys(multiLevelTags.Department.tags)) { + expect(policyData.current?.tags?.Department?.tags[tagName]?.enabled).toBe(false); + } + for (const tagName of Object.keys(multiLevelTags.Region.tags)) { + expect(policyData.current?.tags?.Region?.tags[tagName]?.enabled).toBe(true); + } + + mockFetch.resume(); + await waitForBatchedUpdates(); + }); + + it('should re-enable only the first level when re-enabling a multi-level tag policy', async () => { + // Given a multi-level tag policy whose tags are all currently disabled (Department first, Region second) + const fakePolicy = createRandomPolicy(0); + fakePolicy.areTagsEnabled = false; + + const multiLevelTags: PolicyTagLists = { + ...createRandomPolicyTags('Department', 2), + ...createRandomPolicyTags('Region', 2), + }; + multiLevelTags.Region.orderWeight = 1; + for (const tagList of Object.values(multiLevelTags)) { + for (const tag of Object.values(tagList.tags)) { + tag.enabled = false; + } + } + + mockFetch.pause(); + + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${fakePolicy.id}`, multiLevelTags); + await waitForBatchedUpdates(); + + const {result: policyData, rerender} = renderHook(() => usePolicyData(fakePolicy.id), {wrapper: OnyxListItemProvider}); + + // When re-enabling the feature + enablePolicyTags(policyData.current, true); + await waitForBatchedUpdates(); + + rerender(fakePolicy.id); + + // Then only the first level's tags are restored (mirrors the disable that only turned off the first level) + for (const tagName of Object.keys(multiLevelTags.Department.tags)) { + expect(policyData.current?.tags?.Department?.tags[tagName]?.enabled).toBe(true); + } + for (const tagName of Object.keys(multiLevelTags.Region.tags)) { + expect(policyData.current?.tags?.Region?.tags[tagName]?.enabled).toBe(false); + } + + mockFetch.resume(); + await waitForBatchedUpdates(); + }); + it('should reset changes when API returns error', async () => { // Given a policy with disabled tags const fakePolicy = createRandomPolicy(0); diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 70f37b716c5e..98a4dfc2fa6d 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -10359,6 +10359,74 @@ describe('ReportUtils', () => { expect(onyxData).toMatchObject(expectedOnyxData); }); + + it('should push a missingCategory violation for an Uncategorized expense when categories are enabled', () => { + // A new expense is seeded with the 'Uncategorized' sentinel, not an empty category. Enabling categories must + // treat that sentinel as missing so the violation is written immediately. + const fakePolicyCategories = createRandomPolicyCategories(3); + const fakeCategoryName = Object.keys(fakePolicyCategories).at(0) ?? ''; + + // Enabling a category keeps the categories update non-empty so the recompute actually runs. + const fakePolicyCategoriesUpdate = { + [fakeCategoryName]: {enabled: true}, + }; + + // Enabling categories flips requiresCategory/areCategoriesEnabled on, matching enablePolicyCategories. + const fakePolicyUpdate = {requiresCategory: true, areCategoriesEnabled: true}; + + const fakePolicyID = '0'; + const fakePolicy = { + ...createRandomPolicy(0), + id: fakePolicyID, + requiresTag: false, + areTagsEnabled: false, + requiresCategory: false, + areCategoriesEnabled: false, + }; + + const openIOUReport: Report = { + ...mockIOUReport, + policyID: fakePolicyID, + }; + + const transaction: Transaction = { + ...mockTransaction, + reportID: openIOUReport.reportID, + category: CONST.SEARCH.CATEGORY_DEFAULT_VALUE, + tag: '', + }; + + const policyData: PolicyData = { + policy: fakePolicy, + categories: fakePolicyCategories, + tags: {}, + reports: [openIOUReport], + transactionsAndViolations: { + [openIOUReport.reportID]: { + transactions: { + [mockTransaction.transactionID]: transaction, + }, + violations: {}, + }, + }, + }; + + const onyxData = {optimisticData: [], failureData: []}; + + pushTransactionViolationsOnyxData(onyxData, policyData, fakePolicyUpdate, fakePolicyCategoriesUpdate); + + expect(onyxData.optimisticData).toContainEqual({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${mockTransaction.transactionID}`, + value: expect.arrayContaining([ + { + name: CONST.VIOLATIONS.MISSING_CATEGORY, + type: CONST.VIOLATION_TYPES.VIOLATION, + showInReview: true, + }, + ]), + }); + }); }); describe('pushTransactionAutoSelectionsOnyxData', () => { diff --git a/tests/unit/ViolationUtilsTest.ts b/tests/unit/ViolationUtilsTest.ts index 379551426f0b..9bf2498932f2 100644 --- a/tests/unit/ViolationUtilsTest.ts +++ b/tests/unit/ViolationUtilsTest.ts @@ -1247,6 +1247,20 @@ describe('getViolationsOnyxData', () => { expect(result.value).not.toContainEqual(categoryOutOfPolicyViolation); }); + it('should add missingCategory violation when category is the Uncategorized sentinel and categories are required', () => { + transaction.category = CONST.SEARCH.CATEGORY_DEFAULT_VALUE; + const result = ViolationsUtils.getViolationsOnyxData({ + updatedTransaction: transaction, + transactionViolations, + policy, + policyTagList: policyTags, + policyCategories, + hasDependentTags: false, + isInvoiceTransaction: false, + }); + expect(result.value).toEqual(expect.arrayContaining([missingCategoryViolation, ...transactionViolations])); + }); + it('should not add categoryOutOfPolicy violation when category is none', () => { transaction.category = 'none'; const result = ViolationsUtils.getViolationsOnyxData({ @@ -1772,7 +1786,7 @@ describe('getViolationsOnyxData', () => { }); expect(result.value).toEqual([]); }); - it('should not return tagOutOfPolicy when the selected tag level has no enabled tags', () => { + it('should return tagOutOfPolicy when the selected tag is disabled and its level has no enabled tags left', () => { policyTags.Department.tags.Accounting.enabled = false; transaction.tag = 'Africa:Accounting:Project1'; @@ -1786,7 +1800,7 @@ describe('getViolationsOnyxData', () => { isInvoiceTransaction: false, }); - expect(result.value).toEqual([]); + expect(result.value).toEqual([{...tagOutOfPolicyViolation, data: {tagName: 'Department'}}]); }); it('should return tagOutOfPolicy when selected tag is disabled and another tag in that level is enabled', () => { @@ -1810,7 +1824,9 @@ describe('getViolationsOnyxData', () => { expect(result.value).toEqual([violation]); }); - it('should not return tagOutOfPolicy when no tags are enabled in the policy', () => { + it('should return a single tagOutOfPolicy for the first stale level when the Tags feature is disabled and no tags are enabled', () => { + // The backend emits one tagOutOfPolicy for the whole multi-level tag (the first out-of-policy level by + // orderWeight), so the optimistic recompute must match it instead of flagging every level. policyTags.Department.tags.Accounting.enabled = false; policyTags.Region.tags.Africa.enabled = false; policyTags.Project.tags.Project1.enabled = false; @@ -1826,7 +1842,8 @@ describe('getViolationsOnyxData', () => { isInvoiceTransaction: false, }); - expect(result.value).toEqual([]); + // Region has the lowest orderWeight (1), so it is the level the violation is reported on. + expect(result.value).toEqual([{...tagOutOfPolicyViolation, data: {tagName: 'Region'}}]); }); it('should not return allTagLevelsRequired when only non-required dependent tag levels are empty', () => { // Make Department non-required @@ -2304,6 +2321,30 @@ describe('getViolationsOnyxData', () => { }); expect(result.value).not.toContainEqual(taxOutOfPolicyViolation); }); + + it('should keep taxOutOfPolicy violation when the tax rate is disabled but still present in the policy', () => { + // Disabling a tax rate keeps its key in the policy with `isDisabled: true` - it is no longer valid, + // so an unrelated client-side recompute (e.g. deleting a tag offline) must not drop the violation. + transaction.taxCode = 'TAX_10'; + policy.taxRates = { + name: 'Taxes', + defaultExternalID: 'TAX_10', + defaultValue: '10%', + foreignTaxDefault: 'TAX_10', + taxes: {TAX_10: {name: '10%', value: '10%', isDisabled: true}}, + }; + transactionViolations = [taxOutOfPolicyViolation]; + const result = ViolationsUtils.getViolationsOnyxData({ + updatedTransaction: transaction, + transactionViolations, + policy, + policyTagList: policyTags, + policyCategories, + hasDependentTags: false, + isInvoiceTransaction: false, + }); + expect(result.value).toContainEqual(taxOutOfPolicyViolation); + }); }); describe('when tax tracking is disabled', () => { @@ -3524,6 +3565,15 @@ describe('getIsViolationFixed', () => { }); expect(result).toBe(false); }); + + it('should return false when the taxCode exists but its rate is disabled', () => { + const result = getIsViolationFixed('violations.taxOutOfPolicy', { + ...defaultParams, + taxCode: 'TAX_10', + policyTaxRates: {TAX_10: {name: '10%', value: '10', isDisabled: true}}, + }); + expect(result).toBe(false); + }); }); describe('violations.customUnitRateOutOfDateRange', () => {