From a0076bb9d10869224f7882e7181e0ada65d9a139 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 16 Oct 2026 01:07:50 +0530 Subject: [PATCH 01/24] Support multiple conditions on an expense default rule Signed-off-by: krishna2323 --- src/libs/ExpenseDefaultRuleUtils.ts | 184 +++++++++++++++--- src/libs/RuleUtils.ts | 9 +- .../MerchantRules/MerchantRulePageBase.tsx | 24 ++- src/types/form/MerchantRuleForm.ts | 2 + tests/unit/ExpenseDefaultRuleUtilsTest.ts | 123 +++++++++++- tests/unit/VendorMatchingMerchantRulesTest.ts | 1 + 6 files changed, 291 insertions(+), 52 deletions(-) diff --git a/src/libs/ExpenseDefaultRuleUtils.ts b/src/libs/ExpenseDefaultRuleUtils.ts index 2f211e7014ce..38ea0736acac 100644 --- a/src/libs/ExpenseDefaultRuleUtils.ts +++ b/src/libs/ExpenseDefaultRuleUtils.ts @@ -27,10 +27,16 @@ import {fromIndexMap, getRuleFilterLeaves, isExpenseDefaultRule, isRuleFilterCom /** The form shape the merchant rule editor round-trips a rule through. */ type MerchantRuleFormValues = { /** The merchant string an expense has to match */ - merchantToMatch: string; + merchantToMatch?: string; /** Whether the merchant has to match exactly (`eq`) or partially (`contains`) */ - matchType: ValueOf; + matchType?: ValueOf; + + /** The categories an expense has to be in. The list is matched as an OR, so any one of them matches */ + categoriesToMatch?: string[]; + + /** The tags an expense has to carry. The list is matched as an OR, so any one of them matches */ + tagsToMatch?: string[]; /** The merchant the expense is renamed to */ merchant?: string; @@ -68,7 +74,19 @@ type RuleWithID = { const {FIELD} = CONST.RULES.EXPENSE_DEFAULT; const {TRIGGERS: TRIGGER, ACTIONS: ACTION} = CONST.RULES; -const {EQUAL_TO, CONTAINS} = CONST.SEARCH.SYNTAX_OPERATORS; +const {AND, EQUAL_TO, CONTAINS} = CONST.SEARCH.SYNTAX_OPERATORS; + +/** + * The expense fields a rule can match on. + * + * These are search syntax filter keys, which is what the rules engine's filter tree speaks. They are not the + * action field names, even where the two happen to spell a field the same way. + */ +const CONDITION = { + MERCHANT: CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT, + CATEGORY: CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY, + TAG: CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG, +} as const; /** The order actions are written in, which fixes the numeric keys a built rule uses. */ const ACTION_FIELD_ORDER = [FIELD.MERCHANT, FIELD.CATEGORY, FIELD.TAG, FIELD.TAX, FIELD.VENDOR_ID, FIELD.COMMENT, FIELD.REIMBURSABLE, FIELD.BILLABLE] as const; @@ -171,27 +189,77 @@ function buildTaxActionValue(taxKey: string | undefined, policy: Policy | undefi }; } -/** - * A merchant rule as this editor builds it. Stored rules may carry a whole filter tree, but the editor only ever - * produces one merchant comparison, so callers converting a freshly built rule can read the filter directly. - */ +/** A merchant rule as this editor builds it, which is the stored shape minus the fields the server fills in. */ type BuiltMerchantRule = Omit & { - /** The single `merchant eq|contains ` comparison this editor matches on. */ - filters: RuleFilterComparison; + /** The conditions the expense has to meet, ANDed together. */ + filters: RuleFilterNode; }; -/** Builds the filter tree for a merchant rule: a single `merchant eq|contains ` comparison. */ -function buildMerchantRuleFilters(formValues: Partial): RuleFilterComparison | undefined { +/** Drops the blanks a multi-select leaves behind, so an emptied picker reads as no condition at all. */ +function nonEmptyValues(values: string[] | undefined): string[] { + return (values ?? []).map((value) => value.trim()).filter(Boolean); +} + +/** + * Builds the filter tree for a merchant rule, ANDing every condition the form set. + * + * The tree is left nested, so three conditions become `and(and(a, b), c)`. Each node only takes two children, + * and nesting to the left keeps the order the form reads them back in. + */ +function buildMerchantRuleFilters(formValues: Partial): RuleFilterNode | undefined { + const comparisons: RuleFilterComparison[] = []; + const merchantToMatch = formValues.merchantToMatch?.trim(); - if (!merchantToMatch) { + if (merchantToMatch) { + comparisons.push({left: CONDITION.MERCHANT, operator: formValues.matchType ?? CONTAINS, right: merchantToMatch}); + } + + const categoriesToMatch = nonEmptyValues(formValues.categoriesToMatch); + if (categoriesToMatch.length > 0) { + comparisons.push({left: CONDITION.CATEGORY, operator: EQUAL_TO, right: categoriesToMatch}); + } + + const tagsToMatch = nonEmptyValues(formValues.tagsToMatch); + if (tagsToMatch.length > 0) { + comparisons.push({left: CONDITION.TAG, operator: EQUAL_TO, right: tagsToMatch}); + } + + const [firstComparison, ...rest] = comparisons; + if (!firstComparison) { return undefined; } - return { - left: FIELD.MERCHANT, - operator: formValues.matchType ?? CONTAINS, - right: merchantToMatch, - }; + return rest.reduce((left, right) => ({operator: AND, left, right}), firstComparison); +} + +/** Whether the form matches on anything. A rule with no conditions would apply to every expense, and the API rejects it. */ +function hasExpenseDefaultConditions(formValues: Partial): boolean { + return !!buildMerchantRuleFilters(formValues); +} + +/** Compares two multi-select conditions, which are ORed lists rather than ordered ones. */ +function haveSameConditionValues(first: string[] | undefined, second: string[] | undefined): boolean { + const sortedFirst = nonEmptyValues(first).sort(); + const sortedSecond = nonEmptyValues(second).sort(); + + return sortedFirst.length === sortedSecond.length && sortedFirst.every((value, index) => value === sortedSecond.at(index)); +} + +/** Whether two rules match on the same expenses, which is what makes one a duplicate of the other. */ +function haveSameConditions(first: Partial, second: Partial): boolean { + const firstMerchant = first.merchantToMatch?.trim().toLowerCase() ?? ''; + const secondMerchant = second.merchantToMatch?.trim().toLowerCase() ?? ''; + + if (firstMerchant !== secondMerchant) { + return false; + } + + // The match type only tells two rules apart when they both match on a merchant. + if (firstMerchant && (first.matchType ?? CONTAINS) !== (second.matchType ?? CONTAINS)) { + return false; + } + + return haveSameConditionValues(first.categoriesToMatch, second.categoriesToMatch) && haveSameConditionValues(first.tagsToMatch, second.tagsToMatch); } /** Builds the `Set` actions for a merchant rule, keyed by a stringified index in a fixed field order. */ @@ -255,24 +323,80 @@ function areTriggersEditable(triggers: Record | undefined): bool return triggerValues.length > 0 && triggerValues.every((trigger) => trigger === TRIGGER.CREATE_TRANSACTION); } -/** The editor matches on exactly one merchant condition, so anything else (a tree, another field, a list) can't be shown in the form. */ -function getEditableMerchantMatch(filters: RuleFilterNode | undefined): Pick | undefined { - if (!filters || !isRuleFilterNode(filters) || !isRuleFilterComparison(filters)) { +/** The conditions the editor renders, read back off a rule's filter tree. */ +type EditableConditions = Pick; + +/** + * Reads one leaf comparison into the conditions it sets. + * + * Returns undefined when the form can't represent the leaf, which makes the whole rule read-only. + */ +function getConditionFromLeaf(leaf: RuleFilterComparison): EditableConditions | undefined { + // The backend ORs a list of values together, and stores a single value unwrapped. + const values: string[] = []; + for (const value of [leaf.right].flat()) { + if (typeof value !== 'string' || !value) { + return undefined; + } + values.push(value); + } + + if (values.length === 0) { + return undefined; + } + + if (leaf.left === CONDITION.MERCHANT) { + const merchantToMatch = values.at(0); + // The form has a single merchant input, so only one value round-trips. + if (values.length !== 1 || !merchantToMatch || !SUPPORTED_MERCHANT_MATCH_TYPES.has(leaf.operator)) { + return undefined; + } + return {merchantToMatch, matchType: leaf.operator}; + } + + if (leaf.operator !== EQUAL_TO) { + return undefined; + } + + if (leaf.left === CONDITION.CATEGORY) { + return {categoriesToMatch: values}; + } + + if (leaf.left === CONDITION.TAG) { + return {tagsToMatch: values}; + } + + return undefined; +} + +/** The conditions the editor can show for a rule, or undefined when its filter tree can't be represented. */ +function getEditableConditions(filters: RuleFilterNode | undefined): EditableConditions | undefined { + // Rules arrive from the server, so a node can be malformed even though the type says otherwise. + if (!filters || !isRuleFilterNode(filters)) { + return undefined; + } + + if (isRuleFilterComparison(filters)) { + return getConditionFromLeaf(filters); + } + + // The form lists conditions that all have to match, so only an `and` tree round-trips. + if (filters.operator !== AND) { return undefined; } - if (filters.left !== FIELD.MERCHANT || !SUPPORTED_MERCHANT_MATCH_TYPES.has(filters.operator)) { + const left = getEditableConditions(filters.left); + const right = getEditableConditions(filters.right); + if (!left || !right) { return undefined; } - // The backend ORs a list of values together. The form only has one merchant input, so only a single value round-trips. - const rightValues = [filters.right].flat(); - const merchantToMatch = rightValues.at(0); - if (rightValues.length !== 1 || typeof merchantToMatch !== 'string' || !merchantToMatch) { + // The form has one input per field, so a field matched on twice would lose a condition on save. + if (Object.keys(left).some((field) => field in right)) { return undefined; } - return {merchantToMatch, matchType: filters.operator}; + return {...left, ...right}; } /** @@ -288,12 +412,12 @@ function getMerchantRuleFormValues(rule: Rule | ExpenseDefaultRule | undefined): return undefined; } - const merchantMatch = getEditableMerchantMatch(rule.filters); - if (!merchantMatch) { + const conditions = getEditableConditions(rule.filters); + if (!conditions) { return undefined; } - const formValues: MerchantRuleFormValues = {...merchantMatch}; + const formValues: MerchantRuleFormValues = {...conditions}; const actions = getRuleActions(rule); if (actions.length === 0) { @@ -461,7 +585,9 @@ export { getMerchantRuleFormValues, getPolicyExpenseDefaultRules, getRuleMerchantMatchSummary, + hasExpenseDefaultConditions, hasExpenseDefaultRuleErrors, + haveSameConditions, isEditableMerchantRule, isExpenseDefaultTaxValue, }; diff --git a/src/libs/RuleUtils.ts b/src/libs/RuleUtils.ts index 89f873647671..0efbb3a2e8ab 100644 --- a/src/libs/RuleUtils.ts +++ b/src/libs/RuleUtils.ts @@ -44,14 +44,11 @@ function isApprovalWorkflowRule(rule: Rule): rule is Rule & ApprovalWorkflowRule } /** - * An expense default rule fires on transaction creation, matches on a merchant, and sets at least one field. + * An expense default rule fires on transaction creation, matches on at least one condition, and sets at least one field. * * This mirrors the rules engine's own definition. It is a heuristic over triggers, filters and action names rather * than a full check of every action's shape, so callers that go on to read the values still validate them field by * field. - * - * The merchant condition is what keeps category rules out. They are moving into the same collection, and a client - * that has no way to edit them must not list them either. */ function isExpenseDefaultRule(rule: Rule | undefined): rule is Rule & ExpenseDefaultRule { if (!rule) { @@ -63,9 +60,9 @@ function isExpenseDefaultRule(rule: Rule | undefined): rule is Rule & ExpenseDef const actions = fromIndexMap(actionsByIndex); const hasCreateTransactionTrigger = getRuleTriggers(rule).some((trigger) => trigger === CONST.RULES.TRIGGERS.CREATE_TRANSACTION); const hasSetAction = actions.some((action) => action?.name === CONST.RULES.ACTIONS.SET); - const hasMerchantFilter = getRuleFilterLeaves(rule.filters).some((leaf) => leaf.left === CONST.RULES.EXPENSE_DEFAULT.FIELD.MERCHANT); + const hasCondition = getRuleFilterLeaves(rule.filters).length > 0; - return hasCreateTransactionTrigger && hasMerchantFilter && hasSetAction; + return hasCreateTransactionTrigger && hasCondition && hasSetAction; } /** Whether an unknown value read off a rule is shaped like a node of its filter tree. */ diff --git a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx index 5bc4bf6bb7f8..66173a5ba3f8 100644 --- a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx +++ b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx @@ -33,7 +33,15 @@ import Tab from '@libs/actions/Tab'; import {clearDraftMerchantRule, setDraftMerchantRule} from '@libs/actions/User'; import {getCategoryTaxRuleTaxID, getTaxRateDisplayName, hasUsableTaxRates, isCategoryRuleDraft} from '@libs/CategoryTaxRulesUtils'; import {getDecodedCategoryName} from '@libs/CategoryUtils'; -import {buildMerchantRule, canEditMerchantRule, getMerchantRuleFormValues, getPolicyExpenseDefaultRules} from '@libs/ExpenseDefaultRuleUtils'; +import { + buildMerchantRule, + canEditMerchantRule, + getMerchantRuleFormValues, + getPolicyExpenseDefaultRules, + hasExpenseDefaultConditions, + haveSameConditions, +} from '@libs/ExpenseDefaultRuleUtils'; +import type {MerchantRuleFormValues} from '@libs/ExpenseDefaultRuleUtils'; import Navigation from '@libs/Navigation/Navigation'; import {hasEnabledOptions} from '@libs/OptionsListUtils'; import Parser from '@libs/Parser'; @@ -355,18 +363,14 @@ function MerchantRulePageBase({ const taxDisplayName = (taxRateID && getTaxByID(policy, taxRateID) ? getTaxRateDisplayName(policy, taxRateID) : '') || undefined; /** - * Checks if there's a duplicate rule with the same merchant name and match type. - * A duplicate is a rule that has the same merchant to match AND the same match type (contains/exact). + * Checks if another rule already matches on the same conditions. * When editing, we exclude the current rule from the comparison. */ - const checkForDuplicateRule = (merchantToMatch: string | undefined, matchType: string | undefined): boolean => { - if (!merchantToMatch) { + const checkForDuplicateRule = (formValues: Partial): boolean => { + if (!hasExpenseDefaultConditions(formValues)) { return false; } - const normalizedMerchant = merchantToMatch.toLowerCase(); - const currentMatchType = matchType ?? CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS; - return getPolicyExpenseDefaultRules(rules, policyID).some(({ruleID: existingRuleID, rule}) => { // Skip the rule being edited if (isEditing && existingRuleID === ruleID) { @@ -379,7 +383,7 @@ function MerchantRulePageBase({ return false; } - if (existingFormValues.merchantToMatch.toLowerCase() !== normalizedMerchant || existingFormValues.matchType !== currentMatchType) { + if (!haveSameConditions(existingFormValues, formValues)) { return false; } @@ -468,7 +472,7 @@ function MerchantRulePageBase({ } // Check for duplicate rules - const hasDuplicate = checkForDuplicateRule(form.merchantToMatch, form.matchType); + const hasDuplicate = checkForDuplicateRule(form); if (hasDuplicate) { showConfirmModal({ title: translate('workspace.rules.merchantRules.duplicateRuleTitle'), diff --git a/src/types/form/MerchantRuleForm.ts b/src/types/form/MerchantRuleForm.ts index 5d35a8b8671f..34cc3e9ce259 100644 --- a/src/types/form/MerchantRuleForm.ts +++ b/src/types/form/MerchantRuleForm.ts @@ -15,6 +15,7 @@ const INPUT_IDS = { REIMBURSABLE: 'reimbursable', RULE_TYPE: 'ruleType', TAG: 'tag', + TAGS_TO_MATCH: 'tagsToMatch', TAX: 'tax', VENDOR_ID: 'vendorID', } as const; @@ -39,6 +40,7 @@ type MerchantRuleForm = Form< * trip to any picker, rather than having to ride on every picker's route. */ [INPUT_IDS.RULE_TYPE]: ExpenseDefaultRuleType; [INPUT_IDS.TAG]: string; + [INPUT_IDS.TAGS_TO_MATCH]: string[]; [INPUT_IDS.TAX]: string; [INPUT_IDS.VENDOR_ID]: string; } diff --git a/tests/unit/ExpenseDefaultRuleUtilsTest.ts b/tests/unit/ExpenseDefaultRuleUtilsTest.ts index d067e4fcfa92..6c4a2abfe9f1 100644 --- a/tests/unit/ExpenseDefaultRuleUtilsTest.ts +++ b/tests/unit/ExpenseDefaultRuleUtilsTest.ts @@ -7,6 +7,7 @@ import { getPolicyExpenseDefaultRules, getRuleMerchantMatchSummary, hasExpenseDefaultRuleErrors, + haveSameConditions, } from '@libs/ExpenseDefaultRuleUtils'; import type {MerchantRuleFormValues} from '@libs/ExpenseDefaultRuleUtils'; import Parser from '@libs/Parser'; @@ -169,19 +170,118 @@ describe('ExpenseDefaultRuleUtils', () => { }); }); + describe('buildMerchantRule conditions', () => { + it('builds one condition per field, ANDed together and nested to the left', () => { + // Given a form matching on a merchant, two categories and a tag + const formValues: Partial = { + merchantToMatch: 'Starbucks', + matchType: EQUAL_TO, + categoriesToMatch: ['Coffee', 'Travel'], + tagsToMatch: ['Team A'], + category: 'Coffee', + }; + + // When the rule is built + const rule = buildMerchantRule(formValues, policy); + + // Then the conditions are ANDed in form order, with each list left as an OR of its values + expect(rule?.filters).toEqual({ + operator: AND, + left: { + operator: AND, + left: {left: FIELD.MERCHANT, operator: EQUAL_TO, right: 'Starbucks'}, + right: {left: FIELD.CATEGORY, operator: EQUAL_TO, right: ['Coffee', 'Travel']}, + }, + right: {left: FIELD.TAG, operator: EQUAL_TO, right: ['Team A']}, + }); + }); + + it('builds a rule that matches on a category alone', () => { + // Given a form with no merchant, which is a rule the editor could not express before + const rule = buildMerchantRule({categoriesToMatch: ['Coffee'], tax: TAX_KEY}, policy); + + // When its filters are read + // Then the single category condition is the whole tree + expect(rule?.filters).toEqual({left: FIELD.CATEGORY, operator: EQUAL_TO, right: ['Coffee']}); + }); + + it('returns undefined when every condition is blank', () => { + // Given a form whose pickers were emptied, leaving blanks behind + // When the rule is built + // Then there is nothing to match on, so no rule is built + expect(buildMerchantRule({merchantToMatch: ' ', categoriesToMatch: [' '], tagsToMatch: [], category: 'Coffee'}, policy)).toBeUndefined(); + }); + + it('round trips every condition', () => { + // Given a form matching on all three fields + const formValues: MerchantRuleFormValues = { + merchantToMatch: 'Starbucks', + matchType: CONTAINS, + categoriesToMatch: ['Coffee'], + tagsToMatch: ['Team A', 'Team B'], + category: 'Coffee', + }; + + // When the built rule is read back + // Then the form gets the same conditions it wrote + expect(getMerchantRuleFormValues(buildMerchantRule(formValues, policy))).toEqual(formValues); + }); + }); + + describe('haveSameConditions', () => { + it('ignores the order of a multi-select', () => { + // Given two rules picking the same categories in a different order + // When they are compared + // Then they are duplicates, because the list is matched as an OR + expect(haveSameConditions({categoriesToMatch: ['Coffee', 'Travel']}, {categoriesToMatch: ['Travel', 'Coffee']})).toBe(true); + }); + + it('treats a different match type on the same merchant as a different rule', () => { + // Given the same merchant matched exactly by one rule and partially by the other + // When they are compared + // Then they are not duplicates, because they match different expenses + expect(haveSameConditions({merchantToMatch: 'Starbucks', matchType: EQUAL_TO}, {merchantToMatch: 'Starbucks', matchType: CONTAINS})).toBe(false); + }); + + it('ignores the match type when neither rule matches on a merchant', () => { + // Given two category rules whose match type is left at its default + // When they are compared + // Then they are duplicates, because the match type only applies to the merchant input + expect(haveSameConditions({categoriesToMatch: ['Coffee'], matchType: EQUAL_TO}, {categoriesToMatch: ['Coffee'], matchType: CONTAINS})).toBe(true); + }); + + it('is false when one rule matches on a condition the other does not', () => { + // Given two rules on the same merchant, one of which is also narrowed to a tag + // When they are compared + // Then they are not duplicates + expect(haveSameConditions({merchantToMatch: 'Starbucks'}, {merchantToMatch: 'Starbucks', tagsToMatch: ['Team A']})).toBe(false); + }); + }); + describe('getMerchantRuleFormValues rejects rules the form cannot represent', () => { it.each([ [ - 'a nested filter tree', + 'conditions combined with or rather than and', { filters: { left: merchantFilter, - operator: AND, + operator: OR, right: {left: FIELD.CATEGORY, operator: EQUAL_TO, right: 'Coffee'}, }, }, ], - ['a filter on a field the form has no input for', {filters: {left: FIELD.CATEGORY, operator: EQUAL_TO, right: 'Coffee'}}], + [ + 'two conditions on the same field, which the form has one input for', + { + filters: { + left: {left: FIELD.CATEGORY, operator: EQUAL_TO, right: 'Coffee'}, + operator: AND, + right: {left: FIELD.CATEGORY, operator: EQUAL_TO, right: 'Travel'}, + }, + }, + ], + ['a condition on an operator the form has no control for', {filters: {left: FIELD.CATEGORY, operator: CONTAINS, right: 'Coffee'}}], + ['a filter on a field the form has no input for', {filters: {left: CONST.SEARCH.SYNTAX_FILTER_KEYS.MCC, operator: EQUAL_TO, right: '5812'}}], ['an operator the form has no control for', {filters: {left: FIELD.MERCHANT, operator: GREATER_THAN, right: 'Starbucks'}}], ['a list of merchants, which the form has one input for', {filters: {left: FIELD.MERCHANT, operator: OR, right: ['Starbucks', 'Costa']}}], ['a trigger the form does not set', {triggers: toIndexMap([TRIGGER.CREATE_TRANSACTION, CONST.RULES.TRIGGERS.REPORT_SUBMIT])}], @@ -223,12 +323,21 @@ describe('ExpenseDefaultRuleUtils', () => { expect(isExpenseDefaultRule(rule)).toBe(false); }); - it('is false for a rule that matches on something other than a merchant', () => { - // Given a category rule, which the merchant rule editor has no way to open - const rule = asStoredRule(buildRuleWithOverrides({filters: {left: FIELD.CATEGORY, operator: CONTAINS, right: 'Coffee'}})); + it('is true for a rule that matches on a category rather than a merchant', () => { + // Given a category rule, which the editor can now open + const rule = asStoredRule(buildRuleWithOverrides({filters: {left: FIELD.CATEGORY, operator: EQUAL_TO, right: 'Coffee'}})); + + // When it is tested + // Then it is an expense default, because merchant is only one of the conditions it can match on + expect(isExpenseDefaultRule(rule)).toBe(true); + }); + + it('is false for a rule with no conditions', () => { + // Given a rule that sets a field but matches on nothing, which would apply to every expense + const rule = asStoredRule(buildRuleWithOverrides({filters: undefined})); // When it is tested - // Then it is not an expense default, so a client that cannot edit it does not list it either + // Then it is not an expense default expect(isExpenseDefaultRule(rule)).toBe(false); }); diff --git a/tests/unit/VendorMatchingMerchantRulesTest.ts b/tests/unit/VendorMatchingMerchantRulesTest.ts index 21f5338dd9dd..6e4e52cdf39d 100644 --- a/tests/unit/VendorMatchingMerchantRulesTest.ts +++ b/tests/unit/VendorMatchingMerchantRulesTest.ts @@ -27,6 +27,7 @@ const buildForm = (overrides: Partial = {}): MerchantRuleForm categoriesToMatch: [], ruleType: CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.MERCHANT, tag: '', + tagsToMatch: [], tax: '', vendorID: '', comment: '', From f707380d0f57a2c95c51238489463824a7ab7c6c Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 16 Oct 2026 01:26:39 +0530 Subject: [PATCH 02/24] Fix the condition text, validation and duplicate prompt for multi-condition rules Signed-off-by: krishna2323 --- src/languages/de.ts | 1 + src/languages/el.ts | 1 + src/languages/en.ts | 1 + src/languages/es.ts | 1 + src/languages/fr.ts | 1 + src/languages/it.ts | 1 + src/languages/ja.ts | 1 + src/languages/nl.ts | 1 + src/languages/pl.ts | 1 + src/languages/pt-BR.ts | 1 + src/languages/zh-hans.ts | 1 + src/libs/ExpenseDefaultRuleUtils.ts | 39 +++++++++++++++++- src/libs/MerchantTypeRulesUtils.ts | 29 +++++++++++-- .../MerchantRules/MerchantRulePageBase.tsx | 12 ++++-- tests/unit/ExpenseDefaultRuleUtilsTest.ts | 41 +++++++++++++++++++ 15 files changed, 123 insertions(+), 9 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index e42bd2acfd59..31e9c4661fd3 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -8831,6 +8831,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc rename: 'Umbenennen', update: 'Aktualisieren', merchantIs: (merchant: string) => `Händler ist „${merchant}“`, + tagIs: (tag: string) => `Tag ist „${tag}“`, merchantTypeIs: (merchantType: string) => `Händlertyp ist „${merchantType}"`, categoryIs: (category: string) => `Kategorie ist „${category}“`, }, diff --git a/src/languages/el.ts b/src/languages/el.ts index 62b80cdfdaff..cc4ccb9ec2b3 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -8923,6 +8923,7 @@ ${reportName}`, rename: 'Μετονομασία', update: 'Ενημέρωση', merchantIs: (merchant: string) => `Ο έμπορος είναι «${merchant}»`, + tagIs: (tag: string) => `Η ετικέτα είναι «${tag}»`, merchantTypeIs: (merchantType: string) => `Ο τύπος εμπόρου είναι «${merchantType}»`, categoryIs: (category: string) => `Η κατηγορία είναι «${category}»`, }, diff --git a/src/languages/en.ts b/src/languages/en.ts index 407ec916fa7e..37620b9f9e54 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -9109,6 +9109,7 @@ const translations = { update: 'Update', categoryIs: (category: string) => `Category is "${category}"`, merchantIs: (merchant: string) => `Merchant is "${merchant}"`, + tagIs: (tag: string) => `Tag is "${tag}"`, merchantTypeIs: (merchantType: string) => `Merchant type is "${merchantType}"`, }, merchantTypeRule: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 0bed217f7d97..98492df20ae2 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -8836,6 +8836,7 @@ ${reportName}`, rename: 'Cambiar nombre', update: 'Actualizar', merchantIs: (merchant: string) => `El comercio es «${merchant}»`, + tagIs: (tag: string) => `La etiqueta es «${tag}»`, merchantTypeIs: (merchantType: string) => `El tipo de comercio es "${merchantType}"`, categoryIs: (category: string) => `La categoría es «${category}»`, }, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index a8cdf815c64d..a1dbd5626cea 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -8854,6 +8854,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e rename: 'Renommer', update: 'Mettre à jour', merchantIs: (merchant: string) => `Le commerçant est « ${merchant} »`, + tagIs: (tag: string) => `Le tag est « ${tag} »`, merchantTypeIs: (merchantType: string) => `Le type de commerçant est « ${merchantType} »`, categoryIs: (category: string) => `La catégorie est « ${category} »`, }, diff --git a/src/languages/it.ts b/src/languages/it.ts index b1a160bba0bd..94f257d7eccb 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -8782,6 +8782,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, rename: 'Rinomina', update: 'Aggiorna', merchantIs: (merchant: string) => `L'esercente è "${merchant}"`, + tagIs: (tag: string) => `L'etichetta è "${tag}"`, merchantTypeIs: (merchantType: string) => `Il tipo di esercente è "${merchantType}"`, categoryIs: (category: string) => `La categoria è "${category}"`, }, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index de2eed8382e2..120f3b0615bf 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -8671,6 +8671,7 @@ ${reportName}`, rename: '名前を変更', update: '更新', merchantIs: (merchant: string) => `加盟店名は「${merchant}」です`, + tagIs: (tag: string) => `タグは「${tag}」です`, merchantTypeIs: (merchantType: string) => `加盟店タイプ: 「${merchantType}」`, categoryIs: (category: string) => `カテゴリは「${category}」です`, }, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 8cfb07823eef..c573af893864 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -8756,6 +8756,7 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, rename: 'Naam wijzigen', update: 'Bijwerken', merchantIs: (merchant: string) => `Handelaar is "${merchant}"`, + tagIs: (tag: string) => `Label is "${tag}"`, merchantTypeIs: (merchantType: string) => `Type leverancier is "${merchantType}"`, categoryIs: (category: string) => `Categorie is "${category}"`, }, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 7d5050940989..0cfd73240151 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -8781,6 +8781,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, rename: 'Zmień nazwę', update: 'Zaktualizuj', merchantIs: (merchant: string) => `Sprzedawca to „${merchant}”`, + tagIs: (tag: string) => `Tag to „${tag}”`, merchantTypeIs: (merchantType: string) => `Typ sprzedawcy to „${merchantType}"`, categoryIs: (category: string) => `Kategoria to „${category}”`, }, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 2f919085e388..c9b41b63321e 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -8763,6 +8763,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, rename: 'Renomear', update: 'Atualizar', merchantIs: (merchant: string) => `O comerciante é "${merchant}"`, + tagIs: (tag: string) => `A etiqueta é "${tag}"`, merchantTypeIs: (merchantType: string) => `O tipo de estabelecimento é "${merchantType}"`, categoryIs: (category: string) => `A categoria é "${category}"`, }, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 17343adca41e..fa8a3362b9ed 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -8453,6 +8453,7 @@ ${reportName}`, rename: '重命名', update: '更新', merchantIs: (merchant: string) => `商户为“${merchant}”`, + tagIs: (tag: string) => `标签为“${tag}”`, merchantTypeIs: (merchantType: string) => `商户类型为"${merchantType}"`, categoryIs: (category: string) => `类别为“${category}”`, }, diff --git a/src/libs/ExpenseDefaultRuleUtils.ts b/src/libs/ExpenseDefaultRuleUtils.ts index 38ea0736acac..e27b1fcc42d0 100644 --- a/src/libs/ExpenseDefaultRuleUtils.ts +++ b/src/libs/ExpenseDefaultRuleUtils.ts @@ -482,6 +482,42 @@ function getRuleMerchantMatchSummary(filters: RuleFilterNode | undefined): {merc }; } +/** The values a rule matches on, grouped by field. */ +type ExpenseDefaultConditionSummary = { + /** The merchants the rule matches on */ + merchants: string[]; + + /** The categories the rule matches on, as they are stored on the policy */ + categories: string[]; + + /** The tags the rule matches on, as they are stored on the policy */ + tags: string[]; +}; + +/** + * Groups a rule's conditions by field, for the rules table's condition text and its search tokens. + * + * Derived from the filter tree rather than from `getMerchantRuleFormValues`, so a rule the editor can't + * represent still shows what it matches and can still be found by it while staying read-only. + */ +function getExpenseDefaultConditionSummary(filters: RuleFilterNode | undefined): ExpenseDefaultConditionSummary { + const summary: ExpenseDefaultConditionSummary = {merchants: [], categories: [], tags: []}; + + for (const leaf of getRuleFilterLeaves(filters)) { + const values = [leaf.right].flat().filter((value): value is string => typeof value === 'string'); + + if (leaf.left === CONDITION.MERCHANT) { + summary.merchants.push(...values); + } else if (leaf.left === CONDITION.CATEGORY) { + summary.categories.push(...values); + } else if (leaf.left === CONDITION.TAG) { + summary.tags.push(...values); + } + } + + return summary; +} + /** A single field a rule sets, normalized for display. */ type ExpenseDefaultRuleSummaryField = { /** The expense field being set */ @@ -575,11 +611,12 @@ function canEditMerchantRule(rule: Rule | undefined, policyID: string | undefine return isPolicyScopedRule(rule, policyID) && isEditableMerchantRule(rule); } -export type {MerchantRuleFormValues}; +export type {ExpenseDefaultConditionSummary, MerchantRuleFormValues}; export { buildCopiedExpenseDefaultRules, buildMerchantRule, canEditMerchantRule, + getExpenseDefaultConditionSummary, getExpenseDefaultRuleCount, getExpenseDefaultRuleSummaryFields, getMerchantRuleFormValues, diff --git a/src/libs/MerchantTypeRulesUtils.ts b/src/libs/MerchantTypeRulesUtils.ts index 9104d6e88044..afb6158cef0b 100644 --- a/src/libs/MerchantTypeRulesUtils.ts +++ b/src/libs/MerchantTypeRulesUtils.ts @@ -15,7 +15,13 @@ import {setWorkspaceDefaultSpendCategory} from './actions/Policy/Policy'; import {clearMerchantRuleErrors} from './actions/Policy/Rules'; import {getCategoryTaxRulesTableData, getTaxRateDisplayName} from './CategoryTaxRulesUtils'; import {getDecodedCategoryName} from './CategoryUtils'; -import {getExpenseDefaultRuleSummaryFields, getPolicyExpenseDefaultRules, getRuleMerchantMatchSummary, isEditableMerchantRule, isExpenseDefaultTaxValue} from './ExpenseDefaultRuleUtils'; +import { + getExpenseDefaultConditionSummary, + getExpenseDefaultRuleSummaryFields, + getPolicyExpenseDefaultRules, + isEditableMerchantRule, + isExpenseDefaultTaxValue, +} from './ExpenseDefaultRuleUtils'; import {getMccGroupDisplayName} from './PolicyRulesUtils'; import {getCommaSeparatedTagNameWithSanitizedColons, getVendorRuleDisplayValue, isTagInPolicy, isXeroActiveMatchingSource} from './PolicyUtils'; @@ -150,7 +156,21 @@ function getMerchantRulesTableData({ const summaryFields = getExpenseDefaultRuleSummaryFields(rule).filter( ({field, value}) => field !== FIELD.TAG || !policyTags || typeof value !== 'string' || isTagInPolicy(value, policyTags), ); - const {merchants: merchantName} = getRuleMerchantMatchSummary(rule.filters); + const {merchants, categories, tags} = getExpenseDefaultConditionSummary(rule.filters); + const merchantNames = merchants.join(', '); + const categoryNames = categories.map(getDecodedCategoryName).join(', '); + const tagNames = tags.map(getCommaSeparatedTagNameWithSanitizedColons).join(', '); + + const conditions: string[] = []; + if (merchantNames) { + conditions.push(translate('workspace.rules.expenseDefaultsTable.merchantIs', merchantNames)); + } + if (categoryNames) { + conditions.push(translate('workspace.rules.expenseDefaultsTable.categoryIs', categoryNames)); + } + if (tagNames) { + conditions.push(translate('workspace.rules.expenseDefaultsTable.tagIs', tagNames)); + } const hasOnlyMerchantRename = summaryFields.length === 1 && summaryFields.at(0)?.field === FIELD.MERCHANT; const typeLabel = hasOnlyMerchantRename ? translate('workspace.rules.expenseDefaultsTable.rename') : translate('workspace.rules.expenseDefaultsTable.update'); @@ -207,9 +227,10 @@ function getMerchantRulesTableData({ section: CONST.POLICY.EXPENSE_DEFAULTS_SECTION.MERCHANTS, isRename: hasOnlyMerchantRename, typeLabel, - conditionText: translate('workspace.rules.expenseDefaultsTable.merchantIs', merchantName), + // A rule matches on every condition it carries, so the row reads them as one sentence. + conditionText: conditions.join(` ${translate('common.and')} `), ruleDescription, - searchTokens: [merchantName, ruleDescription], + searchTokens: [merchantNames, categoryNames, tagNames, ruleDescription].filter(Boolean), pendingAction, errors: rule.errors, onCloseError: () => clearMerchantRuleErrors(ruleID, rule), diff --git a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx index 66173a5ba3f8..f7745a812a69 100644 --- a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx +++ b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx @@ -135,12 +135,13 @@ const getCategoryRuleErrorMessage = (translate: LocalizedTranslate, taxID: strin return ''; }; -/** Only ever a merchant rule: a category rule is scoped before this page opens and validates through `getCategoryRuleErrorMessage`. */ +/** A rule needs something to match on and something to set. A category tax default validates through `getCategoryRuleErrorMessage` instead. */ const getErrorMessage = (translate: LocalizedTranslate, form?: MerchantRuleForm) => { const matchingCriteriaFields = new Set([ MERCHANT_RULE_INPUT_IDS.MERCHANT_TO_MATCH, MERCHANT_RULE_INPUT_IDS.MATCH_TYPE, MERCHANT_RULE_INPUT_IDS.CATEGORIES_TO_MATCH, + MERCHANT_RULE_INPUT_IDS.TAGS_TO_MATCH, // Scoping, not a default the admin set. MERCHANT_RULE_INPUT_IDS.RULE_TYPE, ]); @@ -153,13 +154,14 @@ const getErrorMessage = (translate: LocalizedTranslate, form?: MerchantRuleForm) } return value !== undefined && value !== ''; }); - if (form?.merchantToMatch && hasAtLeastOneUpdate) { + const hasCondition = hasExpenseDefaultConditions(form ?? {}); + if (hasCondition && hasAtLeastOneUpdate) { return ''; } if (hasAtLeastOneUpdate) { return translate('workspace.rules.merchantRules.confirmErrorCondition'); } - if (form?.merchantToMatch) { + if (hasCondition) { return translate('workspace.rules.merchantRules.confirmErrorUpdate'); } return translate('workspace.rules.merchantRules.confirmErrorConditionAndDefault'); @@ -474,9 +476,11 @@ function MerchantRulePageBase({ // Check for duplicate rules const hasDuplicate = checkForDuplicateRule(form); if (hasDuplicate) { + // A duplicate does not have to match on a merchant, so the prompt names the first condition the two share. + const duplicateCondition = [form.merchantToMatch, categoriesToMatchDisplayName, form.tagsToMatch?.join(', ')].find(Boolean) ?? ''; showConfirmModal({ title: translate('workspace.rules.merchantRules.duplicateRuleTitle'), - prompt: translate('workspace.rules.merchantRules.duplicateRulePrompt', form.merchantToMatch ?? ''), + prompt: translate('workspace.rules.merchantRules.duplicateRulePrompt', duplicateCondition), confirmText: translate('workspace.rules.merchantRules.saveAnyway'), cancelText: translate('common.cancel'), }).then((result) => { diff --git a/tests/unit/ExpenseDefaultRuleUtilsTest.ts b/tests/unit/ExpenseDefaultRuleUtilsTest.ts index 6c4a2abfe9f1..43372c47a894 100644 --- a/tests/unit/ExpenseDefaultRuleUtilsTest.ts +++ b/tests/unit/ExpenseDefaultRuleUtilsTest.ts @@ -1,6 +1,7 @@ import { buildMerchantRule, canEditMerchantRule, + getExpenseDefaultConditionSummary, getExpenseDefaultRuleCount, getExpenseDefaultRuleSummaryFields, getMerchantRuleFormValues, @@ -228,6 +229,46 @@ describe('ExpenseDefaultRuleUtils', () => { }); }); + describe('getExpenseDefaultConditionSummary', () => { + it('groups every condition by the field it matches on', () => { + // Given a rule matching on a merchant, two categories and a tag + const rule = buildMerchantRule({merchantToMatch: 'Starbucks', categoriesToMatch: ['Coffee', 'Travel'], tagsToMatch: ['Team A'], category: 'Coffee'}, policy); + + // When its conditions are summarized + // Then each field keeps its own values, so the table can name them separately + expect(getExpenseDefaultConditionSummary(rule?.filters)).toEqual({merchants: ['Starbucks'], categories: ['Coffee', 'Travel'], tags: ['Team A']}); + }); + + it('summarizes a rule the editor cannot open', () => { + // Given two merchants ORed together, which the form has one input for + const filters: RuleFilterNode = { + left: merchantFilter, + operator: OR, + right: {left: FIELD.MERCHANT, operator: CONTAINS, right: 'Costa'}, + }; + + // When it is summarized + // Then both merchants are listed, so a read-only rule still shows what it matches + expect(getExpenseDefaultConditionSummary(filters)).toEqual({merchants: ['Starbucks', 'Costa'], categories: [], tags: []}); + }); + + it('ignores a field the table has no wording for', () => { + // Given a rule matching on a merchant category code + const filters: RuleFilterNode = {left: CONST.SEARCH.SYNTAX_FILTER_KEYS.MCC, operator: EQUAL_TO, right: '5812'}; + + // When it is summarized + // Then nothing is reported, rather than the code being labelled as a merchant + expect(getExpenseDefaultConditionSummary(filters)).toEqual({merchants: [], categories: [], tags: []}); + }); + + it('reports nothing for a rule with no filters', () => { + // Given a rule whose filters never loaded + // When it is summarized + // Then every field is empty + expect(getExpenseDefaultConditionSummary(undefined)).toEqual({merchants: [], categories: [], tags: []}); + }); + }); + describe('haveSameConditions', () => { it('ignores the order of a multi-select', () => { // Given two rules picking the same categories in a different order From 6bbcb67b94ef07be73f7173bd6835c7014740d15 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 16 Oct 2026 02:18:06 +0530 Subject: [PATCH 03/24] Support category, tag and amount in expense default rules Signed-off-by: krishna2323 --- src/CONST/index.ts | 1 + src/ROUTES.ts | 12 ++ src/SCREENS.ts | 3 + src/libs/CategoryTaxRulesUtils.ts | 12 +- src/libs/ExpenseDefaultRuleUtils.ts | 30 ++- src/libs/MerchantTypeRulesUtils.ts | 11 ++ .../ModalStackNavigators/index.tsx | 3 + .../RELATIONS/WORKSPACE_TO_RHP.ts | 2 + src/libs/Navigation/linkingConfig/config.ts | 7 + src/libs/Navigation/types.ts | 12 ++ .../rules/MerchantRules/AddAmountPage.tsx | 80 ++++++++ .../rules/MerchantRules/AddTagToMatchPage.tsx | 171 ++++++++++++++++++ .../MerchantRules/MerchantRulePageBase.tsx | 32 +++- src/types/form/MerchantRuleForm.ts | 2 + src/types/onyx/ExpenseDefaultRules.ts | 2 +- tests/unit/ExpenseDefaultRuleUtilsTest.ts | 36 ++++ tests/unit/VendorMatchingMerchantRulesTest.ts | 1 + 17 files changed, 403 insertions(+), 14 deletions(-) create mode 100644 src/pages/workspace/rules/MerchantRules/AddAmountPage.tsx create mode 100644 src/pages/workspace/rules/MerchantRules/AddTagToMatchPage.tsx diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 01fda140b479..ded0278e317a 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -9065,6 +9065,7 @@ const CONST = { EXPENSE_DEFAULT: { /** Expense fields a `Set` action can write to */ FIELD: { + AMOUNT: 'amount', BILLABLE: 'billable', CATEGORY: 'category', COMMENT: 'comment', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 6d8f0867ef1d..9290127594af 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1201,6 +1201,10 @@ const DYNAMIC_ROUTES = { path: 'rule-billable', entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], }, + RULES_MERCHANT_AMOUNT_FROM_EXPENSE: { + path: 'rule-amount', + entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + }, RULES_MERCHANT_PREVIEW_MATCHES_FROM_EXPENSE: { path: 'rule-matches', entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], @@ -4478,6 +4482,10 @@ const ROUTES = { route: 'workspaces/:policyID/rules/merchant-rules/:ruleID/billable', getRoute: (policyID: string, ruleID?: string) => `workspaces/${policyID}/rules/merchant-rules/${ruleID ?? 'new'}/billable` as const, }, + RULES_MERCHANT_AMOUNT: { + route: 'workspaces/:policyID/rules/merchant-rules/:ruleID/amount', + getRoute: (policyID: string, ruleID?: string) => `workspaces/${policyID}/rules/merchant-rules/${ruleID ?? 'new'}/amount` as const, + }, RULES_MERCHANT_EDIT: { route: 'workspaces/:policyID/rules/merchant-rules/:ruleID', getRoute: (policyID: string, ruleID: string) => `workspaces/${policyID}/rules/merchant-rules/${ruleID}` as const, @@ -4492,6 +4500,10 @@ const ROUTES = { getRoute: (policyID: string, ruleID?: string, categoryName?: string) => `workspaces/${policyID}/rules/merchant-rules/${ruleID ?? 'new'}/category-to-match${getOptionalCategoryNameQuery(categoryName)}` as const, }, + RULES_TAG_TO_MATCH: { + route: 'workspaces/:policyID/rules/merchant-rules/:ruleID/tag-to-match', + getRoute: (policyID: string, ruleID?: string) => `workspaces/${policyID}/rules/merchant-rules/${ruleID ?? 'new'}/tag-to-match` as const, + }, RULES_CATEGORY_TAX_EDIT: { route: 'workspaces/:policyID/rules/category-tax-rules/edit/:categoryName', getRoute: (policyID: string, categoryName: string) => `workspaces/${policyID}/rules/category-tax-rules/edit/${encodeURIComponent(categoryName)}` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 9905d3ad4829..67b6d1b1ff6a 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -1050,8 +1050,10 @@ const SCREENS = { RULES_MERCHANT_DESCRIPTION: 'Rules_Merchant_Description', RULES_MERCHANT_REIMBURSABLE: 'Rules_Merchant_Reimbursable', RULES_MERCHANT_BILLABLE: 'Rules_Merchant_Billable', + RULES_MERCHANT_AMOUNT: 'Rules_Merchant_Amount', RULES_MERCHANT_PREVIEW_MATCHES: 'Rules_Merchant_Preview_Matches', RULES_CATEGORY_TO_MATCH: 'Rules_Category_To_Match', + RULES_TAG_TO_MATCH: 'Rules_Tag_To_Match', RULES_CATEGORY_TAX_EDIT: 'Rules_Category_Tax_Edit', RULES_MERCHANT_EDIT: 'Rules_Merchant_Edit', DYNAMIC_RULES_MERCHANT_NEW: 'Dynamic_Rules_Merchant_New', @@ -1065,6 +1067,7 @@ const SCREENS = { DYNAMIC_RULES_MERCHANT_DESCRIPTION: 'Dynamic_Rules_Merchant_Description', DYNAMIC_RULES_MERCHANT_REIMBURSABLE: 'Dynamic_Rules_Merchant_Reimbursable', DYNAMIC_RULES_MERCHANT_BILLABLE: 'Dynamic_Rules_Merchant_Billable', + DYNAMIC_RULES_MERCHANT_AMOUNT: 'Dynamic_Rules_Merchant_Amount', DYNAMIC_RULES_MERCHANT_PREVIEW_MATCHES: 'Dynamic_Rules_Merchant_Preview_Matches', RULES_SPEND_MERCHANTS: 'Rules_Spend_Merchants', RULES_SPEND_MERCHANT_EDIT: 'Rules_Spend_Merchant_Edit', diff --git a/src/libs/CategoryTaxRulesUtils.ts b/src/libs/CategoryTaxRulesUtils.ts index b274d681983c..3dd4fcdec511 100644 --- a/src/libs/CategoryTaxRulesUtils.ts +++ b/src/libs/CategoryTaxRulesUtils.ts @@ -20,13 +20,15 @@ const CATEGORY_TAX_RULE_KEY_PREFIX = 'category-tax:'; /** * Whether the rule being written is a category tax default rather than a merchant rule. * - * The editor and every picker it opens have to agree on this — the editor picks its rows from it, the tax picker which - * rates it may offer — so they read it here rather than each rebuilding the same disjunction. A saved rule arrives by - * category name, an unsaved one carries the type the chooser put in its draft, and a draft that already holds a - * category counts either way. + * The editor and every picker it opens have to agree on this. The editor picks its rows from it and the tax picker + * which rates it may offer, so they read it here rather than each rebuilding the same disjunction. A saved rule + * arrives by category name and an unsaved one carries the type the chooser put in its draft. + * + * A category in the draft is not enough on its own. An ordinary rule can match on a category too, and it is stored in + * the `rules_` collection rather than as a category tax default. */ function isCategoryRuleDraft(form: MerchantRuleForm | undefined, editingCategoryName?: string): boolean { - return !!editingCategoryName || form?.ruleType === CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.CATEGORY || !!form?.categoriesToMatch?.length; + return !!editingCategoryName || form?.ruleType === CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.CATEGORY; } /** Whether a rule has a tax rate to apply. Tracking being on isn't enough on its own, it needs a rate to choose from. */ diff --git a/src/libs/ExpenseDefaultRuleUtils.ts b/src/libs/ExpenseDefaultRuleUtils.ts index e27b1fcc42d0..d297879a6046 100644 --- a/src/libs/ExpenseDefaultRuleUtils.ts +++ b/src/libs/ExpenseDefaultRuleUtils.ts @@ -50,6 +50,9 @@ type MerchantRuleFormValues = { /** The external ID of the tax rate set on the expense */ tax?: string; + /** The amount set on the expense, as positive whole cents. A form input can only hold a string, and the rule stores the value negated */ + amount?: string; + /** The external ID of the vendor set on the expense */ vendorID?: string; @@ -89,7 +92,7 @@ const CONDITION = { } as const; /** The order actions are written in, which fixes the numeric keys a built rule uses. */ -const ACTION_FIELD_ORDER = [FIELD.MERCHANT, FIELD.CATEGORY, FIELD.TAG, FIELD.TAX, FIELD.VENDOR_ID, FIELD.COMMENT, FIELD.REIMBURSABLE, FIELD.BILLABLE] as const; +const ACTION_FIELD_ORDER = [FIELD.MERCHANT, FIELD.CATEGORY, FIELD.TAG, FIELD.TAX, FIELD.AMOUNT, FIELD.VENDOR_ID, FIELD.COMMENT, FIELD.REIMBURSABLE, FIELD.BILLABLE] as const; /** Merchant match types the editor can represent. Any other operator on the merchant node makes a rule read-only. */ const SUPPORTED_MERCHANT_MATCH_TYPES = new Set>([EQUAL_TO, CONTAINS]); @@ -189,6 +192,21 @@ function buildTaxActionValue(taxKey: string | undefined, policy: Policy | undefi }; } +/** + * Builds the `amount` action value. + * + * The rules engine stores an expense amount negated, so $30.00 is saved as -3000. The form holds what the admin + * typed, and the sign is applied here so only one place knows the convention. + */ +function buildAmountActionValue(amount: string | undefined): number | undefined { + const amountInCents = Number(amount); + if (!amount || !Number.isFinite(amountInCents) || amountInCents === 0) { + return undefined; + } + + return -Math.abs(amountInCents); +} + /** A merchant rule as this editor builds it, which is the stored shape minus the fields the server fills in. */ type BuiltMerchantRule = Omit & { /** The conditions the expense has to meet, ANDed together. */ @@ -270,6 +288,7 @@ function buildMerchantRuleActions(formValues: Partial, p [FIELD.CATEGORY]: emptyToUndefined(formValues.category), [FIELD.TAG]: emptyToUndefined(formValues.tag), [FIELD.TAX]: buildTaxActionValue(formValues.tax, policy), + [FIELD.AMOUNT]: buildAmountActionValue(formValues.amount), [FIELD.VENDOR_ID]: emptyToUndefined(formValues.vendorID), [FIELD.COMMENT]: comment ? Parser.replace(comment) : undefined, [FIELD.REIMBURSABLE]: formValues.reimbursable, @@ -453,6 +472,15 @@ function getMerchantRuleFormValues(rule: Rule | ExpenseDefaultRule | undefined): continue; } + if (field === FIELD.AMOUNT) { + // A rule setting a credit rather than an expense has no input in the form, so it stays read-only. + if (typeof value !== 'number' || value >= 0) { + return undefined; + } + formValues.amount = String(Math.abs(value)); + continue; + } + if (field === FIELD.TAX) { if (!isExpenseDefaultTaxValue(value)) { return undefined; diff --git a/src/libs/MerchantTypeRulesUtils.ts b/src/libs/MerchantTypeRulesUtils.ts index afb6158cef0b..b6c61826837d 100644 --- a/src/libs/MerchantTypeRulesUtils.ts +++ b/src/libs/MerchantTypeRulesUtils.ts @@ -15,6 +15,7 @@ import {setWorkspaceDefaultSpendCategory} from './actions/Policy/Policy'; import {clearMerchantRuleErrors} from './actions/Policy/Rules'; import {getCategoryTaxRulesTableData, getTaxRateDisplayName} from './CategoryTaxRulesUtils'; import {getDecodedCategoryName} from './CategoryUtils'; +import {convertAmountToDisplayString} from './CurrencyUtils'; import { getExpenseDefaultConditionSummary, getExpenseDefaultRuleSummaryFields, @@ -133,6 +134,7 @@ function getMerchantRulesTableData({ tag: translate('common.tag').toLowerCase(), description: translate('common.description').toLowerCase(), tax: translate('common.tax').toLowerCase(), + amount: translate('iou.amount').toLowerCase(), vendor: translate(isOnXero ? 'common.supplier' : 'common.vendor').toLowerCase(), }; const {FIELD} = CONST.RULES.EXPENSE_DEFAULT; @@ -196,6 +198,15 @@ function getMerchantRulesTableData({ getTaxRateDisplayName(policy, value.field_id_TAX.externalID, value.field_id_TAX), ), ); + } else if (field === FIELD.AMOUNT && typeof value === 'number') { + // The rule stores the amount negated, the way an expense carries it. + actions.push( + translate( + 'workspace.rules.merchantRules.ruleSummarySubtitleUpdateField', + fieldLabels.amount, + convertAmountToDisplayString(Math.abs(value), policy?.outputCurrency), + ), + ); } else if (field === FIELD.VENDOR_ID && typeof value === 'string') { const unavailableLabel = translate(isOnXero ? 'workspace.rules.merchantRules.supplierUnavailable' : 'workspace.rules.merchantRules.vendorUnavailable'); actions.push( diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 488a2f3c4fbd..be0a76e9eafc 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -1276,6 +1276,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/rules/MerchantRules/AddDescriptionPage').default, [SCREENS.WORKSPACE.RULES_MERCHANT_REIMBURSABLE]: () => require('../../../../pages/workspace/rules/MerchantRules/AddReimbursablePage').default, [SCREENS.WORKSPACE.RULES_MERCHANT_BILLABLE]: () => require('../../../../pages/workspace/rules/MerchantRules/AddBillablePage').default, + [SCREENS.WORKSPACE.RULES_MERCHANT_AMOUNT]: () => require('../../../../pages/workspace/rules/MerchantRules/AddAmountPage').default, [SCREENS.WORKSPACE.RULES_MERCHANT_PREVIEW_MATCHES]: () => require('../../../../pages/workspace/rules/MerchantRules/PreviewMatchesPage').default, [SCREENS.WORKSPACE.RULES_MERCHANT_EDIT]: () => require('../../../../pages/workspace/rules/MerchantRules/EditMerchantRulePage').default, [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW]: () => require('../../../../pages/workspace/rules/MerchantRules/AddMerchantRulePage').default, @@ -1289,8 +1290,10 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/rules/MerchantRules/AddDescriptionPage').default, [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_REIMBURSABLE]: () => require('../../../../pages/workspace/rules/MerchantRules/AddReimbursablePage').default, [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_BILLABLE]: () => require('../../../../pages/workspace/rules/MerchantRules/AddBillablePage').default, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_AMOUNT]: () => require('../../../../pages/workspace/rules/MerchantRules/AddAmountPage').default, [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_PREVIEW_MATCHES]: () => require('../../../../pages/workspace/rules/MerchantRules/PreviewMatchesPage').default, [SCREENS.WORKSPACE.RULES_CATEGORY_TO_MATCH]: () => require('../../../../pages/workspace/rules/MerchantRules/AddCategoryToMatchPage').default, + [SCREENS.WORKSPACE.RULES_TAG_TO_MATCH]: () => require('../../../../pages/workspace/rules/MerchantRules/AddTagToMatchPage').default, [SCREENS.WORKSPACE.RULES_CATEGORY_TAX_EDIT]: () => require('../../../../pages/workspace/rules/MerchantRules/EditCategoryTaxRulePage').default, [SCREENS.WORKSPACE.RULES_AGENT_NEW]: () => require('../../../../pages/workspace/rules/AgentRules/AddAgentRulePage').default, [SCREENS.WORKSPACE.RULES_AGENT_EDIT]: () => require('../../../../pages/workspace/rules/AgentRules/EditAgentRulePage').default, diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts index 4e9826d10229..cd054fc060d9 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts @@ -519,9 +519,11 @@ const WORKSPACE_TO_RHP: Partial['config'] = { [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_DESCRIPTION]: DYNAMIC_ROUTES.RULES_MERCHANT_DESCRIPTION_FROM_EXPENSE.path, [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_REIMBURSABLE]: DYNAMIC_ROUTES.RULES_MERCHANT_REIMBURSABLE_FROM_EXPENSE.path, [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_BILLABLE]: DYNAMIC_ROUTES.RULES_MERCHANT_BILLABLE_FROM_EXPENSE.path, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_AMOUNT]: DYNAMIC_ROUTES.RULES_MERCHANT_AMOUNT_FROM_EXPENSE.path, [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_PREVIEW_MATCHES]: DYNAMIC_ROUTES.RULES_MERCHANT_PREVIEW_MATCHES_FROM_EXPENSE.path, [SCREENS.WORKSPACE.RULES_MERCHANT_IMPORT]: { path: ROUTES.RULES_MERCHANT_IMPORT.route, @@ -1542,9 +1543,15 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.RULES_MERCHANT_BILLABLE]: { path: ROUTES.RULES_MERCHANT_BILLABLE.route, }, + [SCREENS.WORKSPACE.RULES_MERCHANT_AMOUNT]: { + path: ROUTES.RULES_MERCHANT_AMOUNT.route, + }, [SCREENS.WORKSPACE.RULES_CATEGORY_TO_MATCH]: { path: ROUTES.RULES_CATEGORY_TO_MATCH.route, }, + [SCREENS.WORKSPACE.RULES_TAG_TO_MATCH]: { + path: ROUTES.RULES_TAG_TO_MATCH.route, + }, [SCREENS.WORKSPACE.RULES_CATEGORY_TAX_EDIT]: { path: ROUTES.RULES_CATEGORY_TAX_EDIT.route, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index ae375caa37e1..f33e958d99f0 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1975,6 +1975,10 @@ type SettingsNavigatorParamList = { policyID: string; ruleID?: undefined; }; + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_AMOUNT]: { + policyID: string; + ruleID?: undefined; + }; [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_PREVIEW_MATCHES]: { policyID: string; ruleID?: undefined; @@ -2127,6 +2131,10 @@ type SettingsNavigatorParamList = { policyID: string; ruleID: string; }; + [SCREENS.WORKSPACE.RULES_MERCHANT_AMOUNT]: { + policyID: string; + ruleID: string; + }; [SCREENS.WORKSPACE.RULES_MERCHANT_EDIT]: { policyID: string; ruleID: string; @@ -2140,6 +2148,10 @@ type SettingsNavigatorParamList = { ruleID: string; categoryName?: string; }; + [SCREENS.WORKSPACE.RULES_TAG_TO_MATCH]: { + policyID: string; + ruleID: string; + }; [SCREENS.WORKSPACE.RULES_CATEGORY_TAX_EDIT]: { policyID: string; categoryName: string; diff --git a/src/pages/workspace/rules/MerchantRules/AddAmountPage.tsx b/src/pages/workspace/rules/MerchantRules/AddAmountPage.tsx new file mode 100644 index 000000000000..c392aea1aaa5 --- /dev/null +++ b/src/pages/workspace/rules/MerchantRules/AddAmountPage.tsx @@ -0,0 +1,80 @@ +import AmountForm from '@components/AmountForm'; +import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; +import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; +import ScreenWrapper from '@components/ScreenWrapper'; + +import {useCurrencyListActions} from '@hooks/useCurrencyList'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePolicy from '@hooks/usePolicy'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {updateDraftMerchantRule} from '@libs/actions/User'; +import {convertToBackendAmount, convertToFrontendAmountAsString} from '@libs/CurrencyUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +import React, {useState} from 'react'; +import {View} from 'react-native'; + +import useMerchantRuleRoute from './useMerchantRuleRoute'; + +type AddAmountPageProps = PlatformStackScreenProps; + +function AddAmountPage({route}: AddAmountPageProps) { + const {policyID, ruleID} = route.params; + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {getCurrencyDecimals} = useCurrencyListActions(); + const policy = usePolicy(policyID); + const {backToRoute} = useMerchantRuleRoute(DYNAMIC_ROUTES.RULES_MERCHANT_AMOUNT_FROM_EXPENSE.path, policyID, ruleID); + + const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM); + const currency = policy?.outputCurrency ?? CONST.CURRENCY.USD; + // The draft holds whole cents. The input works in whole currency, so it is converted on the way in and back out. + const [amount, setAmount] = useState(() => (form?.amount ? convertToFrontendAmountAsString(Number(form.amount), getCurrencyDecimals(currency)) : '')); + + const goBack = () => Navigation.goBack(backToRoute); + + const onSave = () => { + updateDraftMerchantRule({amount: amount ? String(convertToBackendAmount(Number(amount))) : ''}); + Navigation.goBack(backToRoute, {shouldSkipFocusRestore: true}); + }; + + return ( + + + + + + + + ); +} + +export default AddAmountPage; diff --git a/src/pages/workspace/rules/MerchantRules/AddTagToMatchPage.tsx b/src/pages/workspace/rules/MerchantRules/AddTagToMatchPage.tsx new file mode 100644 index 000000000000..bbc864b42c9a --- /dev/null +++ b/src/pages/workspace/rules/MerchantRules/AddTagToMatchPage.tsx @@ -0,0 +1,171 @@ +import BlockingView from '@components/BlockingViews/BlockingView'; +import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; +import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; +import SelectionList from '@components/SelectionList'; +import MultiSelectListItem from '@components/SelectionList/ListItem/MultiSelectListItem'; +import type {ListItem} from '@components/SelectionList/types'; + +import useInitialSelection from '@hooks/useInitialSelection'; +import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useSearchResults from '@hooks/useSearchResults'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {updateDraftMerchantRule} from '@libs/actions/User'; +import {canUseTouchScreen} from '@libs/DeviceCapabilities'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import {getCleanedTagName, getTagLists} from '@libs/PolicyUtils'; +import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; +import tokenizedSearch from '@libs/tokenizedSearch'; + +import variables from '@styles/variables'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {PolicyTagLists} from '@src/types/onyx'; +import getEmptyArray from '@src/types/utils/getEmptyArray'; + +import type {ValueOf} from 'type-fest'; + +import React, {useState} from 'react'; + +type AddTagToMatchPageProps = PlatformStackScreenProps; + +type TagListItem = ListItem & { + value: string; +}; + +function AddTagToMatchPage({route}: AddTagToMatchPageProps) { + const {policyID, ruleID} = route.params; + const styles = useThemeStyles(); + const {translate, localeCompare} = useLocalize(); + const illustrations = useMemoizedLazyIllustrations(['Telescope']); + + const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM); + const [policyTagLists = getEmptyArray>()] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, {selector: getTagLists}); + + const draftTags = form?.tagsToMatch ?? []; + const [selectedTags, setSelectedTags] = useState(draftTags); + const initialSelectedTags = useInitialSelection(draftTags, {resetOnFocus: true}); + + // The rule matches any one of the tags it carries, so the levels are offered as one flat list rather than a row + // per level. A name shared by two levels is one option, since the rule stores the name and not the level. + const tagItems: TagListItem[] = []; + const seenTagNames = new Set(); + for (const tagList of policyTagLists) { + for (const tag of Object.values(tagList.tags)) { + if (tag.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || seenTagNames.has(tag.name)) { + continue; + } + // A rule keeps a tag it already matches on even once the tag is disabled, so it stays removable. + if (!tag.enabled && !selectedTags.includes(tag.name)) { + continue; + } + seenTagNames.add(tag.name); + tagItems.push({ + keyForList: tag.name, + text: getCleanedTagName(tag.name), + value: tag.name, + isSelected: selectedTags.includes(tag.name), + }); + } + } + + const filterTag = (item: TagListItem, searchInput: string) => tokenizedSearch([item], searchInput, (tag) => [tag.text ?? '']).length > 0; + + // Pin the initially selected tags to the top of the full sorted list, so they stay at the top while searching. + const sortedTagItems = moveInitialSelectionToTop( + [...tagItems].sort((first, second) => localeCompare(first.text ?? '', second.text ?? '')), + initialSelectedTags, + ); + + const [inputValue, setInputValue, filteredTagItems] = useSearchResults(sortedTagItems, filterTag); + + const toggleTag = (item: TagListItem) => { + setSelectedTags((previous) => (previous.includes(item.value) ? previous.filter((tagName) => tagName !== item.value) : [...previous, item.value])); + }; + + const toggleSelectAll = () => { + const visibleValues = filteredTagItems.map((item) => item.value); + const allVisibleSelected = visibleValues.length > 0 && visibleValues.every((value) => selectedTags.includes(value)); + + if (allVisibleSelected) { + const visibleSet = new Set(visibleValues); + setSelectedTags((previous) => previous.filter((value) => !visibleSet.has(value))); + return; + } + + setSelectedTags((previous) => Array.from(new Set([...previous, ...visibleValues]))); + }; + + // Passed explicitly like every sibling picker, so a deep link has somewhere to pop to. + const backToRoute = !ruleID || ruleID === ROUTES.NEW ? ROUTES.RULES_MERCHANT_NEW.getRoute(policyID) : ROUTES.RULES_MERCHANT_EDIT.getRoute(policyID, ruleID); + const goBackToRule = () => Navigation.goBack(backToRoute); + + const handleSave = () => { + updateDraftMerchantRule({tagsToMatch: selectedTags}); + Navigation.goBack(backToRoute, {shouldSkipFocusRestore: true}); + }; + + return ( + + + 0 ? toggleSelectAll : undefined} + textInputOptions={{ + value: inputValue, + label: translate('common.search'), + onChangeText: setInputValue, + }} + style={{ + listHeaderWrapperStyle: [styles.pt5, styles.pb2], + listHeaderSelectAllTextStyle: [styles.textLabelSupporting], + }} + listEmptyContent={ + + + + } + footerContent={ + + } + /> + + ); +} + +export default AddTagToMatchPage; diff --git a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx index f7745a812a69..a2244e7b2677 100644 --- a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx +++ b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx @@ -33,6 +33,7 @@ import Tab from '@libs/actions/Tab'; import {clearDraftMerchantRule, setDraftMerchantRule} from '@libs/actions/User'; import {getCategoryTaxRuleTaxID, getTaxRateDisplayName, hasUsableTaxRates, isCategoryRuleDraft} from '@libs/CategoryTaxRulesUtils'; import {getDecodedCategoryName} from '@libs/CategoryUtils'; +import {convertAmountToDisplayString} from '@libs/CurrencyUtils'; import { buildMerchantRule, canEditMerchantRule, @@ -192,7 +193,7 @@ function MerchantRulePageBase({ const isEditingSavedRule = isEditing || isEditingCategoryTaxRule; const isInLandscapeMode = useIsInLandscapeMode(); const {isBetaEnabled} = usePermissions(); - const icons = useMemoizedLazyExpensifyIcons(['Basket', 'Folder', 'Pencil', 'InvoiceGeneric', 'Tag', 'Paycheck']); + const icons = useMemoizedLazyExpensifyIcons(['Basket', 'Cash', 'Folder', 'Pencil', 'InvoiceGeneric', 'Tag', 'Paycheck']); const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); @@ -310,8 +311,8 @@ function MerchantRulePageBase({ const unavailableLabel = translate(isOnXero ? 'workspace.rules.merchantRules.supplierUnavailable' : 'workspace.rules.merchantRules.vendorUnavailable'); const vendorDisplayName = form?.vendorID ? getVendorRuleDisplayValue(policy, form.vendorID, unavailableLabel) : undefined; - // `Expense defaults` has not been migrated to the new rules system, so a rule can only carry one condition. The - // type is chosen before this page opens, so the condition and the defaults the type can't carry are simply absent. + // A rule matches on every condition it carries, so all three rows are offered together. A category tax default is + // the exception: it is still stored the old way and carries its one category. const categoriesToMatch = form?.categoriesToMatch ?? []; const hasCategoryCondition = categoriesToMatch.length > 0; const hasMerchantCondition = !!form?.merchantToMatch; @@ -359,6 +360,9 @@ function MerchantRulePageBase({ // One rule per category is saved, so the condition row lists every category the admin picked. const categoriesToMatchDisplayName = hasCategoryCondition ? categoriesToMatch.map(getDecodedCategoryName).join(', ') : undefined; + const tagsToMatch = form?.tagsToMatch ?? []; + const tagsToMatchDisplayName = tagsToMatch.length > 0 ? tagsToMatch.map(getCleanedTagName).join(', ') : undefined; + const amountDisplayName = form?.amount ? convertAmountToDisplayString(Number(form.amount), policy?.outputCurrency) : undefined; const categoryDisplayName = form?.category ? getDecodedCategoryName(form.category) : undefined; // Blank rather than the raw ID once the rate is gone from the workspace. const taxRateID = isCategoryRule ? categoryTaxID : form?.tax; @@ -529,8 +533,6 @@ function MerchantRulePageBase({ : { key: 'merchantToMatch', description: translate('common.merchant'), - // The rule's only condition, since the type is chosen before this page opens. - required: true, title: form?.merchantToMatch, onPress: () => Navigation.navigate( @@ -538,17 +540,26 @@ function MerchantRulePageBase({ ), icon: icons.Basket, }, - isScopedToCategory + hasCategories() ? { key: 'categoriesToMatch', description: translate('common.category'), - required: true, + required: isScopedToCategory, title: categoriesToMatchDisplayName, onPress: () => Navigation.navigate(ROUTES.RULES_CATEGORY_TO_MATCH.getRoute(policyID, ruleID, editCategoryTaxRuleFor)), isLocked: isCategoryLocked, icon: icons.Folder, } : undefined, + isScopedToCategory || !hasTags() + ? undefined + : { + key: 'tagsToMatch', + description: translate('common.tag'), + title: tagsToMatchDisplayName, + onPress: () => Navigation.navigate(ROUTES.RULES_TAG_TO_MATCH.getRoute(policyID, ruleID)), + icon: icons.Tag, + }, ], }, { @@ -607,6 +618,13 @@ function MerchantRulePageBase({ icon: icons.InvoiceGeneric, } : undefined, + { + key: 'amount', + description: translate('iou.amount'), + title: amountDisplayName, + onPress: () => Navigation.navigate(getRuleRoute(DYNAMIC_ROUTES.RULES_MERCHANT_AMOUNT_FROM_EXPENSE.path, ROUTES.RULES_MERCHANT_AMOUNT.getRoute(policyID, ruleID))), + icon: icons.Cash, + }, isVendorFeatureEnabled ? { key: 'vendorID', diff --git a/src/types/form/MerchantRuleForm.ts b/src/types/form/MerchantRuleForm.ts index 34cc3e9ce259..77a516b83a46 100644 --- a/src/types/form/MerchantRuleForm.ts +++ b/src/types/form/MerchantRuleForm.ts @@ -5,6 +5,7 @@ import type {ValueOf} from 'type-fest'; import type Form from './Form'; const INPUT_IDS = { + AMOUNT: 'amount', BILLABLE: 'billable', CATEGORY: 'category', CATEGORIES_TO_MATCH: 'categoriesToMatch', @@ -28,6 +29,7 @@ type ExpenseDefaultRuleType = ValueOf { }); }); + describe('buildMerchantRule amount default', () => { + it('stores the amount negated, the way an expense carries it', () => { + // Given a rule setting the amount to $30.00 + const rule = buildMerchantRule({merchantToMatch: 'Starbucks', amount: '3000'}, policy); + + // When the action is read + // Then the stored value is negative cents + expect(Object.values(rule?.actions ?? {})).toEqual([{name: ACTION.SET, field: FIELD.AMOUNT, value: -3000}]); + }); + + it('round trips the amount as the admin typed it', () => { + // Given a rule setting an amount + const formValues: MerchantRuleFormValues = {merchantToMatch: 'Starbucks', matchType: CONTAINS, amount: '3000'}; + + // When it is read back + // Then the form gets the positive amount, not the negated one + expect(getMerchantRuleFormValues(buildMerchantRule(formValues, policy))).toEqual(formValues); + }); + + it('leaves out an amount of zero, which is not a default', () => { + // Given a rule whose amount was cleared + // When it is built + // Then nothing is set, so the rule has no default and is not built + expect(buildMerchantRule({merchantToMatch: 'Starbucks', amount: '0'}, policy)).toBeUndefined(); + }); + + it('is read-only for a rule that sets a credit', () => { + // Given a rule storing a positive amount, which sets a credit rather than an expense + const rule = buildRuleWithOverrides({actions: toIndexMap([{name: ACTION.SET, field: FIELD.AMOUNT, value: 3000}])}); + + // When the form tries to read it + // Then it is refused, because saving the form back would flip the sign + expect(getMerchantRuleFormValues(rule)).toBeUndefined(); + }); + }); + describe('getExpenseDefaultConditionSummary', () => { it('groups every condition by the field it matches on', () => { // Given a rule matching on a merchant, two categories and a tag diff --git a/tests/unit/VendorMatchingMerchantRulesTest.ts b/tests/unit/VendorMatchingMerchantRulesTest.ts index 6e4e52cdf39d..ff4b898546c7 100644 --- a/tests/unit/VendorMatchingMerchantRulesTest.ts +++ b/tests/unit/VendorMatchingMerchantRulesTest.ts @@ -26,6 +26,7 @@ const buildForm = (overrides: Partial = {}): MerchantRuleForm category: '', categoriesToMatch: [], ruleType: CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.MERCHANT, + amount: '', tag: '', tagsToMatch: [], tax: '', From 6668c23d2fdfb17a0befd2814ff1a6aea2413d9d Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 16 Oct 2026 02:27:50 +0530 Subject: [PATCH 04/24] Follow the existing picker patterns for the new rule condition and amount pages Signed-off-by: krishna2323 --- src/ROUTES.ts | 8 ++ src/SCREENS.ts | 2 + .../ModalStackNavigators/index.tsx | 2 + src/libs/Navigation/linkingConfig/config.ts | 2 + src/libs/Navigation/types.ts | 9 ++ .../rules/MerchantRules/AddAmountPage.tsx | 57 ++++++----- .../MerchantRules/AddCategoryToMatchPage.tsx | 15 ++- .../rules/MerchantRules/AddTagToMatchPage.tsx | 97 +++++++++++-------- .../MerchantRules/MerchantRulePageBase.tsx | 9 +- 9 files changed, 128 insertions(+), 73 deletions(-) diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 9290127594af..48a08c1d4413 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1205,6 +1205,14 @@ const DYNAMIC_ROUTES = { path: 'rule-amount', entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], }, + RULES_CATEGORY_TO_MATCH_FROM_EXPENSE: { + path: 'rule-category-to-match', + entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + }, + RULES_TAG_TO_MATCH_FROM_EXPENSE: { + path: 'rule-tag-to-match', + entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + }, RULES_MERCHANT_PREVIEW_MATCHES_FROM_EXPENSE: { path: 'rule-matches', entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 67b6d1b1ff6a..c1a714266de8 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -1068,6 +1068,8 @@ const SCREENS = { DYNAMIC_RULES_MERCHANT_REIMBURSABLE: 'Dynamic_Rules_Merchant_Reimbursable', DYNAMIC_RULES_MERCHANT_BILLABLE: 'Dynamic_Rules_Merchant_Billable', DYNAMIC_RULES_MERCHANT_AMOUNT: 'Dynamic_Rules_Merchant_Amount', + DYNAMIC_RULES_CATEGORY_TO_MATCH: 'Dynamic_Rules_Category_To_Match', + DYNAMIC_RULES_TAG_TO_MATCH: 'Dynamic_Rules_Tag_To_Match', DYNAMIC_RULES_MERCHANT_PREVIEW_MATCHES: 'Dynamic_Rules_Merchant_Preview_Matches', RULES_SPEND_MERCHANTS: 'Rules_Spend_Merchants', RULES_SPEND_MERCHANT_EDIT: 'Rules_Spend_Merchant_Edit', diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index be0a76e9eafc..4b55f264c49f 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -1291,6 +1291,8 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/rules/MerchantRules/AddReimbursablePage').default, [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_BILLABLE]: () => require('../../../../pages/workspace/rules/MerchantRules/AddBillablePage').default, [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_AMOUNT]: () => require('../../../../pages/workspace/rules/MerchantRules/AddAmountPage').default, + [SCREENS.WORKSPACE.DYNAMIC_RULES_CATEGORY_TO_MATCH]: () => require('../../../../pages/workspace/rules/MerchantRules/AddCategoryToMatchPage').default, + [SCREENS.WORKSPACE.DYNAMIC_RULES_TAG_TO_MATCH]: () => require('../../../../pages/workspace/rules/MerchantRules/AddTagToMatchPage').default, [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_PREVIEW_MATCHES]: () => require('../../../../pages/workspace/rules/MerchantRules/PreviewMatchesPage').default, [SCREENS.WORKSPACE.RULES_CATEGORY_TO_MATCH]: () => require('../../../../pages/workspace/rules/MerchantRules/AddCategoryToMatchPage').default, [SCREENS.WORKSPACE.RULES_TAG_TO_MATCH]: () => require('../../../../pages/workspace/rules/MerchantRules/AddTagToMatchPage').default, diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 0e16d1480a74..53d63c8d8daa 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1437,6 +1437,8 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_REIMBURSABLE]: DYNAMIC_ROUTES.RULES_MERCHANT_REIMBURSABLE_FROM_EXPENSE.path, [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_BILLABLE]: DYNAMIC_ROUTES.RULES_MERCHANT_BILLABLE_FROM_EXPENSE.path, [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_AMOUNT]: DYNAMIC_ROUTES.RULES_MERCHANT_AMOUNT_FROM_EXPENSE.path, + [SCREENS.WORKSPACE.DYNAMIC_RULES_CATEGORY_TO_MATCH]: DYNAMIC_ROUTES.RULES_CATEGORY_TO_MATCH_FROM_EXPENSE.path, + [SCREENS.WORKSPACE.DYNAMIC_RULES_TAG_TO_MATCH]: DYNAMIC_ROUTES.RULES_TAG_TO_MATCH_FROM_EXPENSE.path, [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_PREVIEW_MATCHES]: DYNAMIC_ROUTES.RULES_MERCHANT_PREVIEW_MATCHES_FROM_EXPENSE.path, [SCREENS.WORKSPACE.RULES_MERCHANT_IMPORT]: { path: ROUTES.RULES_MERCHANT_IMPORT.route, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index f33e958d99f0..e151779e0a7a 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1979,6 +1979,15 @@ type SettingsNavigatorParamList = { policyID: string; ruleID?: undefined; }; + [SCREENS.WORKSPACE.DYNAMIC_RULES_CATEGORY_TO_MATCH]: { + policyID: string; + ruleID?: undefined; + categoryName?: undefined; + }; + [SCREENS.WORKSPACE.DYNAMIC_RULES_TAG_TO_MATCH]: { + policyID: string; + ruleID?: undefined; + }; [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_PREVIEW_MATCHES]: { policyID: string; ruleID?: undefined; diff --git a/src/pages/workspace/rules/MerchantRules/AddAmountPage.tsx b/src/pages/workspace/rules/MerchantRules/AddAmountPage.tsx index c392aea1aaa5..92fca2fb3cc4 100644 --- a/src/pages/workspace/rules/MerchantRules/AddAmountPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddAmountPage.tsx @@ -1,8 +1,11 @@ import AmountForm from '@components/AmountForm'; -import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormOnyxValues} from '@components/Form/types'; import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; import ScreenWrapper from '@components/ScreenWrapper'; +import useAutoFocusInput from '@hooks/useAutoFocusInput'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -19,8 +22,9 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; +import MERCHANT_RULE_INPUT_IDS from '@src/types/form/MerchantRuleForm'; -import React, {useState} from 'react'; +import React from 'react'; import {View} from 'react-native'; import useMerchantRuleRoute from './useMerchantRuleRoute'; @@ -31,48 +35,55 @@ function AddAmountPage({route}: AddAmountPageProps) { const {policyID, ruleID} = route.params; const styles = useThemeStyles(); const {translate} = useLocalize(); + const {inputCallbackRef} = useAutoFocusInput(); const {getCurrencyDecimals} = useCurrencyListActions(); const policy = usePolicy(policyID); const {backToRoute} = useMerchantRuleRoute(DYNAMIC_ROUTES.RULES_MERCHANT_AMOUNT_FROM_EXPENSE.path, policyID, ruleID); const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM); const currency = policy?.outputCurrency ?? CONST.CURRENCY.USD; - // The draft holds whole cents. The input works in whole currency, so it is converted on the way in and back out. - const [amount, setAmount] = useState(() => (form?.amount ? convertToFrontendAmountAsString(Number(form.amount), getCurrencyDecimals(currency)) : '')); + // The rule stores whole cents and the input works in whole currency, so the two are converted at this boundary. + const defaultValue = form?.amount ? convertToFrontendAmountAsString(Number(form.amount), getCurrencyDecimals(currency)) : ''; const goBack = () => Navigation.goBack(backToRoute); - const onSave = () => { - updateDraftMerchantRule({amount: amount ? String(convertToBackendAmount(Number(amount))) : ''}); - Navigation.goBack(backToRoute, {shouldSkipFocusRestore: true}); + const onSubmit = ({amount}: FormOnyxValues) => { + const amountInCents = amount ? convertToBackendAmount(Number(amount)) : 0; + updateDraftMerchantRule({amount: amountInCents ? String(amountInCents) : ''}); + goBack(); }; return ( - - - - + > + + + + ); } diff --git a/src/pages/workspace/rules/MerchantRules/AddCategoryToMatchPage.tsx b/src/pages/workspace/rules/MerchantRules/AddCategoryToMatchPage.tsx index 01df6a002373..3974f9f83fb3 100644 --- a/src/pages/workspace/rules/MerchantRules/AddCategoryToMatchPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddCategoryToMatchPage.tsx @@ -20,7 +20,7 @@ import useSearchResults from '@hooks/useSearchResults'; import useThemeStyles from '@hooks/useThemeStyles'; import {updateDraftMerchantRule} from '@libs/actions/User'; -import {categoryHasTaxRule} from '@libs/CategoryTaxRulesUtils'; +import {categoryHasTaxRule, isCategoryRuleDraft} from '@libs/CategoryTaxRulesUtils'; import {getDecodedCategoryName} from '@libs/CategoryUtils'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import Navigation from '@libs/Navigation/Navigation'; @@ -66,10 +66,15 @@ function AddCategoryToMatchPage({route}: AddCategoryToMatchPageProps) { isLoading: arePolicyCategoriesLoading, } = usePolicyCategoryPickerCategories({ policyID, - // Keep current selections so they stay removable; don't offer a category that already has a tax default. - // The rule being edited stays listed even once the selection moves off it, since its own category still has a - // tax rule and would otherwise drop out of the list with no way to switch back. - isEligible: (category) => selectedCategories.includes(category.name) || category.name === editingCategoryName || !categoryHasTaxRule(policy?.rules?.expenseRules, category.name), + // One category holds one tax default, so a category that already has one is not offered again. An ordinary + // rule only matches on the category, so it can pick any of them. + // Keep current selections so they stay removable. The rule being edited stays listed even once the selection + // moves off it, since its own category still has a tax rule and would otherwise drop out with no way back. + isEligible: (category) => + !isCategoryRuleDraft(form, editingCategoryName) || + selectedCategories.includes(category.name) || + category.name === editingCategoryName || + !categoryHasTaxRule(policy?.rules?.expenseRules, category.name), }); const categoryItems: CategoryListItem[] = categories.map((category) => ({ diff --git a/src/pages/workspace/rules/MerchantRules/AddTagToMatchPage.tsx b/src/pages/workspace/rules/MerchantRules/AddTagToMatchPage.tsx index bbc864b42c9a..3c70144d4afa 100644 --- a/src/pages/workspace/rules/MerchantRules/AddTagToMatchPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddTagToMatchPage.tsx @@ -1,3 +1,4 @@ +import ActivityIndicator from '@components/ActivityIndicator'; import BlockingView from '@components/BlockingViews/BlockingView'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; @@ -9,6 +10,7 @@ import type {ListItem} from '@components/SelectionList/types'; import useInitialSelection from '@hooks/useInitialSelection'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLoadPolicyTags from '@hooks/useLoadPolicyTags'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useSearchResults from '@hooks/useSearchResults'; @@ -27,7 +29,7 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {PolicyTagLists} from '@src/types/onyx'; import getEmptyArray from '@src/types/utils/getEmptyArray'; @@ -35,8 +37,11 @@ import getEmptyArray from '@src/types/utils/getEmptyArray'; import type {ValueOf} from 'type-fest'; import React, {useState} from 'react'; +import {View} from 'react-native'; -type AddTagToMatchPageProps = PlatformStackScreenProps; +import useMerchantRuleRoute from './useMerchantRuleRoute'; + +type AddTagToMatchPageProps = PlatformStackScreenProps; type TagListItem = ListItem & { value: string; @@ -44,10 +49,14 @@ type TagListItem = ListItem & { function AddTagToMatchPage({route}: AddTagToMatchPageProps) { const {policyID, ruleID} = route.params; + const {backToRoute} = useMerchantRuleRoute(DYNAMIC_ROUTES.RULES_TAG_TO_MATCH_FROM_EXPENSE.path, policyID, ruleID); const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); const illustrations = useMemoizedLazyIllustrations(['Telescope']); + // Tags are lazy loaded, so a picker that only reads Onyx shows an empty list on an account that has never + // opened the tags page. + const {isLoadingPolicyTags} = useLoadPolicyTags(policyID); const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM); const [policyTagLists = getEmptyArray>()] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, {selector: getTagLists}); @@ -105,8 +114,6 @@ function AddTagToMatchPage({route}: AddTagToMatchPageProps) { setSelectedTags((previous) => Array.from(new Set([...previous, ...visibleValues]))); }; - // Passed explicitly like every sibling picker, so a deep link has somewhere to pop to. - const backToRoute = !ruleID || ruleID === ROUTES.NEW ? ROUTES.RULES_MERCHANT_NEW.getRoute(policyID) : ROUTES.RULES_MERCHANT_EDIT.getRoute(policyID, ruleID); const goBackToRule = () => Navigation.goBack(backToRoute); const handleSave = () => { @@ -125,45 +132,51 @@ function AddTagToMatchPage({route}: AddTagToMatchPageProps) { title={translate('common.tag')} onBackButtonPress={goBackToRule} /> - 0 ? toggleSelectAll : undefined} - textInputOptions={{ - value: inputValue, - label: translate('common.search'), - onChangeText: setInputValue, - }} - style={{ - listHeaderWrapperStyle: [styles.pt5, styles.pb2], - listHeaderSelectAllTextStyle: [styles.textLabelSupporting], - }} - listEmptyContent={ - - + + + ) : ( + 0 ? toggleSelectAll : undefined} + textInputOptions={{ + value: inputValue, + label: translate('common.search'), + onChangeText: setInputValue, + }} + style={{ + listHeaderWrapperStyle: [styles.pt5, styles.pb2], + listHeaderSelectAllTextStyle: [styles.textLabelSupporting], + }} + listEmptyContent={ + + + + } + footerContent={ + - - } - footerContent={ - - } - /> + } + /> + )} ); } diff --git a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx index a2244e7b2677..42c3beac967c 100644 --- a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx +++ b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx @@ -481,7 +481,7 @@ function MerchantRulePageBase({ const hasDuplicate = checkForDuplicateRule(form); if (hasDuplicate) { // A duplicate does not have to match on a merchant, so the prompt names the first condition the two share. - const duplicateCondition = [form.merchantToMatch, categoriesToMatchDisplayName, form.tagsToMatch?.join(', ')].find(Boolean) ?? ''; + const duplicateCondition = [form.merchantToMatch, categoriesToMatchDisplayName, tagsToMatchDisplayName].find(Boolean) ?? ''; showConfirmModal({ title: translate('workspace.rules.merchantRules.duplicateRuleTitle'), prompt: translate('workspace.rules.merchantRules.duplicateRulePrompt', duplicateCondition), @@ -546,7 +546,10 @@ function MerchantRulePageBase({ description: translate('common.category'), required: isScopedToCategory, title: categoriesToMatchDisplayName, - onPress: () => Navigation.navigate(ROUTES.RULES_CATEGORY_TO_MATCH.getRoute(policyID, ruleID, editCategoryTaxRuleFor)), + onPress: () => + Navigation.navigate( + getRuleRoute(DYNAMIC_ROUTES.RULES_CATEGORY_TO_MATCH_FROM_EXPENSE.path, ROUTES.RULES_CATEGORY_TO_MATCH.getRoute(policyID, ruleID, editCategoryTaxRuleFor)), + ), isLocked: isCategoryLocked, icon: icons.Folder, } @@ -557,7 +560,7 @@ function MerchantRulePageBase({ key: 'tagsToMatch', description: translate('common.tag'), title: tagsToMatchDisplayName, - onPress: () => Navigation.navigate(ROUTES.RULES_TAG_TO_MATCH.getRoute(policyID, ruleID)), + onPress: () => Navigation.navigate(getRuleRoute(DYNAMIC_ROUTES.RULES_TAG_TO_MATCH_FROM_EXPENSE.path, ROUTES.RULES_TAG_TO_MATCH.getRoute(policyID, ruleID))), icon: icons.Tag, }, ], From 1c24c392f123d670796daef3b64f1c571a96eaf3 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 16 Oct 2026 02:32:35 +0530 Subject: [PATCH 05/24] Require a tag before offering the tag condition Signed-off-by: krishna2323 --- .../rules/MerchantRules/MerchantRulePageBase.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx index 42c3beac967c..1730001a1559 100644 --- a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx +++ b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx @@ -300,6 +300,16 @@ function MerchantRulePageBase({ } return policyTags.length > 0; }; + + // Turning tags on creates a tag list whether or not it holds anything, so the condition row needs a tag to + // actually match on. A tag the rule already matches on keeps the row, the way `hasCategories` keeps it for a + // category the rule already sets. + const hasTagsToMatch = () => { + if (!policy?.areTagsEnabled) { + return false; + } + return !!form?.tagsToMatch?.length || policyTags.some((tagList) => hasEnabledOptions(tagList.tags)); + }; const formTags = getTagArrayFromName(form?.tag ?? ''); const isBillableEnabled = policy?.disabledFields?.defaultBillable !== true; @@ -554,7 +564,7 @@ function MerchantRulePageBase({ icon: icons.Folder, } : undefined, - isScopedToCategory || !hasTags() + isScopedToCategory || !hasTagsToMatch() ? undefined : { key: 'tagsToMatch', From f92c9e2144955ed7e01a08ecac6ce863c4925a90 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 16 Oct 2026 02:56:36 +0530 Subject: [PATCH 06/24] Require a tag before offering the tag condition Signed-off-by: krishna2323 --- src/CONST/index.ts | 5 - src/ROUTES.ts | 4 - src/SCREENS.ts | 2 - src/hooks/useMerchantRuleSuggestion.ts | 2 +- src/libs/CategoryContextualRulesUtils.ts | 16 +- src/libs/CategoryTaxRulesUtils.ts | 40 +-- src/libs/ExpenseDefaultRuleUtils.ts | 36 +++ src/libs/MerchantRuleSuggestionUtils.ts | 2 +- src/libs/MerchantTypeRulesUtils.ts | 10 +- .../ModalStackNavigators/index.tsx | 2 - .../RELATIONS/WORKSPACE_TO_RHP.ts | 2 - src/libs/Navigation/linkingConfig/config.ts | 4 - src/libs/Navigation/types.ts | 7 - src/libs/actions/Policy/Category.ts | 250 +----------------- .../categories/CategorySettingsPage.tsx | 2 + .../DynamicCategoryDefaultTaxRatePage.tsx | 127 --------- .../MerchantRules/AddCategoryTaxRulePage.tsx | 13 +- .../MerchantRules/AddCategoryToMatchPage.tsx | 14 +- .../MerchantRules/AddMerchantRulePage.tsx | 24 +- .../rules/MerchantRules/AddTaxPage.tsx | 11 +- .../MerchantRules/EditCategoryTaxRulePage.tsx | 14 +- .../MerchantRules/ExpenseDefaultTypePage.tsx | 132 --------- .../MerchantRules/MerchantRulePageBase.tsx | 233 ++++------------ src/pages/workspace/rules/RulesNewPage.tsx | 25 +- .../rules/tabs/RulesExpenseDefaultsTab.tsx | 2 - .../rules/tabs/useRulesTableBulkActions.ts | 17 +- src/types/form/MerchantRuleForm.ts | 9 +- tests/actions/PolicyCategoryTest.ts | 102 ------- .../DynamicCategoryDefaultTaxRatePageTest.tsx | 160 ----------- .../unit/CategoryContextualRulesUtilsTest.ts | 27 +- tests/unit/CategoryTaxRulesUtilsTest.ts | 146 ++-------- tests/unit/MerchantRuleSuggestionUtilsTest.ts | 3 +- tests/unit/VendorMatchingMerchantRulesTest.ts | 1 - 33 files changed, 173 insertions(+), 1271 deletions(-) delete mode 100644 src/pages/workspace/categories/DynamicCategoryDefaultTaxRatePage.tsx delete mode 100644 src/pages/workspace/rules/MerchantRules/ExpenseDefaultTypePage.tsx delete mode 100644 tests/ui/DynamicCategoryDefaultTaxRatePageTest.tsx diff --git a/src/CONST/index.ts b/src/CONST/index.ts index ded0278e317a..f90ab856d549 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -4667,11 +4667,6 @@ const CONST = { MERCHANTS: 'merchants', MERCHANT_TYPES: 'merchantTypes', }, - /** What an expense default rule matches on, chosen before the editor opens and kept in its draft. */ - EXPENSE_DEFAULT_RULE_TYPE: { - MERCHANT: 'merchant', - CATEGORY: 'category', - }, DEFAULT_REPORT_NAME_PATTERN: '{report:type} {report:startdate}', DEFAULT_FIELD_LIST_TYPE: 'formula', DEFAULT_FIELD_LIST_TARGET: 'expense', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 48a08c1d4413..ebb9ef010a34 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -4399,10 +4399,6 @@ const ROUTES = { route: 'workspaces/:policyID/rules/new', getRoute: (policyID: string, categoryName?: string) => `workspaces/${policyID}/rules/new${getOptionalCategoryNameQuery(categoryName)}` as const, }, - RULES_EXPENSE_DEFAULT_TYPE: { - route: 'workspaces/:policyID/rules/expense-defaults/new', - getRoute: (policyID: string) => `workspaces/${policyID}/rules/expense-defaults/new` as const, - }, RULES_MERCHANT_NEW: { route: 'workspaces/:policyID/rules/merchant-rules/new', getRoute: (policyID: string, categoryName?: string) => `workspaces/${policyID}/rules/merchant-rules/new${getOptionalCategoryNameQuery(categoryName)}` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index c1a714266de8..a371fdbed7f7 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -933,7 +933,6 @@ const SCREENS = { DYNAMIC_CATEGORY_PAYROLL_CODE: 'Dynamic_Category_Payroll_Code', DYNAMIC_CATEGORY_GL_CODE: 'Dynamic_Category_GL_Code', DYNAMIC_CATEGORY_SETTINGS: 'Dynamic_Category_Settings', - DYNAMIC_CATEGORY_DEFAULT_TAX_RATE: 'Dynamic_Category_Default_Tax_Rate', DYNAMIC_CATEGORY_FLAG_AMOUNTS_OVER: 'Dynamic_Category_Flag_Amounts_Over', DYNAMIC_EXPENSE_LIMIT_TYPE_SELECTOR: 'Dynamic_Expense_Limit_Type_Selector', DYNAMIC_IMPORTED_MEMBERS_ROLE: 'Dynamic_Imported_Members_Role', @@ -1016,7 +1015,6 @@ const SCREENS = { RULES_CUSTOM: 'Rules_Custom', RULES_PROHIBITED_DEFAULT: 'Rules_Prohibited_Default', RULES_NEW: 'Rules_New', - RULES_EXPENSE_DEFAULT_TYPE: 'Rules_Expense_Default_Type', RULES_MERCHANT_NEW: 'Rules_Merchant_New', RULES_MERCHANT_IMPORT: 'Rules_Merchant_Import', RULES_MERCHANT_IMPORTED: 'Rules_Merchant_Imported', diff --git a/src/hooks/useMerchantRuleSuggestion.ts b/src/hooks/useMerchantRuleSuggestion.ts index 3f4475094325..3f6e9caff15b 100644 --- a/src/hooks/useMerchantRuleSuggestion.ts +++ b/src/hooks/useMerchantRuleSuggestion.ts @@ -65,7 +65,7 @@ function useMerchantRuleSuggestion(reportID: string | undefined, policyID: strin // Built here rather than only on press, so an offer that would apply nothing never appears. Clearing a field is // still an edit worth recording, but the rule it would make sets that field to empty, which changes nothing. const draft = suggestion && transaction ? getMerchantRuleDraftFromTransaction(transaction, fields, policy, editedTagLevels) : undefined; - const hasUpdatesToApply = !!draft && Object.keys(draft).some((key) => key !== INPUT_IDS.MERCHANT_TO_MATCH && key !== INPUT_IDS.RULE_TYPE); + const hasUpdatesToApply = !!draft && Object.keys(draft).some((key) => key !== INPUT_IDS.MERCHANT_TO_MATCH); // A rule matches on merchant, so an expense without one (a receipt still scanning) can't seed one. Nor can an // offer with nothing recorded, which is how an expense reads once its fields are cleared. diff --git a/src/libs/CategoryContextualRulesUtils.ts b/src/libs/CategoryContextualRulesUtils.ts index 26f8091f479d..a1c7f300d60d 100644 --- a/src/libs/CategoryContextualRulesUtils.ts +++ b/src/libs/CategoryContextualRulesUtils.ts @@ -9,10 +9,13 @@ import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList'; import CONST from '@src/CONST'; import type {DynamicRouteSuffix} from '@src/ROUTES'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; -import type {Policy, PolicyCategories, PolicyCategory} from '@src/types/onyx'; +import type {Policy, PolicyCategories, PolicyCategory, Rule} from '@src/types/onyx'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; -import {getCategoryTaxRuleTaxID, getRuleDeletionPendingAction, getTaxRateDisplayName} from './CategoryTaxRulesUtils'; +import type {OnyxCollection} from 'react-native-onyx'; + +import {getRuleDeletionPendingAction, getTaxRateDisplayName} from './CategoryTaxRulesUtils'; +import {getCategoryTaxDefaultID} from './ExpenseDefaultRuleUtils'; import {hasExplicitFlagAmount} from './FlagForReviewRulesUtils'; import {getTaxByID} from './PolicyUtils'; import { @@ -59,6 +62,7 @@ function getFlagForReviewContextualSummary( function getCategoryContextualRules({ policy, policyCategories, + policyRules, category, categoryName, translate, @@ -68,6 +72,8 @@ function getCategoryContextualRules({ policy: Policy | undefined; /** Read for the pending delete of the category a tax default depends on. */ policyCategories: PolicyCategories | undefined; + /** Holds the rule that acts as this category's tax default. */ + policyRules?: OnyxCollection; category: PolicyCategory | undefined; categoryName: string; translate: LocaleContextProps['translate']; @@ -115,9 +121,9 @@ function getCategoryContextualRules({ } } - // A tax default lives in `policy.rules.expenseRules` rather than on the category, and carries no pending state of - // its own, so it follows the Expense defaults table and borrows the delete of the category or rate it depends on. - const taxID = getCategoryTaxRuleTaxID(policy.rules?.expenseRules, categoryName); + // A tax default is an ordinary rule matching on this category alone, so it is looked up rather than read off + // the category. It borrows the delete of the category or rate it depends on, the way the table does. + const taxID = getCategoryTaxDefaultID(policyRules, policy.id, categoryName); if (taxID) { const taxPendingAction = getRuleDeletionPendingAction(policy, policyCategories, categoryName, taxID); const isTaxPendingDelete = taxPendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; diff --git a/src/libs/CategoryTaxRulesUtils.ts b/src/libs/CategoryTaxRulesUtils.ts index 3dd4fcdec511..d20b15904ebb 100644 --- a/src/libs/CategoryTaxRulesUtils.ts +++ b/src/libs/CategoryTaxRulesUtils.ts @@ -7,7 +7,6 @@ import type {ExpenseDefaultTableItem} from '@components/Tables/WorkspaceExpenseD import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; -import type {MerchantRuleForm} from '@src/types/form'; import type {Policy, PolicyCategories, TaxRate} from '@src/types/onyx'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; import type {ExpenseRule} from '@src/types/onyx/Policy'; @@ -17,45 +16,14 @@ import {getTaxByID} from './PolicyUtils'; const CATEGORY_TAX_RULE_KEY_PREFIX = 'category-tax:'; -/** - * Whether the rule being written is a category tax default rather than a merchant rule. - * - * The editor and every picker it opens have to agree on this. The editor picks its rows from it and the tax picker - * which rates it may offer, so they read it here rather than each rebuilding the same disjunction. A saved rule - * arrives by category name and an unsaved one carries the type the chooser put in its draft. - * - * A category in the draft is not enough on its own. An ordinary rule can match on a category too, and it is stored in - * the `rules_` collection rather than as a category tax default. - */ -function isCategoryRuleDraft(form: MerchantRuleForm | undefined, editingCategoryName?: string): boolean { - return !!editingCategoryName || form?.ruleType === CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.CATEGORY; -} - /** Whether a rule has a tax rate to apply. Tracking being on isn't enough on its own, it needs a rate to choose from. */ function hasUsableTaxRates(policy: Policy | undefined): boolean { return !!policy?.tax?.trackingEnabled && Object.keys(policy?.taxRates?.taxes ?? {}).length > 0; } -/** - * Whether a rule may pick this rate. A disabled rate, or one on its way out, is not an option for any rule, and the - * workspace default is not one for a category rule, since writing that rate is what deletes the rule. - * - * The tax picker and the chooser that decides whether to offer a category rule at all both read this, so a locked - * option and an empty picker can't disagree about what counts as a usable rate. - */ -function isSelectableTaxRate(policy: Policy | undefined, taxID: string, taxRate: TaxRate, isCategoryRule: boolean): boolean { - return !taxRate.isDisabled && taxRate.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !(isCategoryRule && taxID === policy?.taxRates?.defaultExternalID); -} - -/** - * Whether a new category tax default has any rate to choose. Holding rates is not enough: they can all be disabled, or - * the workspace default can be the only one left, which a category rule can't use. - */ -function hasSelectableCategoryTaxRate(policy: Policy | undefined): boolean { - if (!hasUsableTaxRates(policy)) { - return false; - } - return Object.entries(policy?.taxRates?.taxes ?? {}).some(([taxID, taxRate]) => isSelectableTaxRate(policy, taxID, taxRate, true)); +/** Whether a rule may pick this rate. A disabled rate, or one on its way out, is not an option for any rule. */ +function isSelectableTaxRate(taxRate: TaxRate): boolean { + return !taxRate.isDisabled && taxRate.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; } function getCategoryTaxRuleKey(categoryName: string) { @@ -202,9 +170,7 @@ export { getRuleCategoryName, getRuleDeletionPendingAction, getTaxRateDisplayName, - hasSelectableCategoryTaxRate, hasUsableTaxRates, - isCategoryRuleDraft, isCategoryTaxRuleKey, isSelectableTaxRate, matchesCategoryTaxRule, diff --git a/src/libs/ExpenseDefaultRuleUtils.ts b/src/libs/ExpenseDefaultRuleUtils.ts index d297879a6046..7b66a0b04811 100644 --- a/src/libs/ExpenseDefaultRuleUtils.ts +++ b/src/libs/ExpenseDefaultRuleUtils.ts @@ -546,6 +546,40 @@ function getExpenseDefaultConditionSummary(filters: RuleFilterNode | undefined): return summary; } +/** + * The rule that holds a category's tax default: one that matches on that category alone and sets a tax. + * + * Category settings addresses this rule by category name rather than by ID, because a category holds one tax + * default, so the ID is resolved here instead of being carried on every route. + */ +function getCategoryTaxDefaultRule(rulesCollection: OnyxCollection | undefined, policyID: string | undefined, categoryName: string | undefined): RuleWithID | undefined { + if (!categoryName) { + return undefined; + } + + return getPolicyExpenseDefaultRules(rulesCollection, policyID).find(({rule}) => { + const {merchants, categories, tags} = getExpenseDefaultConditionSummary(rule.filters); + if (merchants.length > 0 || tags.length > 0 || categories.length !== 1 || categories.at(0) !== categoryName) { + return false; + } + + return getRuleActions(rule).some((action) => action?.name === ACTION.SET && 'field' in action && action.field === FIELD.TAX); + }); +} + +/** The tax rate a category's tax default applies. See `getCategoryTaxDefaultRule`. */ +function getCategoryTaxDefaultID(rulesCollection: OnyxCollection | undefined, policyID: string | undefined, categoryName: string | undefined): string | undefined { + const rule = getCategoryTaxDefaultRule(rulesCollection, policyID, categoryName)?.rule; + + for (const action of getRuleActions(rule)) { + if (action?.name === ACTION.SET && 'field' in action && action.field === FIELD.TAX && isExpenseDefaultTaxValue(action.value)) { + return action.value.field_id_TAX.externalID; + } + } + + return undefined; +} + /** A single field a rule sets, normalized for display. */ type ExpenseDefaultRuleSummaryField = { /** The expense field being set */ @@ -644,6 +678,8 @@ export { buildCopiedExpenseDefaultRules, buildMerchantRule, canEditMerchantRule, + getCategoryTaxDefaultID, + getCategoryTaxDefaultRule, getExpenseDefaultConditionSummary, getExpenseDefaultRuleCount, getExpenseDefaultRuleSummaryFields, diff --git a/src/libs/MerchantRuleSuggestionUtils.ts b/src/libs/MerchantRuleSuggestionUtils.ts index 239c11e38f80..8349d460a1e7 100644 --- a/src/libs/MerchantRuleSuggestionUtils.ts +++ b/src/libs/MerchantRuleSuggestionUtils.ts @@ -102,7 +102,7 @@ function getMerchantRuleDraftFromTransaction( // The callout only ever creates a merchant rule, so it names the type itself. Without it the editor bounces to the // type chooser, which would drop the user a step back from where the callout promised to take them. - const draft: Partial = {ruleType: CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.MERCHANT, merchantToMatch: getMerchant(transaction)}; + const draft: Partial = {merchantToMatch: getMerchant(transaction)}; // Each field sets a different property, so edit order does not matter. for (const field of fields) { diff --git a/src/libs/MerchantTypeRulesUtils.ts b/src/libs/MerchantTypeRulesUtils.ts index b6c61826837d..08d36ad16458 100644 --- a/src/libs/MerchantTypeRulesUtils.ts +++ b/src/libs/MerchantTypeRulesUtils.ts @@ -6,14 +6,14 @@ import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import INPUT_IDS from '@src/types/form/MerchantTypeRuleForm'; import type {MerchantTypeRuleForm} from '@src/types/form/MerchantTypeRuleForm'; -import type {Policy, PolicyCategories, PolicyTagLists, Rule} from '@src/types/onyx'; +import type {Policy, PolicyTagLists, Rule} from '@src/types/onyx'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {DEFAULT_MCC_GROUP, isDefaultMccGroupID} from './actions/Policy/OptimisticPolicyCategoriesAndMccGroups'; import {setWorkspaceDefaultSpendCategory} from './actions/Policy/Policy'; import {clearMerchantRuleErrors} from './actions/Policy/Rules'; -import {getCategoryTaxRulesTableData, getTaxRateDisplayName} from './CategoryTaxRulesUtils'; +import {getTaxRateDisplayName} from './CategoryTaxRulesUtils'; import {getDecodedCategoryName} from './CategoryUtils'; import {convertAmountToDisplayString} from './CurrencyUtils'; import { @@ -256,7 +256,6 @@ function getExpenseDefaultsTableData({ policy, policyID, rules, - policyCategories, policyTags, translate, isOffline, @@ -265,19 +264,16 @@ function getExpenseDefaultsTableData({ policy: Policy | undefined; policyID: string; rules: OnyxCollection | undefined; - /** Read for the pending state of a category a rule depends on, so the rule shows as deleting alongside it. */ - policyCategories: PolicyCategories | undefined; /** Read to hide a deleted tag from a merchant rule's summary. */ policyTags: OnyxEntry; translate: LocaleContextProps['translate']; isOffline: boolean; onNavigate: (route: Route) => void; }): ExpenseDefaultTableItem[] { - const categoryTaxRules = getCategoryTaxRulesTableData({policy, policyCategories, translate, isOffline, onNavigate}); const merchantRules = getMerchantRulesTableData({policy, policyID, rules, policyTags, translate, isOffline, onNavigate}); const merchantTypeRules = getMerchantTypeRulesTableData({policy, translate, onNavigate}); - return [...categoryTaxRules, ...merchantRules, ...merchantTypeRules]; + return [...merchantRules, ...merchantTypeRules]; } export { diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 4b55f264c49f..e706011f16ca 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -625,7 +625,6 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/categories/DynamicEditCategoryPage').default, [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_PAYROLL_CODE]: () => require('../../../../pages/workspace/categories/CategoryPayrollCodePage').default, [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_GL_CODE]: () => require('../../../../pages/workspace/categories/CategoryGLCodePage').default, - [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_DEFAULT_TAX_RATE]: () => require('../../../../pages/workspace/categories/DynamicCategoryDefaultTaxRatePage').default, [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_FLAG_AMOUNTS_OVER]: () => require('../../../../pages/workspace/categories/DynamicCategoryFlagAmountsOverPage').default, [SCREENS.WORKSPACE.DYNAMIC_EXPENSE_LIMIT_TYPE_SELECTOR]: () => require('../../../../pages/workspace/categories/DynamicExpenseLimitTypeSelectorPage').default, [SCREENS.WORKSPACE.DYNAMIC_IMPORTED_MEMBERS_ROLE]: () => require('../../../../pages/workspace/members/DynamicImportedMembersRoleSelectionPage').default, @@ -1234,7 +1233,6 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/rules/RulesCustomPage').default, [SCREENS.WORKSPACE.RULES_PROHIBITED_DEFAULT]: () => require('../../../../pages/workspace/rules/RulesProhibitedDefaultPage').default, [SCREENS.WORKSPACE.RULES_NEW]: () => require('../../../../pages/workspace/rules/RulesNewPage').default, - [SCREENS.WORKSPACE.RULES_EXPENSE_DEFAULT_TYPE]: () => require('../../../../pages/workspace/rules/MerchantRules/ExpenseDefaultTypePage').default, [SCREENS.WORKSPACE.RULES_MERCHANT_NEW]: () => require('../../../../pages/workspace/rules/MerchantRules/AddMerchantRulePage').default, [SCREENS.WORKSPACE.RULES_MERCHANT_IMPORT]: () => require('../../../../pages/workspace/rules/MerchantRules/ImportMerchantRulesPage').default, [SCREENS.WORKSPACE.RULES_MERCHANT_IMPORTED]: () => require('../../../../pages/workspace/rules/MerchantRules/ImportedMerchantRulesPage').default, diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts index cd054fc060d9..b308dfd3499a 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts @@ -357,7 +357,6 @@ const WORKSPACE_TO_RHP: Partial['config'] = { [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_EDIT]: DYNAMIC_ROUTES.WORKSPACE_CATEGORY_EDIT.path, [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_PAYROLL_CODE]: DYNAMIC_ROUTES.WORKSPACE_CATEGORY_PAYROLL_CODE.path, [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_GL_CODE]: DYNAMIC_ROUTES.WORKSPACE_CATEGORY_GL_CODE.path, - [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_DEFAULT_TAX_RATE]: DYNAMIC_ROUTES.WORKSPACE_CATEGORY_DEFAULT_TAX_RATE.path, [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_FLAG_AMOUNTS_OVER]: DYNAMIC_ROUTES.WORKSPACE_CATEGORY_FLAG_AMOUNTS_OVER.path, [SCREENS.WORKSPACE.DYNAMIC_EXPENSE_LIMIT_TYPE_SELECTOR]: DYNAMIC_ROUTES.EXPENSE_LIMIT_TYPE_SELECTOR.path, [SCREENS.WORKSPACE.DYNAMIC_IMPORTED_MEMBERS_ROLE]: DYNAMIC_ROUTES.IMPORTED_MEMBERS_ROLE.path, @@ -1419,9 +1418,6 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.RULES_NEW]: { path: ROUTES.RULES_NEW.route, }, - [SCREENS.WORKSPACE.RULES_EXPENSE_DEFAULT_TYPE]: { - path: ROUTES.RULES_EXPENSE_DEFAULT_TYPE.route, - }, [SCREENS.WORKSPACE.RULES_MERCHANT_NEW]: { path: ROUTES.RULES_MERCHANT_NEW.route, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index e151779e0a7a..27fc23406f20 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -447,10 +447,6 @@ type SettingsNavigatorParamList = { // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md backTo?: Routes; }; - [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_DEFAULT_TAX_RATE]: { - policyID: string; - categoryName: string; - }; [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_FLAG_AMOUNTS_OVER]: { policyID: string; categoryName: string; @@ -1921,9 +1917,6 @@ type SettingsNavigatorParamList = { policyID: string; categoryName?: string; }; - [SCREENS.WORKSPACE.RULES_EXPENSE_DEFAULT_TYPE]: { - policyID: string; - }; [SCREENS.WORKSPACE.RULES_MERCHANT_NEW]: { policyID: string; categoryName?: string; diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts index 2d0cd1e85419..7bcd411ce945 100644 --- a/src/libs/actions/Policy/Category.ts +++ b/src/libs/actions/Policy/Category.ts @@ -24,7 +24,6 @@ import type { } from '@libs/API/parameters'; import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import * as ApiUtils from '@libs/ApiUtils'; -import {getRuleCategoryName, matchesCategoryTaxRule} from '@libs/CategoryTaxRulesUtils'; import * as CategoryUtils from '@libs/CategoryUtils'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import * as ErrorUtils from '@libs/ErrorUtils'; @@ -43,7 +42,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, PolicyCategories, PolicyCategory, Report, ReportAction} from '@src/types/onyx'; import type {ImportFinalModal} from '@src/types/onyx/ImportedSpreadsheet'; -import type {ApprovalRule, CustomUnit, ExpenseRule, MccGroup} from '@src/types/onyx/Policy'; +import type {ApprovalRule, CustomUnit, MccGroup} from '@src/types/onyx/Policy'; import type {PolicyCategoryExpenseLimitType} from '@src/types/onyx/PolicyCategory'; import type {OnyxData} from '@src/types/onyx/Request'; @@ -854,32 +853,12 @@ function renamePolicyCategory(policyData: PolicyData, policyCategory: {oldName: const policyCategoryApproverRule = CategoryUtils.getCategoryApproverRule(policy?.rules?.approvalRules ?? [], policyCategory.oldName); const approvalRules = policy?.rules?.approvalRules ?? []; - const expenseRules = policy?.rules?.expenseRules ?? []; const mccGroup = policy?.mccGroup ?? {}; const updatedApprovalRules: ApprovalRule[] = lodashCloneDeep(approvalRules); - const updatedExpenseRules: ExpenseRule[] = lodashCloneDeep(expenseRules); const clonedMccGroup: Record = lodashCloneDeep(mccGroup); const updatedMccGroup = CategoryUtils.updateCategoryInMccGroup(clonedMccGroup, policyCategory.oldName, policyCategory.newName); const updatedMccGroupWithClearedPendingAction = CategoryUtils.updateCategoryInMccGroup(clonedMccGroup, policyCategory.oldName, policyCategory.newName, true); - // Found by its category condition rather than by `id`, because a rule created in NewDot has none: comparing ids - // made every rule without one equal, so a rename overwrote whichever sat earliest in the array and destroyed it. - // The match is rewritten as a copy for the same reason — the rule read off the policy is the live object. - const expenseRuleIndex = updatedExpenseRules.findIndex((rule) => matchesCategoryTaxRule(rule, policyCategory.oldName)); - const ruleToRename = updatedExpenseRules.at(expenseRuleIndex); - if (expenseRuleIndex !== -1 && ruleToRename) { - updatedExpenseRules[expenseRuleIndex] = { - ...ruleToRename, - applyWhen: ruleToRename.applyWhen.map((applyWhen) => ({ - ...applyWhen, - ...(applyWhen.field === CONST.POLICY.FIELDS.CATEGORY && - applyWhen.value === policyCategory.oldName && { - value: policyCategory.newName, - }), - })), - }; - } - // Its related by name, so the corresponding rule has to be updated to handle offline scenario if (policyCategoryApproverRule) { const indexToUpdate = updatedApprovalRules.findIndex((rule) => rule.id === policyCategoryApproverRule.id); @@ -898,7 +877,6 @@ function renamePolicyCategory(policyData: PolicyData, policyCategory: {oldName: const policyOptimisticData = { rules: { approvalRules: updatedApprovalRules, - expenseRules: updatedExpenseRules, }, mccGroup: updatedMccGroup, }; @@ -972,7 +950,6 @@ function renamePolicyCategory(policyData: PolicyData, policyCategory: {oldName: value: { rules: { approvalRules, - expenseRules, }, mccGroup, }, @@ -1811,226 +1788,6 @@ function setPolicyCategoryApprover(policyID: string, categoryName: string, appro API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_APPROVER, parameters, onyxData); } -/** - * The categories an expense rule already matches on, for membership tests that would otherwise scan the array once per - * name. A rate isn't required, since a rule is found here to have one written to it. - */ -function getCategoriesWithExpenseRule(expenseRules: ExpenseRule[]): Set { - return new Set(expenseRules.map(getRuleCategoryName)); -} - -/** Builds the expense rule that carries a category's default tax rate. */ -function buildCategoryTaxRule(categoryName: string, taxID: string): ExpenseRule { - return { - tax: { - // eslint-disable-next-line @typescript-eslint/naming-convention - field_id_TAX: {externalID: taxID}, - }, - applyWhen: [ - { - condition: CONST.POLICY.RULE_CONDITIONS.MATCHES, - field: CONST.POLICY.FIELDS.CATEGORY, - value: categoryName, - }, - ], - }; -} - -/** - * Applies a tax rate to each category, returning the whole rules array. - * - * Onyx replaces arrays wholesale, so a write can never patch one rule. Every stage supplies the complete array. - */ -function withCategoryTaxRates(expenseRules: ExpenseRule[], taxRatesByCategory: Map): ExpenseRule[] { - const updated = expenseRules.map((rule) => { - const categoryName = getRuleCategoryName(rule); - const taxID = categoryName ? taxRatesByCategory.get(categoryName) : undefined; - - if (!taxID) { - return rule; - } - // eslint-disable-next-line @typescript-eslint/naming-convention - return {...rule, tax: {field_id_TAX: {...rule.tax?.field_id_TAX, externalID: taxID}}}; - }); - - const categoriesWithExpenseRule = getCategoriesWithExpenseRule(expenseRules); - const added = [...taxRatesByCategory.entries()].flatMap(([categoryName, taxID]) => - !taxID || categoriesWithExpenseRule.has(categoryName) ? [] : [buildCategoryTaxRule(categoryName, taxID)], - ); - - return [...updated, ...added]; -} - -/** - * Sets the same default tax rate on one or more categories. - * - * No `successData`, like `setPolicyCategoryApprover`: it would carry a whole-array snapshot, and `API.write` persists - * those, so queued offline writes would replay stale arrays over each other. The server response is authoritative. - * - * The command is per-category, so a bulk save is one write each, and every rollback is the array as it stood before the - * save. Onyx replaces the array wholesale, so a rollback can't patch a single rule: whichever failure lands last is the - * array. Snapshots that each spared their own siblings therefore contradicted each other once two writes failed, and - * the survivor made a rejected category look saved. One identical baseline makes the order stop mattering. - * - * The cost is that a partial failure discards the writes that succeeded too, so they read stale until the next policy - * read. That beats showing a rate the server rejected, which is what the admin would otherwise save on top of. - */ -function setPolicyCategoryTaxes(policy: OnyxEntry, categoryNames: string[], taxID: string) { - if (!policy?.id || categoryNames.length === 0 || !taxID) { - Log.warn('Invalid params for setPolicyCategoryTaxes'); - return; - } - const policyID = policy.id; - const expenseRules = policy.rules?.expenseRules ?? []; - const requested = new Map(categoryNames.map((categoryName) => [categoryName, taxID])); - - // Every write shares this array. They are all enqueued at once, so the optimistic state has to be the same - // end state for each of them rather than a running total that the last write would truncate. - const optimisticExpenseRules = withCategoryTaxRates(expenseRules, requested); - - const onyxData: OnyxData = { - optimisticData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: {rules: {expenseRules: optimisticExpenseRules}}, - }, - ], - failureData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: {rules: {expenseRules}}, - }, - ], - }; - - for (const categoryName of categoryNames) { - API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_TAX, {policyID, categoryName, taxID}, onyxData); - } -} - -/** Sets a single category's default tax rate. */ -function setPolicyCategoryTax(policy: OnyxEntry, categoryName: string, taxID: string) { - setPolicyCategoryTaxes(policy, [categoryName], taxID); -} - -/** - * Removes the tax defaults for one or more categories. There is no dedicated delete command: writing the workspace's own - * default rate is what clears an override, since `getCategoryDefaultTaxRate` falls back to that same rate when no rule - * exists. The rules are dropped optimistically so the rows go straight away. - * - * Dropped rather than marked as deleting, so no grey pending row offline. A rule can't carry a pending flag: clearing - * one on success means rewriting the whole array, `API.write` persists that snapshot, and queued writes then replay - * stale arrays over each other. That was the state before dc9c34f9a97 removed `pendingAction` from `ExpenseRule`. - * - * Every rollback is the array as it stood before the delete, for the reason in `setPolicyCategoryTaxes`: Onyx replaces - * the array wholesale, so per-category snapshots contradict each other and the last failure to land hides the rest. - */ -function deletePolicyCategoryTaxes(policy: OnyxEntry, categoryNames: string[]) { - const defaultExternalID = policy?.taxRates?.defaultExternalID; - if (!policy?.id || !defaultExternalID || categoryNames.length === 0) { - Log.warn('Invalid params for deletePolicyCategoryTaxes'); - return; - } - const policyID = policy.id; - const expenseRules = policy.rules?.expenseRules ?? []; - const categoriesWithExpenseRule = getCategoriesWithExpenseRule(expenseRules); - const targets = categoryNames.filter((categoryName) => categoriesWithExpenseRule.has(categoryName)); - - if (targets.length === 0) { - return; - } - - // Shared like the save path: the writes are enqueued together, so each needs the same end state. - const targetedCategories = new Set(targets); - const optimisticExpenseRules = expenseRules.filter((rule) => { - const categoryName = getRuleCategoryName(rule); - return !categoryName || !targetedCategories.has(categoryName); - }); - - const onyxData: OnyxData = { - optimisticData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: {rules: {expenseRules: optimisticExpenseRules}}, - }, - ], - failureData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: {rules: {expenseRules}}, - }, - ], - }; - - for (const categoryName of targets) { - API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_TAX, {policyID, categoryName, taxID: defaultExternalID}, onyxData); - } -} - -/** Removes a single category's default tax rate. */ -function deletePolicyCategoryTax(policy: OnyxEntry, categoryName: string) { - deletePolicyCategoryTaxes(policy, [categoryName]); -} - -/** - * Moves a category's default tax rate to another category. - * - * The command is per-category, so a move is two writes: clearing the old category and setting the new one. They share - * one rollback array — the state before the move — because Onyx replaces the array wholesale. Rolling each write back - * against its own baseline instead lets the second failure overwrite what the first restored, dropping both rules. - * - * If exactly one write fails the local array goes back to the state before the move, so the one that succeeded reads - * stale until the next policy read. Two commands with no transaction between them can't do better, and it beats - * leaving the rate on two categories at once. - * - * The server can be left mid-move for the same reason — the rate on both categories, or on neither — and no rollback - * here can undo a request that already landed. A compensating write is not the answer: it can fail too, and offline it - * queues behind the failure it is meant to repair. The next policy read reconciles, and until the API can move a rate - * in one command that is the honest bound on this flow. - */ -function movePolicyCategoryTax(policy: OnyxEntry, fromCategoryName: string, toCategoryName: string, taxID: string) { - const defaultExternalID = policy?.taxRates?.defaultExternalID; - if (!policy?.id || !defaultExternalID || !fromCategoryName || !toCategoryName || !taxID) { - Log.warn('Invalid params for movePolicyCategoryTax'); - return; - } - const policyID = policy.id; - const originalExpenseRules = policy.rules?.expenseRules ?? []; - const withoutOldCategory = originalExpenseRules.filter((rule) => !matchesCategoryTaxRule(rule, fromCategoryName)); - const optimisticExpenseRules = withCategoryTaxRates(withoutOldCategory, new Map([[toCategoryName, taxID]])); - - // Writing the workspace default rate is what clears an override, so the move's first half is a write like any other. - const writes = [ - {categoryName: fromCategoryName, taxID: defaultExternalID}, - {categoryName: toCategoryName, taxID}, - ]; - - const onyxData: OnyxData = { - optimisticData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: {rules: {expenseRules: optimisticExpenseRules}}, - }, - ], - failureData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: {rules: {expenseRules: originalExpenseRules}}, - }, - ], - }; - - for (const write of writes) { - API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_TAX, {policyID, ...write}, onyxData); - } -} - function setPolicyCategoryAttendeesRequired(policyID: string, categoryName: string, areAttendeesRequired: boolean, policyCategories: PolicyCategories = {}) { const policyCategoryToUpdate = policyCategories?.[categoryName]; const originalAreAttendeesRequired = policyCategoryToUpdate?.areAttendeesRequired; @@ -2105,8 +1862,6 @@ function setPolicyCategoryAttendeesRequired(policyID: string, categoryName: stri export { clearCategoryErrors, - deletePolicyCategoryTax, - deletePolicyCategoryTaxes, createPolicyCategory, deleteWorkspaceCategories, downloadCategoriesCSV, @@ -2117,7 +1872,6 @@ export { openPolicyCategoriesPage, removePolicyCategoryReceiptsRequired, removePolicyCategoryItemizedReceiptsRequired, - movePolicyCategoryTax, renamePolicyCategory, setPolicyCategoryApprover, setPolicyCategoryAttendeesRequired, @@ -2128,8 +1882,6 @@ export { setPolicyCategoryReceiptsAndItemizedReceiptRequired, setPolicyCategoryReceiptsRequired, setPolicyCategoryItemizedReceiptsRequired, - setPolicyCategoryTax, - setPolicyCategoryTaxes, setPolicyCustomUnitDefaultCategory, setWorkspaceCategoryDescriptionHint, setWorkspaceCategoryEnabled, diff --git a/src/pages/workspace/categories/CategorySettingsPage.tsx b/src/pages/workspace/categories/CategorySettingsPage.tsx index 1c871d4be279..e732964276f6 100644 --- a/src/pages/workspace/categories/CategorySettingsPage.tsx +++ b/src/pages/workspace/categories/CategorySettingsPage.tsx @@ -73,6 +73,7 @@ function CategorySettingsPage({route: {params, name}, navigation}: CategorySetti const {environmentURL} = useEnvironment(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const {isOffline} = useNetwork(); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const policyCategory = policyCategories?.[categoryName] ?? Object.values(policyCategories).find((category) => category.previousCategoryName === categoryName); const decodedCategoryName = getDecodedCategoryName(policyCategory?.name ?? ''); @@ -83,6 +84,7 @@ function CategorySettingsPage({route: {params, name}, navigation}: CategorySetti : getCategoryContextualRules({ policy, policyCategories, + policyRules: rules, category: policyCategory, categoryName: policyCategory.name, translate, diff --git a/src/pages/workspace/categories/DynamicCategoryDefaultTaxRatePage.tsx b/src/pages/workspace/categories/DynamicCategoryDefaultTaxRatePage.tsx deleted file mode 100644 index bc002ed1a679..000000000000 --- a/src/pages/workspace/categories/DynamicCategoryDefaultTaxRatePage.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; -import SelectionList from '@components/SelectionList'; -import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; -import type {ListItem} from '@components/SelectionList/types'; - -import useDynamicBackPath from '@hooks/useDynamicBackPath'; -import useInitialSelection from '@hooks/useInitialSelection'; -import useLocalize from '@hooks/useLocalize'; -import usePolicy from '@hooks/usePolicy'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import {formatDefaultTaxRateText, getCategoryDefaultTaxRate} from '@libs/CategoryUtils'; -import Navigation from '@libs/Navigation/Navigation'; -import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; - -import type {SettingsNavigatorParamList} from '@navigation/types'; - -import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; - -import {setPolicyCategoryTax} from '@userActions/Policy/Category'; - -import CONST from '@src/CONST'; -import {DYNAMIC_ROUTES} from '@src/ROUTES'; -import type SCREENS from '@src/SCREENS'; -import type {TaxRate} from '@src/types/onyx'; - -import React, {useCallback, useMemo, useState} from 'react'; - -type DynamicCategoryDefaultTaxRatePageProps = PlatformStackScreenProps; - -type TaxRateListItem = ListItem & { - value: string; -}; - -function DynamicCategoryDefaultTaxRatePage({ - route: { - params: {policyID, categoryName}, - }, -}: DynamicCategoryDefaultTaxRatePageProps) { - const styles = useThemeStyles(); - const {translate, localeCompare} = useLocalize(); - const policy = usePolicy(policyID); - const categorySettingsBackPath = useDynamicBackPath(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_DEFAULT_TAX_RATE.path); - - const persistedTaxRate = getCategoryDefaultTaxRate(policy?.rules?.expenseRules ?? [], categoryName, policy?.taxRates?.defaultExternalID); - - const [draftTaxRate, setDraftTaxRate] = useState(); - const selectedTaxRate = draftTaxRate ?? persistedTaxRate; - const hasChanges = !!selectedTaxRate && selectedTaxRate !== persistedTaxRate; - const initialSelectedTaxRate = useInitialSelection(selectedTaxRate, {isVisible: !!persistedTaxRate, resetOnFocus: true}); - - const textForDefault = useCallback((taxID: string, taxRate: TaxRate) => formatDefaultTaxRateText(translate, taxID, taxRate, policy?.taxRates), [policy?.taxRates, translate]); - - const taxesList = useMemo(() => { - if (!policy) { - return []; - } - return Object.entries(policy.taxRates?.taxes ?? {}) - .map(([key, value]) => ({ - text: textForDefault(key, value), - value: key, - keyForList: key, - isSelected: key === selectedTaxRate, - isDisabled: value.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - pendingAction: value.pendingAction ?? (Object.keys(value.pendingFields ?? {}).length > 0 ? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE : null), - })) - .sort((a, b) => localeCompare(a.text ?? a.keyForList ?? '', b.text ?? b.keyForList ?? '')); - }, [policy, selectedTaxRate, textForDefault, localeCompare]); - const orderedTaxesList = moveInitialSelectionToTop(taxesList, initialSelectedTaxRate ? [initialSelectedTaxRate] : []); - - const saveAndGoBack = () => { - if (hasChanges) { - setPolicyCategoryTax(policy, categoryName, selectedTaxRate); - Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.goBack(categorySettingsBackPath)); - return; - } - Navigation.goBack(categorySettingsBackPath); - }; - - const confirmButtonOptions = { - showButton: true, - text: translate('common.save'), - onConfirm: saveAndGoBack, - isDisabled: !hasChanges, - }; - - return ( - - - Navigation.goBack(categorySettingsBackPath)} - /> - { - if (!item.keyForList) { - return; - } - setDraftTaxRate(item.keyForList); - }} - confirmButtonOptions={confirmButtonOptions} - shouldSingleExecuteRowSelect - addBottomSafeAreaPadding - initiallyFocusedItemKey={initialSelectedTaxRate} - shouldScrollToFocusedIndexOnMount={false} - shouldUpdateFocusedIndex - style={{containerStyle: styles.pt3}} - /> - - - ); -} - -export default DynamicCategoryDefaultTaxRatePage; diff --git a/src/pages/workspace/rules/MerchantRules/AddCategoryTaxRulePage.tsx b/src/pages/workspace/rules/MerchantRules/AddCategoryTaxRulePage.tsx index fc5ce474ced9..66d0a3a9bcce 100644 --- a/src/pages/workspace/rules/MerchantRules/AddCategoryTaxRulePage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddCategoryTaxRulePage.tsx @@ -1,10 +1,11 @@ import useCategoryRuleCreateBackPath from '@hooks/useCategoryRuleCreateBackPath'; -import usePolicy from '@hooks/usePolicy'; +import useOnyx from '@hooks/useOnyx'; -import {categoryHasTaxRule} from '@libs/CategoryTaxRulesUtils'; +import {getCategoryTaxDefaultRule} from '@libs/ExpenseDefaultRuleUtils'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES, getWorkspaceCategorySettingsRoute} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; @@ -16,18 +17,18 @@ type AddCategoryTaxRulePageProps = PlatformStackScreenProps - !isCategoryRuleDraft(form, editingCategoryName) || - selectedCategories.includes(category.name) || - category.name === editingCategoryName || - !categoryHasTaxRule(policy?.rules?.expenseRules, category.name), + // A rule only matches on the category, so every category is eligible. + isEligible: () => true, }); const categoryItems: CategoryListItem[] = categories.map((category) => ({ diff --git a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx index a774872dda81..2495da8af84d 100644 --- a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx @@ -1,15 +1,9 @@ -import useOnyx from '@hooks/useOnyx'; - -import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; -import React, {useEffect} from 'react'; +import React from 'react'; import MerchantRulePageBase from './MerchantRulePageBase'; @@ -17,22 +11,6 @@ type AddMerchantRulePageProps = PlatformStackScreenProps { - if (!shouldRedirectToTypePicker) { - return; - } - Navigation.navigate(ROUTES.RULES_EXPENSE_DEFAULT_TYPE.getRoute(policyID), {forceReplace: true}); - }, [shouldRedirectToTypePicker, policyID]); - - if (shouldRedirectToTypePicker) { - return null; - } return ( taxKey === form?.tax || isSelectableTaxRate(policy, taxKey, tax, isCategoryRule)) + .filter(([taxKey, tax]) => taxKey === form?.tax || isSelectableTaxRate(tax)) .map(([taxKey, tax]) => ({ name: `${tax.name} (${tax.value})`, value: taxKey, @@ -68,10 +66,7 @@ function AddTaxPage({route}: AddTaxPageProps) { items={taxItems} onSave={onSave} backToRoute={backToRoute} - // A category rule exists to set a rate, so it has no "leave the tax alone" state to offer. Reading - // "None" as the current value is wrong too: without a rule the category falls back to the - // workspace default rate, which this list deliberately leaves out. - allowNoneOption={!isCategoryRule} + allowNoneOption /> ) : ( diff --git a/src/pages/workspace/rules/MerchantRules/EditCategoryTaxRulePage.tsx b/src/pages/workspace/rules/MerchantRules/EditCategoryTaxRulePage.tsx index 44a321b4dff0..cb8a562ff22f 100644 --- a/src/pages/workspace/rules/MerchantRules/EditCategoryTaxRulePage.tsx +++ b/src/pages/workspace/rules/MerchantRules/EditCategoryTaxRulePage.tsx @@ -1,6 +1,10 @@ +import useOnyx from '@hooks/useOnyx'; + +import {getCategoryTaxDefaultRule} from '@libs/ExpenseDefaultRuleUtils'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; import React from 'react'; @@ -12,10 +16,16 @@ type EditCategoryTaxRulePageProps = | PlatformStackScreenProps; function EditCategoryTaxRulePage({route}: EditCategoryTaxRulePageProps) { + const {policyID, categoryName} = route.params; + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); + // Category settings links by category, since a category holds one tax default, so the rule is resolved here. + const ruleID = getCategoryTaxDefaultRule(rules, policyID, categoryName)?.ruleID; + return ( diff --git a/src/pages/workspace/rules/MerchantRules/ExpenseDefaultTypePage.tsx b/src/pages/workspace/rules/MerchantRules/ExpenseDefaultTypePage.tsx deleted file mode 100644 index 34468f65c7ef..000000000000 --- a/src/pages/workspace/rules/MerchantRules/ExpenseDefaultTypePage.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; -import MenuItem from '@components/MenuItem'; -import ScreenWrapper from '@components/ScreenWrapper'; -import ScrollView from '@components/ScrollView'; -import Text from '@components/Text'; - -import useConfirmModal from '@hooks/useConfirmModal'; -import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; -import useLocalize from '@hooks/useLocalize'; -import usePolicy from '@hooks/usePolicy'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import {setDraftMerchantRule} from '@libs/actions/User'; -import {hasSelectableCategoryTaxRate, hasUsableTaxRates} from '@libs/CategoryTaxRulesUtils'; -import Navigation from '@libs/Navigation/Navigation'; -import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; - -import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; - -import variables from '@styles/variables'; - -import CONST from '@src/CONST'; -import ROUTES from '@src/ROUTES'; -import type SCREENS from '@src/SCREENS'; -import type {ExpenseDefaultRuleType} from '@src/types/form/MerchantRuleForm'; - -import React from 'react'; -import {View} from 'react-native'; - -type ExpenseDefaultTypePageProps = PlatformStackScreenProps; - -/** - * Picks what an expense default matches on before the editor opens, so the editor can drop the condition and the - * defaults the chosen type can't carry instead of showing them locked. - */ -function ExpenseDefaultTypePage({route}: ExpenseDefaultTypePageProps) { - const {policyID} = route.params; - const {translate} = useLocalize(); - const styles = useThemeStyles(); - const policy = usePolicy(policyID); - const illustrations = useMemoizedLazyIllustrations(['FoodTruck', 'FolderOpen']); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['Lock']); - - const {showConfirmModal} = useConfirmModal(); - - // A category rule only sets a default tax rate, so it needs a rate to choose from. The card stays listed either - // way: hiding it would drop the admin on a one-option page with no hint of what is missing. - // - // Taxes being on is not enough. Every rate can be disabled, and the workspace default can be the only one left, - // which a category rule can't apply. Opening the editor then led to a picker with nothing in it, so the lock reads - // the same rule the picker does and the two reasons get their own explainer. - const areTaxesEnabled = hasUsableTaxRates(policy); - const canPickCategoryTaxRate = hasSelectableCategoryTaxRate(policy); - - // Scoping rides in the draft rather than the URL, so the editor keeps it when a picker routes back to the plain - // create URL. Setting it here also starts the rule from a clean draft. - const openEditorScopedTo = (ruleType: ExpenseDefaultRuleType) => { - setDraftMerchantRule({ruleType}); - Navigation.navigate(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); - }; - - const showMissingTaxRateExplainer = () => { - showConfirmModal({ - title: translate(areTaxesEnabled ? 'workspace.rules.merchantRules.addTaxRateFirstTitle' : 'workspace.rules.merchantRules.turnOnTaxesFirstTitle'), - prompt: translate(areTaxesEnabled ? 'workspace.rules.merchantRules.addTaxRateFirstPrompt' : 'workspace.rules.merchantRules.turnOnTaxesFirstPrompt'), - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - }); - }; - - const options = [ - { - key: 'merchant', - icon: illustrations.FoodTruck, - title: translate('workspace.rules.expenseDefaultType.merchant'), - description: translate('workspace.rules.expenseDefaultType.merchantDescription'), - onPress: () => openEditorScopedTo(CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.MERCHANT), - isLocked: false, - }, - { - key: 'category', - icon: illustrations.FolderOpen, - title: translate('workspace.rules.expenseDefaultType.category'), - description: translate('workspace.rules.expenseDefaultType.categoryDescription'), - onPress: canPickCategoryTaxRate ? () => openEditorScopedTo(CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.CATEGORY) : showMissingTaxRateExplainer, - isLocked: !canPickCategoryTaxRate, - }, - ]; - - return ( - - - - - {translate('workspace.rules.expenseDefaultType.subtitle')} - - {options.map((option) => ( - - ))} - - - - - ); -} - -export default ExpenseDefaultTypePage; diff --git a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx index 1730001a1559..afb58590ca99 100644 --- a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx +++ b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx @@ -26,12 +26,12 @@ import usePressLoading from '@hooks/usePressLoading'; import useRulesPrefetch from '@hooks/useRulesPrefetch'; import useThemeStyles from '@hooks/useThemeStyles'; -import {deletePolicyCategoryTax, movePolicyCategoryTax, openPolicyCategoriesPage, setPolicyCategoryTaxes} from '@libs/actions/Policy/Category'; +import {openPolicyCategoriesPage} from '@libs/actions/Policy/Category'; import {deleteRule, setRule} from '@libs/actions/Policy/Rules'; import {openPolicyTagsPage} from '@libs/actions/Policy/Tag'; import Tab from '@libs/actions/Tab'; import {clearDraftMerchantRule, setDraftMerchantRule} from '@libs/actions/User'; -import {getCategoryTaxRuleTaxID, getTaxRateDisplayName, hasUsableTaxRates, isCategoryRuleDraft} from '@libs/CategoryTaxRulesUtils'; +import {getTaxRateDisplayName, hasUsableTaxRates} from '@libs/CategoryTaxRulesUtils'; import {getDecodedCategoryName} from '@libs/CategoryUtils'; import {convertAmountToDisplayString} from '@libs/CurrencyUtils'; import { @@ -64,7 +64,6 @@ import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import type {MerchantRuleForm} from '@src/types/form'; import MERCHANT_RULE_INPUT_IDS from '@src/types/form/MerchantRuleForm'; -import type {ExpenseDefaultRuleType} from '@src/types/form/MerchantRuleForm'; import type {PolicyTagLists} from '@src/types/onyx'; import type {GeneratedRuleValues} from '@src/types/onyx/GeneratedRule'; import getEmptyArray from '@src/types/utils/getEmptyArray'; @@ -84,14 +83,8 @@ type MerchantRulePageBaseProps = { ruleID?: string; /** Pre-scopes the category default when creating a rule (e.g. from the category details RHP). */ initialCategoryName?: string; - /** - * Edits the existing category tax default for this category. Category rules live in `policy.rules.expenseRules` - * keyed by category name rather than in `codingRules` keyed by a ruleID, so they arrive here by category instead - * of through `ruleID`. - */ - editCategoryTaxRuleFor?: string; - /** Starts a new category tax default for this category, used by the category details RHP. */ - newCategoryTaxRuleFor?: string; + /** Pre-scopes a new rule to this category, used by the category details RHP. */ + initialCategoryToMatch?: string; /** When true, the category field is non-interactive (category-scoped create/edit). */ isCategoryLocked?: boolean; /** Where saving or deleting returns to when the rule was opened from category settings through the New rule hub. */ @@ -122,29 +115,13 @@ const getBooleanTitle = (value: boolean | undefined, translate: LocalizedTransla return translate(value ? 'common.yes' : 'common.no'); }; -/** A category rule matches on categories and can only set a tax, so both halves are required and nothing else counts. */ -const getCategoryRuleErrorMessage = (translate: LocalizedTranslate, taxID: string | undefined, isMoveBlocked: boolean, form?: MerchantRuleForm) => { - if (!form?.categoriesToMatch?.length) { - return translate('workspace.rules.merchantRules.confirmErrorCategory'); - } - if (isMoveBlocked) { - return translate('workspace.rules.merchantRules.confirmErrorCategoryTaxMoveIsWorkspaceDefault'); - } - if (!taxID) { - return translate('workspace.rules.merchantRules.confirmErrorCategoryTax'); - } - return ''; -}; - -/** A rule needs something to match on and something to set. A category tax default validates through `getCategoryRuleErrorMessage` instead. */ +/** A rule needs something to match on and something to set. */ const getErrorMessage = (translate: LocalizedTranslate, form?: MerchantRuleForm) => { const matchingCriteriaFields = new Set([ MERCHANT_RULE_INPUT_IDS.MERCHANT_TO_MATCH, MERCHANT_RULE_INPUT_IDS.MATCH_TYPE, MERCHANT_RULE_INPUT_IDS.CATEGORIES_TO_MATCH, MERCHANT_RULE_INPUT_IDS.TAGS_TO_MATCH, - // Scoping, not a default the admin set. - MERCHANT_RULE_INPUT_IDS.RULE_TYPE, ]); const hasAtLeastOneUpdate = Object.entries(form ?? {}).some(([key, value]) => { if (matchingCriteriaFields.has(key)) { @@ -168,16 +145,7 @@ const getErrorMessage = (translate: LocalizedTranslate, form?: MerchantRuleForm) return translate('workspace.rules.merchantRules.confirmErrorConditionAndDefault'); }; -function MerchantRulePageBase({ - policyID, - ruleID, - initialCategoryName, - editCategoryTaxRuleFor, - newCategoryTaxRuleFor, - isCategoryLocked = false, - categorySettingsBackPath, - testID, -}: MerchantRulePageBaseProps) { +function MerchantRulePageBase({policyID, ruleID, initialCategoryName, initialCategoryToMatch, isCategoryLocked = false, categorySettingsBackPath, testID}: MerchantRulePageBaseProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const policy = usePolicy(policyID); @@ -188,9 +156,7 @@ function MerchantRulePageBase({ const {isLoading, startWithLoading} = usePressLoading(); const isEditing = !!ruleID; const {isCreatedFromExpense, backToRoute, getRuleRoute} = useMerchantRuleRoute(DYNAMIC_ROUTES.RULES_MERCHANT_NEW_FROM_EXPENSE.path, policyID, ruleID); - const isEditingCategoryTaxRule = !!editCategoryTaxRuleFor; - // A category tax default has no ruleID, so neither flag alone means "saved". - const isEditingSavedRule = isEditing || isEditingCategoryTaxRule; + const isEditingSavedRule = isEditing; const isInLandscapeMode = useIsInLandscapeMode(); const {isBetaEnabled} = usePermissions(); const icons = useMemoizedLazyExpensifyIcons(['Basket', 'Cash', 'Folder', 'Pencil', 'InvoiceGeneric', 'Tag', 'Paycheck']); @@ -204,7 +170,6 @@ function MerchantRulePageBase({ const [shouldUpdateMatchingTransactions, setShouldUpdateMatchingTransactions] = useState(false); // The footer remounts when it moves on rotation, so the page owns the modal to keep it open const [isDescribeRuleModalVisible, setIsDescribeRuleModalVisible] = useState(false); - const seededCategoryTaxRuleRef = useRef(undefined); const didSeedInitialCategoryRef = useRef(false); // The "Set vendor to" row gate below reads policy.connections (via hasVendorFeature and @@ -224,26 +189,14 @@ function MerchantRulePageBase({ const areRulesLoading = isLoadingOnyxValue(rulesResult) || isRulesFetchPending; // Get the existing rule from the rules collection (for edit mode) const existingRule = ruleID ? rules?.[`${ONYXKEYS.COLLECTION.RULE}${ruleID}`] : undefined; - const existingCategoryTaxID = editCategoryTaxRuleFor ? getCategoryTaxRuleTaxID(policy?.rules?.expenseRules, editCategoryTaxRuleFor) : undefined; - // Initialize the form with existing rule data (for edit mode) useEffect(() => { - if (isEditingCategoryTaxRule) { - // Seed once per rule, or this overwrites the category picked in the picker. - if (!existingCategoryTaxID || seededCategoryTaxRuleRef.current === editCategoryTaxRuleFor) { - return; - } - seededCategoryTaxRuleRef.current = editCategoryTaxRuleFor; - setDraftMerchantRule({categoriesToMatch: [editCategoryTaxRuleFor], tax: existingCategoryTaxID}); - return; - } - - if (newCategoryTaxRuleFor) { + if (initialCategoryToMatch) { if (didSeedInitialCategoryRef.current) { return; } didSeedInitialCategoryRef.current = true; - setDraftMerchantRule({ruleType: CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.CATEGORY, categoriesToMatch: [newCategoryTaxRuleFor]}); + setDraftMerchantRule({categoriesToMatch: [initialCategoryToMatch]}); return; } @@ -264,7 +217,7 @@ function MerchantRulePageBase({ return; } setDraftMerchantRule(formValues); - }, [isEditing, existingRule, isEditingCategoryTaxRule, editCategoryTaxRuleFor, existingCategoryTaxID, initialCategoryName, newCategoryTaxRuleFor]); + }, [isEditing, existingRule, initialCategoryName, initialCategoryToMatch]); // Clear the form on unmount useEffect(() => () => clearDraftMerchantRule(), []); @@ -321,49 +274,14 @@ function MerchantRulePageBase({ const unavailableLabel = translate(isOnXero ? 'workspace.rules.merchantRules.supplierUnavailable' : 'workspace.rules.merchantRules.vendorUnavailable'); const vendorDisplayName = form?.vendorID ? getVendorRuleDisplayValue(policy, form.vendorID, unavailableLabel) : undefined; - // A rule matches on every condition it carries, so all three rows are offered together. A category tax default is - // the exception: it is still stored the old way and carries its one category. + // A rule matches on every condition it carries, so all three rows are offered together. const categoriesToMatch = form?.categoriesToMatch ?? []; const hasCategoryCondition = categoriesToMatch.length > 0; const hasMerchantCondition = !!form?.merchantToMatch; - // A saved rule already is one kind or the other, so editing is scoped the same way creating is. Creating reads the - // draft rather than the route, so scoping survives a trip to any picker and every picker routes back to one URL. - // Three branches, and `no-nested-ternary` rules out folding them into one expression. - const getScopedRuleType = (): ExpenseDefaultRuleType | undefined => { - if (isEditingCategoryTaxRule) { - return CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.CATEGORY; - } - if (isEditing) { - return CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.MERCHANT; - } - return form?.ruleType; - }; - const scopedRuleType = getScopedRuleType(); - const isScopedToCategory = scopedRuleType === CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.CATEGORY; - const isCategoryRule = isCategoryRuleDraft(form, editCategoryTaxRuleFor); - // Deleting means writing the workspace default rate back, so without one there is nothing to write. - const canDeleteCategoryTaxRule = isEditingCategoryTaxRule && !!policy?.taxRates?.defaultExternalID; - // A draft carrying the workspace default rate means "no rule", since saving it would delete the rule. - const isDraftTaxTheWorkspaceDefault = isCategoryRule && !isEditingCategoryTaxRule && form?.tax === policy?.taxRates?.defaultExternalID; - const categoryTaxID = isDraftTaxTheWorkspaceDefault ? undefined : form?.tax; - // Safe to show a saved rule holding the default rate, but not to write it: that write is what deletes the rule. - const isSavedTaxTheWorkspaceDefault = isCategoryRule && isEditingCategoryTaxRule && !!form?.tax && form.tax === policy?.taxRates?.defaultExternalID; - // Editing is single-select, so a move has exactly one destination. - const movedToCategory = editCategoryTaxRuleFor && !categoriesToMatch.includes(editCategoryTaxRuleFor) ? categoriesToMatch.at(0) : undefined; - // A move still needs a write the command can't express while the rate is the default, so that's the one case blocked. - const isCategoryTaxRuleMoveBlocked = isSavedTaxTheWorkspaceDefault && !!movedToCategory; - const showCategoryRulesApplyGoingForwardExplainer = () => { - showConfirmModal({ - title: translate('workspace.rules.merchantRules.categoryRulesApplyGoingForwardTitle'), - prompt: translate('workspace.rules.merchantRules.categoryRulesApplyGoingForwardPrompt'), - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - }); - }; - /** Clears the condition and every default, keeping the type the rule was scoped to. */ + /** Clears every condition and every default. */ const resetRule = () => { - setDraftMerchantRule(scopedRuleType ? {ruleType: scopedRuleType} : {}); + setDraftMerchantRule({}); setShouldShowError(false); setShouldUpdateMatchingTransactions(false); }; @@ -375,8 +293,7 @@ function MerchantRulePageBase({ const amountDisplayName = form?.amount ? convertAmountToDisplayString(Number(form.amount), policy?.outputCurrency) : undefined; const categoryDisplayName = form?.category ? getDecodedCategoryName(form.category) : undefined; // Blank rather than the raw ID once the rate is gone from the workspace. - const taxRateID = isCategoryRule ? categoryTaxID : form?.tax; - const taxDisplayName = (taxRateID && getTaxByID(policy, taxRateID) ? getTaxRateDisplayName(policy, taxRateID) : '') || undefined; + const taxDisplayName = (form?.tax && getTaxByID(policy, form.tax) ? getTaxRateDisplayName(policy, form.tax) : '') || undefined; /** * Checks if another rule already matches on the same conditions. @@ -413,7 +330,7 @@ function MerchantRulePageBase({ }); }; - const errorMessage = isCategoryRule ? getCategoryRuleErrorMessage(translate, categoryTaxID, isCategoryTaxRuleMoveBlocked, form) : getErrorMessage(translate, form); + const errorMessage = getErrorMessage(translate, form); const goBackToExpenseDefaults = () => { Tab.setSelectedTab(CONST.TAB.RULES_TAB_TYPE, CONST.TAB.RULES.EXPENSE_DEFAULTS); @@ -428,34 +345,6 @@ function MerchantRulePageBase({ return; } - // Category rules are stored as `policy.rules.expenseRules`, the same objects Expensify Classic reads, so that a - // default tax rate set here is the one Classic already understands. - if (isCategoryRule) { - if (!hasCategoryCondition || !categoryTaxID) { - return; - } - // Nothing to write for an unchanged save; a move is blocked earlier by the error message instead. - if (isSavedTaxTheWorkspaceDefault) { - setIsClosing(true); - Navigation.goBack(categorySettingsBackPath); - return; - } - setIsClosing(true); - // A move clears the old category and sets the new one as a pair, sharing one rollback. - if (editCategoryTaxRuleFor && movedToCategory) { - movePolicyCategoryTax(policy, editCategoryTaxRuleFor, movedToCategory, categoryTaxID); - } else { - // The command is per-category, so a bulk selection saves one rule for each category picked. - setPolicyCategoryTaxes(policy, categoriesToMatch, categoryTaxID); - } - if (isEditingCategoryTaxRule || categorySettingsBackPath) { - Navigation.goBack(categorySettingsBackPath); - } else { - goBackToExpenseDefaults(); - } - return; - } - setRule(policyID, buildMerchantRule(form, policy), CONST.RULES.EXPENSE_DEFAULT.PRIORITY, ruleID, existingRule, shouldUpdateMatchingTransactions); if (isCreatedFromExpense) { // Opened from the callout, so this page is a suffix on the expense's path. Dropping it returns to the @@ -480,13 +369,6 @@ function MerchantRulePageBase({ return; } - // A category rule matches on a category that the picker already excluded if it had a rule, so there is no - // duplicate to warn about. - if (isCategoryRule) { - startWithLoading(() => saveRule()); - return; - } - // Check for duplicate rules const hasDuplicate = checkForDuplicateRule(form); if (hasDuplicate) { @@ -514,18 +396,15 @@ function MerchantRulePageBase({ return false; } setIsClosing(true); - if (editCategoryTaxRuleFor) { - deletePolicyCategoryTax(policy, editCategoryTaxRuleFor); - } else if (ruleID) { + if (ruleID) { deleteRule(ruleID, existingRule); } return true; }; - // A category tax rule is its category, so it carries no pending state of its own. Only a merchant rule can - // already be on its way out. Declared above the not-found returns below, since a hook can't run conditionally. + // Declared above the not-found returns below, since a hook can't run conditionally. const isRuleBeingDeleted = existingRule?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; - const canDeleteRule = canWriteRules && !!policy && !isRuleBeingDeleted && (isEditing || canDeleteCategoryTaxRule); + const canDeleteRule = canWriteRules && !!policy && !isRuleBeingDeleted && isEditing; const {deleteIconButtonProps} = useRuleDeleteHeaderProps({ canDelete: canDeleteRule, @@ -538,33 +417,28 @@ function MerchantRulePageBase({ { titleTranslationKey: 'workspace.rules.merchantRules.expensesWith', items: [ - isScopedToCategory - ? undefined - : { - key: 'merchantToMatch', - description: translate('common.merchant'), - title: form?.merchantToMatch, - onPress: () => - Navigation.navigate( - getRuleRoute(DYNAMIC_ROUTES.RULES_MERCHANT_MERCHANT_TO_MATCH_FROM_EXPENSE.path, ROUTES.RULES_MERCHANT_MERCHANT_TO_MATCH.getRoute(policyID, ruleID)), - ), - icon: icons.Basket, - }, + { + key: 'merchantToMatch', + description: translate('common.merchant'), + title: form?.merchantToMatch, + onPress: () => + Navigation.navigate( + getRuleRoute(DYNAMIC_ROUTES.RULES_MERCHANT_MERCHANT_TO_MATCH_FROM_EXPENSE.path, ROUTES.RULES_MERCHANT_MERCHANT_TO_MATCH.getRoute(policyID, ruleID)), + ), + icon: icons.Basket, + }, hasCategories() ? { key: 'categoriesToMatch', description: translate('common.category'), - required: isScopedToCategory, title: categoriesToMatchDisplayName, onPress: () => - Navigation.navigate( - getRuleRoute(DYNAMIC_ROUTES.RULES_CATEGORY_TO_MATCH_FROM_EXPENSE.path, ROUTES.RULES_CATEGORY_TO_MATCH.getRoute(policyID, ruleID, editCategoryTaxRuleFor)), - ), + Navigation.navigate(getRuleRoute(DYNAMIC_ROUTES.RULES_CATEGORY_TO_MATCH_FROM_EXPENSE.path, ROUTES.RULES_CATEGORY_TO_MATCH.getRoute(policyID, ruleID))), isLocked: isCategoryLocked, icon: icons.Folder, } : undefined, - isScopedToCategory || !hasTagsToMatch() + !hasTagsToMatch() ? undefined : { key: 'tagsToMatch', @@ -617,17 +491,12 @@ function MerchantRulePageBase({ }; }) : []), - // Tax is a category rule's only default, so the row stays with taxes off rather than leaving the - // section blank. The picker then explains that taxes are disabled. - hasUsableTaxRates(policy) || isCategoryRule + hasUsableTaxRates(policy) ? { key: 'tax', description: translate('common.tax'), title: taxDisplayName, - onPress: () => - Navigation.navigate( - getRuleRoute(DYNAMIC_ROUTES.RULES_MERCHANT_TAX_FROM_EXPENSE.path, ROUTES.RULES_MERCHANT_TAX.getRoute(policyID, ruleID, editCategoryTaxRuleFor)), - ), + onPress: () => Navigation.navigate(getRuleRoute(DYNAMIC_ROUTES.RULES_MERCHANT_TAX_FROM_EXPENSE.path, ROUTES.RULES_MERCHANT_TAX.getRoute(policyID, ruleID))), icon: icons.InvoiceGeneric, } : undefined, @@ -674,8 +543,7 @@ function MerchantRulePageBase({ icon: icons.Paycheck, } : undefined, - // Tax is the only default a category rule can set, so the other rows are dropped rather than shown. - ].filter((item) => !isCategoryRule || item?.key === 'tax'), + ], }, ]; @@ -706,35 +574,28 @@ function MerchantRulePageBase({ return ; } - if (isEditingCategoryTaxRule && !existingCategoryTaxID && !isClosing) { - return ; - } - - // A saved rule stays readable without write access, so only creating one is gated. `isEditing` alone would miss a - // category rule, which is keyed by its category and so carries no ruleID, and send auditors to a not-found page. + // A saved rule stays readable without write access, so only creating one is gated. if (!isEditingSavedRule && !!policy && !canWriteRules) { return ; } const shouldShowDescribeRule = !isEditingSavedRule && !isCategoryLocked; const applyGeneratedRule = (values: GeneratedRuleValues) => { - setDraftMerchantRule({...values, ruleType: CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.MERCHANT}); + setDraftMerchantRule(values); }; const footer = canWriteRules ? ( - {!isCategoryRule && ( - - {translate('workspace.rules.merchantRules.previewMatchesDescription')} - - - )} + + {translate('workspace.rules.merchantRules.previewMatchesDescription')} + + {isInLandscapeMode && footer} diff --git a/src/pages/workspace/rules/RulesNewPage.tsx b/src/pages/workspace/rules/RulesNewPage.tsx index 5cc21f7f416b..9f01e547b37c 100644 --- a/src/pages/workspace/rules/RulesNewPage.tsx +++ b/src/pages/workspace/rules/RulesNewPage.tsx @@ -4,13 +4,10 @@ import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; -import useConfirmModal from '@hooks/useConfirmModal'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; -import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; -import {categoryHasTaxRule, hasSelectableCategoryTaxRate, hasUsableTaxRates} from '@libs/CategoryTaxRulesUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -50,25 +47,9 @@ function RulesNewPage({route}: RulesNewPageProps) { const illustrations = useMemoizedLazyIllustrations(['CardReaderAlt', 'Flag', 'CheckboxText', 'ReportReceipt', 'AiBot']); const isCategorySettingsFlow = route.name === SCREENS.WORKSPACE.DYNAMIC_CATEGORY_RULES_NEW; const isCategoryScopedCreate = isCategorySettingsFlow || !!categoryName; - const policy = usePolicy(policyID); - const {showConfirmModal} = useConfirmModal(); - // From category settings the only expense default is the category's tax rate, so the type chooser is skipped. It - // can't be set without a rate to pick, so that case gets the chooser's explainer instead of an empty picker. - const openCategoryTaxRule = () => { - const hasCategoryTaxRule = !!categoryName && categoryHasTaxRule(policy?.rules?.expenseRules, categoryName); - if (!hasCategoryTaxRule && !hasSelectableCategoryTaxRate(policy)) { - const areTaxesEnabled = hasUsableTaxRates(policy); - showConfirmModal({ - title: translate(areTaxesEnabled ? 'workspace.rules.merchantRules.addTaxRateFirstTitle' : 'workspace.rules.merchantRules.turnOnTaxesFirstTitle'), - prompt: translate(areTaxesEnabled ? 'workspace.rules.merchantRules.addTaxRateFirstPrompt' : 'workspace.rules.merchantRules.turnOnTaxesFirstPrompt'), - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - }); - return; - } - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_RULES_TAX_NEW.path)); - }; + // From category settings the rule is pre-scoped to the category, so it opens the editor directly. + const openCategoryScopedRule = () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_RULES_TAX_NEW.path)); const newRuleOptions: NewRuleOption[] = [ { @@ -111,7 +92,7 @@ function RulesNewPage({route}: RulesNewPageProps) { icon: illustrations.ReportReceipt, title: translate('workspace.rules.newRule.applyExpenseDefaults'), description: translate('workspace.rules.newRule.applyExpenseDefaultsDescription'), - onPress: () => (isCategorySettingsFlow ? openCategoryTaxRule() : Navigation.navigate(ROUTES.RULES_EXPENSE_DEFAULT_TYPE.getRoute(policyID))), + onPress: () => (isCategorySettingsFlow ? openCategoryScopedRule() : Navigation.navigate(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID))), sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.RULES.NEW_RULE_MENU_ITEM_APPLY_EXPENSE_DEFAULTS, // Only category settings can scope the editor to its category, so a category passed any other way drops it. isWorkspaceOnly: !isCategorySettingsFlow, diff --git a/src/pages/workspace/rules/tabs/RulesExpenseDefaultsTab.tsx b/src/pages/workspace/rules/tabs/RulesExpenseDefaultsTab.tsx index 1cc91fc7cc44..b4868c4aa106 100644 --- a/src/pages/workspace/rules/tabs/RulesExpenseDefaultsTab.tsx +++ b/src/pages/workspace/rules/tabs/RulesExpenseDefaultsTab.tsx @@ -26,14 +26,12 @@ function RulesExpenseDefaultsTab({policyID, canWriteRules, selectedKeys, onSelec const policy = usePolicy(policyID); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); - const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`); const expenseDefaultsTableData = getExpenseDefaultsTableData({ policy, policyID, rules, - policyCategories, policyTags, translate, isOffline, diff --git a/src/pages/workspace/rules/tabs/useRulesTableBulkActions.ts b/src/pages/workspace/rules/tabs/useRulesTableBulkActions.ts index c2c5ae0ba5c7..7748a59c7425 100644 --- a/src/pages/workspace/rules/tabs/useRulesTableBulkActions.ts +++ b/src/pages/workspace/rules/tabs/useRulesTableBulkActions.ts @@ -17,10 +17,9 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import {deleteExpensifyCardRule} from '@libs/actions/Card'; -import {deletePolicyCategoryTaxes, openPolicyCategoriesPage} from '@libs/actions/Policy/Category'; +import {openPolicyCategoriesPage} from '@libs/actions/Policy/Category'; import {openPolicyExpensifyCardsPage} from '@libs/actions/Policy/Policy'; import {deleteRule} from '@libs/actions/Policy/Rules'; -import {getCategoryNameFromTaxRuleKey, isCategoryTaxRuleKey} from '@libs/CategoryTaxRulesUtils'; import {deleteFlagForReviewRule, getFlagForReviewTableData} from '@libs/FlagForReviewRulesUtils'; import {getExpenseDefaultsTableData, isMerchantTypeRuleKey} from '@libs/MerchantTypeRulesUtils'; import Navigation from '@libs/Navigation/Navigation'; @@ -158,8 +157,6 @@ function useRulesTableBulkActions({policyID, activeTab, selectedRuleKeysByTab, c policy, policyID, rules, - // Unlike the tables below, the raw value: a category pending deletion is exactly what marks its rule deleting. - policyCategories: policyCategoriesOnyx, policyTags: policyTagsOnyx, translate, isOffline, @@ -254,26 +251,14 @@ function useRulesTableBulkActions({policyID, activeTab, selectedRuleKeysByTab, c return; } - // Category tax defaults live in `expenseRules`, a plain array, so they delete together in one pass that threads - // the array through rather than one independent write each. - const selectedCategoryNames: string[] = []; - for (const ruleID of filteredSelectedExpenseDefaultKeys) { if (isMerchantTypeRuleKey(ruleID)) { continue; } - if (isCategoryTaxRuleKey(ruleID)) { - selectedCategoryNames.push(getCategoryNameFromTaxRuleKey(ruleID)); - continue; - } - deleteRule(ruleID, rules?.[`${ONYXKEYS.COLLECTION.RULE}${ruleID}`]); } - if (selectedCategoryNames.length > 0) { - deletePolicyCategoryTaxes(policy, selectedCategoryNames); - } clearTableSelection(); }, [ isVendorMatchingBetaEnabled, diff --git a/src/types/form/MerchantRuleForm.ts b/src/types/form/MerchantRuleForm.ts index 77a516b83a46..c01ac1295742 100644 --- a/src/types/form/MerchantRuleForm.ts +++ b/src/types/form/MerchantRuleForm.ts @@ -14,7 +14,6 @@ const INPUT_IDS = { MERCHANT_TO_MATCH: 'merchantToMatch', MERCHANT: 'merchant', REIMBURSABLE: 'reimbursable', - RULE_TYPE: 'ruleType', TAG: 'tag', TAGS_TO_MATCH: 'tagsToMatch', TAX: 'tax', @@ -23,9 +22,6 @@ const INPUT_IDS = { type InputID = ValueOf; -/** What the rule matches on, chosen before the editor opens. */ -type ExpenseDefaultRuleType = ValueOf; - type MerchantRuleForm = Form< InputID, { @@ -38,9 +34,6 @@ type MerchantRuleForm = Form< [INPUT_IDS.MERCHANT]: string; [INPUT_IDS.MERCHANT_TO_MATCH]: string; [INPUT_IDS.REIMBURSABLE]: boolean; - /** Which kind of expense default this is, chosen before the editor opened. Kept in the draft so it survives a - * trip to any picker, rather than having to ride on every picker's route. */ - [INPUT_IDS.RULE_TYPE]: ExpenseDefaultRuleType; [INPUT_IDS.TAG]: string; [INPUT_IDS.TAGS_TO_MATCH]: string[]; [INPUT_IDS.TAX]: string; @@ -48,5 +41,5 @@ type MerchantRuleForm = Form< } >; -export type {ExpenseDefaultRuleType, MerchantRuleForm}; +export type {MerchantRuleForm}; export default INPUT_IDS; diff --git a/tests/actions/PolicyCategoryTest.ts b/tests/actions/PolicyCategoryTest.ts index 2587abc3d0f8..34ef7eb37755 100644 --- a/tests/actions/PolicyCategoryTest.ts +++ b/tests/actions/PolicyCategoryTest.ts @@ -11,7 +11,6 @@ import { importPolicyCategories, renamePolicyCategory, setPolicyCategoryReceiptsAndItemizedReceiptRequired, - setPolicyCategoryTax, setWorkspaceCategoryEnabled, setWorkspaceRequiresCategory, } from '@libs/actions/Policy/Category'; @@ -421,107 +420,6 @@ describe('actions/PolicyCategory', () => { }); }); - describe('SetPolicyCategoryTax', () => { - it('should set expense rule when category expense rule is not present', async () => { - // Given a policy - const fakePolicy = createRandomPolicy(0); - fakePolicy.areCategoriesEnabled = true; - const categoryName = 'Fake category'; - const fakePolicyCategories = { - [categoryName]: { - name: categoryName, - enabled: false, - // eslint-disable-next-line @typescript-eslint/naming-convention - 'GL Code': '', - unencodedName: categoryName, - externalID: '', - areCommentsRequired: false, - origin: '', - }, - }; - - mockFetch.pause(); - - await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy); - await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${fakePolicy.id}`, fakePolicyCategories); - - setPolicyCategoryTax(fakePolicy, categoryName, 'VAT'); - await waitForBatchedUpdates(); - - // Then the approval rule should be created with the tag name - const updatedPolicy = await OnyxUtils.get(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`); - - expect(updatedPolicy?.rules?.expenseRules).toHaveLength(1); - expect(updatedPolicy?.rules?.expenseRules?.[0]?.applyWhen?.[0]?.value).toBe(categoryName); - expect(updatedPolicy?.rules?.expenseRules?.[0]?.applyWhen?.[0]?.condition).toBe(CONST.POLICY.RULE_CONDITIONS.MATCHES); - expect(updatedPolicy?.rules?.expenseRules?.[0]?.applyWhen?.[0]?.field).toBe(CONST.POLICY.FIELDS.CATEGORY); - expect(updatedPolicy?.rules?.expenseRules?.[0].tax.field_id_TAX.externalID).toBe('VAT'); - - mockFetch.resume(); - await waitForBatchedUpdates(); - }); - - it('should update expense rule when category expense rule is present', async () => { - // Given a policy with approval rules that reference a tag - const fakePolicy = createRandomPolicy(0); - fakePolicy.areCategoriesEnabled = true; - const categoryName = 'Fake category'; - const fakePolicyCategories = { - [categoryName]: { - name: categoryName, - enabled: false, - // eslint-disable-next-line @typescript-eslint/naming-convention - 'GL Code': '', - unencodedName: categoryName, - externalID: '', - areCommentsRequired: false, - origin: '', - }, - }; - - // Create expense rule that uses the tag - fakePolicy.rules = { - expenseRules: [ - { - tax: { - // eslint-disable-next-line @typescript-eslint/naming-convention - field_id_TAX: { - externalID: 'GST', - }, - }, - applyWhen: [ - { - condition: CONST.POLICY.RULE_CONDITIONS.MATCHES, - field: CONST.POLICY.FIELDS.CATEGORY, - value: categoryName, - }, - ], - }, - ], - }; - - mockFetch.pause(); - - await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy); - await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${fakePolicy.id}`, fakePolicyCategories); - - setPolicyCategoryTax(fakePolicy, categoryName, 'VAT'); - await waitForBatchedUpdates(); - - // Then the approval rule should be created with the tag name - const updatedPolicy = await OnyxUtils.get(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`); - - expect(updatedPolicy?.rules?.expenseRules).toHaveLength(1); - expect(updatedPolicy?.rules?.expenseRules?.[0]?.applyWhen?.[0]?.value).toBe(categoryName); - expect(updatedPolicy?.rules?.expenseRules?.[0]?.applyWhen?.[0]?.condition).toBe(CONST.POLICY.RULE_CONDITIONS.MATCHES); - expect(updatedPolicy?.rules?.expenseRules?.[0]?.applyWhen?.[0]?.field).toBe(CONST.POLICY.FIELDS.CATEGORY); - expect(updatedPolicy?.rules?.expenseRules?.[0].tax.field_id_TAX.externalID).toBe('VAT'); - - mockFetch.resume(); - await waitForBatchedUpdates(); - }); - }); - describe('createPolicyCategory with onboarding task completion', () => { it('should complete SETUP_CATEGORIES_AND_TAGS task when creating category and tags already exist', async () => { const fakePolicy = createRandomPolicy(0); diff --git a/tests/ui/DynamicCategoryDefaultTaxRatePageTest.tsx b/tests/ui/DynamicCategoryDefaultTaxRatePageTest.tsx deleted file mode 100644 index cb7cd8eda8ae..000000000000 --- a/tests/ui/DynamicCategoryDefaultTaxRatePageTest.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import {act, render} from '@testing-library/react-native'; - -import SelectionList from '@components/SelectionList'; - -import DynamicCategoryDefaultTaxRatePage from '@pages/workspace/categories/DynamicCategoryDefaultTaxRatePage'; - -import CONST from '@src/CONST'; -import type {Policy, TaxRate} from '@src/types/onyx'; - -import type * as ReactNavigation from '@react-navigation/native'; -import type {PropsWithChildren} from 'react'; - -import React from 'react'; - -let mockIsFocused = true; -let mockPolicy: Policy | undefined; -let mockPersistedTaxRate: string | undefined; - -jest.mock('@react-navigation/native', () => { - const actualNavigation: typeof ReactNavigation = jest.requireActual('@react-navigation/native'); - const ReactMock = jest.requireActual('react'); - - return { - ...actualNavigation, - useFocusEffect: jest.fn((callback: () => void) => { - ReactMock.useEffect(() => { - if (!mockIsFocused) { - return; - } - - return callback(); - }, [callback, mockIsFocused]); - }), - }; -}); - -jest.mock('@components/SelectionList', () => jest.fn(() => null)); -jest.mock('@components/SelectionList/ListItem/SingleSelectListItem', () => jest.fn(() => null)); -jest.mock('@components/ScreenWrapper', () => jest.fn(({children}: PropsWithChildren) => children)); -jest.mock('@components/HeaderWithBackButton', () => jest.fn(() => null)); -jest.mock('@pages/workspace/AccessOrNotFoundWrapper', () => jest.fn(({children}: PropsWithChildren) => children)); - -jest.mock('@hooks/usePolicy', () => jest.fn(() => mockPolicy)); -jest.mock('@hooks/useDynamicBackPath', () => jest.fn(() => '')); -jest.mock('@hooks/useThemeStyles', () => jest.fn(() => new Proxy({}, {get: () => ({})}))); -jest.mock('@hooks/useLocalize', () => - jest.fn(() => ({ - translate: (key: string) => key, - localeCompare: (a: string, b: string) => a.localeCompare(b), - })), -); - -// Return the persisted rate and use the taxID itself as the display text so the alphabetical sort is predictable. -jest.mock('@libs/CategoryUtils', () => ({ - getCategoryDefaultTaxRate: jest.fn(() => mockPersistedTaxRate), - formatDefaultTaxRateText: jest.fn((_translate: unknown, taxID: string) => taxID), -})); - -jest.mock('@libs/Navigation/Navigation', () => ({ - goBack: jest.fn(), - setNavigationActionToMicrotaskQueue: jest.fn(), -})); - -jest.mock('@userActions/Policy/Category', () => ({ - setPolicyCategoryTax: jest.fn(), -})); - -const POLICY_ID = 'policy1'; -const CATEGORY_NAME = 'Travel'; - -type MockTaxItem = {value: string; keyForList?: string; text?: string; isSelected?: boolean}; - -type MockSelectionListProps = { - data: MockTaxItem[]; - onSelectRow?: (item: MockTaxItem) => void; - initiallyFocusedItemKey?: string; - shouldScrollToFocusedIndexOnMount?: boolean; - shouldUpdateFocusedIndex?: boolean; -}; - -/** Build a policy whose taxRates.taxes has `count` entries keyed id00..id{count-1} (zero-padded so the text sort is numeric). */ -function buildPolicyWithTaxes(count: number): Policy { - const taxes: Record = {}; - for (let index = 0; index < count; index++) { - const key = `id${String(index).padStart(2, '0')}`; - taxes[key] = {name: key, value: '0%'} as TaxRate; - } - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test-only minimal Policy stub - return {taxRates: {name: 'Tax', defaultExternalID: 'id00', foreignTaxDefault: 'id00', taxes}} as Policy; -} - -function renderPage() { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test-only route stub; the page only reads route.params - const props = {route: {params: {policyID: POLICY_ID, categoryName: CATEGORY_NAME}}} as React.ComponentProps; - return render(); -} - -describe('DynamicCategoryDefaultTaxRatePage', () => { - const mockedSelectionList = jest.mocked(SelectionList); - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test-only: narrow the props captured from the mocked SelectionList - const getSelectionListProps = () => mockedSelectionList.mock.lastCall?.[0] as MockSelectionListProps | undefined; - - beforeEach(() => { - mockedSelectionList.mockClear(); - mockIsFocused = true; - mockPolicy = buildPolicyWithTaxes(CONST.STANDARD_LIST_ITEM_LIMIT + 2); - // "id07" sorts to the middle, so seeing it at the top proves pinning (not the natural order) put it there. - mockPersistedTaxRate = 'id07'; - }); - - it('pins the persisted tax rate to the top on open', () => { - renderPage(); - - const props = getSelectionListProps(); - - expect(props?.data.at(0)?.keyForList).toBe('id07'); - expect(props?.data.at(0)?.isSelected).toBe(true); - expect(props?.initiallyFocusedItemKey).toBe('id07'); - // Alphabetically "id00" would be first if nothing were pinned. - expect(props?.data.at(0)?.value).not.toBe('id00'); - // Keyboard focus starts on the selected rate, and shouldUpdateFocusedIndex + no mount scroll keep the list from auto-scrolling on select. - expect(props?.shouldScrollToFocusedIndexOnMount).toBe(false); - expect(props?.shouldUpdateFocusedIndex).toBe(true); - }); - - it('keeps the persisted rate pinned while the live selection changes during the same session', () => { - renderPage(); - - let props = getSelectionListProps(); - const otherItem = props?.data.find((item) => item.keyForList === 'id03'); - expect(otherItem).toBeDefined(); - - act(() => { - if (!otherItem) { - return; - } - props?.onSelectRow?.(otherItem); - }); - - props = getSelectionListProps(); - - // The originally persisted rate stays pinned at the top; the newly selected one does not jump above it. - expect(props?.data.at(0)?.keyForList).toBe('id07'); - expect(props?.data.at(0)?.isSelected).toBe(false); - expect(props?.data.find((item) => item.keyForList === 'id03')?.isSelected).toBe(true); - expect(props?.data.findIndex((item) => item.keyForList === 'id03')).toBeGreaterThan(0); - }); - - it('does not reorder when the list is under the item-limit threshold', () => { - mockPolicy = buildPolicyWithTaxes(CONST.STANDARD_LIST_ITEM_LIMIT - 2); - - renderPage(); - - const props = getSelectionListProps(); - - // Below the threshold moveInitialSelectionToTop is a no-op, so the natural alphabetical order is preserved. - expect(props?.data.at(0)?.keyForList).toBe('id00'); - expect(props?.data.findIndex((item) => item.keyForList === 'id07')).toBe(7); - }); -}); diff --git a/tests/unit/CategoryContextualRulesUtilsTest.ts b/tests/unit/CategoryContextualRulesUtilsTest.ts index 5be5c76e16b8..fd73d6431624 100644 --- a/tests/unit/CategoryContextualRulesUtilsTest.ts +++ b/tests/unit/CategoryContextualRulesUtilsTest.ts @@ -1,9 +1,13 @@ import getCategoryContextualRules from '@libs/CategoryContextualRulesUtils'; +import {toIndexMap} from '@libs/RuleUtils'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; +import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; -import type {Policy, PolicyCategory} from '@src/types/onyx'; +import type {Policy, PolicyCategory, Rule} from '@src/types/onyx'; + +import type {OnyxCollection} from 'react-native-onyx'; import createRandomPolicy from '../utils/collections/policies'; import {convertToDisplayString, translateLocal} from '../utils/TestHelper'; @@ -135,14 +139,16 @@ describe('getCategoryContextualRules', () => { TAX_VAT: {name: 'VAT', value: '20%'}, }, }, - rules: { - expenseRules: [ - { - // eslint-disable-next-line @typescript-eslint/naming-convention -- matches the backend's expense rule shape - tax: {field_id_TAX: {externalID: 'TAX_VAT'}}, - applyWhen: [{condition: CONST.POLICY.RULE_CONDITIONS.MATCHES, field: CONST.POLICY.FIELDS.CATEGORY, value: 'Travel'}], - }, - ], + }; + // A category's tax default is an ordinary rule matching that category alone and setting a tax. + const taxDefaultRules: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.RULE}rule1`]: { + scope: CONST.RULES.SCOPE.POLICY, + scopeID: policy.id, + triggers: toIndexMap([CONST.RULES.TRIGGERS.CREATE_TRANSACTION]), + filters: {left: CONST.RULES.EXPENSE_DEFAULT.FIELD.CATEGORY, operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, right: ['Travel']}, + // eslint-disable-next-line @typescript-eslint/naming-convention -- matches the rules engine's tax value shape + actions: toIndexMap([{name: CONST.RULES.ACTIONS.SET, field: CONST.RULES.EXPENSE_DEFAULT.FIELD.TAX, value: {field_id_TAX: {externalID: 'TAX_VAT'}}}]), }, }; const category: PolicyCategory = {name: 'Travel', enabled: true}; @@ -153,6 +159,7 @@ describe('getCategoryContextualRules', () => { const rules = getCategoryContextualRules({ policy: policyWithTaxRule, policyCategories: undefined, + policyRules: taxDefaultRules, category, categoryName: 'Travel', translate: translateLocal, @@ -172,6 +179,7 @@ describe('getCategoryContextualRules', () => { const rules = getCategoryContextualRules({ policy: policyWithTaxRule, policyCategories: undefined, + policyRules: taxDefaultRules, category: {name: 'Meals', enabled: true}, categoryName: 'Meals', translate: translateLocal, @@ -202,6 +210,7 @@ describe('getCategoryContextualRules', () => { const params = { policy: policyWithDeletingRate, policyCategories: undefined, + policyRules: taxDefaultRules, category, categoryName: 'Travel', translate: translateLocal, diff --git a/tests/unit/CategoryTaxRulesUtilsTest.ts b/tests/unit/CategoryTaxRulesUtilsTest.ts index 4e6f31a5de7e..f378e8e8da21 100644 --- a/tests/unit/CategoryTaxRulesUtilsTest.ts +++ b/tests/unit/CategoryTaxRulesUtilsTest.ts @@ -1,143 +1,37 @@ -import {getCategoryTaxRulesTableData, hasSelectableCategoryTaxRate, isSelectableTaxRate} from '@libs/CategoryTaxRulesUtils'; +import {isSelectableTaxRate} from '@libs/CategoryTaxRulesUtils'; import CONST from '@src/CONST'; -import IntlStore from '@src/languages/IntlStore'; -import type {Policy} from '@src/types/onyx'; -import type {ExpenseRule, TaxRate} from '@src/types/onyx/Policy'; +import type {TaxRate} from '@src/types/onyx/Policy'; -import createRandomPolicy from '../utils/collections/policies'; import createMock from '../utils/createMock'; -import {translateLocal} from '../utils/TestHelper'; -import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; - -const CATEGORY_NAME = 'Travel'; -const TAX_ID = 'id_TAX_RATE_1'; -const DEFAULT_TAX_ID = 'id_TAX_EXEMPT'; - -/** A saved category tax rule, which stores the rate as an ID with no label of its own. */ -const buildCategoryTaxRule = (taxID: string): ExpenseRule => ({ - tax: { - // eslint-disable-next-line @typescript-eslint/naming-convention - field_id_TAX: {externalID: taxID}, - }, - applyWhen: [ - { - condition: CONST.POLICY.RULE_CONDITIONS.MATCHES, - field: CONST.POLICY.FIELDS.CATEGORY, - value: CATEGORY_NAME, - }, - ], -}); - -/** A policy holding one category tax rule, with `taxes` describing which rates the workspace still has. */ -const buildPolicy = (taxes: Record, expenseRules: ExpenseRule[] = [buildCategoryTaxRule(TAX_ID)]): Policy => - createMock({ - ...createRandomPolicy(0), - taxRates: { - name: 'Taxes', - defaultExternalID: DEFAULT_TAX_ID, - defaultValue: '0%', - foreignTaxDefault: DEFAULT_TAX_ID, - taxes, - }, - rules: {expenseRules}, - }); - -const buildTableData = (policy: Policy) => - getCategoryTaxRulesTableData({ - policy, - policyCategories: {[CATEGORY_NAME]: createMock({name: CATEGORY_NAME, enabled: true})}, - translate: translateLocal, - isOffline: false, - onNavigate: () => {}, - }); describe('CategoryTaxRulesUtils', () => { - beforeAll(() => { - IntlStore.load(CONST.LOCALES.EN); - return waitForBatchedUpdates(); - }); - - describe('hasSelectableCategoryTaxRate', () => { - /** `hasSelectableCategoryTaxRate` reads tracking as well as the rates, so this states it rather than leaving it to the random policy. */ - const buildTaxTrackingPolicy = (taxes: Record, trackingEnabled = true): Policy => - createMock({ - ...buildPolicy(taxes), - tax: {trackingEnabled}, - }); - - it('is true when a rate other than the workspace default is enabled', () => { - const policy = buildTaxTrackingPolicy({ - [DEFAULT_TAX_ID]: createMock({name: 'Tax exempt', value: '0%'}), - [TAX_ID]: createMock({name: 'VAT', value: '5%'}), - }); - expect(hasSelectableCategoryTaxRate(policy)).toBe(true); - }); - - it('is false when the workspace default is the only rate left', () => { - const policy = buildTaxTrackingPolicy({[DEFAULT_TAX_ID]: createMock({name: 'Tax exempt', value: '0%'})}); - expect(hasSelectableCategoryTaxRate(policy)).toBe(false); - }); - - it('is false when every other rate is disabled', () => { - const policy = buildTaxTrackingPolicy({ - [DEFAULT_TAX_ID]: createMock({name: 'Tax exempt', value: '0%'}), - [TAX_ID]: createMock({name: 'VAT', value: '5%', isDisabled: true}), - }); - expect(hasSelectableCategoryTaxRate(policy)).toBe(false); - }); - - it('is false when every other rate is being deleted', () => { - const policy = buildTaxTrackingPolicy({ - [DEFAULT_TAX_ID]: createMock({name: 'Tax exempt', value: '0%'}), - [TAX_ID]: createMock({name: 'VAT', value: '5%', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}), - }); - expect(hasSelectableCategoryTaxRate(policy)).toBe(false); - }); - - it('is false with tax tracking off, whatever rates the workspace holds', () => { - const policy = buildTaxTrackingPolicy({[TAX_ID]: createMock({name: 'VAT', value: '5%'})}, false); - expect(hasSelectableCategoryTaxRate(policy)).toBe(false); - }); - }); - describe('isSelectableTaxRate', () => { - it('offers the workspace default to a merchant rule but not to a category rule', () => { - const policy = buildPolicy({[DEFAULT_TAX_ID]: createMock({name: 'Tax exempt', value: '0%'})}); - const defaultRate = createMock({name: 'Tax exempt', value: '0%'}); - expect(isSelectableTaxRate(policy, DEFAULT_TAX_ID, defaultRate, false)).toBe(true); - expect(isSelectableTaxRate(policy, DEFAULT_TAX_ID, defaultRate, true)).toBe(false); - }); - }); + it('offers a rate the workspace still has', () => { + // Given an ordinary tax rate + const taxRate = createMock({name: 'VAT', value: '5%'}); - describe('getCategoryTaxRulesTableData', () => { - it('names the rate the rule holds', () => { - const policy = buildPolicy({[TAX_ID]: createMock({name: 'VAT', value: '5%'})}); - expect(buildTableData(policy).at(0)?.ruleDescription).toContain('VAT (5%)'); + // When a rule's tax picker asks whether it can be chosen + // Then it can, including the workspace default, which a rule may set like any other rate + expect(isSelectableTaxRate(taxRate)).toBe(true); }); - it('reads the rename rather than the label saved when the rule was created', () => { - const policy = buildPolicy({[TAX_ID]: createMock({name: 'VAT renamed', value: '7%'})}); - expect(buildTableData(policy).at(0)?.ruleDescription).toContain('VAT renamed (7%)'); - }); + it('does not offer a disabled rate', () => { + // Given a rate turned off for the workspace + const taxRate = createMock({name: 'VAT', value: '5%', isDisabled: true}); - it('never shows the raw tax ID once the rate has left the workspace', () => { - // A category tax rule stores only the ID, so with the rate gone there is nothing to name it from. The row - // has to read as unset, the way the editor it opens does, rather than printing the ID at the admin. - const row = buildTableData(buildPolicy({})).at(0); - expect(row).toBeDefined(); - expect(row?.ruleDescription).not.toContain(TAX_ID); - expect(row?.searchTokens).not.toContain(TAX_ID); + // When the picker asks + // Then it is not offered, since a new rule should not start applying it + expect(isSelectableTaxRate(taxRate)).toBe(false); }); - it('still lists the rule when its rate is disabled, since the rule keeps applying it', () => { - const policy = buildPolicy({[TAX_ID]: createMock({name: 'VAT', value: '5%', isDisabled: true})}); - expect(buildTableData(policy).at(0)?.ruleDescription).toContain('VAT (5%)'); - }); + it('does not offer a rate on its way out', () => { + // Given a rate being deleted + const taxRate = createMock({name: 'VAT', value: '5%', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}); - it('still lists the rule when its rate has become the workspace default', () => { - const policy = buildPolicy({[DEFAULT_TAX_ID]: createMock({name: 'Tax exempt', value: '0%'})}, [buildCategoryTaxRule(DEFAULT_TAX_ID)]); - expect(buildTableData(policy).at(0)?.ruleDescription).toContain('Tax exempt (0%)'); + // When the picker asks + // Then it is not offered, because it is about to stop existing + expect(isSelectableTaxRate(taxRate)).toBe(false); }); }); }); diff --git a/tests/unit/MerchantRuleSuggestionUtilsTest.ts b/tests/unit/MerchantRuleSuggestionUtilsTest.ts index 4c95590467f0..9415c6aa6427 100644 --- a/tests/unit/MerchantRuleSuggestionUtilsTest.ts +++ b/tests/unit/MerchantRuleSuggestionUtilsTest.ts @@ -118,9 +118,8 @@ describe('getMerchantRuleDraftFromTransaction', () => { expect(getMerchantRuleDraftFromTransaction(transaction, [CONST.MERCHANT_RULE_SUGGESTION_FIELDS.CATEGORY], undefined)).toBeUndefined(); }); - it('names the rule type itself, so the editor opens rather than the type chooser', () => { + it('seeds the merchant the rule matches on', () => { const draft = getMerchantRuleDraftFromTransaction(buildTransaction(), [], undefined); - expect(draft?.ruleType).toBe(CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.MERCHANT); expect(draft?.merchantToMatch).toBe('Starbucks'); }); diff --git a/tests/unit/VendorMatchingMerchantRulesTest.ts b/tests/unit/VendorMatchingMerchantRulesTest.ts index ff4b898546c7..34e39f866a0d 100644 --- a/tests/unit/VendorMatchingMerchantRulesTest.ts +++ b/tests/unit/VendorMatchingMerchantRulesTest.ts @@ -25,7 +25,6 @@ const buildForm = (overrides: Partial = {}): MerchantRuleForm merchant: '', category: '', categoriesToMatch: [], - ruleType: CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.MERCHANT, amount: '', tag: '', tagsToMatch: [], From f8b9603e69f28ea06b1e8e2fe8df51d6449ed3fa Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 16 Oct 2026 02:59:17 +0530 Subject: [PATCH 07/24] Gate the category condition row on a category to match Signed-off-by: krishna2323 --- .../rules/MerchantRules/MerchantRulePageBase.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx index afb58590ca99..ab313696eb87 100644 --- a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx +++ b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx @@ -254,9 +254,18 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, initialCat return policyTags.length > 0; }; + // `hasCategories` falls back to the category the rule sets, which says nothing about what it matches on, so the + // condition row asks separately. A category the rule already matches on keeps the row even once the workspace has + // none left to pick, otherwise the condition would be invisible and still saved. + const hasCategoriesToMatch = () => { + if (!policy?.areCategoriesEnabled) { + return false; + } + return !!form?.categoriesToMatch?.length || hasEnabledOptions(policyCategories ?? {}); + }; + // Turning tags on creates a tag list whether or not it holds anything, so the condition row needs a tag to - // actually match on. A tag the rule already matches on keeps the row, the way `hasCategories` keeps it for a - // category the rule already sets. + // actually match on. A tag the rule already matches on keeps the row, the same way. const hasTagsToMatch = () => { if (!policy?.areTagsEnabled) { return false; @@ -427,7 +436,7 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, initialCat ), icon: icons.Basket, }, - hasCategories() + hasCategoriesToMatch() ? { key: 'categoriesToMatch', description: translate('common.category'), From 063426018b1411846758e4e1fcc61819ddde0c4e Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 16 Oct 2026 03:02:16 +0530 Subject: [PATCH 08/24] Keep a deleted category or tag listed in the condition pickers Signed-off-by: krishna2323 --- .../MerchantRules/AddCategoryToMatchPage.tsx | 10 ++++++++++ .../rules/MerchantRules/AddTagToMatchPage.tsx | 20 +++++++++++++++---- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/pages/workspace/rules/MerchantRules/AddCategoryToMatchPage.tsx b/src/pages/workspace/rules/MerchantRules/AddCategoryToMatchPage.tsx index ff4ea54bcc09..e34d2351e795 100644 --- a/src/pages/workspace/rules/MerchantRules/AddCategoryToMatchPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddCategoryToMatchPage.tsx @@ -74,6 +74,16 @@ function AddCategoryToMatchPage({route}: AddCategoryToMatchPageProps) { isSelected: selectedCategories.includes(category.name), })); + // A rule keeps matching on a category the workspace has since deleted. Listing it is the only way to see what the + // rule matches on and the only way to take it off, so it is added back rather than left out of the picker. + const listedCategoryNames = new Set(categories.map((category) => category.name)); + for (const categoryName of selectedCategories) { + if (listedCategoryNames.has(categoryName)) { + continue; + } + categoryItems.push({keyForList: categoryName, text: getDecodedCategoryName(categoryName), value: categoryName, isSelected: true}); + } + // Tokenized like the main category picker, so the words can be typed in any order and a nested name is still found // by any part of it. const filterCategory = (item: CategoryListItem, searchInput: string) => tokenizedSearch([item], searchInput, (category) => [category.text ?? '']).length > 0; diff --git a/src/pages/workspace/rules/MerchantRules/AddTagToMatchPage.tsx b/src/pages/workspace/rules/MerchantRules/AddTagToMatchPage.tsx index 3c70144d4afa..b4778cb9cd18 100644 --- a/src/pages/workspace/rules/MerchantRules/AddTagToMatchPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddTagToMatchPage.tsx @@ -70,11 +70,13 @@ function AddTagToMatchPage({route}: AddTagToMatchPageProps) { const seenTagNames = new Set(); for (const tagList of policyTagLists) { for (const tag of Object.values(tagList.tags)) { - if (tag.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || seenTagNames.has(tag.name)) { + if (seenTagNames.has(tag.name)) { continue; } - // A rule keeps a tag it already matches on even once the tag is disabled, so it stays removable. - if (!tag.enabled && !selectedTags.includes(tag.name)) { + // A rule keeps a tag it already matches on even once the tag is disabled or on its way out, so it stays + // removable. + const isTagMatched = selectedTags.includes(tag.name); + if ((!tag.enabled || tag.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) && !isTagMatched) { continue; } seenTagNames.add(tag.name); @@ -82,11 +84,21 @@ function AddTagToMatchPage({route}: AddTagToMatchPageProps) { keyForList: tag.name, text: getCleanedTagName(tag.name), value: tag.name, - isSelected: selectedTags.includes(tag.name), + isSelected: isTagMatched, }); } } + // A rule keeps matching on a tag the workspace has since deleted. Listing it is the only way to see what the rule + // matches on and the only way to take it off, so it is added back rather than left out of the picker. + for (const tagName of selectedTags) { + if (seenTagNames.has(tagName)) { + continue; + } + seenTagNames.add(tagName); + tagItems.push({keyForList: tagName, text: getCleanedTagName(tagName), value: tagName, isSelected: true}); + } + const filterTag = (item: TagListItem, searchInput: string) => tokenizedSearch([item], searchInput, (tag) => [tag.text ?? '']).length > 0; // Pin the initially selected tags to the top of the full sorted list, so they stay at the top while searching. From 4c2cd5b7efd521cd890c5f94f151d14ffcc83645 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 16 Oct 2026 03:06:15 +0530 Subject: [PATCH 09/24] remove unused translations keys. Signed-off-by: krishna2323 --- src/languages/de.ts | 18 ------------------ src/languages/el.ts | 20 -------------------- src/languages/en.ts | 16 ---------------- src/languages/es.ts | 19 ------------------- src/languages/fr.ts | 19 ------------------- src/languages/it.ts | 18 ------------------ src/languages/ja.ts | 16 ---------------- src/languages/nl.ts | 17 ----------------- src/languages/pl.ts | 17 ----------------- src/languages/pt-BR.ts | 17 ----------------- src/languages/zh-hans.ts | 16 ---------------- 11 files changed, 193 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 31e9c4661fd3..1852cd24d020 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -8505,18 +8505,8 @@ Fordern Sie Spesendetails wie Belege und Beschreibungen an, legen Sie Limits und vendorUnavailable: 'Anbieter nicht verfügbar', supplierUnavailable: 'Lieferant nicht verfügbar', confirmErrorCategory: 'Bitte wählen Sie eine Kategorie aus', - confirmErrorCategoryTax: 'Bitte wählen Sie einen Steuersatz aus', confirmErrorCondition: 'Bitte geben Sie einen Händler ein', confirmErrorConditionAndDefault: 'Geben Sie einen Händler ein und wenden Sie mindestens eine Standardeinstellung an', - turnOnTaxesFirstTitle: 'Schalten Sie zuerst Steuern ein', - turnOnTaxesFirstPrompt: 'Kategorienregeln legen einen Standardsteuersatz fest. Aktivieren Sie Steuern in Ihren Workspace-Einstellungen, um sie zu verwenden.', - categoryRulesApplyGoingForwardTitle: 'Kategorienregeln gelten ab jetzt', - categoryRulesApplyGoingForwardPrompt: 'Ein Standardsatz für Steuern gilt für neue Ausgaben in dieser Kategorie. Bereits vorhandene Ausgaben werden nicht geändert.', - confirmErrorCategoryTaxMoveIsWorkspaceDefault: - 'Der ausgewählte Steuersatz ist jetzt die Voreinstellung für Ihren Workspace, daher ist diese Regel nicht mehr gültig. Wählen Sie einen anderen Steuersatz.', - addTaxRateFirstTitle: 'Fügen Sie zuerst einen Steuersatz hinzu', - addTaxRateFirstPrompt: - 'Kategorienregeln legen einen standardmäßigen Steuersatz fest. Fügen Sie einen anderen Steuersatz als den Standard Ihres Arbeitsbereichs hinzu, um sie zu verwenden.', createRuleFromExpenseAction: 'Regel erstellen', createRuleFromExpensePrompt: 'um Ihre Änderungen auf alle Ausgaben anzuwenden, die Ihren Kriterien entsprechen.', }, @@ -8903,14 +8893,6 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc cta: 'KI-Regel hinzufügen', }, categoriesDisabledEmptyState: {title: 'Kategorien sind nicht aktiviert', subtitle: 'Aktivieren Sie Kategorien, um Ihre Ausgaben besser zu kontrollieren.'}, - expenseDefaultType: { - title: 'Standardspesen übernehmen', - subtitle: 'Worauf soll diese Regel angewendet werden?', - merchant: 'Händler', - merchantDescription: 'Felder bei Ausgaben eines bestimmten Händlers aktualisieren', - category: 'Kategorie', - categoryDescription: 'Legen Sie einen Standardsteuersatz für eine bestimmte Kategorie fest', - }, taxesDisabledEmptyState: { title: 'Steuern sind nicht aktiviert', subtitle: 'Aktivieren Sie Steuern, um anfallende Steuern zu dokumentieren und zurückzufordern.', diff --git a/src/languages/el.ts b/src/languages/el.ts index cc4ccb9ec2b3..42c96180a74c 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -8876,20 +8876,8 @@ ${reportName}`, vendorUnavailable: 'Ο προμηθευτής δεν είναι διαθέσιμος', supplierUnavailable: 'Ο προμηθευτής δεν είναι διαθέσιμος', confirmErrorCategory: 'Παρακαλώ επιλέξτε κατηγορία', - confirmErrorCategoryTax: 'Επιλέξτε έναν φορολογικό συντελεστή', confirmErrorCondition: 'Παρακαλούμε εισαγάγετε έναν έμπορο', confirmErrorConditionAndDefault: 'Προσθέστε έναν έμπορο και ορίστε τουλάχιστον μία προεπιλογή', - turnOnTaxesFirstTitle: 'Ενεργοποιήστε πρώτα τους φόρους', - turnOnTaxesFirstPrompt: - 'Οι κανόνες κατηγορίας ορίζουν έναν προεπιλεγμένο φορολογικό συντελεστή. Ενεργοποιήστε τους φόρους στις ρυθμίσεις του χώρου εργασίας σας για να τους χρησιμοποιήσετε.', - categoryRulesApplyGoingForwardTitle: 'Οι κανόνες κατηγορίας ισχύουν από εδώ και στο εξής', - categoryRulesApplyGoingForwardPrompt: - 'Ένας προεπιλεγμένος φορολογικός συντελεστής εφαρμόζεται σε νέες δαπάνες σε αυτή την κατηγορία. Οι δαπάνες που υπάρχουν ήδη δεν θα αλλάξουν.', - confirmErrorCategoryTaxMoveIsWorkspaceDefault: - 'Ο επιλεγμένος φορολογικός συντελεστής είναι πλέον η προεπιλογή για τον χώρο εργασίας σας, επομένως αυτός ο κανόνας δεν είναι πλέον έγκυρος. Επιλέξτε έναν διαφορετικό φορολογικό συντελεστή.', - addTaxRateFirstTitle: 'Προσθέστε πρώτα έναν φορολογικό συντελεστή', - addTaxRateFirstPrompt: - 'Οι κανόνες κατηγορίας ορίζουν έναν προεπιλεγμένο φορολογικό συντελεστή. Προσθέστε έναν φορολογικό συντελεστή διαφορετικό από την προεπιλογή του χώρου εργασίας σας για να τους χρησιμοποιήσετε.', createRuleFromExpenseAction: 'Δημιουργία κανόνα', createRuleFromExpensePrompt: 'για να εφαρμόσετε τις αλλαγές σας σε όλες τις δαπάνες που ταιριάζουν με τα κριτήριά σας.', }, @@ -9221,14 +9209,6 @@ ${reportName}`, subtitle: 'Δημιουργήστε έναν κανόνα για την αυτοματοποίηση των πολιτικών του χώρου εργασίας σας.', cta: 'Προσθήκη κανόνα AI', }, - expenseDefaultType: { - title: 'Εφαρμογή προεπιλογών εξόδων', - subtitle: 'Σε τι πρέπει να αντιστοιχεί αυτός ο κανόνας;', - merchant: 'Έμπορος', - merchantDescription: 'Ενημέρωση πεδίων σε δαπάνες από συγκεκριμένο έμπορο', - category: 'Κατηγορία', - categoryDescription: 'Ορίστε προεπιλεγμένο φορολογικό συντελεστή για μια συγκεκριμένη κατηγορία', - }, taxesDisabledEmptyState: { title: 'Οι φόροι δεν είναι ενεργοποιημένοι', subtitle: 'Ενεργοποιήστε τους φόρους για να τεκμηριώσετε και να διεκδικήσετε τους επιλέξιμους φόρους.', diff --git a/src/languages/en.ts b/src/languages/en.ts index 37620b9f9e54..5665fbe5a75b 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -9059,24 +9059,8 @@ const translations = { ifAnyExpenseMatches: 'If any expense matches:', thenApplyFollowingDefaults: 'Then apply the following defaults:', confirmErrorCategory: 'Please select a category', - confirmErrorCategoryTax: 'Please select a tax rate', - confirmErrorCategoryTaxMoveIsWorkspaceDefault: 'The selected tax rate is now your workspace default, so this rule is no longer valid. Choose a different tax rate.', confirmErrorCondition: 'Please enter a merchant', confirmErrorConditionAndDefault: 'Enter a merchant, and apply at least one default', - turnOnTaxesFirstTitle: 'Turn on taxes first', - turnOnTaxesFirstPrompt: 'Category rules set a default tax rate. Turn on taxes in your workspace settings to use them.', - addTaxRateFirstTitle: 'Add a tax rate first', - addTaxRateFirstPrompt: 'Category rules set a default tax rate. Add a tax rate other than your workspace default to use them.', - categoryRulesApplyGoingForwardTitle: 'Category rules apply going forward', - categoryRulesApplyGoingForwardPrompt: "A default tax rate applies to new expenses in this category. Expenses that already exist won't change.", - }, - expenseDefaultType: { - title: 'Apply expense defaults', - subtitle: 'What should this rule match on?', - merchant: 'Merchant', - merchantDescription: 'Update fields on expenses from a specific merchant', - category: 'Category', - categoryDescription: 'Set a default tax rate for a specific category', }, newRule: { title: 'New rule', diff --git a/src/languages/es.ts b/src/languages/es.ts index 98492df20ae2..47e26e60698c 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -8536,19 +8536,8 @@ ${reportName}`, ifAnyExpenseMatches: 'Si algún gasto coincide con:', thenApplyFollowingDefaults: 'Luego aplica las siguientes opciones predeterminadas:', confirmErrorCategory: 'Selecciona una categoría', - confirmErrorCategoryTax: 'Selecciona una tasa de impuesto', confirmErrorCondition: 'Por favor, introduce un comercio', confirmErrorConditionAndDefault: 'Introduce un comercio y aplica al menos un valor predeterminado', - turnOnTaxesFirstTitle: 'Activa primero los impuestos', - turnOnTaxesFirstPrompt: - 'Las reglas de categoría establecen una tasa de impuesto predeterminada. Activa los impuestos en la configuración de tu espacio de trabajo para usarlos.', - categoryRulesApplyGoingForwardTitle: 'Las reglas de categoría se aplican de ahora en adelante', - categoryRulesApplyGoingForwardPrompt: 'Se aplica una tasa de impuesto predeterminada a los nuevos gastos de esta categoría. Los gastos que ya existen no cambiarán.', - confirmErrorCategoryTaxMoveIsWorkspaceDefault: - 'El tipo impositivo seleccionado es ahora el predeterminado de tu espacio de trabajo, por lo que esta regla ya no es válida. Elige un tipo impositivo diferente.', - addTaxRateFirstTitle: 'Añade primero una tasa de impuesto', - addTaxRateFirstPrompt: - 'Las reglas de categoría establecen una tasa de impuestos predeterminada. Añade una tasa de impuestos distinta de la predeterminada de tu espacio de trabajo para poder usarlas.', createRuleFromExpenseAction: 'Crear una norma', createRuleFromExpensePrompt: 'para aplicar tus cambios a todos los gastos que coincidan con tus criterios.', }, @@ -8908,14 +8897,6 @@ ${reportName}`, cta: 'Añadir regla de IA', }, categoriesDisabledEmptyState: {title: 'Las categorías no están habilitadas', subtitle: 'Habilita las categorías para tener más control sobre tus gastos.'}, - expenseDefaultType: { - title: 'Aplicar valores predeterminados de gastos', - subtitle: '¿Con qué debería coincidir esta regla?', - merchant: 'Comercio', - merchantDescription: 'Actualizar campos en gastos de un comerciante específico', - category: 'Categoría', - categoryDescription: 'Establece una tasa de impuesto predeterminada para una categoría específica', - }, taxesDisabledEmptyState: { title: 'Los impuestos no están habilitados', subtitle: 'Habilita los impuestos para documentar y recuperar los impuestos que sean deducibles.', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index a1dbd5626cea..7fbc3190bfdc 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -8527,19 +8527,8 @@ Rendez obligatoires des informations de dépense comme les reçus et les descrip vendorUnavailable: 'Fournisseur indisponible', supplierUnavailable: 'Fournisseur indisponible', confirmErrorCategory: 'Veuillez sélectionner une catégorie', - confirmErrorCategoryTax: 'Veuillez sélectionner un taux de taxe', confirmErrorCondition: 'Veuillez saisir un commerçant', confirmErrorConditionAndDefault: 'Saisissez un commerçant et appliquez au moins un paramètre par défaut', - turnOnTaxesFirstTitle: 'Activer d’abord les taxes', - turnOnTaxesFirstPrompt: 'Les règles de catégorie définissent un taux de taxe par défaut. Activez les taxes dans les paramètres de votre espace de travail pour les utiliser.', - categoryRulesApplyGoingForwardTitle: 'Les règles de catégorie s’appliquent à partir de maintenant', - categoryRulesApplyGoingForwardPrompt: - 'Un taux de taxe par défaut s’applique aux nouvelles dépenses de cette catégorie. Les dépenses déjà existantes ne seront pas modifiées.', - confirmErrorCategoryTaxMoveIsWorkspaceDefault: - 'Le taux de taxe sélectionné est désormais le taux par défaut de votre espace de travail, cette règle n’est donc plus valide. Choisissez un autre taux de taxe.', - addTaxRateFirstTitle: 'Ajoutez d’abord un taux de taxe', - addTaxRateFirstPrompt: - 'Les règles de catégorie définissent un taux de taxe par défaut. Ajoutez un taux de taxe autre que le taux par défaut de votre espace de travail pour les utiliser.', createRuleFromExpenseAction: 'Créer une règle', createRuleFromExpensePrompt: 'pour appliquer vos modifications à toutes les dépenses qui correspondent à vos critères.', }, @@ -8926,14 +8915,6 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e cta: 'Ajouter une règle IA', }, categoriesDisabledEmptyState: {title: 'Les catégories ne sont pas activées', subtitle: 'Activez les catégories pour mieux contrôler vos dépenses.'}, - expenseDefaultType: { - title: 'Appliquer les valeurs de dépense par défaut', - subtitle: 'Sur quoi cette règle doit-elle faire correspondre ?', - merchant: 'Commerçant', - merchantDescription: 'Mettre à jour les champs des dépenses d’un commerçant spécifique', - category: 'Catégorie', - categoryDescription: 'Définir un taux de taxe par défaut pour une catégorie spécifique', - }, taxesDisabledEmptyState: {title: 'Les taxes ne sont pas activées', subtitle: 'Activez les taxes pour documenter et récupérer les taxes admissibles.', cta: 'Activer les taxes'}, }, planTypePage: { diff --git a/src/languages/it.ts b/src/languages/it.ts index 94f257d7eccb..3bc26238c03d 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -8456,18 +8456,8 @@ Richiedi dettagli sulle spese come ricevute e descrizioni, imposta limiti e valo vendorUnavailable: 'Fornitore non disponibile', supplierUnavailable: 'Fornitore non disponibile', confirmErrorCategory: 'Seleziona una categoria', - confirmErrorCategoryTax: "Seleziona un'aliquota fiscale", confirmErrorCondition: 'Inserisci un esercente', confirmErrorConditionAndDefault: 'Inserisci un esercente e applica almeno un valore predefinito', - turnOnTaxesFirstTitle: 'Attiva prima le tasse', - turnOnTaxesFirstPrompt: 'Le regole di categoria impostano un’aliquota fiscale predefinita. Attiva le imposte nelle impostazioni dello spazio di lavoro per usarle.', - categoryRulesApplyGoingForwardTitle: 'Le regole di categoria si applicano da ora in poi', - categoryRulesApplyGoingForwardPrompt: 'Un’aliquota fiscale predefinita viene applicata alle nuove spese in questa categoria. Le spese già esistenti non verranno modificate.', - confirmErrorCategoryTaxMoveIsWorkspaceDefault: - 'L’aliquota fiscale selezionata è ora quella predefinita del tuo workspace, quindi questa regola non è più valida. Scegli un’altra aliquota fiscale.', - addTaxRateFirstTitle: "Aggiungi prima un'aliquota fiscale", - addTaxRateFirstPrompt: - 'Le regole di categoria impostano un’aliquota fiscale predefinita. Aggiungi un’aliquota diversa da quella predefinita dello spazio di lavoro per poterle usare.', createRuleFromExpenseAction: 'Crea una regola', createRuleFromExpensePrompt: 'per applicare le modifiche a tutte le spese che corrispondono ai tuoi criteri.', }, @@ -8850,14 +8840,6 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, }, agentRulesEmptyState: {title: 'Nessuna regola agente aggiunta', subtitle: 'Crea una regola per automatizzare le policy del tuo workspace.', cta: 'Aggiungi regola IA'}, categoriesDisabledEmptyState: {title: 'Le categorie non sono abilitate', subtitle: 'Attiva le categorie per avere un maggiore controllo sulle tue spese.'}, - expenseDefaultType: { - title: 'Applica impostazioni predefinite spesa', - subtitle: 'Su cosa dovrebbe corrispondere questa regola?', - merchant: 'Commerciante', - merchantDescription: 'Aggiorna i campi delle spese di un determinato esercente', - category: 'Categoria', - categoryDescription: "Imposta un'aliquota fiscale predefinita per una categoria specifica", - }, taxesDisabledEmptyState: {title: 'Le imposte non sono abilitate', subtitle: 'Abilita le imposte per documentare e recuperare quelle ammissibili.', cta: 'Abilita imposte'}, }, planTypePage: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 120f3b0615bf..e35c86db085d 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -8349,16 +8349,8 @@ ${reportName}`, vendorUnavailable: 'ベンダーを利用できません', supplierUnavailable: 'サプライヤーを利用できません', confirmErrorCategory: 'カテゴリを選択してください', - confirmErrorCategoryTax: '税率を選択してください', confirmErrorCondition: '店舗名を入力してください', confirmErrorConditionAndDefault: '取引先を入力し、少なくとも 1 つのデフォルトを適用してください', - turnOnTaxesFirstTitle: '先に税金を有効にしてください', - turnOnTaxesFirstPrompt: 'カテゴリルールでは、デフォルトの税率を設定できます。利用するには、ワークスペース設定で税金を有効にしてください。', - categoryRulesApplyGoingForwardTitle: 'カテゴリルールは今後に適用されます', - categoryRulesApplyGoingForwardPrompt: 'このカテゴリーの新しい経費には、デフォルトの税率が適用されます。既存の経費は変更されません。', - confirmErrorCategoryTaxMoveIsWorkspaceDefault: '選択した税率がワークスペースのデフォルトになっているため、このルールは無効になりました。別の税率を選択してください。', - addTaxRateFirstTitle: '先に税率を追加してください', - addTaxRateFirstPrompt: 'カテゴリルールではデフォルトの税率が設定されます。ワークスペースのデフォルト以外の税率を追加してから、カテゴリルールを使用してください。', createRuleFromExpenseAction: 'ルールを作成', createRuleFromExpensePrompt: '条件に一致するすべての経費に変更を適用します。', }, @@ -8739,14 +8731,6 @@ ${reportName}`, }, agentRulesEmptyState: {title: 'エージェントルールが追加されていません', subtitle: 'ワークスペースのポリシーを自動化するルールを作成します。', cta: 'AIルールを追加'}, categoriesDisabledEmptyState: {title: 'カテゴリが有効になっていません', subtitle: 'カテゴリを有効にして、支出をより細かく管理しましょう。'}, - expenseDefaultType: { - title: '経費のデフォルトを適用', - subtitle: 'このルールは何に一致させますか?', - merchant: '加盟店', - merchantDescription: '特定の加盟店の経費フィールドを更新', - category: 'カテゴリ', - categoryDescription: '特定のカテゴリにデフォルトの税率を設定する', - }, taxesDisabledEmptyState: {title: '税金は有効になっていません', subtitle: '税金を有効にして、対象となる税金を記録し還付請求できるようにします。', cta: '税金を有効にする'}, }, planTypePage: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index c573af893864..078539a87920 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -8432,17 +8432,8 @@ Vereis onkostendetails zoals bonnen en beschrijvingen, stel limieten en standaar vendorUnavailable: 'Leverancier niet beschikbaar', supplierUnavailable: 'Leverancier niet beschikbaar', confirmErrorCategory: 'Selecteer een categorie', - confirmErrorCategoryTax: 'Selecteer een belastingtarief', confirmErrorCondition: 'Voer een leverancier in', confirmErrorConditionAndDefault: 'Voer een leverancier in en stel minstens één standaard in', - turnOnTaxesFirstTitle: 'Zet eerst belastingen aan', - turnOnTaxesFirstPrompt: 'Categorischregels stellen een standaardbelastingtarief in. Schakel belastingen in bij de instellingen van je workspace om ze te gebruiken.', - categoryRulesApplyGoingForwardTitle: 'Categoriegregels gelden vanaf nu', - categoryRulesApplyGoingForwardPrompt: 'Een standaardbelastingtarief is van toepassing op nieuwe uitgaven in deze categorie. Bestaande uitgaven veranderen niet.', - confirmErrorCategoryTaxMoveIsWorkspaceDefault: - 'Het geselecteerde belastingtarief is nu de standaard voor je werkruimte, waardoor deze regel niet meer geldig is. Kies een ander belastingtarief.', - addTaxRateFirstTitle: 'Voeg eerst een belastingtarief toe', - addTaxRateFirstPrompt: 'Categorielimieten stellen een standaardbelastingtarief in. Voeg een ander belastingtarief toe dan de standaard van je werkruimte om ze te gebruiken.', createRuleFromExpenseAction: 'Maak een regel', createRuleFromExpensePrompt: 'om je wijzigingen toe te passen op alle onkosten die aan je criteria voldoen.', }, @@ -8824,14 +8815,6 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, }, agentRulesEmptyState: {title: 'Geen agentregels toegevoegd', subtitle: 'Maak een regel om je werkruimtebeleid te automatiseren.', cta: 'AI-regel toevoegen'}, categoriesDisabledEmptyState: {title: 'Categorieën zijn niet ingeschakeld', subtitle: 'Schakel categorieën in om meer controle te krijgen over je uitgaven.'}, - expenseDefaultType: { - title: 'Standaardwaarden voor uitgaven toepassen', - subtitle: 'Waar moet deze regel op overeenkomen?', - merchant: 'Winkelier', - merchantDescription: 'Veldwaarden bij uitgaven van een specifieke verkoper bijwerken', - category: 'Categorie', - categoryDescription: 'Stel een standaardbelastingtarief in voor een specifieke categorie', - }, taxesDisabledEmptyState: { title: 'Belastingen zijn niet ingeschakeld', subtitle: 'Activeer belastingen om in aanmerking komende belastingen vast te leggen en terug te vorderen.', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 0cfd73240151..7bec14cda9a3 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -8456,17 +8456,8 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i vendorUnavailable: 'Dostawca niedostępny', supplierUnavailable: 'Dostawca niedostępny', confirmErrorCategory: 'Wybierz kategorię', - confirmErrorCategoryTax: 'Wybierz stawkę podatku', confirmErrorCondition: 'Wpisz sprzedawcę', confirmErrorConditionAndDefault: 'Wpisz sprzedawcę i zastosuj co najmniej jedną domyślną opcję', - turnOnTaxesFirstTitle: 'Najpierw włącz podatki', - turnOnTaxesFirstPrompt: 'Reguły kategorii ustawiają domyślną stawkę podatku. Włącz podatki w ustawieniach swojego workspace, aby z nich korzystać.', - categoryRulesApplyGoingForwardTitle: 'Reguły kategorii będą stosowane od teraz', - categoryRulesApplyGoingForwardPrompt: 'Domyślna stawka podatku będzie stosowana do nowych wydatków w tej kategorii. Istniejące wydatki nie zostaną zmienione.', - confirmErrorCategoryTaxMoveIsWorkspaceDefault: - 'Wybrana stawka podatku jest teraz domyślną stawką w twoim zespole, więc ta reguła nie jest już prawidłowa. Wybierz inną stawkę podatku.', - addTaxRateFirstTitle: 'Najpierw dodaj stawkę podatku', - addTaxRateFirstPrompt: 'Reguły kategorii ustawiają domyślną stawkę podatku. Dodaj stawkę podatku inną niż domyślna w twoim obszarze roboczym, żeby z nich korzystać.', createRuleFromExpenseAction: 'Utwórz regułę', createRuleFromExpensePrompt: 'aby zastosować zmiany do wszystkich wydatków spełniających twoje kryteria.', }, @@ -8849,14 +8840,6 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, }, agentRulesEmptyState: {title: 'Nie dodano reguł agenta', subtitle: 'Utwórz regułę, żeby zautomatyzować zasady swojego workspace’u.', cta: 'Dodaj regułę AI'}, categoriesDisabledEmptyState: {title: 'Kategorie nie są włączone', subtitle: 'Włącz kategorie, żeby mieć większą kontrolę nad wydatkami.'}, - expenseDefaultType: { - title: 'Zastosuj domyślne ustawienia wydatków', - subtitle: 'Do czego ta reguła powinna pasować?', - merchant: 'Sprzedawca', - merchantDescription: 'Zaktualizuj pola w wydatkach od konkretnego sprzedawcy', - category: 'Kategoria', - categoryDescription: 'Ustaw domyślną stawkę podatku dla konkretnej kategorii', - }, taxesDisabledEmptyState: {title: 'Podatki nie są włączone', subtitle: 'Włącz podatki, aby udokumentować i odzyskać kwalifikujące się podatki.', cta: 'Włącz podatki'}, }, planTypePage: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index c9b41b63321e..944dfb87bf72 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -8438,17 +8438,8 @@ Exija dados de despesas como recibos e descrições, defina limites e padrões e vendorUnavailable: 'Fornecedor indisponível', supplierUnavailable: 'Fornecedor indisponível', confirmErrorCategory: 'Selecione uma categoria', - confirmErrorCategoryTax: 'Selecione uma alíquota de imposto', confirmErrorCondition: 'Insira um estabelecimento', confirmErrorConditionAndDefault: 'Insira um comerciante e aplique pelo menos um padrão', - turnOnTaxesFirstTitle: 'Ative os impostos primeiro', - turnOnTaxesFirstPrompt: 'As regras de categoria definem uma alíquota de imposto padrão. Ative os impostos nas configurações do seu workspace para usá-los.', - categoryRulesApplyGoingForwardTitle: 'As regras de categoria se aplicam daqui em diante', - categoryRulesApplyGoingForwardPrompt: 'Uma taxa de imposto padrão se aplica às novas despesas desta categoria. As despesas que já existem não serão alteradas.', - confirmErrorCategoryTaxMoveIsWorkspaceDefault: - 'A alíquota de imposto selecionada agora é o padrão do seu workspace, então esta regra não é mais válida. Escolha uma alíquota de imposto diferente.', - addTaxRateFirstTitle: 'Adicione uma alíquota de imposto primeiro', - addTaxRateFirstPrompt: 'As regras de categoria definem uma taxa de imposto padrão. Adicione uma taxa diferente da padrão do seu espaço de trabalho para usá-las.', createRuleFromExpenseAction: 'Criar regra', createRuleFromExpensePrompt: 'para aplicar suas alterações a todas as despesas que correspondem aos seus critérios.', }, @@ -8831,14 +8822,6 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, }, agentRulesEmptyState: {title: 'Nenhuma regra de agente adicionada', subtitle: 'Crie uma regra para automatizar as políticas do seu workspace.', cta: 'Adicionar regra de IA'}, categoriesDisabledEmptyState: {title: 'Categorias não estão ativadas', subtitle: 'Ative categorias para ter mais controle sobre seus gastos.'}, - expenseDefaultType: { - title: 'Aplicar padrões de despesa', - subtitle: 'Com o que esta regra deve corresponder?', - merchant: 'Comerciante', - merchantDescription: 'Atualizar campos em despesas de um comerciante específico', - category: 'Categoria', - categoryDescription: 'Definir uma taxa de imposto padrão para uma categoria específica', - }, taxesDisabledEmptyState: {title: 'Impostos não estão ativados', subtitle: 'Ative impostos para documentar e recuperar impostos elegíveis.', cta: 'Ativar impostos'}, }, planTypePage: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index fa8a3362b9ed..57753dc707f3 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -8144,16 +8144,8 @@ ${reportName}`, vendorUnavailable: '供应商不可用', supplierUnavailable: '供应商不可用', confirmErrorCategory: '请选择一个类别', - confirmErrorCategoryTax: '请选择税率', confirmErrorCondition: '请输入商家', confirmErrorConditionAndDefault: '输入商家,并至少应用一个默认设置', - turnOnTaxesFirstTitle: '请先启用税费', - turnOnTaxesFirstPrompt: '类别规则会设置默认税率。请在工作区设置中启用税费以使用此功能。', - categoryRulesApplyGoingForwardTitle: '类别规则将从现在起生效', - categoryRulesApplyGoingForwardPrompt: '此类别中的新报销将应用默认税率,已存在的报销不会改变。', - confirmErrorCategoryTaxMoveIsWorkspaceDefault: '所选税率现在已成为您的工作区默认税率,因此此规则不再有效。请选择其他税率。', - addTaxRateFirstTitle: '请先添加税率', - addTaxRateFirstPrompt: '类别规则会设置一个默认税率。请添加一个不同于工作区默认值的税率以使用类别规则。', createRuleFromExpenseAction: '创建规则', createRuleFromExpensePrompt: '将您的更改应用于所有符合条件的报销。', }, @@ -8513,14 +8505,6 @@ ${reportName}`, }, agentRulesEmptyState: {title: '未添加代理规则', subtitle: '创建规则以自动化您的工作区策略。', cta: '添加 AI 规则'}, categoriesDisabledEmptyState: {title: '类别未启用', subtitle: '启用类别以更好地控制您的支出。'}, - expenseDefaultType: { - title: '应用报销默认值', - subtitle: '此规则应匹配什么内容?', - merchant: '商户', - merchantDescription: '更新来自特定商家的报销项目字段', - category: '类别', - categoryDescription: '为特定类别设置默认税率', - }, taxesDisabledEmptyState: {title: '税费未启用', subtitle: '启用税费以记录并追回符合条件的税款。', cta: '启用税费'}, }, planTypePage: { From 5289f2f460c48feee9cde4322ca716e5c7e90540 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 16 Oct 2026 03:09:50 +0530 Subject: [PATCH 10/24] Remove dead exports left by the category tax default deletion Signed-off-by: krishna2323 --- src/languages/de.ts | 19 +++- src/languages/el.ts | 21 ++++- src/languages/es.ts | 20 ++++- src/languages/fr.ts | 20 ++++- src/languages/it.ts | 19 +++- src/languages/ja.ts | 17 +++- src/languages/nl.ts | 18 +++- src/languages/pl.ts | 18 +++- src/languages/pt-BR.ts | 18 +++- src/languages/zh-hans.ts | 17 +++- src/libs/CategoryTaxRulesUtils.ts | 135 ++-------------------------- src/libs/CategoryUtils.ts | 23 +---- src/libs/ExpenseDefaultRuleUtils.ts | 2 +- 13 files changed, 185 insertions(+), 162 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 1852cd24d020..e42bd2acfd59 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -8505,8 +8505,18 @@ Fordern Sie Spesendetails wie Belege und Beschreibungen an, legen Sie Limits und vendorUnavailable: 'Anbieter nicht verfügbar', supplierUnavailable: 'Lieferant nicht verfügbar', confirmErrorCategory: 'Bitte wählen Sie eine Kategorie aus', + confirmErrorCategoryTax: 'Bitte wählen Sie einen Steuersatz aus', confirmErrorCondition: 'Bitte geben Sie einen Händler ein', confirmErrorConditionAndDefault: 'Geben Sie einen Händler ein und wenden Sie mindestens eine Standardeinstellung an', + turnOnTaxesFirstTitle: 'Schalten Sie zuerst Steuern ein', + turnOnTaxesFirstPrompt: 'Kategorienregeln legen einen Standardsteuersatz fest. Aktivieren Sie Steuern in Ihren Workspace-Einstellungen, um sie zu verwenden.', + categoryRulesApplyGoingForwardTitle: 'Kategorienregeln gelten ab jetzt', + categoryRulesApplyGoingForwardPrompt: 'Ein Standardsatz für Steuern gilt für neue Ausgaben in dieser Kategorie. Bereits vorhandene Ausgaben werden nicht geändert.', + confirmErrorCategoryTaxMoveIsWorkspaceDefault: + 'Der ausgewählte Steuersatz ist jetzt die Voreinstellung für Ihren Workspace, daher ist diese Regel nicht mehr gültig. Wählen Sie einen anderen Steuersatz.', + addTaxRateFirstTitle: 'Fügen Sie zuerst einen Steuersatz hinzu', + addTaxRateFirstPrompt: + 'Kategorienregeln legen einen standardmäßigen Steuersatz fest. Fügen Sie einen anderen Steuersatz als den Standard Ihres Arbeitsbereichs hinzu, um sie zu verwenden.', createRuleFromExpenseAction: 'Regel erstellen', createRuleFromExpensePrompt: 'um Ihre Änderungen auf alle Ausgaben anzuwenden, die Ihren Kriterien entsprechen.', }, @@ -8821,7 +8831,6 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc rename: 'Umbenennen', update: 'Aktualisieren', merchantIs: (merchant: string) => `Händler ist „${merchant}“`, - tagIs: (tag: string) => `Tag ist „${tag}“`, merchantTypeIs: (merchantType: string) => `Händlertyp ist „${merchantType}"`, categoryIs: (category: string) => `Kategorie ist „${category}“`, }, @@ -8893,6 +8902,14 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc cta: 'KI-Regel hinzufügen', }, categoriesDisabledEmptyState: {title: 'Kategorien sind nicht aktiviert', subtitle: 'Aktivieren Sie Kategorien, um Ihre Ausgaben besser zu kontrollieren.'}, + expenseDefaultType: { + title: 'Standardspesen übernehmen', + subtitle: 'Worauf soll diese Regel angewendet werden?', + merchant: 'Händler', + merchantDescription: 'Felder bei Ausgaben eines bestimmten Händlers aktualisieren', + category: 'Kategorie', + categoryDescription: 'Legen Sie einen Standardsteuersatz für eine bestimmte Kategorie fest', + }, taxesDisabledEmptyState: { title: 'Steuern sind nicht aktiviert', subtitle: 'Aktivieren Sie Steuern, um anfallende Steuern zu dokumentieren und zurückzufordern.', diff --git a/src/languages/el.ts b/src/languages/el.ts index 42c96180a74c..62b80cdfdaff 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -8876,8 +8876,20 @@ ${reportName}`, vendorUnavailable: 'Ο προμηθευτής δεν είναι διαθέσιμος', supplierUnavailable: 'Ο προμηθευτής δεν είναι διαθέσιμος', confirmErrorCategory: 'Παρακαλώ επιλέξτε κατηγορία', + confirmErrorCategoryTax: 'Επιλέξτε έναν φορολογικό συντελεστή', confirmErrorCondition: 'Παρακαλούμε εισαγάγετε έναν έμπορο', confirmErrorConditionAndDefault: 'Προσθέστε έναν έμπορο και ορίστε τουλάχιστον μία προεπιλογή', + turnOnTaxesFirstTitle: 'Ενεργοποιήστε πρώτα τους φόρους', + turnOnTaxesFirstPrompt: + 'Οι κανόνες κατηγορίας ορίζουν έναν προεπιλεγμένο φορολογικό συντελεστή. Ενεργοποιήστε τους φόρους στις ρυθμίσεις του χώρου εργασίας σας για να τους χρησιμοποιήσετε.', + categoryRulesApplyGoingForwardTitle: 'Οι κανόνες κατηγορίας ισχύουν από εδώ και στο εξής', + categoryRulesApplyGoingForwardPrompt: + 'Ένας προεπιλεγμένος φορολογικός συντελεστής εφαρμόζεται σε νέες δαπάνες σε αυτή την κατηγορία. Οι δαπάνες που υπάρχουν ήδη δεν θα αλλάξουν.', + confirmErrorCategoryTaxMoveIsWorkspaceDefault: + 'Ο επιλεγμένος φορολογικός συντελεστής είναι πλέον η προεπιλογή για τον χώρο εργασίας σας, επομένως αυτός ο κανόνας δεν είναι πλέον έγκυρος. Επιλέξτε έναν διαφορετικό φορολογικό συντελεστή.', + addTaxRateFirstTitle: 'Προσθέστε πρώτα έναν φορολογικό συντελεστή', + addTaxRateFirstPrompt: + 'Οι κανόνες κατηγορίας ορίζουν έναν προεπιλεγμένο φορολογικό συντελεστή. Προσθέστε έναν φορολογικό συντελεστή διαφορετικό από την προεπιλογή του χώρου εργασίας σας για να τους χρησιμοποιήσετε.', createRuleFromExpenseAction: 'Δημιουργία κανόνα', createRuleFromExpensePrompt: 'για να εφαρμόσετε τις αλλαγές σας σε όλες τις δαπάνες που ταιριάζουν με τα κριτήριά σας.', }, @@ -8911,7 +8923,6 @@ ${reportName}`, rename: 'Μετονομασία', update: 'Ενημέρωση', merchantIs: (merchant: string) => `Ο έμπορος είναι «${merchant}»`, - tagIs: (tag: string) => `Η ετικέτα είναι «${tag}»`, merchantTypeIs: (merchantType: string) => `Ο τύπος εμπόρου είναι «${merchantType}»`, categoryIs: (category: string) => `Η κατηγορία είναι «${category}»`, }, @@ -9209,6 +9220,14 @@ ${reportName}`, subtitle: 'Δημιουργήστε έναν κανόνα για την αυτοματοποίηση των πολιτικών του χώρου εργασίας σας.', cta: 'Προσθήκη κανόνα AI', }, + expenseDefaultType: { + title: 'Εφαρμογή προεπιλογών εξόδων', + subtitle: 'Σε τι πρέπει να αντιστοιχεί αυτός ο κανόνας;', + merchant: 'Έμπορος', + merchantDescription: 'Ενημέρωση πεδίων σε δαπάνες από συγκεκριμένο έμπορο', + category: 'Κατηγορία', + categoryDescription: 'Ορίστε προεπιλεγμένο φορολογικό συντελεστή για μια συγκεκριμένη κατηγορία', + }, taxesDisabledEmptyState: { title: 'Οι φόροι δεν είναι ενεργοποιημένοι', subtitle: 'Ενεργοποιήστε τους φόρους για να τεκμηριώσετε και να διεκδικήσετε τους επιλέξιμους φόρους.', diff --git a/src/languages/es.ts b/src/languages/es.ts index 47e26e60698c..0bed217f7d97 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -8536,8 +8536,19 @@ ${reportName}`, ifAnyExpenseMatches: 'Si algún gasto coincide con:', thenApplyFollowingDefaults: 'Luego aplica las siguientes opciones predeterminadas:', confirmErrorCategory: 'Selecciona una categoría', + confirmErrorCategoryTax: 'Selecciona una tasa de impuesto', confirmErrorCondition: 'Por favor, introduce un comercio', confirmErrorConditionAndDefault: 'Introduce un comercio y aplica al menos un valor predeterminado', + turnOnTaxesFirstTitle: 'Activa primero los impuestos', + turnOnTaxesFirstPrompt: + 'Las reglas de categoría establecen una tasa de impuesto predeterminada. Activa los impuestos en la configuración de tu espacio de trabajo para usarlos.', + categoryRulesApplyGoingForwardTitle: 'Las reglas de categoría se aplican de ahora en adelante', + categoryRulesApplyGoingForwardPrompt: 'Se aplica una tasa de impuesto predeterminada a los nuevos gastos de esta categoría. Los gastos que ya existen no cambiarán.', + confirmErrorCategoryTaxMoveIsWorkspaceDefault: + 'El tipo impositivo seleccionado es ahora el predeterminado de tu espacio de trabajo, por lo que esta regla ya no es válida. Elige un tipo impositivo diferente.', + addTaxRateFirstTitle: 'Añade primero una tasa de impuesto', + addTaxRateFirstPrompt: + 'Las reglas de categoría establecen una tasa de impuestos predeterminada. Añade una tasa de impuestos distinta de la predeterminada de tu espacio de trabajo para poder usarlas.', createRuleFromExpenseAction: 'Crear una norma', createRuleFromExpensePrompt: 'para aplicar tus cambios a todos los gastos que coincidan con tus criterios.', }, @@ -8825,7 +8836,6 @@ ${reportName}`, rename: 'Cambiar nombre', update: 'Actualizar', merchantIs: (merchant: string) => `El comercio es «${merchant}»`, - tagIs: (tag: string) => `La etiqueta es «${tag}»`, merchantTypeIs: (merchantType: string) => `El tipo de comercio es "${merchantType}"`, categoryIs: (category: string) => `La categoría es «${category}»`, }, @@ -8897,6 +8907,14 @@ ${reportName}`, cta: 'Añadir regla de IA', }, categoriesDisabledEmptyState: {title: 'Las categorías no están habilitadas', subtitle: 'Habilita las categorías para tener más control sobre tus gastos.'}, + expenseDefaultType: { + title: 'Aplicar valores predeterminados de gastos', + subtitle: '¿Con qué debería coincidir esta regla?', + merchant: 'Comercio', + merchantDescription: 'Actualizar campos en gastos de un comerciante específico', + category: 'Categoría', + categoryDescription: 'Establece una tasa de impuesto predeterminada para una categoría específica', + }, taxesDisabledEmptyState: { title: 'Los impuestos no están habilitados', subtitle: 'Habilita los impuestos para documentar y recuperar los impuestos que sean deducibles.', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 7fbc3190bfdc..a8cdf815c64d 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -8527,8 +8527,19 @@ Rendez obligatoires des informations de dépense comme les reçus et les descrip vendorUnavailable: 'Fournisseur indisponible', supplierUnavailable: 'Fournisseur indisponible', confirmErrorCategory: 'Veuillez sélectionner une catégorie', + confirmErrorCategoryTax: 'Veuillez sélectionner un taux de taxe', confirmErrorCondition: 'Veuillez saisir un commerçant', confirmErrorConditionAndDefault: 'Saisissez un commerçant et appliquez au moins un paramètre par défaut', + turnOnTaxesFirstTitle: 'Activer d’abord les taxes', + turnOnTaxesFirstPrompt: 'Les règles de catégorie définissent un taux de taxe par défaut. Activez les taxes dans les paramètres de votre espace de travail pour les utiliser.', + categoryRulesApplyGoingForwardTitle: 'Les règles de catégorie s’appliquent à partir de maintenant', + categoryRulesApplyGoingForwardPrompt: + 'Un taux de taxe par défaut s’applique aux nouvelles dépenses de cette catégorie. Les dépenses déjà existantes ne seront pas modifiées.', + confirmErrorCategoryTaxMoveIsWorkspaceDefault: + 'Le taux de taxe sélectionné est désormais le taux par défaut de votre espace de travail, cette règle n’est donc plus valide. Choisissez un autre taux de taxe.', + addTaxRateFirstTitle: 'Ajoutez d’abord un taux de taxe', + addTaxRateFirstPrompt: + 'Les règles de catégorie définissent un taux de taxe par défaut. Ajoutez un taux de taxe autre que le taux par défaut de votre espace de travail pour les utiliser.', createRuleFromExpenseAction: 'Créer une règle', createRuleFromExpensePrompt: 'pour appliquer vos modifications à toutes les dépenses qui correspondent à vos critères.', }, @@ -8843,7 +8854,6 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e rename: 'Renommer', update: 'Mettre à jour', merchantIs: (merchant: string) => `Le commerçant est « ${merchant} »`, - tagIs: (tag: string) => `Le tag est « ${tag} »`, merchantTypeIs: (merchantType: string) => `Le type de commerçant est « ${merchantType} »`, categoryIs: (category: string) => `La catégorie est « ${category} »`, }, @@ -8915,6 +8925,14 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e cta: 'Ajouter une règle IA', }, categoriesDisabledEmptyState: {title: 'Les catégories ne sont pas activées', subtitle: 'Activez les catégories pour mieux contrôler vos dépenses.'}, + expenseDefaultType: { + title: 'Appliquer les valeurs de dépense par défaut', + subtitle: 'Sur quoi cette règle doit-elle faire correspondre ?', + merchant: 'Commerçant', + merchantDescription: 'Mettre à jour les champs des dépenses d’un commerçant spécifique', + category: 'Catégorie', + categoryDescription: 'Définir un taux de taxe par défaut pour une catégorie spécifique', + }, taxesDisabledEmptyState: {title: 'Les taxes ne sont pas activées', subtitle: 'Activez les taxes pour documenter et récupérer les taxes admissibles.', cta: 'Activer les taxes'}, }, planTypePage: { diff --git a/src/languages/it.ts b/src/languages/it.ts index 3bc26238c03d..b1a160bba0bd 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -8456,8 +8456,18 @@ Richiedi dettagli sulle spese come ricevute e descrizioni, imposta limiti e valo vendorUnavailable: 'Fornitore non disponibile', supplierUnavailable: 'Fornitore non disponibile', confirmErrorCategory: 'Seleziona una categoria', + confirmErrorCategoryTax: "Seleziona un'aliquota fiscale", confirmErrorCondition: 'Inserisci un esercente', confirmErrorConditionAndDefault: 'Inserisci un esercente e applica almeno un valore predefinito', + turnOnTaxesFirstTitle: 'Attiva prima le tasse', + turnOnTaxesFirstPrompt: 'Le regole di categoria impostano un’aliquota fiscale predefinita. Attiva le imposte nelle impostazioni dello spazio di lavoro per usarle.', + categoryRulesApplyGoingForwardTitle: 'Le regole di categoria si applicano da ora in poi', + categoryRulesApplyGoingForwardPrompt: 'Un’aliquota fiscale predefinita viene applicata alle nuove spese in questa categoria. Le spese già esistenti non verranno modificate.', + confirmErrorCategoryTaxMoveIsWorkspaceDefault: + 'L’aliquota fiscale selezionata è ora quella predefinita del tuo workspace, quindi questa regola non è più valida. Scegli un’altra aliquota fiscale.', + addTaxRateFirstTitle: "Aggiungi prima un'aliquota fiscale", + addTaxRateFirstPrompt: + 'Le regole di categoria impostano un’aliquota fiscale predefinita. Aggiungi un’aliquota diversa da quella predefinita dello spazio di lavoro per poterle usare.', createRuleFromExpenseAction: 'Crea una regola', createRuleFromExpensePrompt: 'per applicare le modifiche a tutte le spese che corrispondono ai tuoi criteri.', }, @@ -8772,7 +8782,6 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, rename: 'Rinomina', update: 'Aggiorna', merchantIs: (merchant: string) => `L'esercente è "${merchant}"`, - tagIs: (tag: string) => `L'etichetta è "${tag}"`, merchantTypeIs: (merchantType: string) => `Il tipo di esercente è "${merchantType}"`, categoryIs: (category: string) => `La categoria è "${category}"`, }, @@ -8840,6 +8849,14 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, }, agentRulesEmptyState: {title: 'Nessuna regola agente aggiunta', subtitle: 'Crea una regola per automatizzare le policy del tuo workspace.', cta: 'Aggiungi regola IA'}, categoriesDisabledEmptyState: {title: 'Le categorie non sono abilitate', subtitle: 'Attiva le categorie per avere un maggiore controllo sulle tue spese.'}, + expenseDefaultType: { + title: 'Applica impostazioni predefinite spesa', + subtitle: 'Su cosa dovrebbe corrispondere questa regola?', + merchant: 'Commerciante', + merchantDescription: 'Aggiorna i campi delle spese di un determinato esercente', + category: 'Categoria', + categoryDescription: "Imposta un'aliquota fiscale predefinita per una categoria specifica", + }, taxesDisabledEmptyState: {title: 'Le imposte non sono abilitate', subtitle: 'Abilita le imposte per documentare e recuperare quelle ammissibili.', cta: 'Abilita imposte'}, }, planTypePage: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index e35c86db085d..de2eed8382e2 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -8349,8 +8349,16 @@ ${reportName}`, vendorUnavailable: 'ベンダーを利用できません', supplierUnavailable: 'サプライヤーを利用できません', confirmErrorCategory: 'カテゴリを選択してください', + confirmErrorCategoryTax: '税率を選択してください', confirmErrorCondition: '店舗名を入力してください', confirmErrorConditionAndDefault: '取引先を入力し、少なくとも 1 つのデフォルトを適用してください', + turnOnTaxesFirstTitle: '先に税金を有効にしてください', + turnOnTaxesFirstPrompt: 'カテゴリルールでは、デフォルトの税率を設定できます。利用するには、ワークスペース設定で税金を有効にしてください。', + categoryRulesApplyGoingForwardTitle: 'カテゴリルールは今後に適用されます', + categoryRulesApplyGoingForwardPrompt: 'このカテゴリーの新しい経費には、デフォルトの税率が適用されます。既存の経費は変更されません。', + confirmErrorCategoryTaxMoveIsWorkspaceDefault: '選択した税率がワークスペースのデフォルトになっているため、このルールは無効になりました。別の税率を選択してください。', + addTaxRateFirstTitle: '先に税率を追加してください', + addTaxRateFirstPrompt: 'カテゴリルールではデフォルトの税率が設定されます。ワークスペースのデフォルト以外の税率を追加してから、カテゴリルールを使用してください。', createRuleFromExpenseAction: 'ルールを作成', createRuleFromExpensePrompt: '条件に一致するすべての経費に変更を適用します。', }, @@ -8663,7 +8671,6 @@ ${reportName}`, rename: '名前を変更', update: '更新', merchantIs: (merchant: string) => `加盟店名は「${merchant}」です`, - tagIs: (tag: string) => `タグは「${tag}」です`, merchantTypeIs: (merchantType: string) => `加盟店タイプ: 「${merchantType}」`, categoryIs: (category: string) => `カテゴリは「${category}」です`, }, @@ -8731,6 +8738,14 @@ ${reportName}`, }, agentRulesEmptyState: {title: 'エージェントルールが追加されていません', subtitle: 'ワークスペースのポリシーを自動化するルールを作成します。', cta: 'AIルールを追加'}, categoriesDisabledEmptyState: {title: 'カテゴリが有効になっていません', subtitle: 'カテゴリを有効にして、支出をより細かく管理しましょう。'}, + expenseDefaultType: { + title: '経費のデフォルトを適用', + subtitle: 'このルールは何に一致させますか?', + merchant: '加盟店', + merchantDescription: '特定の加盟店の経費フィールドを更新', + category: 'カテゴリ', + categoryDescription: '特定のカテゴリにデフォルトの税率を設定する', + }, taxesDisabledEmptyState: {title: '税金は有効になっていません', subtitle: '税金を有効にして、対象となる税金を記録し還付請求できるようにします。', cta: '税金を有効にする'}, }, planTypePage: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 078539a87920..8cfb07823eef 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -8432,8 +8432,17 @@ Vereis onkostendetails zoals bonnen en beschrijvingen, stel limieten en standaar vendorUnavailable: 'Leverancier niet beschikbaar', supplierUnavailable: 'Leverancier niet beschikbaar', confirmErrorCategory: 'Selecteer een categorie', + confirmErrorCategoryTax: 'Selecteer een belastingtarief', confirmErrorCondition: 'Voer een leverancier in', confirmErrorConditionAndDefault: 'Voer een leverancier in en stel minstens één standaard in', + turnOnTaxesFirstTitle: 'Zet eerst belastingen aan', + turnOnTaxesFirstPrompt: 'Categorischregels stellen een standaardbelastingtarief in. Schakel belastingen in bij de instellingen van je workspace om ze te gebruiken.', + categoryRulesApplyGoingForwardTitle: 'Categoriegregels gelden vanaf nu', + categoryRulesApplyGoingForwardPrompt: 'Een standaardbelastingtarief is van toepassing op nieuwe uitgaven in deze categorie. Bestaande uitgaven veranderen niet.', + confirmErrorCategoryTaxMoveIsWorkspaceDefault: + 'Het geselecteerde belastingtarief is nu de standaard voor je werkruimte, waardoor deze regel niet meer geldig is. Kies een ander belastingtarief.', + addTaxRateFirstTitle: 'Voeg eerst een belastingtarief toe', + addTaxRateFirstPrompt: 'Categorielimieten stellen een standaardbelastingtarief in. Voeg een ander belastingtarief toe dan de standaard van je werkruimte om ze te gebruiken.', createRuleFromExpenseAction: 'Maak een regel', createRuleFromExpensePrompt: 'om je wijzigingen toe te passen op alle onkosten die aan je criteria voldoen.', }, @@ -8747,7 +8756,6 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, rename: 'Naam wijzigen', update: 'Bijwerken', merchantIs: (merchant: string) => `Handelaar is "${merchant}"`, - tagIs: (tag: string) => `Label is "${tag}"`, merchantTypeIs: (merchantType: string) => `Type leverancier is "${merchantType}"`, categoryIs: (category: string) => `Categorie is "${category}"`, }, @@ -8815,6 +8823,14 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, }, agentRulesEmptyState: {title: 'Geen agentregels toegevoegd', subtitle: 'Maak een regel om je werkruimtebeleid te automatiseren.', cta: 'AI-regel toevoegen'}, categoriesDisabledEmptyState: {title: 'Categorieën zijn niet ingeschakeld', subtitle: 'Schakel categorieën in om meer controle te krijgen over je uitgaven.'}, + expenseDefaultType: { + title: 'Standaardwaarden voor uitgaven toepassen', + subtitle: 'Waar moet deze regel op overeenkomen?', + merchant: 'Winkelier', + merchantDescription: 'Veldwaarden bij uitgaven van een specifieke verkoper bijwerken', + category: 'Categorie', + categoryDescription: 'Stel een standaardbelastingtarief in voor een specifieke categorie', + }, taxesDisabledEmptyState: { title: 'Belastingen zijn niet ingeschakeld', subtitle: 'Activeer belastingen om in aanmerking komende belastingen vast te leggen en terug te vorderen.', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 7bec14cda9a3..7d5050940989 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -8456,8 +8456,17 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i vendorUnavailable: 'Dostawca niedostępny', supplierUnavailable: 'Dostawca niedostępny', confirmErrorCategory: 'Wybierz kategorię', + confirmErrorCategoryTax: 'Wybierz stawkę podatku', confirmErrorCondition: 'Wpisz sprzedawcę', confirmErrorConditionAndDefault: 'Wpisz sprzedawcę i zastosuj co najmniej jedną domyślną opcję', + turnOnTaxesFirstTitle: 'Najpierw włącz podatki', + turnOnTaxesFirstPrompt: 'Reguły kategorii ustawiają domyślną stawkę podatku. Włącz podatki w ustawieniach swojego workspace, aby z nich korzystać.', + categoryRulesApplyGoingForwardTitle: 'Reguły kategorii będą stosowane od teraz', + categoryRulesApplyGoingForwardPrompt: 'Domyślna stawka podatku będzie stosowana do nowych wydatków w tej kategorii. Istniejące wydatki nie zostaną zmienione.', + confirmErrorCategoryTaxMoveIsWorkspaceDefault: + 'Wybrana stawka podatku jest teraz domyślną stawką w twoim zespole, więc ta reguła nie jest już prawidłowa. Wybierz inną stawkę podatku.', + addTaxRateFirstTitle: 'Najpierw dodaj stawkę podatku', + addTaxRateFirstPrompt: 'Reguły kategorii ustawiają domyślną stawkę podatku. Dodaj stawkę podatku inną niż domyślna w twoim obszarze roboczym, żeby z nich korzystać.', createRuleFromExpenseAction: 'Utwórz regułę', createRuleFromExpensePrompt: 'aby zastosować zmiany do wszystkich wydatków spełniających twoje kryteria.', }, @@ -8772,7 +8781,6 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, rename: 'Zmień nazwę', update: 'Zaktualizuj', merchantIs: (merchant: string) => `Sprzedawca to „${merchant}”`, - tagIs: (tag: string) => `Tag to „${tag}”`, merchantTypeIs: (merchantType: string) => `Typ sprzedawcy to „${merchantType}"`, categoryIs: (category: string) => `Kategoria to „${category}”`, }, @@ -8840,6 +8848,14 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, }, agentRulesEmptyState: {title: 'Nie dodano reguł agenta', subtitle: 'Utwórz regułę, żeby zautomatyzować zasady swojego workspace’u.', cta: 'Dodaj regułę AI'}, categoriesDisabledEmptyState: {title: 'Kategorie nie są włączone', subtitle: 'Włącz kategorie, żeby mieć większą kontrolę nad wydatkami.'}, + expenseDefaultType: { + title: 'Zastosuj domyślne ustawienia wydatków', + subtitle: 'Do czego ta reguła powinna pasować?', + merchant: 'Sprzedawca', + merchantDescription: 'Zaktualizuj pola w wydatkach od konkretnego sprzedawcy', + category: 'Kategoria', + categoryDescription: 'Ustaw domyślną stawkę podatku dla konkretnej kategorii', + }, taxesDisabledEmptyState: {title: 'Podatki nie są włączone', subtitle: 'Włącz podatki, aby udokumentować i odzyskać kwalifikujące się podatki.', cta: 'Włącz podatki'}, }, planTypePage: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 944dfb87bf72..2f919085e388 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -8438,8 +8438,17 @@ Exija dados de despesas como recibos e descrições, defina limites e padrões e vendorUnavailable: 'Fornecedor indisponível', supplierUnavailable: 'Fornecedor indisponível', confirmErrorCategory: 'Selecione uma categoria', + confirmErrorCategoryTax: 'Selecione uma alíquota de imposto', confirmErrorCondition: 'Insira um estabelecimento', confirmErrorConditionAndDefault: 'Insira um comerciante e aplique pelo menos um padrão', + turnOnTaxesFirstTitle: 'Ative os impostos primeiro', + turnOnTaxesFirstPrompt: 'As regras de categoria definem uma alíquota de imposto padrão. Ative os impostos nas configurações do seu workspace para usá-los.', + categoryRulesApplyGoingForwardTitle: 'As regras de categoria se aplicam daqui em diante', + categoryRulesApplyGoingForwardPrompt: 'Uma taxa de imposto padrão se aplica às novas despesas desta categoria. As despesas que já existem não serão alteradas.', + confirmErrorCategoryTaxMoveIsWorkspaceDefault: + 'A alíquota de imposto selecionada agora é o padrão do seu workspace, então esta regra não é mais válida. Escolha uma alíquota de imposto diferente.', + addTaxRateFirstTitle: 'Adicione uma alíquota de imposto primeiro', + addTaxRateFirstPrompt: 'As regras de categoria definem uma taxa de imposto padrão. Adicione uma taxa diferente da padrão do seu espaço de trabalho para usá-las.', createRuleFromExpenseAction: 'Criar regra', createRuleFromExpensePrompt: 'para aplicar suas alterações a todas as despesas que correspondem aos seus critérios.', }, @@ -8754,7 +8763,6 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, rename: 'Renomear', update: 'Atualizar', merchantIs: (merchant: string) => `O comerciante é "${merchant}"`, - tagIs: (tag: string) => `A etiqueta é "${tag}"`, merchantTypeIs: (merchantType: string) => `O tipo de estabelecimento é "${merchantType}"`, categoryIs: (category: string) => `A categoria é "${category}"`, }, @@ -8822,6 +8830,14 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, }, agentRulesEmptyState: {title: 'Nenhuma regra de agente adicionada', subtitle: 'Crie uma regra para automatizar as políticas do seu workspace.', cta: 'Adicionar regra de IA'}, categoriesDisabledEmptyState: {title: 'Categorias não estão ativadas', subtitle: 'Ative categorias para ter mais controle sobre seus gastos.'}, + expenseDefaultType: { + title: 'Aplicar padrões de despesa', + subtitle: 'Com o que esta regra deve corresponder?', + merchant: 'Comerciante', + merchantDescription: 'Atualizar campos em despesas de um comerciante específico', + category: 'Categoria', + categoryDescription: 'Definir uma taxa de imposto padrão para uma categoria específica', + }, taxesDisabledEmptyState: {title: 'Impostos não estão ativados', subtitle: 'Ative impostos para documentar e recuperar impostos elegíveis.', cta: 'Ativar impostos'}, }, planTypePage: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 57753dc707f3..17343adca41e 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -8144,8 +8144,16 @@ ${reportName}`, vendorUnavailable: '供应商不可用', supplierUnavailable: '供应商不可用', confirmErrorCategory: '请选择一个类别', + confirmErrorCategoryTax: '请选择税率', confirmErrorCondition: '请输入商家', confirmErrorConditionAndDefault: '输入商家,并至少应用一个默认设置', + turnOnTaxesFirstTitle: '请先启用税费', + turnOnTaxesFirstPrompt: '类别规则会设置默认税率。请在工作区设置中启用税费以使用此功能。', + categoryRulesApplyGoingForwardTitle: '类别规则将从现在起生效', + categoryRulesApplyGoingForwardPrompt: '此类别中的新报销将应用默认税率,已存在的报销不会改变。', + confirmErrorCategoryTaxMoveIsWorkspaceDefault: '所选税率现在已成为您的工作区默认税率,因此此规则不再有效。请选择其他税率。', + addTaxRateFirstTitle: '请先添加税率', + addTaxRateFirstPrompt: '类别规则会设置一个默认税率。请添加一个不同于工作区默认值的税率以使用类别规则。', createRuleFromExpenseAction: '创建规则', createRuleFromExpensePrompt: '将您的更改应用于所有符合条件的报销。', }, @@ -8445,7 +8453,6 @@ ${reportName}`, rename: '重命名', update: '更新', merchantIs: (merchant: string) => `商户为“${merchant}”`, - tagIs: (tag: string) => `标签为“${tag}”`, merchantTypeIs: (merchantType: string) => `商户类型为"${merchantType}"`, categoryIs: (category: string) => `类别为“${category}”`, }, @@ -8505,6 +8512,14 @@ ${reportName}`, }, agentRulesEmptyState: {title: '未添加代理规则', subtitle: '创建规则以自动化您的工作区策略。', cta: '添加 AI 规则'}, categoriesDisabledEmptyState: {title: '类别未启用', subtitle: '启用类别以更好地控制您的支出。'}, + expenseDefaultType: { + title: '应用报销默认值', + subtitle: '此规则应匹配什么内容?', + merchant: '商户', + merchantDescription: '更新来自特定商家的报销项目字段', + category: '类别', + categoryDescription: '为特定类别设置默认税率', + }, taxesDisabledEmptyState: {title: '税费未启用', subtitle: '启用税费以记录并追回符合条件的税款。', cta: '启用税费'}, }, planTypePage: { diff --git a/src/libs/CategoryTaxRulesUtils.ts b/src/libs/CategoryTaxRulesUtils.ts index d20b15904ebb..2735bd4593a8 100644 --- a/src/libs/CategoryTaxRulesUtils.ts +++ b/src/libs/CategoryTaxRulesUtils.ts @@ -1,20 +1,10 @@ /** - * Helpers for reading, shaping and identifying the category tax default rules stored in `policy.rules.expenseRules`. + * Tax helpers shared by the rules pages: which rates a rule may apply, how a rate reads, and when a rule row borrows + * the pending delete of something it depends on. */ -import type {LocaleContextProps} from '@components/LocaleContextProvider'; -import type {ExpenseDefaultTableItem} from '@components/Tables/WorkspaceExpenseDefaultsTable'; - import CONST from '@src/CONST'; -import ROUTES from '@src/ROUTES'; -import type {Route} from '@src/ROUTES'; import type {Policy, PolicyCategories, TaxRate} from '@src/types/onyx'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; -import type {ExpenseRule} from '@src/types/onyx/Policy'; - -import {getDecodedCategoryName} from './CategoryUtils'; -import {getTaxByID} from './PolicyUtils'; - -const CATEGORY_TAX_RULE_KEY_PREFIX = 'category-tax:'; /** Whether a rule has a tax rate to apply. Tracking being on isn't enough on its own, it needs a rate to choose from. */ function hasUsableTaxRates(policy: Policy | undefined): boolean { @@ -26,46 +16,9 @@ function isSelectableTaxRate(taxRate: TaxRate): boolean { return !taxRate.isDisabled && taxRate.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; } -function getCategoryTaxRuleKey(categoryName: string) { - return `${CATEGORY_TAX_RULE_KEY_PREFIX}${categoryName}`; -} - -function isCategoryTaxRuleKey(key: string) { - return key.startsWith(CATEGORY_TAX_RULE_KEY_PREFIX); -} - -/** The category a rule matches on. A category tax default only ever carries the one `category matches ` condition. */ -function getRuleCategoryName(rule: ExpenseRule): string | undefined { - return rule.applyWhen?.find(({condition, field}) => condition === CONST.POLICY.RULE_CONDITIONS.MATCHES && field === CONST.POLICY.FIELDS.CATEGORY)?.value; -} - -/** Only rules with an explicit tax default. `getCategoryDefaultTaxRate` falls back to the workspace default, which - * would make every category look covered. */ -function getCategoryTaxRules(expenseRules: ExpenseRule[] | undefined): ExpenseRule[] { - return (expenseRules ?? []).filter((rule) => !!rule.tax?.field_id_TAX?.externalID && !!getRuleCategoryName(rule)); -} - -/** Whether an expense rule is this category's tax default. Unlike `getCategoryTaxRule` it doesn't require a rate yet, so - * a write can find the rule it is about to set one on. */ -function matchesCategoryTaxRule(rule: ExpenseRule, categoryName: string): boolean { - return getRuleCategoryName(rule) === categoryName; -} - -function getCategoryTaxRule(expenseRules: ExpenseRule[] | undefined, categoryName: string): ExpenseRule | undefined { - return getCategoryTaxRules(expenseRules).find((rule) => matchesCategoryTaxRule(rule, categoryName)); -} - -function categoryHasTaxRule(expenseRules: ExpenseRule[] | undefined, categoryName: string): boolean { - return !!getCategoryTaxRule(expenseRules, categoryName); -} - -function getCategoryTaxRuleTaxID(expenseRules: ExpenseRule[] | undefined, categoryName: string): string | undefined { - return getCategoryTaxRule(expenseRules, categoryName)?.tax?.field_id_TAX?.externalID; -} - /** The `Name (Value)` tax label. Prefers the workspace rate so renames read correctly, then the label the rule saved * inline, then the raw ID. Callers with no inline label should check `getTaxByID` first: an ID is a fallback for - * a merchant rule that carries its own name, not something to show an admin. */ + * a rule that carries its own name, not something to show an admin. */ function getTaxRateDisplayName(policy: Policy | undefined, taxID: string | undefined, savedTaxRate?: {name?: string; value?: string}): string { if (!taxID) { return ''; @@ -84,8 +37,8 @@ function getTaxRateDisplayName(policy: Policy | undefined, taxID: string | undef * Marks a rule row as deleting while the category or tax rate it depends on is itself being deleted. * * The backend drops the rule along with them, so this only borrows their pending state instead of writing one of its - * own — nothing to roll back, and the row clears when the delete lands. Only deletion is borrowed: a disabled category - * or tax keeps its rule, which fires again once it is re-enabled. + * own. There is nothing to roll back, and the row clears when the delete lands. Only deletion is borrowed: a disabled + * category or tax keeps its rule, which fires again once it is re-enabled. */ function getRuleDeletionPendingAction( policy: Policy | undefined, @@ -98,80 +51,4 @@ function getRuleDeletionPendingAction( return isCategoryDeleting || isTaxDeleting ? CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE : undefined; } -function getCategoryTaxRulesTableData({ - policy, - policyCategories, - translate, - isOffline, - onNavigate, -}: { - policy: Policy | undefined; - policyCategories: PolicyCategories | undefined; - translate: LocaleContextProps['translate']; - isOffline: boolean; - onNavigate: (route: Route) => void; -}): ExpenseDefaultTableItem[] { - if (!policy?.id) { - return []; - } - - const policyID = policy.id; - const typeLabel = translate('workspace.rules.expenseDefaultsTable.update'); - const fieldLabel = translate('common.tax').toLowerCase(); - // The rule is both its category and its tax rate, so deleting either one takes it down. - const getPendingAction = (rule: ExpenseRule) => getRuleDeletionPendingAction(policy, policyCategories, getRuleCategoryName(rule), rule.tax?.field_id_TAX?.externalID); - - return ( - getCategoryTaxRules(policy.rules?.expenseRules) - // Only about the category or tax rate going away, never the rule's own delete: `expenseRules` is a plain - // array, so a rule carries no pending state of its own and `deletePolicyCategoryTaxes` drops it outright. - // While its category or rate is being deleted, online the delete resolves in a moment, so the row goes - // rather than flashing greyed; offline it stays, styled as deleting, since there is nothing to wait for. - .filter((rule) => isOffline || getPendingAction(rule) !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) - .map((rule) => { - // `getCategoryTaxRules` already dropped the rules without a category, so this is always set. - const categoryName = getRuleCategoryName(rule) ?? ''; - const decodedCategoryName = getDecodedCategoryName(categoryName); - const taxID = rule.tax?.field_id_TAX?.externalID; - // Blank rather than the raw ID once the rate is gone from the workspace, matching the editor this row - // opens. The rule still holds the ID, so it starts naming the rate again if the rate comes back. - const taxDisplayName = taxID && getTaxByID(policy, taxID) ? getTaxRateDisplayName(policy, taxID) : ''; - const conditionText = translate('workspace.rules.expenseDefaultsTable.categoryIs', decodedCategoryName); - const ruleDescription = translate('workspace.rules.merchantRules.ruleSummarySubtitleUpdateField', fieldLabel, taxDisplayName); - const pendingAction = getPendingAction(rule); - - return { - keyForList: getCategoryTaxRuleKey(categoryName), - ruleID: getCategoryTaxRuleKey(categoryName), - section: CONST.POLICY.EXPENSE_DEFAULTS_SECTION.CATEGORIES, - isRename: false, - typeLabel, - conditionText, - ruleDescription, - searchTokens: [decodedCategoryName, conditionText, ruleDescription, taxDisplayName], - pendingAction, - disabled: pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - action: () => onNavigate(ROUTES.RULES_CATEGORY_TAX_EDIT.getRoute(policyID, categoryName)), - }; - }) - ); -} - -/** The category a rule key from the Expense defaults table refers to. */ -function getCategoryNameFromTaxRuleKey(key: string): string { - return key.slice(CATEGORY_TAX_RULE_KEY_PREFIX.length); -} - -export { - categoryHasTaxRule, - getCategoryNameFromTaxRuleKey, - getCategoryTaxRulesTableData, - getCategoryTaxRuleTaxID, - getRuleCategoryName, - getRuleDeletionPendingAction, - getTaxRateDisplayName, - hasUsableTaxRates, - isCategoryTaxRuleKey, - isSelectableTaxRate, - matchesCategoryTaxRule, -}; +export {getRuleDeletionPendingAction, getTaxRateDisplayName, hasUsableTaxRates, isSelectableTaxRate}; diff --git a/src/libs/CategoryUtils.ts b/src/libs/CategoryUtils.ts index d0944c48c5ec..8ee332de918c 100644 --- a/src/libs/CategoryUtils.ts +++ b/src/libs/CategoryUtils.ts @@ -4,7 +4,7 @@ import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy, PolicyCategories, TaxRate, TaxRatesWithDefault} from '@src/types/onyx'; +import type {Policy, PolicyCategories} from '@src/types/onyx'; import type {ApprovalRule, ExpenseRule, MccGroup} from '@src/types/onyx/Policy'; import type {OnyxCollection} from 'react-native-onyx'; @@ -14,26 +14,6 @@ import {Str} from 'expensify-common'; import StringUtils from './StringUtils'; import {containsHtmlTag} from './ValidationUtils'; -function formatDefaultTaxRateText(translate: LocaleContextProps['translate'], taxID: string, taxRate: TaxRate, policyTaxRates?: TaxRatesWithDefault) { - const taxRateText = `${taxRate.name} ${CONST.DOT_SEPARATOR} ${taxRate.value}`; - - if (!policyTaxRates) { - return taxRateText; - } - - const {defaultExternalID, foreignTaxDefault} = policyTaxRates; - let suffix; - - if (taxID === defaultExternalID && taxID === foreignTaxDefault) { - suffix = translate('common.default'); - } else if (taxID === defaultExternalID) { - suffix = translate('workspace.taxes.workspaceDefault'); - } else if (taxID === foreignTaxDefault) { - suffix = translate('workspace.taxes.foreignDefault'); - } - return `${taxRateText}${suffix ? ` ${CONST.DOT_SEPARATOR} ${suffix}` : ``}`; -} - function formatRequireItemizedReceiptsOverText( translate: LocaleContextProps['translate'], policy: Policy, @@ -292,7 +272,6 @@ function hasAnyCategoryRules(categories: PolicyCategories | undefined): boolean } export { - formatDefaultTaxRateText, formatRequireItemizedReceiptsOverText, getCategoryApproverRule, getCategoryDefaultTaxRate, diff --git a/src/libs/ExpenseDefaultRuleUtils.ts b/src/libs/ExpenseDefaultRuleUtils.ts index 7b66a0b04811..d32da19337b6 100644 --- a/src/libs/ExpenseDefaultRuleUtils.ts +++ b/src/libs/ExpenseDefaultRuleUtils.ts @@ -673,7 +673,7 @@ function canEditMerchantRule(rule: Rule | undefined, policyID: string | undefine return isPolicyScopedRule(rule, policyID) && isEditableMerchantRule(rule); } -export type {ExpenseDefaultConditionSummary, MerchantRuleFormValues}; +export type {MerchantRuleFormValues}; export { buildCopiedExpenseDefaultRules, buildMerchantRule, From 5cee48a12f303cb491c261af7544fe18b5b28217 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 16 Oct 2026 03:16:49 +0530 Subject: [PATCH 11/24] Preview every condition, and rename the merchant-only rule API Signed-off-by: krishna2323 --- src/libs/ExpenseDefaultRuleUtils.ts | 69 ++++---- src/libs/MerchantTypeRulesUtils.ts | 6 +- src/libs/actions/Policy/Rules.ts | 5 +- .../MerchantRules/AddCategoryTaxRulePage.tsx | 4 +- .../MerchantRules/AddMerchantRulePage.tsx | 4 +- .../MerchantRules/EditCategoryTaxRulePage.tsx | 4 +- .../MerchantRules/EditMerchantRulePage.tsx | 4 +- ...ase.tsx => ExpenseDefaultRulePageBase.tsx} | 36 +++-- .../ImportedMerchantRulesPage.tsx | 10 +- .../MerchantRules/PreviewMatchesPage.tsx | 18 +-- tests/actions/PolicyRulesTest.ts | 22 +-- tests/unit/AddVendorPageTest.ts | 4 +- tests/unit/ExpenseDefaultRuleUtilsTest.ts | 147 ++++++------------ tests/unit/VendorMatchingMerchantRulesTest.ts | 12 +- 14 files changed, 138 insertions(+), 207 deletions(-) rename src/pages/workspace/rules/MerchantRules/{MerchantRulePageBase.tsx => ExpenseDefaultRulePageBase.tsx} (96%) diff --git a/src/libs/ExpenseDefaultRuleUtils.ts b/src/libs/ExpenseDefaultRuleUtils.ts index d32da19337b6..58e70d96b86f 100644 --- a/src/libs/ExpenseDefaultRuleUtils.ts +++ b/src/libs/ExpenseDefaultRuleUtils.ts @@ -25,7 +25,7 @@ import Parser from './Parser'; import {fromIndexMap, getRuleFilterLeaves, isExpenseDefaultRule, isRuleFilterComparison, isRuleFilterNode, toIndexMap} from './RuleUtils'; /** The form shape the merchant rule editor round-trips a rule through. */ -type MerchantRuleFormValues = { +type ExpenseDefaultFormValues = { /** The merchant string an expense has to match */ merchantToMatch?: string; @@ -109,13 +109,13 @@ const STRING_ACTION_FIELD_TO_FORM_KEY = { [FIELD.TAG]: 'tag', [FIELD.VENDOR_ID]: 'vendorID', [FIELD.COMMENT]: 'comment', -} as const satisfies Partial>; +} as const satisfies Partial>; /** See `STRING_ACTION_FIELD_TO_FORM_KEY`. */ const BOOLEAN_ACTION_FIELD_TO_FORM_KEY = { [FIELD.REIMBURSABLE]: 'reimbursable', [FIELD.BILLABLE]: 'billable', -} as const satisfies Partial>; +} as const satisfies Partial>; function isStringActionField(field: ExpenseDefaultActionField): field is keyof typeof STRING_ACTION_FIELD_TO_FORM_KEY { return field in STRING_ACTION_FIELD_TO_FORM_KEY; @@ -208,7 +208,7 @@ function buildAmountActionValue(amount: string | undefined): number | undefined } /** A merchant rule as this editor builds it, which is the stored shape minus the fields the server fills in. */ -type BuiltMerchantRule = Omit & { +type BuiltExpenseDefaultRule = Omit & { /** The conditions the expense has to meet, ANDed together. */ filters: RuleFilterNode; }; @@ -224,7 +224,7 @@ function nonEmptyValues(values: string[] | undefined): string[] { * The tree is left nested, so three conditions become `and(and(a, b), c)`. Each node only takes two children, * and nesting to the left keeps the order the form reads them back in. */ -function buildMerchantRuleFilters(formValues: Partial): RuleFilterNode | undefined { +function buildExpenseDefaultFilters(formValues: Partial): RuleFilterNode | undefined { const comparisons: RuleFilterComparison[] = []; const merchantToMatch = formValues.merchantToMatch?.trim(); @@ -251,8 +251,8 @@ function buildMerchantRuleFilters(formValues: Partial): } /** Whether the form matches on anything. A rule with no conditions would apply to every expense, and the API rejects it. */ -function hasExpenseDefaultConditions(formValues: Partial): boolean { - return !!buildMerchantRuleFilters(formValues); +function hasExpenseDefaultConditions(formValues: Partial): boolean { + return !!buildExpenseDefaultFilters(formValues); } /** Compares two multi-select conditions, which are ORed lists rather than ordered ones. */ @@ -264,7 +264,7 @@ function haveSameConditionValues(first: string[] | undefined, second: string[] | } /** Whether two rules match on the same expenses, which is what makes one a duplicate of the other. */ -function haveSameConditions(first: Partial, second: Partial): boolean { +function haveSameConditions(first: Partial, second: Partial): boolean { const firstMerchant = first.merchantToMatch?.trim().toLowerCase() ?? ''; const secondMerchant = second.merchantToMatch?.trim().toLowerCase() ?? ''; @@ -281,7 +281,7 @@ function haveSameConditions(first: Partial, second: Part } /** Builds the `Set` actions for a merchant rule, keyed by a stringified index in a fixed field order. */ -function buildMerchantRuleActions(formValues: Partial, policy: Policy | undefined): ExpenseDefaultActions { +function buildExpenseDefaultActions(formValues: Partial, policy: Policy | undefined): ExpenseDefaultActions { const comment = emptyToUndefined(formValues.comment); const valuesByField: Partial> = { [FIELD.MERCHANT]: emptyToUndefined(formValues.merchant), @@ -313,9 +313,9 @@ function buildMerchantRuleActions(formValues: Partial, p * as the `value` param of `SetRule`. * Returns undefined when the form has nothing to match on or nothing to set, which the API rejects. */ -function buildMerchantRule(formValues: Partial, policy: Policy | undefined): BuiltMerchantRule | undefined { - const filters = buildMerchantRuleFilters(formValues); - const actions = buildMerchantRuleActions(formValues, policy); +function buildExpenseDefaultRule(formValues: Partial, policy: Policy | undefined): BuiltExpenseDefaultRule | undefined { + const filters = buildExpenseDefaultFilters(formValues); + const actions = buildExpenseDefaultActions(formValues, policy); if (!filters || Object.keys(actions).length === 0) { return undefined; @@ -343,7 +343,7 @@ function areTriggersEditable(triggers: Record | undefined): bool } /** The conditions the editor renders, read back off a rule's filter tree. */ -type EditableConditions = Pick; +type EditableConditions = Pick; /** * Reads one leaf comparison into the conditions it sets. @@ -426,7 +426,7 @@ function getEditableConditions(filters: RuleFilterNode | undefined): EditableCon * Callers MUST treat undefined as "show this rule read-only": rendering a partial form and saving it back * would silently drop everything the form couldn't represent. */ -function getMerchantRuleFormValues(rule: Rule | ExpenseDefaultRule | undefined): MerchantRuleFormValues | undefined { +function getExpenseDefaultFormValues(rule: Rule | ExpenseDefaultRule | undefined): ExpenseDefaultFormValues | undefined { if (!rule || !areTriggersEditable(rule.triggers)) { return undefined; } @@ -436,7 +436,7 @@ function getMerchantRuleFormValues(rule: Rule | ExpenseDefaultRule | undefined): return undefined; } - const formValues: MerchantRuleFormValues = {...conditions}; + const formValues: ExpenseDefaultFormValues = {...conditions}; const actions = getRuleActions(rule); if (actions.length === 0) { @@ -495,21 +495,6 @@ function getMerchantRuleFormValues(rule: Rule | ExpenseDefaultRule | undefined): return formValues; } -/** - * Summarizes the merchants a rule matches on, for the condition text and the search index. - * - * Derived from the filter tree rather than from `getMerchantRuleFormValues`, so a rule the editor can't - * represent still shows what it matches and can still be found by merchant while staying read-only. - */ -function getRuleMerchantMatchSummary(filters: RuleFilterNode | undefined): {merchants: string; isExactMatch: boolean} { - const merchantLeaves = getRuleFilterLeaves(filters).filter((leaf) => leaf.left === FIELD.MERCHANT); - - return { - merchants: merchantLeaves.flatMap((leaf) => [leaf.right].flat()).join(', '), - isExactMatch: merchantLeaves.length > 0 && merchantLeaves.every((leaf) => leaf.operator === EQUAL_TO), - }; -} - /** The values a rule matches on, grouped by field. */ type ExpenseDefaultConditionSummary = { /** The merchants the rule matches on */ @@ -525,7 +510,7 @@ type ExpenseDefaultConditionSummary = { /** * Groups a rule's conditions by field, for the rules table's condition text and its search tokens. * - * Derived from the filter tree rather than from `getMerchantRuleFormValues`, so a rule the editor can't + * Derived from the filter tree rather than from `getExpenseDefaultFormValues`, so a rule the editor can't * represent still shows what it matches and can still be found by it while staying read-only. */ function getExpenseDefaultConditionSummary(filters: RuleFilterNode | undefined): ExpenseDefaultConditionSummary { @@ -655,9 +640,9 @@ function hasExpenseDefaultRuleErrors(rules: OnyxCollection | undefined, po return getPolicyExpenseDefaultRules(rules, policyID).some(({rule}) => Object.keys(rule.errors ?? {}).length > 0); } -/** Whether the merchant rule editor can safely open this rule. See `getMerchantRuleFormValues`. */ -function isEditableMerchantRule(rule: Rule | ExpenseDefaultRule | undefined): boolean { - return !!getMerchantRuleFormValues(rule); +/** Whether the merchant rule editor can safely open this rule. See `getExpenseDefaultFormValues`. */ +function isEditableExpenseDefaultRule(rule: Rule | ExpenseDefaultRule | undefined): boolean { + return !!getExpenseDefaultFormValues(rule); } /** @@ -669,26 +654,26 @@ function isEditableMerchantRule(rule: Rule | ExpenseDefaultRule | undefined): bo * refused rather than opened. A rule the form can edit is already an expense default rule, because the form only * accepts `CreateTransaction` triggers and `Set` actions. */ -function canEditMerchantRule(rule: Rule | undefined, policyID: string | undefined): boolean { - return isPolicyScopedRule(rule, policyID) && isEditableMerchantRule(rule); +function canEditExpenseDefaultRule(rule: Rule | undefined, policyID: string | undefined): boolean { + return isPolicyScopedRule(rule, policyID) && isEditableExpenseDefaultRule(rule); } -export type {MerchantRuleFormValues}; +export type {ExpenseDefaultFormValues}; export { buildCopiedExpenseDefaultRules, - buildMerchantRule, - canEditMerchantRule, + buildExpenseDefaultFilters, + buildExpenseDefaultRule, + canEditExpenseDefaultRule, getCategoryTaxDefaultID, getCategoryTaxDefaultRule, getExpenseDefaultConditionSummary, getExpenseDefaultRuleCount, getExpenseDefaultRuleSummaryFields, - getMerchantRuleFormValues, + getExpenseDefaultFormValues, getPolicyExpenseDefaultRules, - getRuleMerchantMatchSummary, hasExpenseDefaultConditions, hasExpenseDefaultRuleErrors, haveSameConditions, - isEditableMerchantRule, + isEditableExpenseDefaultRule, isExpenseDefaultTaxValue, }; diff --git a/src/libs/MerchantTypeRulesUtils.ts b/src/libs/MerchantTypeRulesUtils.ts index 08d36ad16458..a4071f6cbdda 100644 --- a/src/libs/MerchantTypeRulesUtils.ts +++ b/src/libs/MerchantTypeRulesUtils.ts @@ -20,7 +20,7 @@ import { getExpenseDefaultConditionSummary, getExpenseDefaultRuleSummaryFields, getPolicyExpenseDefaultRules, - isEditableMerchantRule, + isEditableExpenseDefaultRule, isExpenseDefaultTaxValue, } from './ExpenseDefaultRuleUtils'; import {getMccGroupDisplayName} from './PolicyRulesUtils'; @@ -222,9 +222,9 @@ function getMerchantRulesTableData({ const pendingAction = rule.pendingAction; // A rule the editor can't represent would lose whatever the form can't show if it were saved back, - // so the row summarizes it but doesn't open it. See `getMerchantRuleFormValues`. It stays selectable + // so the row summarizes it but doesn't open it. See `getExpenseDefaultFormValues`. It stays selectable // either way, because deleting it is the only thing an admin can still do with it. - const isEditable = isEditableMerchantRule(rule); + const isEditable = isEditableExpenseDefaultRule(rule); const openEditor = () => { if (!isEditable) { return; diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index d8f1d326d58b..7dd35959ce5b 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -26,10 +26,11 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {GeneratedRuleState, GeneratedRuleType} from '@src/types/onyx/GeneratedRule'; import type {ImportFinalModal} from '@src/types/onyx/ImportedSpreadsheet'; import type Policy from '@src/types/onyx/Policy'; -import type {AgentRule, CodingRule, CodingRuleFilter} from '@src/types/onyx/Policy'; +import type {AgentRule, CodingRule} from '@src/types/onyx/Policy'; import type {OnyxData} from '@src/types/onyx/Request'; import type Rule from '@src/types/onyx/Rule'; import type {RuleBody} from '@src/types/onyx/Rule'; +import type {RuleFilterNode} from '@src/types/onyx/RuleFilters'; import type {OnyxUpdate} from 'react-native-onyx'; @@ -269,7 +270,7 @@ async function importMerchantRulesSpreadsheet(policyID: string, rules: Record> = [ { onyxMethod: Onyx.METHOD.MERGE, diff --git a/src/pages/workspace/rules/MerchantRules/AddCategoryTaxRulePage.tsx b/src/pages/workspace/rules/MerchantRules/AddCategoryTaxRulePage.tsx index 66d0a3a9bcce..95af5097447b 100644 --- a/src/pages/workspace/rules/MerchantRules/AddCategoryTaxRulePage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddCategoryTaxRulePage.tsx @@ -11,7 +11,7 @@ import type SCREENS from '@src/SCREENS'; import React from 'react'; -import MerchantRulePageBase from './MerchantRulePageBase'; +import ExpenseDefaultRulePageBase from './ExpenseDefaultRulePageBase'; type AddCategoryTaxRulePageProps = PlatformStackScreenProps; @@ -25,7 +25,7 @@ function AddCategoryTaxRulePage({route}: AddCategoryTaxRulePageProps) { const existingRuleID = getCategoryTaxDefaultRule(rules, policyID, categoryName)?.ruleID; return ( - ; @@ -13,7 +13,7 @@ function AddMerchantRulePage({route}: AddMerchantRulePageProps) { const {policyID} = route.params; return ( - @@ -22,7 +22,7 @@ function EditCategoryTaxRulePage({route}: EditCategoryTaxRulePageProps) { const ruleID = getCategoryTaxDefaultRule(rules, policyID, categoryName)?.ruleID; return ( - ; function EditMerchantRulePage({route}: EditMerchantRulePageProps) { return ( - ): boolean => { + const checkForDuplicateRule = (formValues: Partial): boolean => { if (!hasExpenseDefaultConditions(formValues)) { return false; } @@ -320,7 +328,7 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, initialCat } // A rule this form can't represent can't be a duplicate of what this form is about to save. - const existingFormValues = getMerchantRuleFormValues(rule); + const existingFormValues = getExpenseDefaultFormValues(rule); if (!existingFormValues) { return false; } @@ -354,7 +362,7 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, initialCat return; } - setRule(policyID, buildMerchantRule(form, policy), CONST.RULES.EXPENSE_DEFAULT.PRIORITY, ruleID, existingRule, shouldUpdateMatchingTransactions); + setRule(policyID, buildExpenseDefaultRule(form, policy), CONST.RULES.EXPENSE_DEFAULT.PRIORITY, ruleID, existingRule, shouldUpdateMatchingTransactions); if (isCreatedFromExpense) { // Opened from the callout, so this page is a suffix on the expense's path. Dropping it returns to the // expense instead of the workspace Rules page. @@ -557,7 +565,7 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, initialCat ]; const previewMatches = () => { - if (!form?.merchantToMatch?.trim()) { + if (!hasExpenseDefaultConditions(form ?? {})) { setShouldShowError(true); return; } @@ -579,7 +587,7 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, initialCat // The rules collection is shared across workspaces and rule kinds, so a stale link can resolve a ruleID that // this editor must not write to. Saving would replace it with a merchant rule and drop whatever it holds. - if (ruleID && !!existingRule && !isClosing && !canEditMerchantRule(existingRule, policyID)) { + if (ruleID && !!existingRule && !isClosing && !canEditExpenseDefaultRule(existingRule, policyID)) { return ; } @@ -732,6 +740,6 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, initialCat ); } -MerchantRulePageBase.displayName = 'MerchantRulePageBase'; +ExpenseDefaultRulePageBase.displayName = 'ExpenseDefaultRulePageBase'; -export default MerchantRulePageBase; +export default ExpenseDefaultRulePageBase; diff --git a/src/pages/workspace/rules/MerchantRules/ImportedMerchantRulesPage.tsx b/src/pages/workspace/rules/MerchantRules/ImportedMerchantRulesPage.tsx index 767e128a047a..6613b7c6efd6 100644 --- a/src/pages/workspace/rules/MerchantRules/ImportedMerchantRulesPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/ImportedMerchantRulesPage.tsx @@ -17,8 +17,8 @@ import type {ImportedMerchantRule} from '@libs/actions/Policy/Rules'; import {importMerchantRulesSpreadsheet} from '@libs/actions/Policy/Rules'; import Tab from '@libs/actions/Tab'; import {getDecodedCategoryName} from '@libs/CategoryUtils'; -import {getMerchantRuleFormValues, getPolicyExpenseDefaultRules} from '@libs/ExpenseDefaultRuleUtils'; -import type {MerchantRuleFormValues} from '@libs/ExpenseDefaultRuleUtils'; +import {getExpenseDefaultFormValues, getPolicyExpenseDefaultRules} from '@libs/ExpenseDefaultRuleUtils'; +import type {ExpenseDefaultFormValues} from '@libs/ExpenseDefaultRuleUtils'; import {findDuplicate, generateColumnNames} from '@libs/importSpreadsheetUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -62,7 +62,7 @@ const ACTION_COLUMNS: string[] = [ * spreadsheet rows that would recreate a rule the policy already has (e.g. the same spreadsheet * imported twice) as well as duplicate rows within the same spreadsheet. */ -function getRuleContentKey(formValues: Partial): string { +function getRuleContentKey(formValues: Partial): string { return JSON.stringify([ formValues.matchType ?? CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS, formValues.merchantToMatch?.toLowerCase() ?? '', @@ -200,7 +200,7 @@ function parseSpreadsheetRules( const seenRuleKeys = new Set( getPolicyExpenseDefaultRules(existingRules, policy?.id) .filter(({rule}) => rule.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) - .map(({rule}) => getMerchantRuleFormValues(rule)) + .map(({rule}) => getExpenseDefaultFormValues(rule)) .filter((formValues) => !!formValues) .map(getRuleContentKey), ); @@ -244,7 +244,7 @@ function parseSpreadsheetRules( continue; } - const formValues: Partial = { + const formValues: Partial = { merchantToMatch, matchType: merchantIsValue ? CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO : CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS, ...(updatedMerchant && {merchant: updatedMerchant}), diff --git a/src/pages/workspace/rules/MerchantRules/PreviewMatchesPage.tsx b/src/pages/workspace/rules/MerchantRules/PreviewMatchesPage.tsx index cf944bc3f67a..6199408ca70f 100644 --- a/src/pages/workspace/rules/MerchantRules/PreviewMatchesPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/PreviewMatchesPage.tsx @@ -12,6 +12,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {getTransactionsMatchingCodingRule} from '@libs/actions/Policy/Rules'; +import {buildExpenseDefaultFilters} from '@libs/ExpenseDefaultRuleUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; @@ -25,7 +26,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {Transaction} from '@src/types/onyx'; -import type {CodingRuleFilter} from '@src/types/onyx/Policy'; import type {ListRenderItem} from '@shopify/flash-list'; @@ -55,25 +55,19 @@ function PreviewMatchesPage({route}: PreviewMatchesPageProps) { const [isLoading] = useOnyx(ONYXKEYS.IS_LOADING_POLICY_CODING_RULES_PREVIEW); const [matchingTransactions] = useOnyx(ONYXKEYS.COLLECTION.CODING_RULE_MATCHING_TRANSACTION); - const merchant = form?.merchantToMatch ?? ''; - const operator = form?.matchType ?? CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS; + // The preview matches on everything the rule does, so it asks with the tree the rule would be saved with. + const filters = buildExpenseDefaultFilters(form ?? {}); useEffect(() => { - if (isOffline) { + if (isOffline || !filters) { return; } - const filters: CodingRuleFilter = { - left: 'merchant', - operator, - right: merchant, - }; - getTransactionsMatchingCodingRule(policyID, filters); - }, [merchant, operator, policyID, isOffline]); + }, [filters, policyID, isOffline]); const matchingTransactionsArray = Object.values(matchingTransactions ?? {}).filter((transaction): transaction is Transaction => !!transaction); - const hasMatchingTransactions = !!(merchant && matchingTransactionsArray.length); + const hasMatchingTransactions = !!filters && matchingTransactionsArray.length > 0; const isLoadedAndEmpty = !isLoading && !hasMatchingTransactions; const isLoadedWithTransactions = !isLoading && hasMatchingTransactions; diff --git a/tests/actions/PolicyRulesTest.ts b/tests/actions/PolicyRulesTest.ts index 05294a26a926..4a17a8042f15 100644 --- a/tests/actions/PolicyRulesTest.ts +++ b/tests/actions/PolicyRulesTest.ts @@ -1,7 +1,7 @@ import OnyxUpdateManager from '@libs/actions/OnyxUpdateManager'; import {addPolicyAgentRule, clearMerchantRuleErrors, clearPolicyAgentRuleErrors, deleteRule, deletePolicyAgentRule, setRule, updatePolicyAgentRule} from '@libs/actions/Policy/Rules'; import {WRITE_COMMANDS} from '@libs/API/types'; -import {buildMerchantRule} from '@libs/ExpenseDefaultRuleUtils'; +import {buildExpenseDefaultRule} from '@libs/ExpenseDefaultRuleUtils'; import {flush as flushSequentialQueue} from '@libs/Network/SequentialQueue'; import {toIndexMap} from '@libs/RuleUtils'; @@ -51,7 +51,7 @@ async function getRules(): Promise> { } /** A minimal merchant rule as stored in the rules collection. */ -function buildMerchantRuleForPolicy(policyID: string): Rule { +function buildExpenseDefaultRuleForPolicy(policyID: string): Rule { return { scope: CONST.RULES.SCOPE.POLICY, scopeID: policyID, @@ -463,7 +463,7 @@ describe('actions/PolicyRules', () => { setRule( fakePolicy.id, - buildMerchantRule({merchantToMatch: 'Starbucks', matchType: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, category: 'Coffee'}, fakePolicy), + buildExpenseDefaultRule({merchantToMatch: 'Starbucks', matchType: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, category: 'Coffee'}, fakePolicy), CONST.RULES.EXPENSE_DEFAULT.PRIORITY, ); await waitForBatchedUpdates(); @@ -494,11 +494,11 @@ describe('actions/PolicyRules', () => { mockFetch?.pause?.(); await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy); - setRule(fakePolicy.id, buildMerchantRule({merchantToMatch: 'Starbucks', category: 'Coffee', tag: 'Team A'}, fakePolicy), CONST.RULES.EXPENSE_DEFAULT.PRIORITY, ruleID); + setRule(fakePolicy.id, buildExpenseDefaultRule({merchantToMatch: 'Starbucks', category: 'Coffee', tag: 'Team A'}, fakePolicy), CONST.RULES.EXPENSE_DEFAULT.PRIORITY, ruleID); await waitForBatchedUpdates(); const existingRule = (await getRules())?.[`${ONYXKEYS.COLLECTION.RULE}${ruleID}`]; - setRule(fakePolicy.id, buildMerchantRule({merchantToMatch: 'Starbucks', category: 'Coffee'}, fakePolicy), CONST.RULES.EXPENSE_DEFAULT.PRIORITY, ruleID, existingRule); + setRule(fakePolicy.id, buildExpenseDefaultRule({merchantToMatch: 'Starbucks', category: 'Coffee'}, fakePolicy), CONST.RULES.EXPENSE_DEFAULT.PRIORITY, ruleID, existingRule); await waitForBatchedUpdates(); const updatedRule = (await getRules())?.[`${ONYXKEYS.COLLECTION.RULE}${ruleID}`]; @@ -516,7 +516,7 @@ describe('actions/PolicyRules', () => { const fakePolicy = createRandomPolicy(0); await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy); - setRule(fakePolicy.id, buildMerchantRule({merchantToMatch: '', category: 'Coffee'}, fakePolicy), CONST.RULES.EXPENSE_DEFAULT.PRIORITY); + setRule(fakePolicy.id, buildExpenseDefaultRule({merchantToMatch: '', category: 'Coffee'}, fakePolicy), CONST.RULES.EXPENSE_DEFAULT.PRIORITY); await waitForBatchedUpdates(); expect(Object.keys((await getRules()) ?? {})).toHaveLength(0); @@ -531,7 +531,7 @@ describe('actions/PolicyRules', () => { // When the rule is saved setRule( fakePolicy.id, - buildMerchantRule({merchantToMatch: 'Starbucks', matchType: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, category: 'Coffee'}, fakePolicy), + buildExpenseDefaultRule({merchantToMatch: 'Starbucks', matchType: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, category: 'Coffee'}, fakePolicy), CONST.RULES.EXPENSE_DEFAULT.PRIORITY, ruleID, ); @@ -557,7 +557,7 @@ describe('actions/PolicyRules', () => { it('marks the rule as pending delete', async () => { const fakePolicy = createRandomPolicy(0); const ruleID = 'merchantRule1'; - const rule = buildMerchantRuleForPolicy(fakePolicy.id); + const rule = buildExpenseDefaultRuleForPolicy(fakePolicy.id); mockFetch?.pause?.(); await Onyx.set(`${ONYXKEYS.COLLECTION.RULE}${ruleID}`, rule); @@ -574,7 +574,7 @@ describe('actions/PolicyRules', () => { // Given a saved merchant rule const fakePolicy = createRandomPolicy(0); const ruleID = 'merchantRule1'; - const rule = buildMerchantRuleForPolicy(fakePolicy.id); + const rule = buildExpenseDefaultRuleForPolicy(fakePolicy.id); await Onyx.set(`${ONYXKEYS.COLLECTION.RULE}${ruleID}`, rule); // When it is deleted @@ -590,7 +590,7 @@ describe('actions/PolicyRules', () => { it('removes the rule entirely when its pendingAction was ADD', async () => { const ruleID = 'merchantRule1'; const rule: Rule = { - ...buildMerchantRuleForPolicy('policy1'), + ...buildExpenseDefaultRuleForPolicy('policy1'), pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, errors: {[ERROR_KEY]: 'boom'}, }; @@ -605,7 +605,7 @@ describe('actions/PolicyRules', () => { it('clears only the errors when the rule has a non-ADD pending action', async () => { const ruleID = 'merchantRule1'; const rule: Rule = { - ...buildMerchantRuleForPolicy('policy1'), + ...buildExpenseDefaultRuleForPolicy('policy1'), pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, errors: {[ERROR_KEY]: 'boom'}, }; diff --git a/tests/unit/AddVendorPageTest.ts b/tests/unit/AddVendorPageTest.ts index ade2877e4058..5be90ccfd928 100644 --- a/tests/unit/AddVendorPageTest.ts +++ b/tests/unit/AddVendorPageTest.ts @@ -143,12 +143,12 @@ describe('AddVendorPage', () => { }); /** - * The "Set vendor to" row in MerchantRulePageBase is assembled from these already-exported helpers + * The "Set vendor to" row in ExpenseDefaultRulePageBase is assembled from these already-exported helpers * (see the `isVendorFeatureEnabled` / `vendorFieldLabel` / `vendorDisplayName` derivations). Asserting * them here pins the row's decision logic; the row's render/JSX wiring and navigation are exercised by * the Playwright click-through. */ - describe('vendor rule row derivation (MerchantRulePageBase)', () => { + describe('vendor rule row derivation (ExpenseDefaultRulePageBase)', () => { const qboPolicy = buildQBOPolicy([{id: 'v-1', name: 'Acme Co', currency: 'USD'}]); const intacctPolicy = buildIntacctPolicy([{id: 'iv-1', name: 'V001', value: 'Acme Intacct'}]); const xeroPolicy = buildXeroPolicy({xc1: {id: 'xc1', name: 'Acme Xero', email: 'acme@example.com'}}); diff --git a/tests/unit/ExpenseDefaultRuleUtilsTest.ts b/tests/unit/ExpenseDefaultRuleUtilsTest.ts index afb6a380c0ad..6d0f56e8c430 100644 --- a/tests/unit/ExpenseDefaultRuleUtilsTest.ts +++ b/tests/unit/ExpenseDefaultRuleUtilsTest.ts @@ -1,16 +1,15 @@ import { - buildMerchantRule, - canEditMerchantRule, + buildExpenseDefaultRule, + canEditExpenseDefaultRule, getExpenseDefaultConditionSummary, getExpenseDefaultRuleCount, getExpenseDefaultRuleSummaryFields, - getMerchantRuleFormValues, + getExpenseDefaultFormValues, getPolicyExpenseDefaultRules, - getRuleMerchantMatchSummary, hasExpenseDefaultRuleErrors, haveSameConditions, } from '@libs/ExpenseDefaultRuleUtils'; -import type {MerchantRuleFormValues} from '@libs/ExpenseDefaultRuleUtils'; +import type {ExpenseDefaultFormValues} from '@libs/ExpenseDefaultRuleUtils'; import Parser from '@libs/Parser'; import {getRuleFilterLeaves, isExpenseDefaultRule, toIndexMap} from '@libs/RuleUtils'; @@ -64,9 +63,9 @@ function buildRuleWithOverrides(overrides: Record): ExpenseDefa } describe('ExpenseDefaultRuleUtils', () => { - describe('buildMerchantRule', () => { + describe('buildExpenseDefaultRule', () => { it('builds the CreateTransaction trigger, the merchant filter and one Set action per field', () => { - const rule = buildMerchantRule( + const rule = buildExpenseDefaultRule( { merchantToMatch: 'Starbucks', matchType: EQUAL_TO, @@ -100,13 +99,13 @@ describe('ExpenseDefaultRuleUtils', () => { }); it('defaults the match type to contains and trims the matched merchant', () => { - const rule = buildMerchantRule({merchantToMatch: ' Uber ', category: 'Travel'}, policy); + const rule = buildExpenseDefaultRule({merchantToMatch: ' Uber ', category: 'Travel'}, policy); expect(rule?.filters).toEqual({left: FIELD.MERCHANT, operator: CONTAINS, right: 'Uber'}); }); it('keeps false booleans, which are meaningful values rather than empty ones', () => { - const rule = buildMerchantRule({merchantToMatch: 'Uber', billable: false, reimbursable: false}, policy); + const rule = buildExpenseDefaultRule({merchantToMatch: 'Uber', billable: false, reimbursable: false}, policy); expect(Object.values(rule?.actions ?? {})).toEqual([ {name: ACTION.SET, field: FIELD.REIMBURSABLE, value: false}, @@ -115,28 +114,28 @@ describe('ExpenseDefaultRuleUtils', () => { }); it('returns undefined when there is nothing to match on', () => { - expect(buildMerchantRule({merchantToMatch: ' ', category: 'Coffee'}, policy)).toBeUndefined(); + expect(buildExpenseDefaultRule({merchantToMatch: ' ', category: 'Coffee'}, policy)).toBeUndefined(); }); it('returns undefined when there is nothing to set', () => { - expect(buildMerchantRule({merchantToMatch: 'Starbucks'}, policy)).toBeUndefined(); + expect(buildExpenseDefaultRule({merchantToMatch: 'Starbucks'}, policy)).toBeUndefined(); }); it('treats a whitespace-only description as nothing to set', () => { - expect(buildMerchantRule({merchantToMatch: 'Starbucks', comment: ' '}, policy)).toBeUndefined(); + expect(buildExpenseDefaultRule({merchantToMatch: 'Starbucks', comment: ' '}, policy)).toBeUndefined(); }); it('still records the tax external ID when the rate is missing from the policy', () => { - const rule = buildMerchantRule({merchantToMatch: 'Starbucks', tax: 'id_UNKNOWN'}, policy); + const rule = buildExpenseDefaultRule({merchantToMatch: 'Starbucks', tax: 'id_UNKNOWN'}, policy); // eslint-disable-next-line @typescript-eslint/naming-convention expect(Object.values(rule?.actions ?? {}).at(0)?.value).toEqual({field_id_TAX: {externalID: 'id_UNKNOWN'}}); }); }); - describe('getMerchantRuleFormValues round trip', () => { + describe('getExpenseDefaultFormValues round trip', () => { it('returns the values it was built from', () => { - const formValues: MerchantRuleFormValues = { + const formValues: ExpenseDefaultFormValues = { merchantToMatch: 'Starbucks', matchType: EQUAL_TO, merchant: 'Starbucks Coffee', @@ -149,32 +148,32 @@ describe('ExpenseDefaultRuleUtils', () => { billable: false, }; - expect(getMerchantRuleFormValues(buildMerchantRule(formValues, policy))).toEqual(formValues); + expect(getExpenseDefaultFormValues(buildExpenseDefaultRule(formValues, policy))).toEqual(formValues); }); it('round trips a rule that only renames the merchant', () => { - const formValues: MerchantRuleFormValues = {merchantToMatch: 'STARBUCKS #123', matchType: CONTAINS, merchant: 'Starbucks'}; + const formValues: ExpenseDefaultFormValues = {merchantToMatch: 'STARBUCKS #123', matchType: CONTAINS, merchant: 'Starbucks'}; - expect(getMerchantRuleFormValues(buildMerchantRule(formValues, policy))).toEqual(formValues); + expect(getExpenseDefaultFormValues(buildExpenseDefaultRule(formValues, policy))).toEqual(formValues); }); it('converts the description back to markdown', () => { - const rule = buildMerchantRule({merchantToMatch: 'Starbucks', comment: 'A description'}, policy); + const rule = buildExpenseDefaultRule({merchantToMatch: 'Starbucks', comment: 'A description'}, policy); - expect(getMerchantRuleFormValues(rule)?.comment).toBe('A description'); + expect(getExpenseDefaultFormValues(rule)?.comment).toBe('A description'); }); it('accepts a single-value list on the right of the merchant filter', () => { const rule = buildRuleWithOverrides({filters: {left: FIELD.MERCHANT, operator: CONTAINS, right: ['Starbucks']}}); - expect(getMerchantRuleFormValues(rule)).toEqual({merchantToMatch: 'Starbucks', matchType: CONTAINS, category: 'Coffee'}); + expect(getExpenseDefaultFormValues(rule)).toEqual({merchantToMatch: 'Starbucks', matchType: CONTAINS, category: 'Coffee'}); }); }); - describe('buildMerchantRule conditions', () => { + describe('buildExpenseDefaultRule conditions', () => { it('builds one condition per field, ANDed together and nested to the left', () => { // Given a form matching on a merchant, two categories and a tag - const formValues: Partial = { + const formValues: Partial = { merchantToMatch: 'Starbucks', matchType: EQUAL_TO, categoriesToMatch: ['Coffee', 'Travel'], @@ -183,7 +182,7 @@ describe('ExpenseDefaultRuleUtils', () => { }; // When the rule is built - const rule = buildMerchantRule(formValues, policy); + const rule = buildExpenseDefaultRule(formValues, policy); // Then the conditions are ANDed in form order, with each list left as an OR of its values expect(rule?.filters).toEqual({ @@ -199,7 +198,7 @@ describe('ExpenseDefaultRuleUtils', () => { it('builds a rule that matches on a category alone', () => { // Given a form with no merchant, which is a rule the editor could not express before - const rule = buildMerchantRule({categoriesToMatch: ['Coffee'], tax: TAX_KEY}, policy); + const rule = buildExpenseDefaultRule({categoriesToMatch: ['Coffee'], tax: TAX_KEY}, policy); // When its filters are read // Then the single category condition is the whole tree @@ -210,12 +209,12 @@ describe('ExpenseDefaultRuleUtils', () => { // Given a form whose pickers were emptied, leaving blanks behind // When the rule is built // Then there is nothing to match on, so no rule is built - expect(buildMerchantRule({merchantToMatch: ' ', categoriesToMatch: [' '], tagsToMatch: [], category: 'Coffee'}, policy)).toBeUndefined(); + expect(buildExpenseDefaultRule({merchantToMatch: ' ', categoriesToMatch: [' '], tagsToMatch: [], category: 'Coffee'}, policy)).toBeUndefined(); }); it('round trips every condition', () => { // Given a form matching on all three fields - const formValues: MerchantRuleFormValues = { + const formValues: ExpenseDefaultFormValues = { merchantToMatch: 'Starbucks', matchType: CONTAINS, categoriesToMatch: ['Coffee'], @@ -225,14 +224,14 @@ describe('ExpenseDefaultRuleUtils', () => { // When the built rule is read back // Then the form gets the same conditions it wrote - expect(getMerchantRuleFormValues(buildMerchantRule(formValues, policy))).toEqual(formValues); + expect(getExpenseDefaultFormValues(buildExpenseDefaultRule(formValues, policy))).toEqual(formValues); }); }); - describe('buildMerchantRule amount default', () => { + describe('buildExpenseDefaultRule amount default', () => { it('stores the amount negated, the way an expense carries it', () => { // Given a rule setting the amount to $30.00 - const rule = buildMerchantRule({merchantToMatch: 'Starbucks', amount: '3000'}, policy); + const rule = buildExpenseDefaultRule({merchantToMatch: 'Starbucks', amount: '3000'}, policy); // When the action is read // Then the stored value is negative cents @@ -241,18 +240,18 @@ describe('ExpenseDefaultRuleUtils', () => { it('round trips the amount as the admin typed it', () => { // Given a rule setting an amount - const formValues: MerchantRuleFormValues = {merchantToMatch: 'Starbucks', matchType: CONTAINS, amount: '3000'}; + const formValues: ExpenseDefaultFormValues = {merchantToMatch: 'Starbucks', matchType: CONTAINS, amount: '3000'}; // When it is read back // Then the form gets the positive amount, not the negated one - expect(getMerchantRuleFormValues(buildMerchantRule(formValues, policy))).toEqual(formValues); + expect(getExpenseDefaultFormValues(buildExpenseDefaultRule(formValues, policy))).toEqual(formValues); }); it('leaves out an amount of zero, which is not a default', () => { // Given a rule whose amount was cleared // When it is built // Then nothing is set, so the rule has no default and is not built - expect(buildMerchantRule({merchantToMatch: 'Starbucks', amount: '0'}, policy)).toBeUndefined(); + expect(buildExpenseDefaultRule({merchantToMatch: 'Starbucks', amount: '0'}, policy)).toBeUndefined(); }); it('is read-only for a rule that sets a credit', () => { @@ -261,14 +260,14 @@ describe('ExpenseDefaultRuleUtils', () => { // When the form tries to read it // Then it is refused, because saving the form back would flip the sign - expect(getMerchantRuleFormValues(rule)).toBeUndefined(); + expect(getExpenseDefaultFormValues(rule)).toBeUndefined(); }); }); describe('getExpenseDefaultConditionSummary', () => { it('groups every condition by the field it matches on', () => { // Given a rule matching on a merchant, two categories and a tag - const rule = buildMerchantRule({merchantToMatch: 'Starbucks', categoriesToMatch: ['Coffee', 'Travel'], tagsToMatch: ['Team A'], category: 'Coffee'}, policy); + const rule = buildExpenseDefaultRule({merchantToMatch: 'Starbucks', categoriesToMatch: ['Coffee', 'Travel'], tagsToMatch: ['Team A'], category: 'Coffee'}, policy); // When its conditions are summarized // Then each field keeps its own values, so the table can name them separately @@ -335,7 +334,7 @@ describe('ExpenseDefaultRuleUtils', () => { }); }); - describe('getMerchantRuleFormValues rejects rules the form cannot represent', () => { + describe('getExpenseDefaultFormValues rejects rules the form cannot represent', () => { it.each([ [ 'conditions combined with or rather than and', @@ -375,11 +374,11 @@ describe('ExpenseDefaultRuleUtils', () => { ['a value of the wrong type', {actions: toIndexMap([{name: ACTION.SET, field: FIELD.BILLABLE, value: 'true'}])}], ['a malformed tax value', {actions: toIndexMap([{name: ACTION.SET, field: FIELD.TAX, value: TAX_KEY}])}], ])('returns undefined for %s', (_description, overrides) => { - expect(getMerchantRuleFormValues(buildRuleWithOverrides(overrides))).toBeUndefined(); + expect(getExpenseDefaultFormValues(buildRuleWithOverrides(overrides))).toBeUndefined(); }); it('returns undefined for an undefined rule', () => { - expect(getMerchantRuleFormValues(undefined)).toBeUndefined(); + expect(getExpenseDefaultFormValues(undefined)).toBeUndefined(); }); }); @@ -488,17 +487,17 @@ describe('ExpenseDefaultRuleUtils', () => { }); }); - describe('canEditMerchantRule', () => { + describe('canEditExpenseDefaultRule', () => { it('allows a representable rule scoped to the policy', () => { - expect(canEditMerchantRule(asStoredRule(merchantRuleBody), POLICY_ID)).toBe(true); + expect(canEditExpenseDefaultRule(asStoredRule(merchantRuleBody), POLICY_ID)).toBe(true); }); it('refuses a rule belonging to another policy', () => { - expect(canEditMerchantRule(asStoredRule(merchantRuleBody, OTHER_POLICY_ID), POLICY_ID)).toBe(false); + expect(canEditExpenseDefaultRule(asStoredRule(merchantRuleBody, OTHER_POLICY_ID), POLICY_ID)).toBe(false); }); it('refuses an account scoped rule', () => { - expect(canEditMerchantRule(asStoredRule(merchantRuleBody, POLICY_ID, CONST.RULES.SCOPE.ACCOUNT), POLICY_ID)).toBe(false); + expect(canEditExpenseDefaultRule(asStoredRule(merchantRuleBody, POLICY_ID, CONST.RULES.SCOPE.ACCOUNT), POLICY_ID)).toBe(false); }); it('refuses an approval workflow rule that happens to share the ruleID', () => { @@ -509,7 +508,7 @@ describe('ExpenseDefaultRuleUtils', () => { }), ); - expect(canEditMerchantRule(approvalWorkflowRule, POLICY_ID)).toBe(false); + expect(canEditExpenseDefaultRule(approvalWorkflowRule, POLICY_ID)).toBe(false); }); it('refuses a rule the form cannot represent, so saving cannot overwrite it', () => { @@ -519,68 +518,12 @@ describe('ExpenseDefaultRuleUtils', () => { }), ); - expect(canEditMerchantRule(nested, POLICY_ID)).toBe(false); + expect(canEditExpenseDefaultRule(nested, POLICY_ID)).toBe(false); }); it('refuses without a rule or without a policy', () => { - expect(canEditMerchantRule(undefined, POLICY_ID)).toBe(false); - expect(canEditMerchantRule(asStoredRule(merchantRuleBody), undefined)).toBe(false); - }); - }); - - describe('getRuleMerchantMatchSummary', () => { - it('reads a single merchant leaf', () => { - expect(getRuleMerchantMatchSummary(merchantFilter)).toEqual({merchants: 'Starbucks', isExactMatch: false}); - }); - - it('flags an exact match', () => { - expect(getRuleMerchantMatchSummary({left: FIELD.MERCHANT, operator: EQUAL_TO, right: 'Starbucks'})).toEqual({merchants: 'Starbucks', isExactMatch: true}); - }); - - it('joins the values of a merchant leaf holding a list, which the editor cannot represent', () => { - const filters: RuleFilterComparison = {left: FIELD.MERCHANT, operator: EQUAL_TO, right: ['Starbucks', 'Costa']}; - - expect(getRuleMerchantMatchSummary(filters)).toEqual({merchants: 'Starbucks, Costa', isExactMatch: true}); - }); - - it('joins the merchants of a nested tree, which the editor cannot represent', () => { - const filters: RuleFilterNode = { - left: merchantFilter, - operator: AND, - right: {left: FIELD.MERCHANT, operator: CONTAINS, right: 'Costa'}, - }; - - expect(getRuleMerchantMatchSummary(filters)).toEqual({merchants: 'Starbucks, Costa', isExactMatch: false}); - // The same rule stays read-only, so the summary is the only thing the row can show. - expect(getMerchantRuleFormValues(asStoredRule({...merchantRuleBody, filters}))).toBeUndefined(); - }); - - it('is not an exact match when the merchant leaves disagree on the operator', () => { - const filters: RuleFilterNode = { - left: {left: FIELD.MERCHANT, operator: EQUAL_TO, right: 'Starbucks'}, - operator: OR, - right: {left: FIELD.MERCHANT, operator: CONTAINS, right: 'Costa'}, - }; - - expect(getRuleMerchantMatchSummary(filters).isExactMatch).toBe(false); - }); - - it('ignores leaves on other fields', () => { - const filters: RuleFilterNode = { - left: merchantFilter, - operator: AND, - right: {left: FIELD.CATEGORY, operator: EQUAL_TO, right: 'Coffee'}, - }; - - expect(getRuleMerchantMatchSummary(filters)).toEqual({merchants: 'Starbucks', isExactMatch: false}); - }); - - it('is empty without a merchant leaf', () => { - expect(getRuleMerchantMatchSummary({left: FIELD.CATEGORY, operator: EQUAL_TO, right: 'Coffee'})).toEqual({merchants: '', isExactMatch: false}); - }); - - it('is empty without filters', () => { - expect(getRuleMerchantMatchSummary(undefined)).toEqual({merchants: '', isExactMatch: false}); + expect(canEditExpenseDefaultRule(undefined, POLICY_ID)).toBe(false); + expect(canEditExpenseDefaultRule(asStoredRule(merchantRuleBody), undefined)).toBe(false); }); }); diff --git a/tests/unit/VendorMatchingMerchantRulesTest.ts b/tests/unit/VendorMatchingMerchantRulesTest.ts index 34e39f866a0d..fa300b3fef2c 100644 --- a/tests/unit/VendorMatchingMerchantRulesTest.ts +++ b/tests/unit/VendorMatchingMerchantRulesTest.ts @@ -1,4 +1,4 @@ -import {buildMerchantRule} from '@libs/ExpenseDefaultRuleUtils'; +import {buildExpenseDefaultRule} from '@libs/ExpenseDefaultRuleUtils'; import {getMerchantRulesTableData} from '@libs/MerchantTypeRulesUtils'; import {hasVendorFeature} from '@libs/PolicyUtils'; import {toIndexMap} from '@libs/RuleUtils'; @@ -108,7 +108,7 @@ const buildQBOWithStaleXeroPolicy = (qboVendors: Array<{id: string; name: string }); const buildVendorRule = (policy: Policy, vendorID: string): Rule => ({ - ...buildMerchantRule({merchantToMatch: 'Coffee Shop', matchType: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, vendorID}, policy), + ...buildExpenseDefaultRule({merchantToMatch: 'Coffee Shop', matchType: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, vendorID}, policy), scope: CONST.RULES.SCOPE.POLICY, scopeID: policy.id, triggers: toIndexMap([CONST.RULES.TRIGGERS.CREATE_TRANSACTION]), @@ -119,14 +119,14 @@ const buildVendorRule = (policy: Policy, vendorID: string): Rule => ({ const withVendorRule = (policy: Policy, vendorID: string) => ({[`${ONYXKEYS.COLLECTION.RULE}rule1`]: buildVendorRule(policy, vendorID)}); describe('Vendor matching on merchant rules', () => { - describe('buildMerchantRule vendor action', () => { + describe('buildExpenseDefaultRule vendor action', () => { it('writes a Set action for a vendorID', () => { - const actions = Object.values(buildMerchantRule(buildForm({vendorID: 'v-1'}), undefined)?.actions ?? {}); + const actions = Object.values(buildExpenseDefaultRule(buildForm({vendorID: 'v-1'}), undefined)?.actions ?? {}); expect(actions).toContainEqual({name: CONST.RULES.ACTIONS.SET, field: CONST.RULES.EXPENSE_DEFAULT.FIELD.VENDOR_ID, value: 'v-1'}); }); it('writes no vendor action when the vendorID is unset, so the rule stops setting it', () => { - const actions = Object.values(buildMerchantRule(buildForm({vendorID: '', category: 'Coffee'}), undefined)?.actions ?? {}); + const actions = Object.values(buildExpenseDefaultRule(buildForm({vendorID: '', category: 'Coffee'}), undefined)?.actions ?? {}); expect(actions.some((action) => action.field === CONST.RULES.EXPENSE_DEFAULT.FIELD.VENDOR_ID)).toBe(false); }); }); @@ -195,7 +195,7 @@ describe('Vendor matching on merchant rules', () => { }); }); - describe('vendor row gating (hasVendorFeature governs MerchantRulePageBase row visibility)', () => { + describe('vendor row gating (hasVendorFeature governs ExpenseDefaultRulePageBase row visibility)', () => { it('is visible when the beta is on and a vendor integration is connected', () => { expect(hasVendorFeature(buildQBOPolicy([{id: 'v-1', name: 'Acme Co', currency: 'USD'}]), true)).toBe(true); }); From d7fdd05d0655bee4c58d6446da4c0f06e613c132 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 16 Oct 2026 03:19:25 +0530 Subject: [PATCH 12/24] fix knip check. Signed-off-by: krishna2323 --- src/types/onyx/Policy.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index f132dccec73d..6e6d37335410 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -4202,7 +4202,6 @@ export type { ApprovalRule, ExpenseRule, CodingRule, - CodingRuleFilter, NetSuiteConnectionConfig, MccGroup, Subrate, From 82e33fcacd7f520c268d7c9404d3034245b5d060 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Mon, 19 Oct 2026 23:52:36 +0530 Subject: [PATCH 13/24] update translations. Signed-off-by: krishna2323 --- src/languages/de.ts | 19 +------------------ src/languages/el.ts | 21 +-------------------- src/languages/es.ts | 20 +------------------- src/languages/fr.ts | 20 +------------------- src/languages/it.ts | 19 +------------------ src/languages/ja.ts | 17 +---------------- src/languages/nl.ts | 18 +----------------- src/languages/pl.ts | 18 +----------------- src/languages/pt-BR.ts | 18 +----------------- src/languages/zh-hans.ts | 17 +---------------- 10 files changed, 10 insertions(+), 177 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 8fe7783c19d1..73bc0d25817f 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -8526,18 +8526,8 @@ Fordern Sie Spesendetails wie Belege und Beschreibungen an, legen Sie Limits und vendorUnavailable: 'Anbieter nicht verfügbar', supplierUnavailable: 'Lieferant nicht verfügbar', confirmErrorCategory: 'Bitte wählen Sie eine Kategorie aus', - confirmErrorCategoryTax: 'Bitte wählen Sie einen Steuersatz aus', confirmErrorCondition: 'Bitte geben Sie einen Händler ein', confirmErrorConditionAndDefault: 'Geben Sie einen Händler ein und wenden Sie mindestens eine Standardeinstellung an', - turnOnTaxesFirstTitle: 'Schalten Sie zuerst Steuern ein', - turnOnTaxesFirstPrompt: 'Kategorienregeln legen einen Standardsteuersatz fest. Aktivieren Sie Steuern in Ihren Workspace-Einstellungen, um sie zu verwenden.', - categoryRulesApplyGoingForwardTitle: 'Kategorienregeln gelten ab jetzt', - categoryRulesApplyGoingForwardPrompt: 'Ein Standardsatz für Steuern gilt für neue Ausgaben in dieser Kategorie. Bereits vorhandene Ausgaben werden nicht geändert.', - confirmErrorCategoryTaxMoveIsWorkspaceDefault: - 'Der ausgewählte Steuersatz ist jetzt die Voreinstellung für Ihren Workspace, daher ist diese Regel nicht mehr gültig. Wählen Sie einen anderen Steuersatz.', - addTaxRateFirstTitle: 'Fügen Sie zuerst einen Steuersatz hinzu', - addTaxRateFirstPrompt: - 'Kategorienregeln legen einen standardmäßigen Steuersatz fest. Fügen Sie einen anderen Steuersatz als den Standard Ihres Arbeitsbereichs hinzu, um sie zu verwenden.', createRuleFromExpenseAction: 'Regel erstellen', createRuleFromExpensePrompt: 'um Ihre Änderungen auf alle Ausgaben anzuwenden, die Ihren Kriterien entsprechen.', }, @@ -8854,6 +8844,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc merchantIs: (merchant: string) => `Händler ist „${merchant}“`, merchantTypeIs: (merchantType: string) => `Händlertyp ist „${merchantType}"`, categoryIs: (category: string) => `Kategorie ist „${category}“`, + tagIs: (tag: string) => `Tag ist „${tag}“`, }, merchantTypeRule: {merchantType: 'Händlerart', saveRule: 'Regel speichern', confirmErrorCategory: 'Bitte wählen Sie eine Kategorie aus.'}, requireFieldsTable: { @@ -8923,14 +8914,6 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc cta: 'KI-Regel hinzufügen', }, categoriesDisabledEmptyState: {title: 'Kategorien sind nicht aktiviert', subtitle: 'Aktivieren Sie Kategorien, um Ihre Ausgaben besser zu kontrollieren.'}, - expenseDefaultType: { - title: 'Standardspesen übernehmen', - subtitle: 'Worauf soll diese Regel angewendet werden?', - merchant: 'Händler', - merchantDescription: 'Felder bei Ausgaben eines bestimmten Händlers aktualisieren', - category: 'Kategorie', - categoryDescription: 'Legen Sie einen Standardsteuersatz für eine bestimmte Kategorie fest', - }, taxesDisabledEmptyState: { title: 'Steuern sind nicht aktiviert', subtitle: 'Aktivieren Sie Steuern, um anfallende Steuern zu dokumentieren und zurückzufordern.', diff --git a/src/languages/el.ts b/src/languages/el.ts index aa8734fa1717..13497b186320 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -8899,20 +8899,8 @@ ${reportName}`, vendorUnavailable: 'Ο προμηθευτής δεν είναι διαθέσιμος', supplierUnavailable: 'Ο προμηθευτής δεν είναι διαθέσιμος', confirmErrorCategory: 'Παρακαλώ επιλέξτε κατηγορία', - confirmErrorCategoryTax: 'Επιλέξτε έναν φορολογικό συντελεστή', confirmErrorCondition: 'Παρακαλούμε εισαγάγετε έναν έμπορο', confirmErrorConditionAndDefault: 'Προσθέστε έναν έμπορο και ορίστε τουλάχιστον μία προεπιλογή', - turnOnTaxesFirstTitle: 'Ενεργοποιήστε πρώτα τους φόρους', - turnOnTaxesFirstPrompt: - 'Οι κανόνες κατηγορίας ορίζουν έναν προεπιλεγμένο φορολογικό συντελεστή. Ενεργοποιήστε τους φόρους στις ρυθμίσεις του χώρου εργασίας σας για να τους χρησιμοποιήσετε.', - categoryRulesApplyGoingForwardTitle: 'Οι κανόνες κατηγορίας ισχύουν από εδώ και στο εξής', - categoryRulesApplyGoingForwardPrompt: - 'Ένας προεπιλεγμένος φορολογικός συντελεστής εφαρμόζεται σε νέες δαπάνες σε αυτή την κατηγορία. Οι δαπάνες που υπάρχουν ήδη δεν θα αλλάξουν.', - confirmErrorCategoryTaxMoveIsWorkspaceDefault: - 'Ο επιλεγμένος φορολογικός συντελεστής είναι πλέον η προεπιλογή για τον χώρο εργασίας σας, επομένως αυτός ο κανόνας δεν είναι πλέον έγκυρος. Επιλέξτε έναν διαφορετικό φορολογικό συντελεστή.', - addTaxRateFirstTitle: 'Προσθέστε πρώτα έναν φορολογικό συντελεστή', - addTaxRateFirstPrompt: - 'Οι κανόνες κατηγορίας ορίζουν έναν προεπιλεγμένο φορολογικό συντελεστή. Προσθέστε έναν φορολογικό συντελεστή διαφορετικό από την προεπιλογή του χώρου εργασίας σας για να τους χρησιμοποιήσετε.', createRuleFromExpenseAction: 'Δημιουργία κανόνα', createRuleFromExpensePrompt: 'για να εφαρμόσετε τις αλλαγές σας σε όλες τις δαπάνες που ταιριάζουν με τα κριτήριά σας.', }, @@ -8948,6 +8936,7 @@ ${reportName}`, merchantIs: (merchant: string) => `Ο έμπορος είναι «${merchant}»`, merchantTypeIs: (merchantType: string) => `Ο τύπος εμπόρου είναι «${merchantType}»`, categoryIs: (category: string) => `Η κατηγορία είναι «${category}»`, + tagIs: (tag: string) => `Η ετικέτα είναι «${tag}»`, }, merchantTypeRule: { merchantType: 'Τύπος εμπόρου', @@ -9243,14 +9232,6 @@ ${reportName}`, subtitle: 'Δημιουργήστε έναν κανόνα για την αυτοματοποίηση των πολιτικών του χώρου εργασίας σας.', cta: 'Προσθήκη κανόνα AI', }, - expenseDefaultType: { - title: 'Εφαρμογή προεπιλογών εξόδων', - subtitle: 'Σε τι πρέπει να αντιστοιχεί αυτός ο κανόνας;', - merchant: 'Έμπορος', - merchantDescription: 'Ενημέρωση πεδίων σε δαπάνες από συγκεκριμένο έμπορο', - category: 'Κατηγορία', - categoryDescription: 'Ορίστε προεπιλεγμένο φορολογικό συντελεστή για μια συγκεκριμένη κατηγορία', - }, taxesDisabledEmptyState: { title: 'Οι φόροι δεν είναι ενεργοποιημένοι', subtitle: 'Ενεργοποιήστε τους φόρους για να τεκμηριώσετε και να διεκδικήσετε τους επιλέξιμους φόρους.', diff --git a/src/languages/es.ts b/src/languages/es.ts index f7643a27f7f3..9b4076a91daf 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -8557,19 +8557,8 @@ ${reportName}`, ifAnyExpenseMatches: 'Si algún gasto coincide con:', thenApplyFollowingDefaults: 'Luego aplica las siguientes opciones predeterminadas:', confirmErrorCategory: 'Selecciona una categoría', - confirmErrorCategoryTax: 'Selecciona una tasa de impuesto', confirmErrorCondition: 'Por favor, introduce un comercio', confirmErrorConditionAndDefault: 'Introduce un comercio y aplica al menos un valor predeterminado', - turnOnTaxesFirstTitle: 'Activa primero los impuestos', - turnOnTaxesFirstPrompt: - 'Las reglas de categoría establecen una tasa de impuesto predeterminada. Activa los impuestos en la configuración de tu espacio de trabajo para usarlos.', - categoryRulesApplyGoingForwardTitle: 'Las reglas de categoría se aplican de ahora en adelante', - categoryRulesApplyGoingForwardPrompt: 'Se aplica una tasa de impuesto predeterminada a los nuevos gastos de esta categoría. Los gastos que ya existen no cambiarán.', - confirmErrorCategoryTaxMoveIsWorkspaceDefault: - 'El tipo impositivo seleccionado es ahora el predeterminado de tu espacio de trabajo, por lo que esta regla ya no es válida. Elige un tipo impositivo diferente.', - addTaxRateFirstTitle: 'Añade primero una tasa de impuesto', - addTaxRateFirstPrompt: - 'Las reglas de categoría establecen una tasa de impuestos predeterminada. Añade una tasa de impuestos distinta de la predeterminada de tu espacio de trabajo para poder usarlas.', createRuleFromExpenseAction: 'Crear una norma', createRuleFromExpensePrompt: 'para aplicar tus cambios a todos los gastos que coincidan con tus criterios.', }, @@ -8859,6 +8848,7 @@ ${reportName}`, merchantIs: (merchant: string) => `El comercio es «${merchant}»`, merchantTypeIs: (merchantType: string) => `El tipo de comercio es "${merchantType}"`, categoryIs: (category: string) => `La categoría es «${category}»`, + tagIs: (tag: string) => `La etiqueta es «${tag}»`, }, merchantTypeRule: {merchantType: 'Tipo de comercio', saveRule: 'Guardar regla', confirmErrorCategory: 'Por favor, selecciona una categoría.'}, requireFieldsTable: { @@ -8928,14 +8918,6 @@ ${reportName}`, cta: 'Añadir regla de IA', }, categoriesDisabledEmptyState: {title: 'Las categorías no están habilitadas', subtitle: 'Habilita las categorías para tener más control sobre tus gastos.'}, - expenseDefaultType: { - title: 'Aplicar valores predeterminados de gastos', - subtitle: '¿Con qué debería coincidir esta regla?', - merchant: 'Comercio', - merchantDescription: 'Actualizar campos en gastos de un comerciante específico', - category: 'Categoría', - categoryDescription: 'Establece una tasa de impuesto predeterminada para una categoría específica', - }, taxesDisabledEmptyState: { title: 'Los impuestos no están habilitados', subtitle: 'Habilita los impuestos para documentar y recuperar los impuestos que sean deducibles.', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 5b11ce2e90ce..eca16fedfbe7 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -8548,19 +8548,8 @@ Rendez obligatoires des informations de dépense comme les reçus et les descrip vendorUnavailable: 'Fournisseur indisponible', supplierUnavailable: 'Fournisseur indisponible', confirmErrorCategory: 'Veuillez sélectionner une catégorie', - confirmErrorCategoryTax: 'Veuillez sélectionner un taux de taxe', confirmErrorCondition: 'Veuillez saisir un commerçant', confirmErrorConditionAndDefault: 'Saisissez un commerçant et appliquez au moins un paramètre par défaut', - turnOnTaxesFirstTitle: 'Activer d’abord les taxes', - turnOnTaxesFirstPrompt: 'Les règles de catégorie définissent un taux de taxe par défaut. Activez les taxes dans les paramètres de votre espace de travail pour les utiliser.', - categoryRulesApplyGoingForwardTitle: 'Les règles de catégorie s’appliquent à partir de maintenant', - categoryRulesApplyGoingForwardPrompt: - 'Un taux de taxe par défaut s’applique aux nouvelles dépenses de cette catégorie. Les dépenses déjà existantes ne seront pas modifiées.', - confirmErrorCategoryTaxMoveIsWorkspaceDefault: - 'Le taux de taxe sélectionné est désormais le taux par défaut de votre espace de travail, cette règle n’est donc plus valide. Choisissez un autre taux de taxe.', - addTaxRateFirstTitle: 'Ajoutez d’abord un taux de taxe', - addTaxRateFirstPrompt: - 'Les règles de catégorie définissent un taux de taxe par défaut. Ajoutez un taux de taxe autre que le taux par défaut de votre espace de travail pour les utiliser.', createRuleFromExpenseAction: 'Créer une règle', createRuleFromExpensePrompt: 'pour appliquer vos modifications à toutes les dépenses qui correspondent à vos critères.', }, @@ -8877,6 +8866,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e merchantIs: (merchant: string) => `Le commerçant est « ${merchant} »`, merchantTypeIs: (merchantType: string) => `Le type de commerçant est « ${merchantType} »`, categoryIs: (category: string) => `La catégorie est « ${category} »`, + tagIs: (tag: string) => `Le tag est « ${tag} »`, }, merchantTypeRule: {merchantType: 'Type de marchand', saveRule: 'Enregistrer la règle', confirmErrorCategory: 'Veuillez sélectionner une catégorie.'}, requireFieldsTable: { @@ -8946,14 +8936,6 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e cta: 'Ajouter une règle IA', }, categoriesDisabledEmptyState: {title: 'Les catégories ne sont pas activées', subtitle: 'Activez les catégories pour mieux contrôler vos dépenses.'}, - expenseDefaultType: { - title: 'Appliquer les valeurs de dépense par défaut', - subtitle: 'Sur quoi cette règle doit-elle faire correspondre ?', - merchant: 'Commerçant', - merchantDescription: 'Mettre à jour les champs des dépenses d’un commerçant spécifique', - category: 'Catégorie', - categoryDescription: 'Définir un taux de taxe par défaut pour une catégorie spécifique', - }, taxesDisabledEmptyState: {title: 'Les taxes ne sont pas activées', subtitle: 'Activez les taxes pour documenter et récupérer les taxes admissibles.', cta: 'Activer les taxes'}, }, planTypePage: { diff --git a/src/languages/it.ts b/src/languages/it.ts index b00d76854404..a6f9895ef83c 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -8477,18 +8477,8 @@ Richiedi dettagli sulle spese come ricevute e descrizioni, imposta limiti e valo vendorUnavailable: 'Fornitore non disponibile', supplierUnavailable: 'Fornitore non disponibile', confirmErrorCategory: 'Seleziona una categoria', - confirmErrorCategoryTax: "Seleziona un'aliquota fiscale", confirmErrorCondition: 'Inserisci un esercente', confirmErrorConditionAndDefault: 'Inserisci un esercente e applica almeno un valore predefinito', - turnOnTaxesFirstTitle: 'Attiva prima le tasse', - turnOnTaxesFirstPrompt: 'Le regole di categoria impostano un’aliquota fiscale predefinita. Attiva le imposte nelle impostazioni dello spazio di lavoro per usarle.', - categoryRulesApplyGoingForwardTitle: 'Le regole di categoria si applicano da ora in poi', - categoryRulesApplyGoingForwardPrompt: 'Un’aliquota fiscale predefinita viene applicata alle nuove spese in questa categoria. Le spese già esistenti non verranno modificate.', - confirmErrorCategoryTaxMoveIsWorkspaceDefault: - 'L’aliquota fiscale selezionata è ora quella predefinita del tuo workspace, quindi questa regola non è più valida. Scegli un’altra aliquota fiscale.', - addTaxRateFirstTitle: "Aggiungi prima un'aliquota fiscale", - addTaxRateFirstPrompt: - 'Le regole di categoria impostano un’aliquota fiscale predefinita. Aggiungi un’aliquota diversa da quella predefinita dello spazio di lavoro per poterle usare.', createRuleFromExpenseAction: 'Crea una regola', createRuleFromExpensePrompt: 'per applicare le modifiche a tutte le spese che corrispondono ai tuoi criteri.', }, @@ -8805,6 +8795,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, merchantIs: (merchant: string) => `L'esercente è "${merchant}"`, merchantTypeIs: (merchantType: string) => `Il tipo di esercente è "${merchantType}"`, categoryIs: (category: string) => `La categoria è "${category}"`, + tagIs: (tag: string) => `Il tag è "${tag}"`, }, merchantTypeRule: {merchantType: 'Tipo di esercente', saveRule: 'Salva regola', confirmErrorCategory: 'Seleziona una categoria.'}, requireFieldsTable: { @@ -8870,14 +8861,6 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, }, agentRulesEmptyState: {title: 'Nessuna regola agente aggiunta', subtitle: 'Crea una regola per automatizzare le policy del tuo workspace.', cta: 'Aggiungi regola IA'}, categoriesDisabledEmptyState: {title: 'Le categorie non sono abilitate', subtitle: 'Attiva le categorie per avere un maggiore controllo sulle tue spese.'}, - expenseDefaultType: { - title: 'Applica impostazioni predefinite spesa', - subtitle: 'Su cosa dovrebbe corrispondere questa regola?', - merchant: 'Commerciante', - merchantDescription: 'Aggiorna i campi delle spese di un determinato esercente', - category: 'Categoria', - categoryDescription: "Imposta un'aliquota fiscale predefinita per una categoria specifica", - }, taxesDisabledEmptyState: {title: 'Le imposte non sono abilitate', subtitle: 'Abilita le imposte per documentare e recuperare quelle ammissibili.', cta: 'Abilita imposte'}, }, planTypePage: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index eef159b8d2fe..0501cbc2af0b 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -8370,16 +8370,8 @@ ${reportName}`, vendorUnavailable: 'ベンダーを利用できません', supplierUnavailable: 'サプライヤーを利用できません', confirmErrorCategory: 'カテゴリを選択してください', - confirmErrorCategoryTax: '税率を選択してください', confirmErrorCondition: '店舗名を入力してください', confirmErrorConditionAndDefault: '取引先を入力し、少なくとも 1 つのデフォルトを適用してください', - turnOnTaxesFirstTitle: '先に税金を有効にしてください', - turnOnTaxesFirstPrompt: 'カテゴリルールでは、デフォルトの税率を設定できます。利用するには、ワークスペース設定で税金を有効にしてください。', - categoryRulesApplyGoingForwardTitle: 'カテゴリルールは今後に適用されます', - categoryRulesApplyGoingForwardPrompt: 'このカテゴリーの新しい経費には、デフォルトの税率が適用されます。既存の経費は変更されません。', - confirmErrorCategoryTaxMoveIsWorkspaceDefault: '選択した税率がワークスペースのデフォルトになっているため、このルールは無効になりました。別の税率を選択してください。', - addTaxRateFirstTitle: '先に税率を追加してください', - addTaxRateFirstPrompt: 'カテゴリルールではデフォルトの税率が設定されます。ワークスペースのデフォルト以外の税率を追加してから、カテゴリルールを使用してください。', createRuleFromExpenseAction: 'ルールを作成', createRuleFromExpensePrompt: '条件に一致するすべての経費に変更を適用します。', }, @@ -8694,6 +8686,7 @@ ${reportName}`, merchantIs: (merchant: string) => `加盟店名は「${merchant}」です`, merchantTypeIs: (merchantType: string) => `加盟店タイプ: 「${merchantType}」`, categoryIs: (category: string) => `カテゴリは「${category}」です`, + tagIs: (tag: string) => `タグは「${tag}」です`, }, merchantTypeRule: {merchantType: '加盟店種別', saveRule: 'ルールを保存', confirmErrorCategory: 'カテゴリを選択してください。'}, requireFieldsTable: { @@ -8759,14 +8752,6 @@ ${reportName}`, }, agentRulesEmptyState: {title: 'エージェントルールが追加されていません', subtitle: 'ワークスペースのポリシーを自動化するルールを作成します。', cta: 'AIルールを追加'}, categoriesDisabledEmptyState: {title: 'カテゴリが有効になっていません', subtitle: 'カテゴリを有効にして、支出をより細かく管理しましょう。'}, - expenseDefaultType: { - title: '経費のデフォルトを適用', - subtitle: 'このルールは何に一致させますか?', - merchant: '加盟店', - merchantDescription: '特定の加盟店の経費フィールドを更新', - category: 'カテゴリ', - categoryDescription: '特定のカテゴリにデフォルトの税率を設定する', - }, taxesDisabledEmptyState: {title: '税金は有効になっていません', subtitle: '税金を有効にして、対象となる税金を記録し還付請求できるようにします。', cta: '税金を有効にする'}, }, planTypePage: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 34c05ae78383..ac44c34b32a8 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -8453,17 +8453,8 @@ Vereis onkostendetails zoals bonnen en beschrijvingen, stel limieten en standaar vendorUnavailable: 'Leverancier niet beschikbaar', supplierUnavailable: 'Leverancier niet beschikbaar', confirmErrorCategory: 'Selecteer een categorie', - confirmErrorCategoryTax: 'Selecteer een belastingtarief', confirmErrorCondition: 'Voer een leverancier in', confirmErrorConditionAndDefault: 'Voer een leverancier in en stel minstens één standaard in', - turnOnTaxesFirstTitle: 'Zet eerst belastingen aan', - turnOnTaxesFirstPrompt: 'Categorischregels stellen een standaardbelastingtarief in. Schakel belastingen in bij de instellingen van je workspace om ze te gebruiken.', - categoryRulesApplyGoingForwardTitle: 'Categoriegregels gelden vanaf nu', - categoryRulesApplyGoingForwardPrompt: 'Een standaardbelastingtarief is van toepassing op nieuwe uitgaven in deze categorie. Bestaande uitgaven veranderen niet.', - confirmErrorCategoryTaxMoveIsWorkspaceDefault: - 'Het geselecteerde belastingtarief is nu de standaard voor je werkruimte, waardoor deze regel niet meer geldig is. Kies een ander belastingtarief.', - addTaxRateFirstTitle: 'Voeg eerst een belastingtarief toe', - addTaxRateFirstPrompt: 'Categorielimieten stellen een standaardbelastingtarief in. Voeg een ander belastingtarief toe dan de standaard van je werkruimte om ze te gebruiken.', createRuleFromExpenseAction: 'Maak een regel', createRuleFromExpensePrompt: 'om je wijzigingen toe te passen op alle onkosten die aan je criteria voldoen.', }, @@ -8779,6 +8770,7 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, merchantIs: (merchant: string) => `Handelaar is "${merchant}"`, merchantTypeIs: (merchantType: string) => `Type leverancier is "${merchantType}"`, categoryIs: (category: string) => `Categorie is "${category}"`, + tagIs: (tag: string) => `Tag is "${tag}"`, }, merchantTypeRule: {merchantType: 'Type handelaar', saveRule: 'Regel opslaan', confirmErrorCategory: 'Selecteer een categorie.'}, requireFieldsTable: { @@ -8844,14 +8836,6 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, }, agentRulesEmptyState: {title: 'Geen agentregels toegevoegd', subtitle: 'Maak een regel om je werkruimtebeleid te automatiseren.', cta: 'AI-regel toevoegen'}, categoriesDisabledEmptyState: {title: 'Categorieën zijn niet ingeschakeld', subtitle: 'Schakel categorieën in om meer controle te krijgen over je uitgaven.'}, - expenseDefaultType: { - title: 'Standaardwaarden voor uitgaven toepassen', - subtitle: 'Waar moet deze regel op overeenkomen?', - merchant: 'Winkelier', - merchantDescription: 'Veldwaarden bij uitgaven van een specifieke verkoper bijwerken', - category: 'Categorie', - categoryDescription: 'Stel een standaardbelastingtarief in voor een specifieke categorie', - }, taxesDisabledEmptyState: { title: 'Belastingen zijn niet ingeschakeld', subtitle: 'Activeer belastingen om in aanmerking komende belastingen vast te leggen en terug te vorderen.', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index f128ab320992..4f93292dde34 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -8477,17 +8477,8 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i vendorUnavailable: 'Dostawca niedostępny', supplierUnavailable: 'Dostawca niedostępny', confirmErrorCategory: 'Wybierz kategorię', - confirmErrorCategoryTax: 'Wybierz stawkę podatku', confirmErrorCondition: 'Wpisz sprzedawcę', confirmErrorConditionAndDefault: 'Wpisz sprzedawcę i zastosuj co najmniej jedną domyślną opcję', - turnOnTaxesFirstTitle: 'Najpierw włącz podatki', - turnOnTaxesFirstPrompt: 'Reguły kategorii ustawiają domyślną stawkę podatku. Włącz podatki w ustawieniach swojego workspace, aby z nich korzystać.', - categoryRulesApplyGoingForwardTitle: 'Reguły kategorii będą stosowane od teraz', - categoryRulesApplyGoingForwardPrompt: 'Domyślna stawka podatku będzie stosowana do nowych wydatków w tej kategorii. Istniejące wydatki nie zostaną zmienione.', - confirmErrorCategoryTaxMoveIsWorkspaceDefault: - 'Wybrana stawka podatku jest teraz domyślną stawką w twoim zespole, więc ta reguła nie jest już prawidłowa. Wybierz inną stawkę podatku.', - addTaxRateFirstTitle: 'Najpierw dodaj stawkę podatku', - addTaxRateFirstPrompt: 'Reguły kategorii ustawiają domyślną stawkę podatku. Dodaj stawkę podatku inną niż domyślna w twoim obszarze roboczym, żeby z nich korzystać.', createRuleFromExpenseAction: 'Utwórz regułę', createRuleFromExpensePrompt: 'aby zastosować zmiany do wszystkich wydatków spełniających twoje kryteria.', }, @@ -8804,6 +8795,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, merchantIs: (merchant: string) => `Sprzedawca to „${merchant}”`, merchantTypeIs: (merchantType: string) => `Typ sprzedawcy to „${merchantType}"`, categoryIs: (category: string) => `Kategoria to „${category}”`, + tagIs: (tag: string) => `Tag to „${tag}”`, }, merchantTypeRule: {merchantType: 'Typ sprzedawcy', saveRule: 'Zapisz regułę', confirmErrorCategory: 'Wybierz kategorię.'}, requireFieldsTable: { @@ -8869,14 +8861,6 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, }, agentRulesEmptyState: {title: 'Nie dodano reguł agenta', subtitle: 'Utwórz regułę, żeby zautomatyzować zasady swojego workspace’u.', cta: 'Dodaj regułę AI'}, categoriesDisabledEmptyState: {title: 'Kategorie nie są włączone', subtitle: 'Włącz kategorie, żeby mieć większą kontrolę nad wydatkami.'}, - expenseDefaultType: { - title: 'Zastosuj domyślne ustawienia wydatków', - subtitle: 'Do czego ta reguła powinna pasować?', - merchant: 'Sprzedawca', - merchantDescription: 'Zaktualizuj pola w wydatkach od konkretnego sprzedawcy', - category: 'Kategoria', - categoryDescription: 'Ustaw domyślną stawkę podatku dla konkretnej kategorii', - }, taxesDisabledEmptyState: {title: 'Podatki nie są włączone', subtitle: 'Włącz podatki, aby udokumentować i odzyskać kwalifikujące się podatki.', cta: 'Włącz podatki'}, }, planTypePage: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index c031b98782af..9afc727f60f1 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -8459,17 +8459,8 @@ Exija dados de despesas como recibos e descrições, defina limites e padrões e vendorUnavailable: 'Fornecedor indisponível', supplierUnavailable: 'Fornecedor indisponível', confirmErrorCategory: 'Selecione uma categoria', - confirmErrorCategoryTax: 'Selecione uma alíquota de imposto', confirmErrorCondition: 'Insira um estabelecimento', confirmErrorConditionAndDefault: 'Insira um comerciante e aplique pelo menos um padrão', - turnOnTaxesFirstTitle: 'Ative os impostos primeiro', - turnOnTaxesFirstPrompt: 'As regras de categoria definem uma alíquota de imposto padrão. Ative os impostos nas configurações do seu workspace para usá-los.', - categoryRulesApplyGoingForwardTitle: 'As regras de categoria se aplicam daqui em diante', - categoryRulesApplyGoingForwardPrompt: 'Uma taxa de imposto padrão se aplica às novas despesas desta categoria. As despesas que já existem não serão alteradas.', - confirmErrorCategoryTaxMoveIsWorkspaceDefault: - 'A alíquota de imposto selecionada agora é o padrão do seu workspace, então esta regra não é mais válida. Escolha uma alíquota de imposto diferente.', - addTaxRateFirstTitle: 'Adicione uma alíquota de imposto primeiro', - addTaxRateFirstPrompt: 'As regras de categoria definem uma taxa de imposto padrão. Adicione uma taxa diferente da padrão do seu espaço de trabalho para usá-las.', createRuleFromExpenseAction: 'Criar regra', createRuleFromExpensePrompt: 'para aplicar suas alterações a todas as despesas que correspondem aos seus critérios.', }, @@ -8786,6 +8777,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, merchantIs: (merchant: string) => `O comerciante é "${merchant}"`, merchantTypeIs: (merchantType: string) => `O tipo de estabelecimento é "${merchantType}"`, categoryIs: (category: string) => `A categoria é "${category}"`, + tagIs: (tag: string) => `A etiqueta é "${tag}"`, }, merchantTypeRule: {merchantType: 'Tipo de comerciante', saveRule: 'Salvar regra', confirmErrorCategory: 'Selecione uma categoria.'}, requireFieldsTable: { @@ -8851,14 +8843,6 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, }, agentRulesEmptyState: {title: 'Nenhuma regra de agente adicionada', subtitle: 'Crie uma regra para automatizar as políticas do seu workspace.', cta: 'Adicionar regra de IA'}, categoriesDisabledEmptyState: {title: 'Categorias não estão ativadas', subtitle: 'Ative categorias para ter mais controle sobre seus gastos.'}, - expenseDefaultType: { - title: 'Aplicar padrões de despesa', - subtitle: 'Com o que esta regra deve corresponder?', - merchant: 'Comerciante', - merchantDescription: 'Atualizar campos em despesas de um comerciante específico', - category: 'Categoria', - categoryDescription: 'Definir uma taxa de imposto padrão para uma categoria específica', - }, taxesDisabledEmptyState: {title: 'Impostos não estão ativados', subtitle: 'Ative impostos para documentar e recuperar impostos elegíveis.', cta: 'Ativar impostos'}, }, planTypePage: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 074df21d443d..3253f6e8f328 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -8165,16 +8165,8 @@ ${reportName}`, vendorUnavailable: '供应商不可用', supplierUnavailable: '供应商不可用', confirmErrorCategory: '请选择一个类别', - confirmErrorCategoryTax: '请选择税率', confirmErrorCondition: '请输入商家', confirmErrorConditionAndDefault: '输入商家,并至少应用一个默认设置', - turnOnTaxesFirstTitle: '请先启用税费', - turnOnTaxesFirstPrompt: '类别规则会设置默认税率。请在工作区设置中启用税费以使用此功能。', - categoryRulesApplyGoingForwardTitle: '类别规则将从现在起生效', - categoryRulesApplyGoingForwardPrompt: '此类别中的新报销将应用默认税率,已存在的报销不会改变。', - confirmErrorCategoryTaxMoveIsWorkspaceDefault: '所选税率现在已成为您的工作区默认税率,因此此规则不再有效。请选择其他税率。', - addTaxRateFirstTitle: '请先添加税率', - addTaxRateFirstPrompt: '类别规则会设置一个默认税率。请添加一个不同于工作区默认值的税率以使用类别规则。', createRuleFromExpenseAction: '创建规则', createRuleFromExpensePrompt: '将您的更改应用于所有符合条件的报销。', }, @@ -8476,6 +8468,7 @@ ${reportName}`, merchantIs: (merchant: string) => `商户为“${merchant}”`, merchantTypeIs: (merchantType: string) => `商户类型为"${merchantType}"`, categoryIs: (category: string) => `类别为“${category}”`, + tagIs: (tag: string) => `标签为“${tag}”`, }, merchantTypeRule: {merchantType: '商户类型', saveRule: '保存规则', confirmErrorCategory: '请选择一个类别。'}, requireFieldsTable: { @@ -8533,14 +8526,6 @@ ${reportName}`, }, agentRulesEmptyState: {title: '未添加代理规则', subtitle: '创建规则以自动化您的工作区策略。', cta: '添加 AI 规则'}, categoriesDisabledEmptyState: {title: '类别未启用', subtitle: '启用类别以更好地控制您的支出。'}, - expenseDefaultType: { - title: '应用报销默认值', - subtitle: '此规则应匹配什么内容?', - merchant: '商户', - merchantDescription: '更新来自特定商家的报销项目字段', - category: '类别', - categoryDescription: '为特定类别设置默认税率', - }, taxesDisabledEmptyState: {title: '税费未启用', subtitle: '启用税费以记录并追回符合条件的税款。', cta: '启用税费'}, }, planTypePage: { From b9727240ce9f4f2f46329acc8a8e19adca46d6bb Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 21 Oct 2026 02:34:43 +0530 Subject: [PATCH 14/24] Fix the category settings expense default flow Signed-off-by: krishna2323 --- src/ROUTES.ts | 34 ++++++++++++------- src/libs/ExpenseDefaultRuleUtils.ts | 10 +++--- src/libs/MerchantTypeRulesUtils.ts | 5 ++- .../MerchantRules/EditCategoryTaxRulePage.tsx | 7 +++- .../ExpenseDefaultRulePageBase.tsx | 6 +++- 5 files changed, 41 insertions(+), 21 deletions(-) diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 5e97846cb6e9..0ca0118031d3 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -104,6 +104,14 @@ const ENABLE_GLOBAL_REIMBURSEMENTS_ENTRY_SCREENS = [ SCREENS.SETTINGS.WALLET.ROOT, ] as const; +// Every step of the expense default editor is reachable from the workspace Rules flow and from category settings, +// which enters through its own dynamic route, so each step has to accept both as an entry. +const EXPENSE_DEFAULT_RULE_STEP_ENTRY_SCREENS = [ + SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW, + SCREENS.WORKSPACE.DYNAMIC_CATEGORY_TAX_RULE_NEW, + SCREENS.WORKSPACE.DYNAMIC_CATEGORY_TAX_RULE_EDIT, +] as const; + const DYNAMIC_ROUTES = { VERIFY_ACCOUNT: { path: 'verify-account', @@ -1154,7 +1162,7 @@ const DYNAMIC_ROUTES = { }, RULES_MERCHANT_MERCHANT_TO_MATCH_FROM_EXPENSE: { path: 'rule-merchant-to-match', - entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + entryScreens: EXPENSE_DEFAULT_RULE_STEP_ENTRY_SCREENS, }, RULES_MERCHANT_MATCH_TYPE_FROM_EXPENSE: { path: 'rule-match-type', @@ -1162,52 +1170,52 @@ const DYNAMIC_ROUTES = { }, RULES_MERCHANT_MERCHANT_FROM_EXPENSE: { path: 'rule-merchant', - entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + entryScreens: EXPENSE_DEFAULT_RULE_STEP_ENTRY_SCREENS, }, RULES_MERCHANT_CATEGORY_FROM_EXPENSE: { path: 'rule-category', - entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + entryScreens: EXPENSE_DEFAULT_RULE_STEP_ENTRY_SCREENS, }, RULES_MERCHANT_TAG_FROM_EXPENSE: { path: 'rule-tag/:orderWeight', - entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + entryScreens: EXPENSE_DEFAULT_RULE_STEP_ENTRY_SCREENS, getRoute: (orderWeight: number) => `rule-tag/${orderWeight}` as const, }, RULES_MERCHANT_TAX_FROM_EXPENSE: { path: 'rule-tax', - entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW, SCREENS.WORKSPACE.DYNAMIC_CATEGORY_TAX_RULE_NEW, SCREENS.WORKSPACE.DYNAMIC_CATEGORY_TAX_RULE_EDIT], + entryScreens: EXPENSE_DEFAULT_RULE_STEP_ENTRY_SCREENS, }, RULES_MERCHANT_VENDOR_FROM_EXPENSE: { path: 'rule-vendor', - entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + entryScreens: EXPENSE_DEFAULT_RULE_STEP_ENTRY_SCREENS, }, RULES_MERCHANT_DESCRIPTION_FROM_EXPENSE: { path: 'rule-description', - entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + entryScreens: EXPENSE_DEFAULT_RULE_STEP_ENTRY_SCREENS, }, RULES_MERCHANT_REIMBURSABLE_FROM_EXPENSE: { path: 'rule-reimbursable', - entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + entryScreens: EXPENSE_DEFAULT_RULE_STEP_ENTRY_SCREENS, }, RULES_MERCHANT_BILLABLE_FROM_EXPENSE: { path: 'rule-billable', - entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + entryScreens: EXPENSE_DEFAULT_RULE_STEP_ENTRY_SCREENS, }, RULES_MERCHANT_AMOUNT_FROM_EXPENSE: { path: 'rule-amount', - entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + entryScreens: EXPENSE_DEFAULT_RULE_STEP_ENTRY_SCREENS, }, RULES_CATEGORY_TO_MATCH_FROM_EXPENSE: { path: 'rule-category-to-match', - entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + entryScreens: EXPENSE_DEFAULT_RULE_STEP_ENTRY_SCREENS, }, RULES_TAG_TO_MATCH_FROM_EXPENSE: { path: 'rule-tag-to-match', - entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + entryScreens: EXPENSE_DEFAULT_RULE_STEP_ENTRY_SCREENS, }, RULES_MERCHANT_PREVIEW_MATCHES_FROM_EXPENSE: { path: 'rule-matches', - entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + entryScreens: EXPENSE_DEFAULT_RULE_STEP_ENTRY_SCREENS, }, NOTIFICATION_PREFERENCES: { // `reportID` is intentionally carried as a distinct path param (`notificationReportID`) rather than diff --git a/src/libs/ExpenseDefaultRuleUtils.ts b/src/libs/ExpenseDefaultRuleUtils.ts index 58e70d96b86f..3cdfd916c17b 100644 --- a/src/libs/ExpenseDefaultRuleUtils.ts +++ b/src/libs/ExpenseDefaultRuleUtils.ts @@ -214,7 +214,7 @@ type BuiltExpenseDefaultRule = Omit & { }; /** Drops the blanks a multi-select leaves behind, so an emptied picker reads as no condition at all. */ -function nonEmptyValues(values: string[] | undefined): string[] { +function getNonEmptyValues(values: string[] | undefined): string[] { return (values ?? []).map((value) => value.trim()).filter(Boolean); } @@ -232,12 +232,12 @@ function buildExpenseDefaultFilters(formValues: Partial 0) { comparisons.push({left: CONDITION.CATEGORY, operator: EQUAL_TO, right: categoriesToMatch}); } - const tagsToMatch = nonEmptyValues(formValues.tagsToMatch); + const tagsToMatch = getNonEmptyValues(formValues.tagsToMatch); if (tagsToMatch.length > 0) { comparisons.push({left: CONDITION.TAG, operator: EQUAL_TO, right: tagsToMatch}); } @@ -257,8 +257,8 @@ function hasExpenseDefaultConditions(formValues: Partial value === sortedSecond.at(index)); } diff --git a/src/libs/MerchantTypeRulesUtils.ts b/src/libs/MerchantTypeRulesUtils.ts index a4071f6cbdda..6deb92c30642 100644 --- a/src/libs/MerchantTypeRulesUtils.ts +++ b/src/libs/MerchantTypeRulesUtils.ts @@ -174,6 +174,9 @@ function getMerchantRulesTableData({ conditions.push(translate('workspace.rules.expenseDefaultsTable.tagIs', tagNames)); } + // The Categories section lists the rules a category alone decides. One that also matches on a + // merchant or a tag reads as a merchant rule, so it stays with them. + const isCategoryOnlyRule = categories.length > 0 && merchants.length === 0 && tags.length === 0; const hasOnlyMerchantRename = summaryFields.length === 1 && summaryFields.at(0)?.field === FIELD.MERCHANT; const typeLabel = hasOnlyMerchantRename ? translate('workspace.rules.expenseDefaultsTable.rename') : translate('workspace.rules.expenseDefaultsTable.update'); @@ -235,7 +238,7 @@ function getMerchantRulesTableData({ return { keyForList: ruleID, ruleID, - section: CONST.POLICY.EXPENSE_DEFAULTS_SECTION.MERCHANTS, + section: isCategoryOnlyRule ? CONST.POLICY.EXPENSE_DEFAULTS_SECTION.CATEGORIES : CONST.POLICY.EXPENSE_DEFAULTS_SECTION.MERCHANTS, isRename: hasOnlyMerchantRename, typeLabel, // A rule matches on every condition it carries, so the row reads them as one sentence. diff --git a/src/pages/workspace/rules/MerchantRules/EditCategoryTaxRulePage.tsx b/src/pages/workspace/rules/MerchantRules/EditCategoryTaxRulePage.tsx index fc83f8a60fd0..bef1a62f326f 100644 --- a/src/pages/workspace/rules/MerchantRules/EditCategoryTaxRulePage.tsx +++ b/src/pages/workspace/rules/MerchantRules/EditCategoryTaxRulePage.tsx @@ -1,3 +1,4 @@ +import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useOnyx from '@hooks/useOnyx'; import {getCategoryTaxDefaultRule} from '@libs/ExpenseDefaultRuleUtils'; @@ -5,6 +6,7 @@ import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavig import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import ONYXKEYS from '@src/ONYXKEYS'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import React from 'react'; @@ -18,6 +20,8 @@ type EditCategoryTaxRulePageProps = function EditCategoryTaxRulePage({route}: EditCategoryTaxRulePageProps) { const {policyID, categoryName} = route.params; const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); + const isOpenedFromCategorySettings = route.name === SCREENS.WORKSPACE.DYNAMIC_CATEGORY_TAX_RULE_EDIT; + const categorySettingsPath = useDynamicBackPath(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_RULES_TAX_EDIT.path, isOpenedFromCategorySettings); // Category settings links by category, since a category holds one tax default, so the rule is resolved here. const ruleID = getCategoryTaxDefaultRule(rules, policyID, categoryName)?.ruleID; @@ -26,7 +30,8 @@ function EditCategoryTaxRulePage({route}: EditCategoryTaxRulePageProps) { policyID={policyID} ruleID={ruleID} initialCategoryToMatch={ruleID ? undefined : categoryName} - isCategoryLocked={route.name === SCREENS.WORKSPACE.DYNAMIC_CATEGORY_TAX_RULE_EDIT} + isCategoryLocked={isOpenedFromCategorySettings} + categorySettingsBackPath={isOpenedFromCategorySettings ? categorySettingsPath : undefined} testID="EditCategoryTaxRulePage" /> ); diff --git a/src/pages/workspace/rules/MerchantRules/ExpenseDefaultRulePageBase.tsx b/src/pages/workspace/rules/MerchantRules/ExpenseDefaultRulePageBase.tsx index 3faceb7cbafe..364e1c14fd85 100644 --- a/src/pages/workspace/rules/MerchantRules/ExpenseDefaultRulePageBase.tsx +++ b/src/pages/workspace/rules/MerchantRules/ExpenseDefaultRulePageBase.tsx @@ -363,7 +363,11 @@ function ExpenseDefaultRulePageBase({ } setRule(policyID, buildExpenseDefaultRule(form, policy), CONST.RULES.EXPENSE_DEFAULT.PRIORITY, ruleID, existingRule, shouldUpdateMatchingTransactions); - if (isCreatedFromExpense) { + if (categorySettingsBackPath) { + // Category settings enters through its own dynamic route, so the way back is the category the rule was + // opened from rather than anything in the workspace Rules flow. + Navigation.goBack(categorySettingsBackPath); + } else if (isCreatedFromExpense) { // Opened from the callout, so this page is a suffix on the expense's path. Dropping it returns to the // expense instead of the workspace Rules page. Navigation.goBack(backToRoute); From e13a24549e1b5691be1c26b7180c2d44e43ee902 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 21 Oct 2026 03:06:27 +0530 Subject: [PATCH 15/24] Open a rule that also fires when an expense is edited Signed-off-by: krishna2323 --- src/CONST/index.ts | 1 + src/libs/ExpenseDefaultRuleUtils.ts | 28 ++++++++++++-- .../ExpenseDefaultRulePageBase.tsx | 2 +- src/types/onyx/ExpenseDefaultRules.ts | 7 ++-- tests/unit/ExpenseDefaultRuleUtilsTest.ts | 37 +++++++++++++++++++ 5 files changed, 67 insertions(+), 8 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 9382076af183..3e08ef85f289 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -236,6 +236,7 @@ const RULE_TRIGGERS = { REPORT_SUBMIT: 'ReportSubmit', REPORT_APPROVE: 'ReportApprove', CREATE_TRANSACTION: 'CreateTransaction', + UPDATE_TRANSACTION: 'UpdateTransaction', } as const; const EMAIL = { diff --git a/src/libs/ExpenseDefaultRuleUtils.ts b/src/libs/ExpenseDefaultRuleUtils.ts index 3cdfd916c17b..16ba837eed4b 100644 --- a/src/libs/ExpenseDefaultRuleUtils.ts +++ b/src/libs/ExpenseDefaultRuleUtils.ts @@ -13,6 +13,7 @@ import type { ExpenseDefaultActions, ExpenseDefaultRule, ExpenseDefaultTaxValue, + ExpenseDefaultTrigger, ExpenseDefaultTriggers, } from '@src/types/onyx/ExpenseDefaultRules'; import type {RuleFilterComparison, RuleFilterNode} from '@src/types/onyx/RuleFilters'; @@ -313,7 +314,7 @@ function buildExpenseDefaultActions(formValues: Partial, policy: Policy | undefined): BuiltExpenseDefaultRule | undefined { +function buildExpenseDefaultRule(formValues: Partial, policy: Policy | undefined, existingTriggers?: Record): BuiltExpenseDefaultRule | undefined { const filters = buildExpenseDefaultFilters(formValues); const actions = buildExpenseDefaultActions(formValues, policy); @@ -321,7 +322,9 @@ function buildExpenseDefaultRule(formValues: Partial, return undefined; } - const triggers: ExpenseDefaultTriggers = toIndexMap([TRIGGER.CREATE_TRANSACTION]); + // A rule the backend wrote can fire on more than one event, and the form has no input for that, so an edit + // hands back the triggers the rule already carries instead of rebuilding them. + const triggers: ExpenseDefaultTriggers = getEditableTriggers(existingTriggers) ?? toIndexMap([TRIGGER.CREATE_TRANSACTION]); return {triggers, filters, actions}; } @@ -336,10 +339,27 @@ function isExpenseDefaultTaxValue(value: unknown): value is ExpenseDefaultTaxVal return !!taxField && typeof taxField === 'object' && 'externalID' in taxField && typeof taxField.externalID === 'string'; } +function isExpenseDefaultTrigger(trigger: string): trigger is ExpenseDefaultTrigger { + return trigger === TRIGGER.CREATE_TRANSACTION || trigger === TRIGGER.UPDATE_TRANSACTION; +} + +/** + * The rule's triggers when the editor knows every one of them, otherwise undefined. + * + * The form has no trigger input, so it can only save a rule whose triggers it is able to hand back untouched. + */ +function getEditableTriggers(triggers: Record | undefined): ExpenseDefaultTriggers | undefined { + const triggerValues = fromIndexMap(triggers); + const editableTriggers = triggerValues.filter(isExpenseDefaultTrigger); + if (editableTriggers.length === 0 || editableTriggers.length !== triggerValues.length) { + return undefined; + } + return toIndexMap(editableTriggers); +} + /** Every trigger has to be one the editor knows about, otherwise saving the form would drop the rest. */ function areTriggersEditable(triggers: Record | undefined): boolean { - const triggerValues = fromIndexMap(triggers); - return triggerValues.length > 0 && triggerValues.every((trigger) => trigger === TRIGGER.CREATE_TRANSACTION); + return !!getEditableTriggers(triggers); } /** The conditions the editor renders, read back off a rule's filter tree. */ diff --git a/src/pages/workspace/rules/MerchantRules/ExpenseDefaultRulePageBase.tsx b/src/pages/workspace/rules/MerchantRules/ExpenseDefaultRulePageBase.tsx index 364e1c14fd85..9c7cae0a37a9 100644 --- a/src/pages/workspace/rules/MerchantRules/ExpenseDefaultRulePageBase.tsx +++ b/src/pages/workspace/rules/MerchantRules/ExpenseDefaultRulePageBase.tsx @@ -362,7 +362,7 @@ function ExpenseDefaultRulePageBase({ return; } - setRule(policyID, buildExpenseDefaultRule(form, policy), CONST.RULES.EXPENSE_DEFAULT.PRIORITY, ruleID, existingRule, shouldUpdateMatchingTransactions); + setRule(policyID, buildExpenseDefaultRule(form, policy, existingRule?.triggers), CONST.RULES.EXPENSE_DEFAULT.PRIORITY, ruleID, existingRule, shouldUpdateMatchingTransactions); if (categorySettingsBackPath) { // Category settings enters through its own dynamic route, so the way back is the category the rule was // opened from rather than anything in the workspace Rules flow. diff --git a/src/types/onyx/ExpenseDefaultRules.ts b/src/types/onyx/ExpenseDefaultRules.ts index 763707c52e52..a8a97e894862 100644 --- a/src/types/onyx/ExpenseDefaultRules.ts +++ b/src/types/onyx/ExpenseDefaultRules.ts @@ -5,9 +5,10 @@ import type {ValueOf} from 'type-fest'; import type {RuleFilterNode} from './RuleFilters'; /** - * A transaction lifecycle event that can fire an expense-default rule (currently only `CreateTransaction`). + * A transaction lifecycle event that can fire an expense-default rule. The backend fires a category tax default on + * both, so that editing the category of an existing expense applies the rule too. */ -type ExpenseDefaultTrigger = typeof CONST.RULES.TRIGGERS.CREATE_TRANSACTION; +type ExpenseDefaultTrigger = typeof CONST.RULES.TRIGGERS.CREATE_TRANSACTION | typeof CONST.RULES.TRIGGERS.UPDATE_TRANSACTION; /** * The triggers of a rule, keyed by a string index starting at 1 (e.g. `{"1": "CreateTransaction"}`). A rule fires when @@ -83,4 +84,4 @@ type ExpenseDefaultRule = { actions: ExpenseDefaultActions; }; -export type {ExpenseDefaultAction, ExpenseDefaultActionField, ExpenseDefaultActions, ExpenseDefaultRule, ExpenseDefaultTaxValue, ExpenseDefaultTriggers}; +export type {ExpenseDefaultAction, ExpenseDefaultActionField, ExpenseDefaultActions, ExpenseDefaultRule, ExpenseDefaultTaxValue, ExpenseDefaultTrigger, ExpenseDefaultTriggers}; diff --git a/tests/unit/ExpenseDefaultRuleUtilsTest.ts b/tests/unit/ExpenseDefaultRuleUtilsTest.ts index 6d0f56e8c430..517ec0fc8e85 100644 --- a/tests/unit/ExpenseDefaultRuleUtilsTest.ts +++ b/tests/unit/ExpenseDefaultRuleUtilsTest.ts @@ -382,6 +382,43 @@ describe('ExpenseDefaultRuleUtils', () => { }); }); + describe('triggers the backend sets', () => { + // Given a category tax default as the backend writes it, which also fires when an expense is edited + const backendTriggers = toIndexMap([TRIGGER.CREATE_TRANSACTION, CONST.RULES.TRIGGERS.UPDATE_TRANSACTION]); + + it('opens a rule that also fires on UpdateTransaction', () => { + // When the editor reads it + const formValues = getExpenseDefaultFormValues(buildRuleWithOverrides({triggers: backendTriggers})); + + // Then it is editable rather than read-only, because the form can hand every trigger back untouched + expect(formValues).toEqual({merchantToMatch: 'Starbucks', matchType: CONTAINS, category: 'Coffee'}); + }); + + it('keeps both triggers when the rule is saved again', () => { + // When the edited rule is rebuilt from the form + const rule = buildExpenseDefaultRule({categoriesToMatch: ['Coffee'], tax: TAX_KEY}, policy, backendTriggers); + + // Then the trigger the form has no input for survives the save + expect(rule?.triggers).toEqual(backendTriggers); + }); + + it('sets only CreateTransaction on a new rule', () => { + // When a rule is built with no existing triggers to carry over + const rule = buildExpenseDefaultRule({categoriesToMatch: ['Coffee'], tax: TAX_KEY}, policy); + + // Then it fires on creation alone + expect(rule?.triggers).toEqual(toIndexMap([TRIGGER.CREATE_TRANSACTION])); + }); + + it('stays read-only when a trigger is one the form does not know', () => { + // When the rule carries a trigger outside the transaction lifecycle + const rule = buildRuleWithOverrides({triggers: toIndexMap([TRIGGER.CREATE_TRANSACTION, CONST.RULES.TRIGGERS.REPORT_APPROVE])}); + + // Then the editor refuses it, since saving would drop that trigger + expect(getExpenseDefaultFormValues(rule)).toBeUndefined(); + }); + }); + describe('isExpenseDefaultRule', () => { it('is true for a rule that runs on transaction creation and sets a field', () => { expect(isExpenseDefaultRule(asStoredRule(merchantRuleBody))).toBe(true); From 27db57e4ba226b55c740b0e62a99011e656604bd Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 21 Oct 2026 03:18:02 +0530 Subject: [PATCH 16/24] Rewrite a renamed category inside the rules that match on it Signed-off-by: krishna2323 --- src/libs/ExpenseDefaultRuleUtils.ts | 63 ++++++++++++++++++ src/libs/ReportUtils.ts | 1 + src/libs/actions/Policy/Category.ts | 23 +++++-- src/libs/actions/Policy/InlineEdit.ts | 7 +- .../categories/DynamicEditCategoryPage.tsx | 8 ++- .../categories/WorkspaceCategoriesPage.tsx | 4 +- tests/unit/ExpenseDefaultRuleUtilsTest.ts | 65 +++++++++++++++++++ 7 files changed, 160 insertions(+), 11 deletions(-) diff --git a/src/libs/ExpenseDefaultRuleUtils.ts b/src/libs/ExpenseDefaultRuleUtils.ts index 16ba837eed4b..e7e4de5eb493 100644 --- a/src/libs/ExpenseDefaultRuleUtils.ts +++ b/src/libs/ExpenseDefaultRuleUtils.ts @@ -329,6 +329,68 @@ function buildExpenseDefaultRule(formValues: Partial, return {triggers, filters, actions}; } +/** Rewrites the renamed category wherever the tree matches on it, or undefined when the tree never names it. */ +function renameCategoryInFilters(filters: RuleFilterNode | undefined, oldName: string, newName: string): RuleFilterNode | undefined { + if (!filters || !isRuleFilterNode(filters)) { + return undefined; + } + + if (isRuleFilterComparison(filters)) { + if (filters.left !== CONDITION.CATEGORY) { + return undefined; + } + + if (Array.isArray(filters.right)) { + if (!filters.right.includes(oldName)) { + return undefined; + } + return {...filters, right: filters.right.map((value) => (value === oldName ? newName : value))}; + } + + return filters.right === oldName ? {...filters, right: newName} : undefined; + } + + const left = renameCategoryInFilters(filters.left, oldName, newName); + const right = renameCategoryInFilters(filters.right, oldName, newName); + if (!left && !right) { + return undefined; + } + + return {...filters, left: left ?? filters.left, right: right ?? filters.right}; +} + +/** + * The policy's rules that match on a renamed category, each with the filter tree it needs once the rename lands. + * + * A rule names its category rather than pointing at it, so a rename leaves it matching a category that is gone. + * The caller writes these optimistically, the way the approval rules on the policy are rewritten alongside. + */ +function getCategoryRenameRuleUpdates( + rulesCollection: OnyxCollection | undefined, + policyID: string | undefined, + oldName: string, + newName: string, +): Array<{ruleID: string; filters: RuleFilterNode; previousFilters: RuleFilterNode}> { + if (!policyID || oldName === newName) { + return []; + } + + const updates: Array<{ruleID: string; filters: RuleFilterNode; previousFilters: RuleFilterNode}> = []; + + for (const [onyxKey, rule] of Object.entries(rulesCollection ?? {})) { + if (!rule || !isPolicyScopedRule(rule, policyID) || !rule.filters) { + continue; + } + const filters = renameCategoryInFilters(rule.filters, oldName, newName); + if (!filters) { + continue; + } + updates.push({ruleID: onyxKey.slice(ONYXKEYS.COLLECTION.RULE.length), filters, previousFilters: rule.filters}); + } + + return updates; +} + /** A tax action value is the only non-primitive value a `Set` action can carry. */ function isExpenseDefaultTaxValue(value: unknown): value is ExpenseDefaultTaxValue { if (!value || typeof value !== 'object' || !('field_id_TAX' in value)) { @@ -684,6 +746,7 @@ export { buildExpenseDefaultFilters, buildExpenseDefaultRule, canEditExpenseDefaultRule, + getCategoryRenameRuleUpdates, getCategoryTaxDefaultID, getCategoryTaxDefaultRule, getExpenseDefaultConditionSummary, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 2312afbeca76..606ac5017e43 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -2162,6 +2162,7 @@ type PolicyOptimisticOnyxData = OnyxData< | typeof ONYXKEYS.COLLECTION.POLICY_TAGS | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS | typeof ONYXKEYS.COLLECTION.TRANSACTION + | typeof ONYXKEYS.COLLECTION.RULE >; /** diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts index 7bcd411ce945..70e368f79cd4 100644 --- a/src/libs/actions/Policy/Category.ts +++ b/src/libs/actions/Policy/Category.ts @@ -27,6 +27,7 @@ import * as ApiUtils from '@libs/ApiUtils'; import * as CategoryUtils from '@libs/CategoryUtils'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import * as ErrorUtils from '@libs/ErrorUtils'; +import {getCategoryRenameRuleUpdates} from '@libs/ExpenseDefaultRuleUtils'; import fileDownload from '@libs/fileDownload'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import Log from '@libs/Log'; @@ -40,13 +41,13 @@ import {getFinishOnboardingTaskOnyxData, withReviewWorkspaceSettingsTaskData} fr import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy, PolicyCategories, PolicyCategory, Report, ReportAction} from '@src/types/onyx'; +import type {Policy, PolicyCategories, PolicyCategory, Report, ReportAction, Rule} from '@src/types/onyx'; import type {ImportFinalModal} from '@src/types/onyx/ImportedSpreadsheet'; import type {ApprovalRule, CustomUnit, MccGroup} from '@src/types/onyx/Policy'; import type {PolicyCategoryExpenseLimitType} from '@src/types/onyx/PolicyCategory'; import type {OnyxData} from '@src/types/onyx/Request'; -import type {OnyxEntry, OnyxUpdate} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import type {PartialDeep} from 'type-fest'; import lodashCloneDeep from 'lodash/cloneDeep'; @@ -846,7 +847,7 @@ async function importPolicyCategories(policyID: string, categories: PolicyCatego } } -function renamePolicyCategory(policyData: PolicyData, policyCategory: {oldName: string; newName: string}, isVendorMatchingBetaEnabled: boolean | undefined) { +function renamePolicyCategory(policyData: PolicyData, policyCategory: {oldName: string; newName: string}, isVendorMatchingBetaEnabled: boolean | undefined, rules?: OnyxCollection) { const policy = policyData.policy; const policyID = policy.id; const policyCategoryToUpdate = policyData.categories?.[policyCategory.oldName]; @@ -874,6 +875,10 @@ function renamePolicyCategory(policyData: PolicyData, policyCategory: {oldName: updatedApprovalRules[indexToUpdate] = policyCategoryApproverRule; } + // A coding rule names its category rather than pointing at it, so it has to be rewritten alongside the approval + // rules above, otherwise it matches a category that no longer exists until the next fetch. + const ruleUpdates = getCategoryRenameRuleUpdates(rules, policyID, policyCategory.oldName, policyCategory.newName); + const policyOptimisticData = { rules: { approvalRules: updatedApprovalRules, @@ -895,7 +900,7 @@ function renamePolicyCategory(policyData: PolicyData, policyCategory: {oldName: }, }; - const onyxData: OnyxData = { + const onyxData: OnyxData = { optimisticData: [ { onyxMethod: Onyx.METHOD.MERGE, @@ -910,6 +915,11 @@ function renamePolicyCategory(policyData: PolicyData, policyCategory: {oldName: key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: policyOptimisticData, }, + ...ruleUpdates.map(({ruleID, filters}) => ({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.RULE}${ruleID}` as const, + value: {filters}, + })), ], successData: [ { @@ -933,6 +943,11 @@ function renamePolicyCategory(policyData: PolicyData, policyCategory: {oldName: }, ], failureData: [ + ...ruleUpdates.map(({ruleID, previousFilters}) => ({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.RULE}${ruleID}` as const, + value: {filters: previousFilters}, + })), { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, diff --git a/src/libs/actions/Policy/InlineEdit.ts b/src/libs/actions/Policy/InlineEdit.ts index 4de4633596ff..0def9ac00ece 100644 --- a/src/libs/actions/Policy/InlineEdit.ts +++ b/src/libs/actions/Policy/InlineEdit.ts @@ -27,12 +27,12 @@ import {updateExpensifyCardLimit, updateExpensifyCardLimitType} from '@userActio import {updateCompanyCardName} from '@userActions/CompanyCards'; import CONST from '@src/CONST'; -import type {Card, Policy} from '@src/types/onyx'; +import type {Card, Policy, Rule} from '@src/types/onyx'; import type {CardLimitType} from '@src/types/onyx/Card'; import type {CompanyCardFeedWithNumber} from '@src/types/onyx/CardFeeds'; import type {CustomUnit, Rate} from '@src/types/onyx/Policy'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import {renamePolicyCategory} from './Category'; @@ -47,6 +47,7 @@ function renameCategoryInline( currentName: string, newName: string, isVendorMatchingBetaEnabled: boolean | undefined, + rules?: OnyxCollection, ): string | undefined { const sanitized = StringUtils.sanitizeName(newName); const currentDisplayName = getDecodedCategoryName(currentName); @@ -60,7 +61,7 @@ function renameCategoryInline( return getCategoryNameErrorMessage(translate, categoryError, newName); } - renamePolicyCategory(policyData, {oldName: currentName, newName: sanitized}, isVendorMatchingBetaEnabled); + renamePolicyCategory(policyData, {oldName: currentName, newName: sanitized}, isVendorMatchingBetaEnabled, rules); } function renameTagInline(translate: LocalizedTranslate, policyData: PolicyData, oldName: string, newName: string, isVendorMatchingBetaEnabled: boolean | undefined): string | undefined { diff --git a/src/pages/workspace/categories/DynamicEditCategoryPage.tsx b/src/pages/workspace/categories/DynamicEditCategoryPage.tsx index 0953be8443cf..9e165b452b70 100644 --- a/src/pages/workspace/categories/DynamicEditCategoryPage.tsx +++ b/src/pages/workspace/categories/DynamicEditCategoryPage.tsx @@ -4,6 +4,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePolicyData from '@hooks/usePolicyData'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -20,7 +21,7 @@ import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import {renamePolicyCategory} from '@userActions/Policy/Category'; import CONST from '@src/CONST'; -import type ONYXKEYS from '@src/ONYXKEYS'; +import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; @@ -35,6 +36,7 @@ type DynamicEditCategoryPageProps = function DynamicEditCategoryPage({route}: DynamicEditCategoryPageProps) { const {policyID, categoryName: currentCategoryName} = route.params; const policyData = usePolicyData(policyID); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const {categories: policyCategories} = policyData; const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -63,7 +65,7 @@ function DynamicEditCategoryPage({route}: DynamicEditCategoryPageProps) { const newCategoryName = StringUtils.sanitizeName(values.categoryName); // Do not call the API if the edited category name is the same as the current category name if (decodedCategoryName !== newCategoryName) { - renamePolicyCategory(policyData, {oldName: currentCategoryName, newName: newCategoryName}, isVendorMatchingBetaEnabled); + renamePolicyCategory(policyData, {oldName: currentCategoryName, newName: newCategoryName}, isVendorMatchingBetaEnabled, rules); } // Ensure Onyx.update is executed before navigation to prevent UI blinking issues, affecting the category name and rate. @@ -71,7 +73,7 @@ function DynamicEditCategoryPage({route}: DynamicEditCategoryPageProps) { Navigation.goBack(isQuickSettingsFlow ? settingsBackPath : workspaceBackPath, {compareParams: false}); }); }, - [decodedCategoryName, currentCategoryName, policyData, isQuickSettingsFlow, settingsBackPath, workspaceBackPath, isVendorMatchingBetaEnabled], + [decodedCategoryName, currentCategoryName, policyData, isQuickSettingsFlow, settingsBackPath, workspaceBackPath, isVendorMatchingBetaEnabled, rules], ); return ( diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index 4f7bb9e9f4ca..e94c976d7221 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -90,6 +90,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { const {backTo, policyID: policyId} = route.params; const isMobileSelectionModeEnabled = useMobileSelectionMode(); const policyData = usePolicyData(policyId); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const {policy, categories: policyCategories} = policyData; const employeePersonalDetails = usePersonalDetailsByLogins(Object.keys(policy?.employeeList ?? {})); useWorkspaceDocumentTitle(policy?.name, 'workspace.common.categories'); @@ -310,7 +311,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { canEditName: canWriteCategories && !isDisabled && !isSelectionModeActive, action: () => navigateToCategory(value), onToggleEnabled: (enabled: boolean) => handleCategoryToggle(enabled, value), - onRenameName: (newName: string) => showInlineEditError(renameCategoryInline(translate, policyData, value.name, newName, isVendorMatchingBetaEnabled)), + onRenameName: (newName: string) => showInlineEditError(renameCategoryInline(translate, policyData, value.name, newName, isVendorMatchingBetaEnabled, rules)), dismissError: () => clearCategoryErrors(policyId, value.name, policyCategories), }); @@ -333,6 +334,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { employeePersonalDetails, formatPhoneNumber, isVendorMatchingBetaEnabled, + rules, ]); const navigateToCreateCategoryPage = () => { diff --git a/tests/unit/ExpenseDefaultRuleUtilsTest.ts b/tests/unit/ExpenseDefaultRuleUtilsTest.ts index 517ec0fc8e85..318f56d9066b 100644 --- a/tests/unit/ExpenseDefaultRuleUtilsTest.ts +++ b/tests/unit/ExpenseDefaultRuleUtilsTest.ts @@ -1,6 +1,7 @@ import { buildExpenseDefaultRule, canEditExpenseDefaultRule, + getCategoryRenameRuleUpdates, getExpenseDefaultConditionSummary, getExpenseDefaultRuleCount, getExpenseDefaultRuleSummaryFields, @@ -419,6 +420,70 @@ describe('ExpenseDefaultRuleUtils', () => { }); }); + describe('getCategoryRenameRuleUpdates', () => { + const categoryRule = asStoredRule({...merchantRuleBody, filters: {left: FIELD.CATEGORY, operator: EQUAL_TO, right: 'Coffee'}}); + + it('rewrites the renamed category in a rule that matches on it', () => { + // Given a rule matching on the category being renamed + const rules = {[`${ONYXKEYS.COLLECTION.RULE}1`]: categoryRule}; + + // When the rename is applied + const updates = getCategoryRenameRuleUpdates(rules, POLICY_ID, 'Coffee', 'Caffeine'); + + // Then the rule carries the new name, and the old tree is kept so a failed rename can put it back + expect(updates).toEqual([ + { + ruleID: '1', + filters: {left: FIELD.CATEGORY, operator: EQUAL_TO, right: 'Caffeine'}, + previousFilters: {left: FIELD.CATEGORY, operator: EQUAL_TO, right: 'Coffee'}, + }, + ]); + }); + + it('rewrites the category inside a nested condition', () => { + // Given a rule that matches on a merchant and the renamed category together + const nested = asStoredRule({ + ...merchantRuleBody, + filters: {left: merchantFilter, operator: AND, right: {left: FIELD.CATEGORY, operator: EQUAL_TO, right: 'Coffee'}}, + }); + + // When the rename is applied + const updates = getCategoryRenameRuleUpdates({[`${ONYXKEYS.COLLECTION.RULE}1`]: nested}, POLICY_ID, 'Coffee', 'Caffeine'); + + // Then only the category branch changes + expect(updates.at(0)?.filters).toEqual({left: merchantFilter, operator: AND, right: {left: FIELD.CATEGORY, operator: EQUAL_TO, right: 'Caffeine'}}); + }); + + it('rewrites one name out of a list the rule matches on', () => { + // Given a rule matching on several categories at once + const multi = asStoredRule({...merchantRuleBody, filters: {left: FIELD.CATEGORY, operator: EQUAL_TO, right: ['Coffee', 'Travel']}}); + + // When one of them is renamed + const updates = getCategoryRenameRuleUpdates({[`${ONYXKEYS.COLLECTION.RULE}1`]: multi}, POLICY_ID, 'Coffee', 'Caffeine'); + + // Then the others are left alone + expect(updates.at(0)?.filters).toEqual({left: FIELD.CATEGORY, operator: EQUAL_TO, right: ['Caffeine', 'Travel']}); + }); + + it('leaves alone a rule that does not name the category', () => { + // Given a rule matching on a merchant only + const rules = {[`${ONYXKEYS.COLLECTION.RULE}1`]: asStoredRule(merchantRuleBody)}; + + // When an unrelated category is renamed + // Then nothing is rewritten, so the rename writes no rule updates + expect(getCategoryRenameRuleUpdates(rules, POLICY_ID, 'Coffee', 'Caffeine')).toEqual([]); + }); + + it('leaves alone a rule belonging to another workspace', () => { + // Given a rule on the same category but scoped to a different policy + const rules = {[`${ONYXKEYS.COLLECTION.RULE}1`]: asStoredRule({...merchantRuleBody, filters: {left: FIELD.CATEGORY, operator: EQUAL_TO, right: 'Coffee'}}, 'OTHER')}; + + // When the rename is applied to this policy + // Then the other workspace's rule is untouched, since the collection holds every workspace the user sees + expect(getCategoryRenameRuleUpdates(rules, POLICY_ID, 'Coffee', 'Caffeine')).toEqual([]); + }); + }); + describe('isExpenseDefaultRule', () => { it('is true for a rule that runs on transaction creation and sets a field', () => { expect(isExpenseDefaultRule(asStoredRule(merchantRuleBody))).toBe(true); From 66f63ad6855b032bff4aae0507e54135d5a8328f Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 21 Oct 2026 03:30:07 +0530 Subject: [PATCH 17/24] Pass the coding rules through the inline category rename in tests Signed-off-by: krishna2323 --- .../inlineEditing/PolicyInlineEdit.test.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/tests/unit/inlineEditing/PolicyInlineEdit.test.ts b/tests/unit/inlineEditing/PolicyInlineEdit.test.ts index c854796e359a..114180a805b1 100644 --- a/tests/unit/inlineEditing/PolicyInlineEdit.test.ts +++ b/tests/unit/inlineEditing/PolicyInlineEdit.test.ts @@ -19,7 +19,8 @@ import {updateWorkspaceMembersRole} from '@userActions/Policy/Member'; import CONST from '@src/CONST'; import type {TranslationParameters, TranslationPaths} from '@src/languages/types'; -import type {Card, Policy} from '@src/types/onyx'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Card, Policy, Rule} from '@src/types/onyx'; import type {PartialDeep} from 'type-fest'; @@ -89,6 +90,10 @@ const policyData = createMock({ const policy = createMock({id: '1'}); +const rules: Record = { + [`${ONYXKEYS.COLLECTION.RULE}1`]: createMock({scope: CONST.RULES.SCOPE.POLICY, scopeID: '1'}), +}; + function buildCard(overrides: PartialDeep = {}): Card { return createMock({ cardID: 10, @@ -139,9 +144,12 @@ describe('PolicyInlineEdit', () => { }); it('delegates a valid rename to the canonical action', () => { - renameCategoryInline(translate, policyData, 'Food', ' Meals ', true); + // Given the coding rules, which the action needs because it rewrites the renamed category inside them + // When the rename is made from the table + renameCategoryInline(translate, policyData, 'Food', ' Meals ', true, rules); - expect(mockRenamePolicyCategory).toHaveBeenCalledWith(policyData, {oldName: 'Food', newName: 'Meals'}, true); + // Then the collection reaches the action alongside the names + expect(mockRenamePolicyCategory).toHaveBeenCalledWith(policyData, {oldName: 'Food', newName: 'Meals'}, true, rules); }); it('does not persist a name that matches an HTML-encoded category', () => { @@ -157,9 +165,9 @@ describe('PolicyInlineEdit', () => { }); it('persists a rename of an HTML-encoded category using the raw stored name', () => { - renameCategoryInline(translate, policyData, encodedFoodAndDrink, 'Snacks', true); + renameCategoryInline(translate, policyData, encodedFoodAndDrink, 'Snacks', true, rules); - expect(mockRenamePolicyCategory).toHaveBeenCalledWith(policyData, {oldName: encodedFoodAndDrink, newName: 'Snacks'}, true); + expect(mockRenamePolicyCategory).toHaveBeenCalledWith(policyData, {oldName: encodedFoodAndDrink, newName: 'Snacks'}, true, rules); }); }); From 468f4b92a3d622a0d9050223302c8459681c90c3 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 22 Oct 2026 05:22:05 +0530 Subject: [PATCH 18/24] Stop rewriting a renamed category inside the rules that match on it Signed-off-by: krishna2323 --- src/libs/ExpenseDefaultRuleUtils.ts | 63 ------------------ src/libs/ReportUtils.ts | 1 - src/libs/actions/Policy/Category.ts | 23 ++----- src/libs/actions/Policy/InlineEdit.ts | 7 +- .../categories/DynamicEditCategoryPage.tsx | 8 +-- .../categories/WorkspaceCategoriesPage.tsx | 4 +- tests/unit/ExpenseDefaultRuleUtilsTest.ts | 65 ------------------- .../inlineEditing/PolicyInlineEdit.test.ts | 18 ++--- 8 files changed, 16 insertions(+), 173 deletions(-) diff --git a/src/libs/ExpenseDefaultRuleUtils.ts b/src/libs/ExpenseDefaultRuleUtils.ts index 56b87fa8c9dc..d27442064914 100644 --- a/src/libs/ExpenseDefaultRuleUtils.ts +++ b/src/libs/ExpenseDefaultRuleUtils.ts @@ -331,68 +331,6 @@ function buildExpenseDefaultRule(formValues: Partial, return {triggers, filters, actions}; } -/** Rewrites the renamed category wherever the tree matches on it, or undefined when the tree never names it. */ -function renameCategoryInFilters(filters: RuleFilterNode | undefined, oldName: string, newName: string): RuleFilterNode | undefined { - if (!filters || !isRuleFilterNode(filters)) { - return undefined; - } - - if (isRuleFilterComparison(filters)) { - if (filters.left !== CONDITION.CATEGORY) { - return undefined; - } - - if (Array.isArray(filters.right)) { - if (!filters.right.includes(oldName)) { - return undefined; - } - return {...filters, right: filters.right.map((value) => (value === oldName ? newName : value))}; - } - - return filters.right === oldName ? {...filters, right: newName} : undefined; - } - - const left = renameCategoryInFilters(filters.left, oldName, newName); - const right = renameCategoryInFilters(filters.right, oldName, newName); - if (!left && !right) { - return undefined; - } - - return {...filters, left: left ?? filters.left, right: right ?? filters.right}; -} - -/** - * The policy's rules that match on a renamed category, each with the filter tree it needs once the rename lands. - * - * A rule names its category rather than pointing at it, so a rename leaves it matching a category that is gone. - * The caller writes these optimistically, the way the approval rules on the policy are rewritten alongside. - */ -function getCategoryRenameRuleUpdates( - rulesCollection: OnyxCollection | undefined, - policyID: string | undefined, - oldName: string, - newName: string, -): Array<{ruleID: string; filters: RuleFilterNode; previousFilters: RuleFilterNode}> { - if (!policyID || oldName === newName) { - return []; - } - - const updates: Array<{ruleID: string; filters: RuleFilterNode; previousFilters: RuleFilterNode}> = []; - - for (const [onyxKey, rule] of Object.entries(rulesCollection ?? {})) { - if (!rule || !isPolicyScopedRule(rule, policyID) || !rule.filters) { - continue; - } - const filters = renameCategoryInFilters(rule.filters, oldName, newName); - if (!filters) { - continue; - } - updates.push({ruleID: onyxKey.slice(ONYXKEYS.COLLECTION.RULE.length), filters, previousFilters: rule.filters}); - } - - return updates; -} - /** A tax action value is the only non-primitive value a `Set` action can carry. */ function isExpenseDefaultTaxValue(value: unknown): value is ExpenseDefaultTaxValue { if (!value || typeof value !== 'object' || !('field_id_TAX' in value)) { @@ -748,7 +686,6 @@ export { buildExpenseDefaultFilters, buildExpenseDefaultRule, canEditExpenseDefaultRule, - getCategoryRenameRuleUpdates, getCategoryTaxDefaultID, getCategoryTaxDefaultRule, getExpenseDefaultConditionSummary, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index e88bfb393332..5f8602dbca98 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -2171,7 +2171,6 @@ type PolicyOptimisticOnyxData = OnyxData< | typeof ONYXKEYS.COLLECTION.POLICY_TAGS | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS | typeof ONYXKEYS.COLLECTION.TRANSACTION - | typeof ONYXKEYS.COLLECTION.RULE >; /** diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts index 70e368f79cd4..7bcd411ce945 100644 --- a/src/libs/actions/Policy/Category.ts +++ b/src/libs/actions/Policy/Category.ts @@ -27,7 +27,6 @@ import * as ApiUtils from '@libs/ApiUtils'; import * as CategoryUtils from '@libs/CategoryUtils'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import * as ErrorUtils from '@libs/ErrorUtils'; -import {getCategoryRenameRuleUpdates} from '@libs/ExpenseDefaultRuleUtils'; import fileDownload from '@libs/fileDownload'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import Log from '@libs/Log'; @@ -41,13 +40,13 @@ import {getFinishOnboardingTaskOnyxData, withReviewWorkspaceSettingsTaskData} fr import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy, PolicyCategories, PolicyCategory, Report, ReportAction, Rule} from '@src/types/onyx'; +import type {Policy, PolicyCategories, PolicyCategory, Report, ReportAction} from '@src/types/onyx'; import type {ImportFinalModal} from '@src/types/onyx/ImportedSpreadsheet'; import type {ApprovalRule, CustomUnit, MccGroup} from '@src/types/onyx/Policy'; import type {PolicyCategoryExpenseLimitType} from '@src/types/onyx/PolicyCategory'; import type {OnyxData} from '@src/types/onyx/Request'; -import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; +import type {OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import type {PartialDeep} from 'type-fest'; import lodashCloneDeep from 'lodash/cloneDeep'; @@ -847,7 +846,7 @@ async function importPolicyCategories(policyID: string, categories: PolicyCatego } } -function renamePolicyCategory(policyData: PolicyData, policyCategory: {oldName: string; newName: string}, isVendorMatchingBetaEnabled: boolean | undefined, rules?: OnyxCollection) { +function renamePolicyCategory(policyData: PolicyData, policyCategory: {oldName: string; newName: string}, isVendorMatchingBetaEnabled: boolean | undefined) { const policy = policyData.policy; const policyID = policy.id; const policyCategoryToUpdate = policyData.categories?.[policyCategory.oldName]; @@ -875,10 +874,6 @@ function renamePolicyCategory(policyData: PolicyData, policyCategory: {oldName: updatedApprovalRules[indexToUpdate] = policyCategoryApproverRule; } - // A coding rule names its category rather than pointing at it, so it has to be rewritten alongside the approval - // rules above, otherwise it matches a category that no longer exists until the next fetch. - const ruleUpdates = getCategoryRenameRuleUpdates(rules, policyID, policyCategory.oldName, policyCategory.newName); - const policyOptimisticData = { rules: { approvalRules: updatedApprovalRules, @@ -900,7 +895,7 @@ function renamePolicyCategory(policyData: PolicyData, policyCategory: {oldName: }, }; - const onyxData: OnyxData = { + const onyxData: OnyxData = { optimisticData: [ { onyxMethod: Onyx.METHOD.MERGE, @@ -915,11 +910,6 @@ function renamePolicyCategory(policyData: PolicyData, policyCategory: {oldName: key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: policyOptimisticData, }, - ...ruleUpdates.map(({ruleID, filters}) => ({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.RULE}${ruleID}` as const, - value: {filters}, - })), ], successData: [ { @@ -943,11 +933,6 @@ function renamePolicyCategory(policyData: PolicyData, policyCategory: {oldName: }, ], failureData: [ - ...ruleUpdates.map(({ruleID, previousFilters}) => ({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.RULE}${ruleID}` as const, - value: {filters: previousFilters}, - })), { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, diff --git a/src/libs/actions/Policy/InlineEdit.ts b/src/libs/actions/Policy/InlineEdit.ts index 93eef9204b59..6eca722692d3 100644 --- a/src/libs/actions/Policy/InlineEdit.ts +++ b/src/libs/actions/Policy/InlineEdit.ts @@ -28,12 +28,12 @@ import {updateExpensifyCardLimit, updateExpensifyCardLimitType} from '@userActio import {updateCompanyCardName} from '@userActions/CompanyCards'; import CONST from '@src/CONST'; -import type {Card, Policy, Rule} from '@src/types/onyx'; +import type {Card, Policy} from '@src/types/onyx'; import type {CardLimitType} from '@src/types/onyx/Card'; import type {CompanyCardFeedWithNumber} from '@src/types/onyx/CardFeeds'; import type {CustomUnit, Rate} from '@src/types/onyx/Policy'; -import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import {renamePolicyCategory} from './Category'; @@ -48,7 +48,6 @@ function renameCategoryInline( currentName: string, newName: string, isVendorMatchingBetaEnabled: boolean | undefined, - rules?: OnyxCollection, ): string | undefined { const sanitized = StringUtils.sanitizeName(newName); const currentDisplayName = getDecodedCategoryName(currentName); @@ -62,7 +61,7 @@ function renameCategoryInline( return getCategoryNameErrorMessage(translate, categoryError, newName); } - renamePolicyCategory(policyData, {oldName: currentName, newName: sanitized}, isVendorMatchingBetaEnabled, rules); + renamePolicyCategory(policyData, {oldName: currentName, newName: sanitized}, isVendorMatchingBetaEnabled); } function renameTagInline(translate: LocalizedTranslate, policyData: PolicyData, oldName: string, newName: string, isVendorMatchingBetaEnabled: boolean | undefined): string | undefined { diff --git a/src/pages/workspace/categories/DynamicEditCategoryPage.tsx b/src/pages/workspace/categories/DynamicEditCategoryPage.tsx index 9e165b452b70..0953be8443cf 100644 --- a/src/pages/workspace/categories/DynamicEditCategoryPage.tsx +++ b/src/pages/workspace/categories/DynamicEditCategoryPage.tsx @@ -4,7 +4,6 @@ import ScreenWrapper from '@components/ScreenWrapper'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePolicyData from '@hooks/usePolicyData'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -21,7 +20,7 @@ import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import {renamePolicyCategory} from '@userActions/Policy/Category'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; +import type ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; @@ -36,7 +35,6 @@ type DynamicEditCategoryPageProps = function DynamicEditCategoryPage({route}: DynamicEditCategoryPageProps) { const {policyID, categoryName: currentCategoryName} = route.params; const policyData = usePolicyData(policyID); - const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const {categories: policyCategories} = policyData; const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -65,7 +63,7 @@ function DynamicEditCategoryPage({route}: DynamicEditCategoryPageProps) { const newCategoryName = StringUtils.sanitizeName(values.categoryName); // Do not call the API if the edited category name is the same as the current category name if (decodedCategoryName !== newCategoryName) { - renamePolicyCategory(policyData, {oldName: currentCategoryName, newName: newCategoryName}, isVendorMatchingBetaEnabled, rules); + renamePolicyCategory(policyData, {oldName: currentCategoryName, newName: newCategoryName}, isVendorMatchingBetaEnabled); } // Ensure Onyx.update is executed before navigation to prevent UI blinking issues, affecting the category name and rate. @@ -73,7 +71,7 @@ function DynamicEditCategoryPage({route}: DynamicEditCategoryPageProps) { Navigation.goBack(isQuickSettingsFlow ? settingsBackPath : workspaceBackPath, {compareParams: false}); }); }, - [decodedCategoryName, currentCategoryName, policyData, isQuickSettingsFlow, settingsBackPath, workspaceBackPath, isVendorMatchingBetaEnabled, rules], + [decodedCategoryName, currentCategoryName, policyData, isQuickSettingsFlow, settingsBackPath, workspaceBackPath, isVendorMatchingBetaEnabled], ); return ( diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index b62c40562feb..ccca3200bc3a 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -90,7 +90,6 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { const {backTo, policyID: policyId} = route.params; const isMobileSelectionModeEnabled = useMobileSelectionMode(); const policyData = usePolicyData(policyId); - const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const {policy, categories: policyCategories} = policyData; const employeePersonalDetails = usePersonalDetailsByLogins(Object.keys(policy?.employeeList ?? {})); useWorkspaceDocumentTitle(policy?.name, 'workspace.common.categories'); @@ -311,7 +310,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { canEditName: canWriteCategories && !isDisabled && !isSelectionModeActive, action: () => navigateToCategory(value), onToggleEnabled: (enabled: boolean) => handleCategoryToggle(enabled, value), - onRenameName: (newName: string) => showInlineEditError(renameCategoryInline(translate, policyData, value.name, newName, isVendorMatchingBetaEnabled, rules)), + onRenameName: (newName: string) => showInlineEditError(renameCategoryInline(translate, policyData, value.name, newName, isVendorMatchingBetaEnabled)), dismissError: () => clearCategoryErrors(policyId, value.name, policyCategories), }); @@ -334,7 +333,6 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { employeePersonalDetails, formatPhoneNumber, isVendorMatchingBetaEnabled, - rules, ]); const navigateToCreateCategoryPage = () => { diff --git a/tests/unit/ExpenseDefaultRuleUtilsTest.ts b/tests/unit/ExpenseDefaultRuleUtilsTest.ts index 318f56d9066b..517ec0fc8e85 100644 --- a/tests/unit/ExpenseDefaultRuleUtilsTest.ts +++ b/tests/unit/ExpenseDefaultRuleUtilsTest.ts @@ -1,7 +1,6 @@ import { buildExpenseDefaultRule, canEditExpenseDefaultRule, - getCategoryRenameRuleUpdates, getExpenseDefaultConditionSummary, getExpenseDefaultRuleCount, getExpenseDefaultRuleSummaryFields, @@ -420,70 +419,6 @@ describe('ExpenseDefaultRuleUtils', () => { }); }); - describe('getCategoryRenameRuleUpdates', () => { - const categoryRule = asStoredRule({...merchantRuleBody, filters: {left: FIELD.CATEGORY, operator: EQUAL_TO, right: 'Coffee'}}); - - it('rewrites the renamed category in a rule that matches on it', () => { - // Given a rule matching on the category being renamed - const rules = {[`${ONYXKEYS.COLLECTION.RULE}1`]: categoryRule}; - - // When the rename is applied - const updates = getCategoryRenameRuleUpdates(rules, POLICY_ID, 'Coffee', 'Caffeine'); - - // Then the rule carries the new name, and the old tree is kept so a failed rename can put it back - expect(updates).toEqual([ - { - ruleID: '1', - filters: {left: FIELD.CATEGORY, operator: EQUAL_TO, right: 'Caffeine'}, - previousFilters: {left: FIELD.CATEGORY, operator: EQUAL_TO, right: 'Coffee'}, - }, - ]); - }); - - it('rewrites the category inside a nested condition', () => { - // Given a rule that matches on a merchant and the renamed category together - const nested = asStoredRule({ - ...merchantRuleBody, - filters: {left: merchantFilter, operator: AND, right: {left: FIELD.CATEGORY, operator: EQUAL_TO, right: 'Coffee'}}, - }); - - // When the rename is applied - const updates = getCategoryRenameRuleUpdates({[`${ONYXKEYS.COLLECTION.RULE}1`]: nested}, POLICY_ID, 'Coffee', 'Caffeine'); - - // Then only the category branch changes - expect(updates.at(0)?.filters).toEqual({left: merchantFilter, operator: AND, right: {left: FIELD.CATEGORY, operator: EQUAL_TO, right: 'Caffeine'}}); - }); - - it('rewrites one name out of a list the rule matches on', () => { - // Given a rule matching on several categories at once - const multi = asStoredRule({...merchantRuleBody, filters: {left: FIELD.CATEGORY, operator: EQUAL_TO, right: ['Coffee', 'Travel']}}); - - // When one of them is renamed - const updates = getCategoryRenameRuleUpdates({[`${ONYXKEYS.COLLECTION.RULE}1`]: multi}, POLICY_ID, 'Coffee', 'Caffeine'); - - // Then the others are left alone - expect(updates.at(0)?.filters).toEqual({left: FIELD.CATEGORY, operator: EQUAL_TO, right: ['Caffeine', 'Travel']}); - }); - - it('leaves alone a rule that does not name the category', () => { - // Given a rule matching on a merchant only - const rules = {[`${ONYXKEYS.COLLECTION.RULE}1`]: asStoredRule(merchantRuleBody)}; - - // When an unrelated category is renamed - // Then nothing is rewritten, so the rename writes no rule updates - expect(getCategoryRenameRuleUpdates(rules, POLICY_ID, 'Coffee', 'Caffeine')).toEqual([]); - }); - - it('leaves alone a rule belonging to another workspace', () => { - // Given a rule on the same category but scoped to a different policy - const rules = {[`${ONYXKEYS.COLLECTION.RULE}1`]: asStoredRule({...merchantRuleBody, filters: {left: FIELD.CATEGORY, operator: EQUAL_TO, right: 'Coffee'}}, 'OTHER')}; - - // When the rename is applied to this policy - // Then the other workspace's rule is untouched, since the collection holds every workspace the user sees - expect(getCategoryRenameRuleUpdates(rules, POLICY_ID, 'Coffee', 'Caffeine')).toEqual([]); - }); - }); - describe('isExpenseDefaultRule', () => { it('is true for a rule that runs on transaction creation and sets a field', () => { expect(isExpenseDefaultRule(asStoredRule(merchantRuleBody))).toBe(true); diff --git a/tests/unit/inlineEditing/PolicyInlineEdit.test.ts b/tests/unit/inlineEditing/PolicyInlineEdit.test.ts index 114180a805b1..c854796e359a 100644 --- a/tests/unit/inlineEditing/PolicyInlineEdit.test.ts +++ b/tests/unit/inlineEditing/PolicyInlineEdit.test.ts @@ -19,8 +19,7 @@ import {updateWorkspaceMembersRole} from '@userActions/Policy/Member'; import CONST from '@src/CONST'; import type {TranslationParameters, TranslationPaths} from '@src/languages/types'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {Card, Policy, Rule} from '@src/types/onyx'; +import type {Card, Policy} from '@src/types/onyx'; import type {PartialDeep} from 'type-fest'; @@ -90,10 +89,6 @@ const policyData = createMock({ const policy = createMock({id: '1'}); -const rules: Record = { - [`${ONYXKEYS.COLLECTION.RULE}1`]: createMock({scope: CONST.RULES.SCOPE.POLICY, scopeID: '1'}), -}; - function buildCard(overrides: PartialDeep = {}): Card { return createMock({ cardID: 10, @@ -144,12 +139,9 @@ describe('PolicyInlineEdit', () => { }); it('delegates a valid rename to the canonical action', () => { - // Given the coding rules, which the action needs because it rewrites the renamed category inside them - // When the rename is made from the table - renameCategoryInline(translate, policyData, 'Food', ' Meals ', true, rules); + renameCategoryInline(translate, policyData, 'Food', ' Meals ', true); - // Then the collection reaches the action alongside the names - expect(mockRenamePolicyCategory).toHaveBeenCalledWith(policyData, {oldName: 'Food', newName: 'Meals'}, true, rules); + expect(mockRenamePolicyCategory).toHaveBeenCalledWith(policyData, {oldName: 'Food', newName: 'Meals'}, true); }); it('does not persist a name that matches an HTML-encoded category', () => { @@ -165,9 +157,9 @@ describe('PolicyInlineEdit', () => { }); it('persists a rename of an HTML-encoded category using the raw stored name', () => { - renameCategoryInline(translate, policyData, encodedFoodAndDrink, 'Snacks', true, rules); + renameCategoryInline(translate, policyData, encodedFoodAndDrink, 'Snacks', true); - expect(mockRenamePolicyCategory).toHaveBeenCalledWith(policyData, {oldName: encodedFoodAndDrink, newName: 'Snacks'}, true, rules); + expect(mockRenamePolicyCategory).toHaveBeenCalledWith(policyData, {oldName: encodedFoodAndDrink, newName: 'Snacks'}, true); }); }); From 7bdbc4904bd5b64d8281d4cb34a5a2fcc839dba2 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 22 Oct 2026 05:22:17 +0530 Subject: [PATCH 19/24] Tell a contains rule apart from an exact match in the rules table Signed-off-by: krishna2323 --- src/languages/en.ts | 3 ++- src/libs/ExpenseDefaultRuleUtils.ts | 15 +++++++++---- src/libs/MerchantTypeRulesUtils.ts | 19 ++++++++++++++--- tests/unit/ExpenseDefaultRuleUtilsTest.ts | 26 +++++++++++++++++++++-- 4 files changed, 53 insertions(+), 10 deletions(-) diff --git a/src/languages/en.ts b/src/languages/en.ts index a442e94c2c84..2b2c05ed71cb 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -9235,7 +9235,8 @@ const translations = { rename: 'Rename', update: 'Update', categoryIs: (category: string) => `Category is "${category}"`, - merchantIs: (merchant: string) => `Merchant is "${merchant}"`, + merchantContains: (merchant: string) => `Merchant contains "${merchant}"`, + merchantExactlyMatches: (merchant: string) => `Merchant exactly matches "${merchant}"`, tagIs: (tag: string) => `Tag is "${tag}"`, merchantTypeIs: (merchantType: string) => `Merchant type is "${merchantType}"`, }, diff --git a/src/libs/ExpenseDefaultRuleUtils.ts b/src/libs/ExpenseDefaultRuleUtils.ts index d27442064914..91d12351c015 100644 --- a/src/libs/ExpenseDefaultRuleUtils.ts +++ b/src/libs/ExpenseDefaultRuleUtils.ts @@ -5,7 +5,6 @@ */ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {MerchantRuleForm} from '@src/types/form'; import type {Policy, Rule} from '@src/types/onyx'; import type {ApprovalWorkflowAction} from '@src/types/onyx/ApprovalWorkflowRules'; import type { @@ -17,7 +16,6 @@ import type { ExpenseDefaultTrigger, ExpenseDefaultTriggers, } from '@src/types/onyx/ExpenseDefaultRules'; -import type {GeneratedRuleValues} from '@src/types/onyx/GeneratedRule'; import type {RuleFilterComparison, RuleFilterNode} from '@src/types/onyx/RuleFilters'; import type {OnyxCollection} from 'react-native-onyx'; @@ -517,10 +515,19 @@ function getExpenseDefaultFormValues(rule: Rule | ExpenseDefaultRule | undefined return formValues; } +/** A merchant a rule matches on, with the way it matches, since a rule can match a merchant exactly or by substring. */ +type MerchantCondition = { + /** The merchant text the rule matches against */ + value: string; + + /** Whether the merchant has to match in full rather than only contain the text */ + isExactMatch: boolean; +}; + /** The values a rule matches on, grouped by field. */ type ExpenseDefaultConditionSummary = { /** The merchants the rule matches on */ - merchants: string[]; + merchants: MerchantCondition[]; /** The categories the rule matches on, as they are stored on the policy */ categories: string[]; @@ -542,7 +549,7 @@ function getExpenseDefaultConditionSummary(filters: RuleFilterNode | undefined): const values = [leaf.right].flat().filter((value): value is string => typeof value === 'string'); if (leaf.left === CONDITION.MERCHANT) { - summary.merchants.push(...values); + summary.merchants.push(...values.map((value) => ({value, isExactMatch: leaf.operator === EQUAL_TO}))); } else if (leaf.left === CONDITION.CATEGORY) { summary.categories.push(...values); } else if (leaf.left === CONDITION.TAG) { diff --git a/src/libs/MerchantTypeRulesUtils.ts b/src/libs/MerchantTypeRulesUtils.ts index ed4b264f1cc9..a21b552e5393 100644 --- a/src/libs/MerchantTypeRulesUtils.ts +++ b/src/libs/MerchantTypeRulesUtils.ts @@ -158,13 +158,26 @@ function getMerchantRulesTableData({ ({field, value}) => field !== FIELD.TAG || !policyTags || typeof value !== 'string' || isTagInPolicy(value, policyTags), ); const {merchants, categories, tags} = getExpenseDefaultConditionSummary(rule.filters); - const merchantNames = merchants.join(', '); + const merchantNames = merchants.map(({value}) => value).join(', '); + const containedMerchantNames = merchants + .filter(({isExactMatch}) => !isExactMatch) + .map(({value}) => value) + .join(', '); + const exactMerchantNames = merchants + .filter(({isExactMatch}) => isExactMatch) + .map(({value}) => value) + .join(', '); const categoryNames = categories.map(getDecodedCategoryName).join(', '); const tagNames = tags.map(getCommaSeparatedTagNameWithSanitizedColons).join(', '); const conditions: string[] = []; - if (merchantNames) { - conditions.push(translate('workspace.rules.expenseDefaultsTable.merchantIs', merchantNames)); + // Spelled out rather than a single "Merchant is", so a contains rule and an exact match rule on the + // same merchant don't read as duplicates. + if (containedMerchantNames) { + conditions.push(translate('workspace.rules.expenseDefaultsTable.merchantContains', containedMerchantNames)); + } + if (exactMerchantNames) { + conditions.push(translate('workspace.rules.expenseDefaultsTable.merchantExactlyMatches', exactMerchantNames)); } if (categoryNames) { conditions.push(translate('workspace.rules.expenseDefaultsTable.categoryIs', categoryNames)); diff --git a/tests/unit/ExpenseDefaultRuleUtilsTest.ts b/tests/unit/ExpenseDefaultRuleUtilsTest.ts index 517ec0fc8e85..7e810a194173 100644 --- a/tests/unit/ExpenseDefaultRuleUtilsTest.ts +++ b/tests/unit/ExpenseDefaultRuleUtilsTest.ts @@ -271,7 +271,11 @@ describe('ExpenseDefaultRuleUtils', () => { // When its conditions are summarized // Then each field keeps its own values, so the table can name them separately - expect(getExpenseDefaultConditionSummary(rule?.filters)).toEqual({merchants: ['Starbucks'], categories: ['Coffee', 'Travel'], tags: ['Team A']}); + expect(getExpenseDefaultConditionSummary(rule?.filters)).toEqual({ + merchants: [{value: 'Starbucks', isExactMatch: false}], + categories: ['Coffee', 'Travel'], + tags: ['Team A'], + }); }); it('summarizes a rule the editor cannot open', () => { @@ -284,7 +288,25 @@ describe('ExpenseDefaultRuleUtils', () => { // When it is summarized // Then both merchants are listed, so a read-only rule still shows what it matches - expect(getExpenseDefaultConditionSummary(filters)).toEqual({merchants: ['Starbucks', 'Costa'], categories: [], tags: []}); + expect(getExpenseDefaultConditionSummary(filters)).toEqual({ + merchants: [ + {value: 'Starbucks', isExactMatch: false}, + {value: 'Costa', isExactMatch: false}, + ], + categories: [], + tags: [], + }); + }); + + it('keeps the match type of each merchant condition', () => { + // Given one rule matching a merchant exactly and another matching it by substring + const exactRule = buildExpenseDefaultRule({merchantToMatch: 'Uber', matchType: EQUAL_TO, category: 'Travel'}, policy); + const containsRule = buildExpenseDefaultRule({merchantToMatch: 'Uber', matchType: CONTAINS, category: 'Travel'}, policy); + + // When each is summarized + // Then the match type comes back with the merchant, so the table can tell the two rules apart + expect(getExpenseDefaultConditionSummary(exactRule?.filters).merchants).toEqual([{value: 'Uber', isExactMatch: true}]); + expect(getExpenseDefaultConditionSummary(containsRule?.filters).merchants).toEqual([{value: 'Uber', isExactMatch: false}]); }); it('ignores a field the table has no wording for', () => { From a3c1518aa3363411d2ecfc3a82f574598ec38d05 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 22 Oct 2026 05:31:50 +0530 Subject: [PATCH 20/24] Keep the rule being edited when picking a category Signed-off-by: krishna2323 --- .../MerchantRules/AddCategoryToMatchPage.tsx | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/pages/workspace/rules/MerchantRules/AddCategoryToMatchPage.tsx b/src/pages/workspace/rules/MerchantRules/AddCategoryToMatchPage.tsx index e34d2351e795..d2bed883bae3 100644 --- a/src/pages/workspace/rules/MerchantRules/AddCategoryToMatchPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddCategoryToMatchPage.tsx @@ -31,22 +31,34 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import React, {useState} from 'react'; import {View} from 'react-native'; -type AddCategoryToMatchPageProps = PlatformStackScreenProps; +import useMerchantRuleRoute from './useMerchantRuleRoute'; + +type AddCategoryToMatchPageProps = PlatformStackScreenProps< + SettingsNavigatorParamList, + typeof SCREENS.WORKSPACE.RULES_CATEGORY_TO_MATCH | typeof SCREENS.WORKSPACE.DYNAMIC_RULES_CATEGORY_TO_MATCH +>; type CategoryListItem = ListItem & { value: string; }; function AddCategoryToMatchPage({route}: AddCategoryToMatchPageProps) { - const {policyID, categoryName: editingCategoryName} = route.params; + const {policyID, ruleID, categoryName: editingCategoryName} = route.params; // Editing a category tax default edits one rule, so its category is swapped, not added to. const isEditingCategoryTaxRule = !!editingCategoryName; + // A category tax default is addressed by category rather than ruleID, so it returns to its own editor. + const {backToRoute} = useMerchantRuleRoute( + DYNAMIC_ROUTES.RULES_CATEGORY_TO_MATCH_FROM_EXPENSE.path, + policyID, + ruleID, + editingCategoryName ? ROUTES.RULES_CATEGORY_TAX_EDIT.getRoute(policyID, editingCategoryName) : undefined, + ); const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); const illustrations = useMemoizedLazyIllustrations(['Telescope']); @@ -119,9 +131,6 @@ function AddCategoryToMatchPage({route}: AddCategoryToMatchPageProps) { setSelectedCategories((prev) => Array.from(new Set([...prev, ...visibleValues]))); }; - // Passed explicitly like every sibling picker, so a deep link has somewhere to pop to. A category tax default is - // addressed by category rather than ruleID. - const backToRoute = editingCategoryName ? ROUTES.RULES_CATEGORY_TAX_EDIT.getRoute(policyID, editingCategoryName) : ROUTES.RULES_MERCHANT_NEW.getRoute(policyID); const goBackToRule = () => Navigation.goBack(backToRoute); const goBackAfterSave = () => Navigation.goBack(backToRoute, {shouldSkipFocusRestore: true}); From 1e9243e36735478a1746a0701b837825d93aba94 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 22 Oct 2026 05:32:00 +0530 Subject: [PATCH 21/24] Stop offering a rule that is being deleted Signed-off-by: krishna2323 --- src/libs/ExpenseDefaultRuleUtils.ts | 6 ++++ .../ExpenseDefaultRulePageBase.tsx | 15 +++++++--- tests/unit/ExpenseDefaultRuleUtilsTest.ts | 30 +++++++++++++++++++ 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/libs/ExpenseDefaultRuleUtils.ts b/src/libs/ExpenseDefaultRuleUtils.ts index 91d12351c015..e175a14d8902 100644 --- a/src/libs/ExpenseDefaultRuleUtils.ts +++ b/src/libs/ExpenseDefaultRuleUtils.ts @@ -572,6 +572,12 @@ function getCategoryTaxDefaultRule(rulesCollection: OnyxCollection | undef } return getPolicyExpenseDefaultRules(rulesCollection, policyID).find(({rule}) => { + // A rule deleted offline sticks around until the request goes through, and category settings would keep + // offering it to edit. + if (rule.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + return false; + } + const {merchants, categories, tags} = getExpenseDefaultConditionSummary(rule.filters); if (merchants.length > 0 || tags.length > 0 || categories.length !== 1 || categories.at(0) !== categoryName) { return false; diff --git a/src/pages/workspace/rules/MerchantRules/ExpenseDefaultRulePageBase.tsx b/src/pages/workspace/rules/MerchantRules/ExpenseDefaultRulePageBase.tsx index 9facb320d332..e4bb2fc9513a 100644 --- a/src/pages/workspace/rules/MerchantRules/ExpenseDefaultRulePageBase.tsx +++ b/src/pages/workspace/rules/MerchantRules/ExpenseDefaultRulePageBase.tsx @@ -13,6 +13,7 @@ import Text from '@components/Text'; import TextLink from '@components/TextLink'; import useConfirmModal from '@hooks/useConfirmModal'; +import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -33,7 +34,6 @@ import Tab from '@libs/actions/Tab'; import {clearDraftMerchantRule, setDraftMerchantRule} from '@libs/actions/User'; import {getTaxRateDisplayName, hasUsableTaxRates} from '@libs/CategoryTaxRulesUtils'; import {getDecodedCategoryName} from '@libs/CategoryUtils'; -import {convertAmountToDisplayString} from '@libs/CurrencyUtils'; import { buildExpenseDefaultRule, canEditExpenseDefaultRule, @@ -156,6 +156,7 @@ function ExpenseDefaultRulePageBase({ }: ExpenseDefaultRulePageBaseProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); + const {convertToDisplayString} = useCurrencyListActions(); const policy = usePolicy(policyID); const {canWrite: canWriteRules} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.RULES); // The page is on its way out, so the rule it was editing no longer being there is expected. A category rule is @@ -304,8 +305,9 @@ function ExpenseDefaultRulePageBase({ // One rule per category is saved, so the condition row lists every category the admin picked. const categoriesToMatchDisplayName = hasCategoryCondition ? categoriesToMatch.map(getDecodedCategoryName).join(', ') : undefined; const tagsToMatch = form?.tagsToMatch ?? []; - const tagsToMatchDisplayName = tagsToMatch.length > 0 ? tagsToMatch.map(getCleanedTagName).join(', ') : undefined; - const amountDisplayName = form?.amount ? convertAmountToDisplayString(Number(form.amount), policy?.outputCurrency) : undefined; + const hasTagCondition = tagsToMatch.length > 0; + const tagsToMatchDisplayName = hasTagCondition ? tagsToMatch.map(getCleanedTagName).join(', ') : undefined; + const amountDisplayName = form?.amount ? convertToDisplayString(Number(form.amount), policy?.outputCurrency) : undefined; const categoryDisplayName = form?.category ? getDecodedCategoryName(form.category) : undefined; // Blank rather than the raw ID once the rate is gone from the workspace. const taxDisplayName = (form?.tax && getTaxByID(policy, form.tax) ? getTaxRateDisplayName(policy, form.tax) : '') || undefined; @@ -325,6 +327,11 @@ function ExpenseDefaultRulePageBase({ return false; } + // A rule deleted offline no longer applies to anything, so recreating it is not a duplicate. + if (rule.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + return false; + } + // A rule this form can't represent can't be a duplicate of what this form is about to save. const existingFormValues = getExpenseDefaultFormValues(rule); if (!existingFormValues) { @@ -698,7 +705,7 @@ function ExpenseDefaultRulePageBase({ {/* Only while a condition is set, and only on an unsaved rule: resetting a saved one would let it switch condition type, which the two storage shapes can't express as one edit. A locked category isn't the admin's to clear. */} - {canWriteRules && !isEditingSavedRule && !isCategoryLocked && (hasMerchantCondition || hasCategoryCondition) && ( + {canWriteRules && !isEditingSavedRule && !isCategoryLocked && (hasMerchantCondition || hasCategoryCondition || hasTagCondition) && ( {translate('common.reset')} )} diff --git a/tests/unit/ExpenseDefaultRuleUtilsTest.ts b/tests/unit/ExpenseDefaultRuleUtilsTest.ts index 7e810a194173..e3ad6cf933d2 100644 --- a/tests/unit/ExpenseDefaultRuleUtilsTest.ts +++ b/tests/unit/ExpenseDefaultRuleUtilsTest.ts @@ -1,6 +1,7 @@ import { buildExpenseDefaultRule, canEditExpenseDefaultRule, + getCategoryTaxDefaultRule, getExpenseDefaultConditionSummary, getExpenseDefaultRuleCount, getExpenseDefaultRuleSummaryFields, @@ -530,6 +531,35 @@ describe('ExpenseDefaultRuleUtils', () => { }); }); + describe('getCategoryTaxDefaultRule', () => { + const categoryTaxRuleBody: ExpenseDefaultRule = { + triggers: toIndexMap([TRIGGER.CREATE_TRANSACTION]), + filters: {left: FIELD.CATEGORY, operator: EQUAL_TO, right: 'Coffee'}, + // eslint-disable-next-line @typescript-eslint/naming-convention -- the rule stores the tax under the field name that the backend uses + actions: toIndexMap([{name: ACTION.SET, field: FIELD.TAX, value: {field_id_TAX: {externalID: TAX_KEY, name: 'GST', value: '10%'}}} as ExpenseDefaultAction]), + }; + + it('finds the rule that sets a tax for the category', () => { + // Given a rule matching that category alone and setting a tax + const collection = {[`${ONYXKEYS.COLLECTION.RULE}1`]: asStoredRule(categoryTaxRuleBody)}; + + // When the category's tax default is looked up + // Then that rule is returned, so category settings can open it + expect(getCategoryTaxDefaultRule(collection, POLICY_ID, 'Coffee')?.ruleID).toBe('1'); + }); + + it('ignores a rule being deleted', () => { + // Given the same rule, deleted while offline so it is still in the collection + const collection = { + [`${ONYXKEYS.COLLECTION.RULE}1`]: {...asStoredRule(categoryTaxRuleBody), pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}, + }; + + // When the category's tax default is looked up + // Then nothing is returned, because a deleted rule no longer applies and must not be offered for editing + expect(getCategoryTaxDefaultRule(collection, POLICY_ID, 'Coffee')).toBeUndefined(); + }); + }); + describe('hasExpenseDefaultRuleErrors', () => { const failedRule = {...asStoredRule(merchantRuleBody), errors: {error: 'Whoops'}}; From 483a9078b2884544aee538b9653d9baa84e2046d Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 22 Oct 2026 05:34:43 +0530 Subject: [PATCH 22/24] Compare every condition when skipping imported duplicates Signed-off-by: krishna2323 --- src/ROUTES.ts | 4 ---- src/languages/en.ts | 4 ++-- .../rules/MerchantRules/ImportedMerchantRulesPage.tsx | 6 ++++++ 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 6d7087e59261..c23bbe05dec2 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1109,10 +1109,6 @@ const DYNAMIC_ROUTES = { path: 'gl-code', entryScreens: [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_SETTINGS], }, - WORKSPACE_CATEGORY_DEFAULT_TAX_RATE: { - path: 'tax-rate', - entryScreens: [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_SETTINGS, SCREENS.SETTINGS_CATEGORIES.DYNAMIC_SETTINGS_CATEGORY_SETTINGS], - }, WORKSPACE_CATEGORY_FLAG_AMOUNTS_OVER: { path: 'flag-amounts', entryScreens: [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_SETTINGS, SCREENS.SETTINGS_CATEGORIES.DYNAMIC_SETTINGS_CATEGORY_SETTINGS], diff --git a/src/languages/en.ts b/src/languages/en.ts index 2b2c05ed71cb..7668d6eb8a33 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -9188,8 +9188,8 @@ const translations = { ifAnyExpenseMatches: 'If any expense matches:', thenApplyFollowingDefaults: 'Then apply the following defaults:', confirmErrorCategory: 'Please select a category', - confirmErrorCondition: 'Please enter a merchant', - confirmErrorConditionAndDefault: 'Enter a merchant, and apply at least one default', + confirmErrorCondition: 'Please add at least one condition', + confirmErrorConditionAndDefault: 'Add at least one condition, and apply at least one default', }, newRule: { title: 'New rule', diff --git a/src/pages/workspace/rules/MerchantRules/ImportedMerchantRulesPage.tsx b/src/pages/workspace/rules/MerchantRules/ImportedMerchantRulesPage.tsx index 6613b7c6efd6..3a480081928a 100644 --- a/src/pages/workspace/rules/MerchantRules/ImportedMerchantRulesPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/ImportedMerchantRulesPage.tsx @@ -66,10 +66,16 @@ function getRuleContentKey(formValues: Partial): strin return JSON.stringify([ formValues.matchType ?? CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS, formValues.merchantToMatch?.toLowerCase() ?? '', + // A spreadsheet can't set these conditions, but a rule the policy already has can carry them, and a rule + // that matches on more than the row does is a different rule. + formValues.categoriesToMatch ?? [], + formValues.tagsToMatch ?? [], formValues.merchant ?? '', formValues.category ?? '', formValues.tag ?? '', formValues.comment ?? '', + formValues.amount ?? null, + formValues.tax ?? null, formValues.reimbursable ?? null, formValues.billable ?? null, formValues.vendorID ?? null, From d0d4d6b9efe05ef83d3c27d9c162578f61e96ba2 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 23 Oct 2026 02:42:16 +0530 Subject: [PATCH 23/24] Translate the new merchant condition strings Signed-off-by: krishna2323 --- src/languages/de.ts | 7 ++++--- src/languages/el.ts | 7 ++++--- src/languages/es.ts | 9 +++++---- src/languages/fr.ts | 7 ++++--- src/languages/it.ts | 7 ++++--- src/languages/ja.ts | 7 ++++--- src/languages/nl.ts | 7 ++++--- src/languages/pl.ts | 7 ++++--- src/languages/pt-BR.ts | 9 +++++---- src/languages/zh-hans.ts | 7 ++++--- src/pages/workspace/rules/DescribeNewRuleForm.tsx | 3 +-- tests/ui/RulesNewPageDescribeRuleTest.tsx | 3 ++- 12 files changed, 45 insertions(+), 35 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 3bcbda2c4f17..d74aceb7d3cb 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -8584,8 +8584,8 @@ Fordern Sie Spesendetails wie Belege und Beschreibungen an, legen Sie Limits und thenApplyFollowingDefaults: 'Wenden Sie dann die folgenden Standardwerte an:', vendorUnavailable: 'Anbieter nicht verfügbar', confirmErrorCategory: 'Bitte wählen Sie eine Kategorie aus', - confirmErrorCondition: 'Bitte geben Sie einen Händler ein', - confirmErrorConditionAndDefault: 'Geben Sie einen Händler ein und wenden Sie mindestens eine Standardeinstellung an', + confirmErrorCondition: 'Bitte fügen Sie mindestens eine Bedingung hinzu', + confirmErrorConditionAndDefault: 'Fügen Sie mindestens eine Bedingung hinzu und wenden Sie mindestens eine Voreinstellung an', createRuleFromExpenseAction: 'Regel erstellen', createRuleFromExpensePrompt: 'um Ihre Änderungen auf alle Ausgaben anzuwenden, die Ihren Kriterien entsprechen.', }, @@ -8916,10 +8916,11 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc findRule: 'Regel suchen', rename: 'Umbenennen', update: 'Aktualisieren', - merchantIs: (merchant: string) => `Händler ist „${merchant}“`, merchantTypeIs: (merchantType: string) => `Händlertyp ist „${merchantType}"`, categoryIs: (category: string) => `Kategorie ist „${category}“`, tagIs: (tag: string) => `Tag ist „${tag}“`, + merchantContains: (merchant: string) => `Händler enthält „${merchant}“`, + merchantExactlyMatches: (merchant: string) => `Händler stimmt genau mit „${merchant}“ überein`, }, merchantTypeRule: {merchantType: 'Händlerart', saveRule: 'Regel speichern', confirmErrorCategory: 'Bitte wählen Sie eine Kategorie aus.'}, requireFieldsTable: { diff --git a/src/languages/el.ts b/src/languages/el.ts index 969473b889bf..175125489a4f 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -8956,8 +8956,8 @@ ${reportName}`, importColumnUpdatedVendor: 'Ενημερώθηκε ο προμηθευτής', vendorUnavailable: 'Ο προμηθευτής δεν είναι διαθέσιμος', confirmErrorCategory: 'Παρακαλώ επιλέξτε κατηγορία', - confirmErrorCondition: 'Παρακαλούμε εισαγάγετε έναν έμπορο', - confirmErrorConditionAndDefault: 'Προσθέστε έναν έμπορο και ορίστε τουλάχιστον μία προεπιλογή', + confirmErrorCondition: 'Παρακαλούμε προσθέστε τουλάχιστον μία συνθήκη', + confirmErrorConditionAndDefault: 'Προσθέστε τουλάχιστον μία συνθήκη και ορίστε τουλάχιστον μία προεπιλογή', createRuleFromExpenseAction: 'Δημιουργία κανόνα', createRuleFromExpensePrompt: 'για να εφαρμόσετε τις αλλαγές σας σε όλες τις δαπάνες που ταιριάζουν με τα κριτήριά σας.', }, @@ -9004,10 +9004,11 @@ ${reportName}`, findRule: 'Εύρεση κανόνα', rename: 'Μετονομασία', update: 'Ενημέρωση', - merchantIs: (merchant: string) => `Ο έμπορος είναι «${merchant}»`, merchantTypeIs: (merchantType: string) => `Ο τύπος εμπόρου είναι «${merchantType}»`, categoryIs: (category: string) => `Η κατηγορία είναι «${category}»`, tagIs: (tag: string) => `Η ετικέτα είναι «${tag}»`, + merchantContains: (merchant: string) => `Ο έμπορος περιέχει «${merchant}»`, + merchantExactlyMatches: (merchant: string) => `Το κατάστημα ταιριάζει ακριβώς με το «${merchant}»`, }, merchantTypeRule: { merchantType: 'Τύπος εμπόρου', diff --git a/src/languages/es.ts b/src/languages/es.ts index 03ea24278fca..1a05f3dbc0c5 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -8618,8 +8618,8 @@ ${reportName}`, ifAnyExpenseMatches: 'Si algún gasto coincide con:', thenApplyFollowingDefaults: 'Luego aplica las siguientes opciones predeterminadas:', confirmErrorCategory: 'Selecciona una categoría', - confirmErrorCondition: 'Por favor, introduce un comercio', - confirmErrorConditionAndDefault: 'Introduce un comercio y aplica al menos un valor predeterminado', + confirmErrorCondition: 'Por favor, añade al menos una condición', + confirmErrorConditionAndDefault: 'Añade al menos una condición y aplica al menos un valor predeterminado', createRuleFromExpenseAction: 'Crear una norma', createRuleFromExpensePrompt: 'para aplicar tus cambios a todos los gastos que coincidan con tus criterios.', }, @@ -8923,10 +8923,11 @@ ${reportName}`, findRule: 'Buscar regla', rename: 'Cambiar nombre', update: 'Actualizar', - merchantIs: (merchant: string) => `El comercio es «${merchant}»`, merchantTypeIs: (merchantType: string) => `El tipo de comercio es "${merchantType}"`, categoryIs: (category: string) => `La categoría es «${category}»`, - tagIs: (tag: string) => `La etiqueta es «${tag}»`, + tagIs: (tag: string) => `La etiqueta es "${tag}"`, + merchantContains: (merchant: string) => `El comercio contiene «${merchant}»`, + merchantExactlyMatches: (merchant: string) => `El comercio coincide exactamente con "${merchant}"`, }, merchantTypeRule: {merchantType: 'Tipo de comercio', saveRule: 'Guardar regla', confirmErrorCategory: 'Por favor, selecciona una categoría.'}, requireFieldsTable: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 6eb574ddc65e..bf55375b7f8c 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -8606,8 +8606,8 @@ Rendez obligatoires des informations de dépense comme les reçus et les descrip thenApplyFollowingDefaults: 'Appliquez ensuite les valeurs par défaut suivantes :', vendorUnavailable: 'Fournisseur indisponible', confirmErrorCategory: 'Veuillez sélectionner une catégorie', - confirmErrorCondition: 'Veuillez saisir un commerçant', - confirmErrorConditionAndDefault: 'Saisissez un commerçant et appliquez au moins un paramètre par défaut', + confirmErrorCondition: 'Veuillez ajouter au moins une condition', + confirmErrorConditionAndDefault: 'Ajoutez au moins une condition et appliquez au moins un par défaut', createRuleFromExpenseAction: 'Créer une règle', createRuleFromExpensePrompt: 'pour appliquer vos modifications à toutes les dépenses qui correspondent à vos critères.', }, @@ -8938,10 +8938,11 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e findRule: 'Trouver une règle', rename: 'Renommer', update: 'Mettre à jour', - merchantIs: (merchant: string) => `Le commerçant est « ${merchant} »`, merchantTypeIs: (merchantType: string) => `Le type de commerçant est « ${merchantType} »`, categoryIs: (category: string) => `La catégorie est « ${category} »`, tagIs: (tag: string) => `Le tag est « ${tag} »`, + merchantContains: (merchant: string) => `Le commerçant contient « ${merchant} »`, + merchantExactlyMatches: (merchant: string) => `Le commerçant correspond exactement à « ${merchant} »`, }, merchantTypeRule: {merchantType: 'Type de marchand', saveRule: 'Enregistrer la règle', confirmErrorCategory: 'Veuillez sélectionner une catégorie.'}, requireFieldsTable: { diff --git a/src/languages/it.ts b/src/languages/it.ts index a83845107fec..f99be1e937cf 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -8538,8 +8538,8 @@ Richiedi dettagli sulle spese come ricevute e descrizioni, imposta limiti e valo thenApplyFollowingDefaults: 'Quindi applica le seguenti impostazioni predefinite:', vendorUnavailable: 'Fornitore non disponibile', confirmErrorCategory: 'Seleziona una categoria', - confirmErrorCondition: 'Inserisci un esercente', - confirmErrorConditionAndDefault: 'Inserisci un esercente e applica almeno un valore predefinito', + confirmErrorCondition: 'Aggiungi almeno una condizione', + confirmErrorConditionAndDefault: 'Aggiungi almeno una condizione e applica almeno un valore predefinito', createRuleFromExpenseAction: 'Crea una regola', createRuleFromExpensePrompt: 'per applicare le modifiche a tutte le spese che corrispondono ai tuoi criteri.', }, @@ -8870,10 +8870,11 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, findRule: 'Trova regola', rename: 'Rinomina', update: 'Aggiorna', - merchantIs: (merchant: string) => `L'esercente è "${merchant}"`, merchantTypeIs: (merchantType: string) => `Il tipo di esercente è "${merchantType}"`, categoryIs: (category: string) => `La categoria è "${category}"`, tagIs: (tag: string) => `Il tag è "${tag}"`, + merchantContains: (merchant: string) => `L'esercente contiene "${merchant}"`, + merchantExactlyMatches: (merchant: string) => `L’esercente corrisponde esattamente a "${merchant}"`, }, merchantTypeRule: {merchantType: 'Tipo di esercente', saveRule: 'Salva regola', confirmErrorCategory: 'Seleziona una categoria.'}, requireFieldsTable: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 86c8c489ec36..4b42cefebf1d 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -8428,8 +8428,8 @@ ${reportName}`, thenApplyFollowingDefaults: '次に、以下のデフォルトを適用します。', vendorUnavailable: 'ベンダーを利用できません', confirmErrorCategory: 'カテゴリを選択してください', - confirmErrorCondition: '店舗名を入力してください', - confirmErrorConditionAndDefault: '取引先を入力し、少なくとも 1 つのデフォルトを適用してください', + confirmErrorCondition: '少なくとも 1 つ条件を追加してください', + confirmErrorConditionAndDefault: '少なくとも 1 つ条件を追加し、少なくとも 1 つデフォルトを適用してください', createRuleFromExpenseAction: 'ルールを作成', createRuleFromExpensePrompt: '条件に一致するすべての経費に変更を適用します。', }, @@ -8758,10 +8758,11 @@ ${reportName}`, findRule: 'ルールを検索', rename: '名前を変更', update: '更新', - merchantIs: (merchant: string) => `加盟店名は「${merchant}」です`, merchantTypeIs: (merchantType: string) => `加盟店タイプ: 「${merchantType}」`, categoryIs: (category: string) => `カテゴリは「${category}」です`, tagIs: (tag: string) => `タグは「${tag}」です`, + merchantContains: (merchant: string) => `加盟店名に「${merchant}」を含む`, + merchantExactlyMatches: (merchant: string) => `支出先が「${merchant}」と完全一致します`, }, merchantTypeRule: {merchantType: '加盟店種別', saveRule: 'ルールを保存', confirmErrorCategory: 'カテゴリを選択してください。'}, requireFieldsTable: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 53fb80a021ec..9df3dfde2e05 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -8514,8 +8514,8 @@ Vereis onkostendetails zoals bonnen en beschrijvingen, stel limieten en standaar thenApplyFollowingDefaults: 'Pas vervolgens de volgende standaardinstellingen toe:', vendorUnavailable: 'Leverancier niet beschikbaar', confirmErrorCategory: 'Selecteer een categorie', - confirmErrorCondition: 'Voer een leverancier in', - confirmErrorConditionAndDefault: 'Voer een leverancier in en stel minstens één standaard in', + confirmErrorCondition: 'Voeg minstens één voorwaarde toe', + confirmErrorConditionAndDefault: 'Voeg minstens één voorwaarde toe en pas minstens één standaardinstelling toe', createRuleFromExpenseAction: 'Maak een regel', createRuleFromExpensePrompt: 'om je wijzigingen toe te passen op alle onkosten die aan je criteria voldoen.', }, @@ -8845,10 +8845,11 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, findRule: 'Regel zoeken', rename: 'Naam wijzigen', update: 'Bijwerken', - merchantIs: (merchant: string) => `Handelaar is "${merchant}"`, merchantTypeIs: (merchantType: string) => `Type leverancier is "${merchantType}"`, categoryIs: (category: string) => `Categorie is "${category}"`, tagIs: (tag: string) => `Tag is "${tag}"`, + merchantContains: (merchant: string) => `Handelaar bevat "${merchant}"`, + merchantExactlyMatches: (merchant: string) => `Handelaar komt exact overeen met "${merchant}"`, }, merchantTypeRule: {merchantType: 'Type handelaar', saveRule: 'Regel opslaan', confirmErrorCategory: 'Selecteer een categorie.'}, requireFieldsTable: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 7c6610cfc068..3290f2909000 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -8540,8 +8540,8 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i thenApplyFollowingDefaults: 'Następnie zastosuj następujące domyślne ustawienia:', vendorUnavailable: 'Dostawca niedostępny', confirmErrorCategory: 'Wybierz kategorię', - confirmErrorCondition: 'Wpisz sprzedawcę', - confirmErrorConditionAndDefault: 'Wpisz sprzedawcę i zastosuj co najmniej jedną domyślną opcję', + confirmErrorCondition: 'Dodaj co najmniej jeden warunek', + confirmErrorConditionAndDefault: 'Dodaj co najmniej jeden warunek i zastosuj co najmniej jedną domyślną opcję', createRuleFromExpenseAction: 'Utwórz regułę', createRuleFromExpensePrompt: 'aby zastosować zmiany do wszystkich wydatków spełniających twoje kryteria.', }, @@ -8872,10 +8872,11 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, findRule: 'Znajdź regułę', rename: 'Zmień nazwę', update: 'Zaktualizuj', - merchantIs: (merchant: string) => `Sprzedawca to „${merchant}”`, merchantTypeIs: (merchantType: string) => `Typ sprzedawcy to „${merchantType}"`, categoryIs: (category: string) => `Kategoria to „${category}”`, tagIs: (tag: string) => `Tag to „${tag}”`, + merchantContains: (merchant: string) => `Sprzedawca zawiera „${merchant}”`, + merchantExactlyMatches: (merchant: string) => `Sprzedawca dokładnie odpowiada „${merchant}”`, }, merchantTypeRule: {merchantType: 'Typ sprzedawcy', saveRule: 'Zapisz regułę', confirmErrorCategory: 'Wybierz kategorię.'}, requireFieldsTable: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 1b9ff0cb376e..287b1f1cef9c 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -8517,8 +8517,8 @@ Exija dados de despesas como recibos e descrições, defina limites e padrões e thenApplyFollowingDefaults: 'Em seguida, aplica os seguintes padrões:', vendorUnavailable: 'Fornecedor indisponível', confirmErrorCategory: 'Selecione uma categoria', - confirmErrorCondition: 'Insira um estabelecimento', - confirmErrorConditionAndDefault: 'Insira um comerciante e aplique pelo menos um padrão', + confirmErrorCondition: 'Adicione pelo menos uma condição', + confirmErrorConditionAndDefault: 'Adicione pelo menos uma condição e aplique pelo menos um padrão', createRuleFromExpenseAction: 'Criar regra', createRuleFromExpensePrompt: 'para aplicar suas alterações a todas as despesas que correspondem aos seus critérios.', }, @@ -8849,10 +8849,11 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, findRule: 'Encontrar regra', rename: 'Renomear', update: 'Atualizar', - merchantIs: (merchant: string) => `O comerciante é "${merchant}"`, merchantTypeIs: (merchantType: string) => `O tipo de estabelecimento é "${merchantType}"`, categoryIs: (category: string) => `A categoria é "${category}"`, - tagIs: (tag: string) => `A etiqueta é "${tag}"`, + tagIs: (tag: string) => `A tag é "${tag}"`, + merchantContains: (merchant: string) => `Estabelecimento contém "${merchant}"`, + merchantExactlyMatches: (merchant: string) => `Estabelecimento corresponde exatamente a "${merchant}"`, }, merchantTypeRule: {merchantType: 'Tipo de comerciante', saveRule: 'Salvar regra', confirmErrorCategory: 'Selecione uma categoria.'}, requireFieldsTable: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 8d283acc7d82..5008440fedf8 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -8223,8 +8223,8 @@ ${reportName}`, thenApplyFollowingDefaults: '然后应用以下默认设置:', vendorUnavailable: '供应商不可用', confirmErrorCategory: '请选择一个类别', - confirmErrorCondition: '请输入商家', - confirmErrorConditionAndDefault: '输入商家,并至少应用一个默认设置', + confirmErrorCondition: '请至少添加一个条件', + confirmErrorConditionAndDefault: '请至少添加一个条件,并应用至少一个默认设置', createRuleFromExpenseAction: '创建规则', createRuleFromExpensePrompt: '将您的更改应用于所有符合条件的报销。', }, @@ -8539,10 +8539,11 @@ ${reportName}`, findRule: '查找规则', rename: '重命名', update: '更新', - merchantIs: (merchant: string) => `商户为“${merchant}”`, merchantTypeIs: (merchantType: string) => `商户类型为"${merchantType}"`, categoryIs: (category: string) => `类别为“${category}”`, tagIs: (tag: string) => `标签为“${tag}”`, + merchantContains: (merchant: string) => `商户包含“${merchant}”`, + merchantExactlyMatches: (merchant: string) => `商家与“${merchant}”完全匹配`, }, merchantTypeRule: {merchantType: '商户类型', saveRule: '保存规则', confirmErrorCategory: '请选择一个类别。'}, requireFieldsTable: { diff --git a/src/pages/workspace/rules/DescribeNewRuleForm.tsx b/src/pages/workspace/rules/DescribeNewRuleForm.tsx index 07dbcb6a93c8..595111b281a2 100644 --- a/src/pages/workspace/rules/DescribeNewRuleForm.tsx +++ b/src/pages/workspace/rules/DescribeNewRuleForm.tsx @@ -33,11 +33,10 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import AGENT_RULE_INPUT_IDS from '@src/types/form/AddAgentRuleForm'; -import type {Policy, PolicyCategory} from '@src/types/onyx'; +import type {PolicyCategory} from '@src/types/onyx'; import type {GeneratedRuleState, GeneratedRuleType, GeneratedRuleValues} from '@src/types/onyx/GeneratedRule'; import type {ReactNode} from 'react'; -import type {OnyxEntry} from 'react-native-onyx'; import {useFocusEffect, useIsFocused} from '@react-navigation/native'; import React from 'react'; diff --git a/tests/ui/RulesNewPageDescribeRuleTest.tsx b/tests/ui/RulesNewPageDescribeRuleTest.tsx index 25265d4d6936..fb4491633468 100644 --- a/tests/ui/RulesNewPageDescribeRuleTest.tsx +++ b/tests/ui/RulesNewPageDescribeRuleTest.tsx @@ -10,6 +10,7 @@ import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import {toIndexMap} from '@libs/RuleUtils'; import RulesNewPage from '@pages/workspace/rules/RulesNewPage'; @@ -366,7 +367,7 @@ describe('New rule describe section', () => { const existingRule: Rule = { scope: CONST.RULES.SCOPE.POLICY, scopeID: POLICY_ID, - triggers: {'1': CONST.RULES.TRIGGERS.CREATE_TRANSACTION}, + triggers: toIndexMap([CONST.RULES.TRIGGERS.CREATE_TRANSACTION]), filters: {left: CONST.RULES.EXPENSE_DEFAULT.FIELD.CATEGORY, operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, right: 'Meals'}, // eslint-disable-next-line @typescript-eslint/naming-convention -- the rule stores the tax under the field name that the backend uses actions: {'1': {name: CONST.RULES.ACTIONS.SET, field: CONST.RULES.EXPENSE_DEFAULT.FIELD.TAX, value: {field_id_TAX: {externalID: 'GST'}}}}, From 55333e5287ce565579c9a67c027c20d383c2f72d Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 23 Oct 2026 03:12:48 +0530 Subject: [PATCH 24/24] Stop predicting a category's tax on the client Signed-off-by: krishna2323 --- .../FieldAutoSelector.tsx | 2 +- src/hooks/useParticipantSubmission.ts | 8 +- src/hooks/useUpdateTransactionCategory.ts | 2 +- src/libs/CategoryUtils.ts | 24 +- src/libs/ExpenseDefaultRuleUtils.ts | 21 +- .../TransactionUtils/getUpdatedTransaction.ts | 15 -- src/libs/TransactionUtils/index.ts | 2 - src/libs/TransactionUtils/tax.ts | 20 -- src/libs/actions/IOU/BulkEdit.ts | 1 - src/libs/actions/IOU/MoneyRequest.ts | 19 +- src/libs/actions/IOU/Split.ts | 1 - src/libs/actions/IOU/UpdateMoneyRequest.ts | 4 +- .../DynamicIOURequestStepCategoryCreate.tsx | 2 +- .../step/DynamicIOURequestStepDestination.tsx | 6 +- .../useParticipantPickerState.ts | 6 +- .../step/IOURequestStepPerDiemWorkspace.tsx | 4 +- .../confirmation/CategoryDefaultsSetter.tsx | 7 +- tests/actions/IOU/MoneyRequestSettersTest.ts | 124 +--------- tests/actions/IOUTest/BulkEditTest.ts | 101 +------- tests/actions/IOUTest/SplitTest.ts | 133 +---------- .../actions/IOUTest/UpdateMoneyRequestTest.ts | 213 +---------------- tests/unit/CategoryUtilsTest.ts | 29 --- tests/unit/ExpenseDefaultRuleUtilsTest.ts | 50 ++++ tests/unit/TransactionUtilsTest.ts | 226 +----------------- .../useUpdateTransactionCategory.test.ts | 4 +- tests/utils/collections/policies.ts | 17 -- 26 files changed, 89 insertions(+), 952 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList/FieldAutoSelector.tsx b/src/components/MoneyRequestConfirmationList/FieldAutoSelector.tsx index 0ea87f49b00f..783a3e701027 100644 --- a/src/components/MoneyRequestConfirmationList/FieldAutoSelector.tsx +++ b/src/components/MoneyRequestConfirmationList/FieldAutoSelector.tsx @@ -66,7 +66,7 @@ function FieldAutoSelector({ if (!transactionID || iouCategory || !shouldShowCategories || enabledCategories.length !== 1 || !isCategoryRequired) { return; } - setMoneyRequestCategory(transactionID, enabledCategories.at(0)?.name ?? '', policy, getCurrencyDecimals, isMovingTransactionFromTrackExpense); + setMoneyRequestCategory(transactionID, enabledCategories.at(0)?.name ?? '', policy, isMovingTransactionFromTrackExpense); // Keep 'transaction' out to ensure that we auto select the option only once // eslint-disable-next-line react-hooks/exhaustive-deps }, [shouldShowCategories, policyCategories, isCategoryRequired, policy?.id, getCurrencyDecimals]); diff --git a/src/hooks/useParticipantSubmission.ts b/src/hooks/useParticipantSubmission.ts index ae493d6a0d66..239ab8fef9b8 100644 --- a/src/hooks/useParticipantSubmission.ts +++ b/src/hooks/useParticipantSubmission.ts @@ -394,13 +394,7 @@ function useParticipantSubmission({ const policyDistance = Object.values(policy?.customUnits ?? {}).find((customUnit) => customUnit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); const defaultCategory = isDistanceRequest(transaction) && policyDistance?.defaultCategory ? policyDistance?.defaultCategory : ''; const category = isMovingTransactionFromTrackExpense ? (transaction?.category ?? '') : defaultCategory; - setMoneyRequestCategory( - transaction.transactionID, - category, - isMovingTransactionFromTrackExpense ? movingPolicy : undefined, - getCurrencyDecimals, - isMovingTransactionFromTrackExpense, - ); + setMoneyRequestCategory(transaction.transactionID, category, isMovingTransactionFromTrackExpense ? movingPolicy : undefined, isMovingTransactionFromTrackExpense); if (shouldUpdateTransactionReportID) { setTransactionReport(transaction.transactionID, {reportID: transactionReportID}, true); diff --git a/src/hooks/useUpdateTransactionCategory.ts b/src/hooks/useUpdateTransactionCategory.ts index 11105fd909f7..2604244b4ef2 100644 --- a/src/hooks/useUpdateTransactionCategory.ts +++ b/src/hooks/useUpdateTransactionCategory.ts @@ -123,7 +123,7 @@ function useUpdateTransactionCategory({ return; } - setMoneyRequestCategory(transactionID, category, policy, getCurrencyDecimals); + setMoneyRequestCategory(transactionID, category, policy); }; return {updateCategory, isDraftUpdate: !isSplitUpdate && !isExistingExpenseUpdate}; diff --git a/src/libs/CategoryUtils.ts b/src/libs/CategoryUtils.ts index a24f72242533..b1467d1b044b 100644 --- a/src/libs/CategoryUtils.ts +++ b/src/libs/CategoryUtils.ts @@ -5,7 +5,7 @@ import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, PolicyCategories} from '@src/types/onyx'; -import type {ApprovalRule, ExpenseRule, MccGroup} from '@src/types/onyx/Policy'; +import type {ApprovalRule, MccGroup} from '@src/types/onyx/Policy'; import type {OnyxCollection} from 'react-native-onyx'; @@ -48,27 +48,6 @@ function getCategoryApproverRule(approvalRules: ApprovalRule[], categoryName: st return approverRule; } -function getCategoryExpenseRule(expenseRules: ExpenseRule[], categoryName: string) { - const expenseRule = expenseRules?.find((rule) => - rule.applyWhen.find(({condition, field, value}) => condition === CONST.POLICY.RULE_CONDITIONS.MATCHES && field === CONST.POLICY.FIELDS.CATEGORY && value === categoryName), - ); - return expenseRule; -} - -function getCategoryDefaultTaxRate(expenseRules: ExpenseRule[], categoryName: string, defaultTaxRate?: string) { - // Matched the same way the rules are written: on a `category matches ` condition rather than on the value - // alone. Matching any condition carrying the name could read a rule that a save or delete never targets, so the - // rate an expense picks up would not be the one the admin set. - const categoryDefaultTaxRate = getCategoryExpenseRule(expenseRules, categoryName)?.tax?.field_id_TAX?.externalID; - - // If the default taxRate is not found in expenseRules, use the default value for policy - if (!categoryDefaultTaxRate) { - return defaultTaxRate; - } - - return categoryDefaultTaxRate; -} - function updateCategoryInMccGroup(mccGroups: Record, oldCategoryName: string, newCategoryName: string, shouldClearPendingAction?: boolean) { if (oldCategoryName === newCategoryName) { return mccGroups; @@ -285,7 +264,6 @@ function hasAnyCategoryRules(categories: PolicyCategories | undefined): boolean export { formatRequireItemizedReceiptsOverText, getCategoryApproverRule, - getCategoryDefaultTaxRate, updateCategoryInMccGroup, getEnabledCategoriesCount, isCategoryMissing, diff --git a/src/libs/ExpenseDefaultRuleUtils.ts b/src/libs/ExpenseDefaultRuleUtils.ts index 5c3616a5c514..c2a218b179b3 100644 --- a/src/libs/ExpenseDefaultRuleUtils.ts +++ b/src/libs/ExpenseDefaultRuleUtils.ts @@ -561,30 +561,37 @@ function getExpenseDefaultConditionSummary(filters: RuleFilterNode | undefined): } /** - * The rule that holds a category's tax default: one that matches on that category alone and sets a tax. + * The rule that holds a category's tax default: any rule the policy has that matches on that category. * * Category settings addresses this rule by category name rather than by ID, because a category holds one tax * default, so the ID is resolved here instead of being carried on every route. + * + * A rule reached from category settings can also match on a merchant or a tag and can leave the tax unset, so + * narrowing to a category-only rule that sets a tax would hide it and offer to add a second one. */ function getCategoryTaxDefaultRule(rulesCollection: OnyxCollection | undefined, policyID: string | undefined, categoryName: string | undefined): RuleWithID | undefined { if (!categoryName) { return undefined; } - return getPolicyExpenseDefaultRules(rulesCollection, policyID).find(({rule}) => { + const matchingRules = getPolicyExpenseDefaultRules(rulesCollection, policyID).filter(({rule}) => { // A rule deleted offline sticks around until the request goes through, and category settings would keep // offering it to edit. if (rule.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { return false; } - const {merchants, categories, tags} = getExpenseDefaultConditionSummary(rule.filters); - if (merchants.length > 0 || tags.length > 0 || categories.length !== 1 || categories.at(0) !== categoryName) { - return false; - } + return getExpenseDefaultConditionSummary(rule.filters).categories.includes(categoryName); + }); - return getRuleActions(rule).some((action) => action?.name === ACTION.SET && 'field' in action && action.field === FIELD.TAX); + // More than one rule can match a category, so the one that matches on nothing else is the category's own + // default. Without this the category would show whichever rule the collection happened to yield first. + const categoryOnlyRule = matchingRules.find(({rule}) => { + const {merchants, categories, tags} = getExpenseDefaultConditionSummary(rule.filters); + return merchants.length === 0 && tags.length === 0 && categories.length === 1; }); + + return categoryOnlyRule ?? matchingRules.at(0); } /** The tax rate a category's tax default applies. See `getCategoryTaxDefaultRule`. */ diff --git a/src/libs/TransactionUtils/getUpdatedTransaction.ts b/src/libs/TransactionUtils/getUpdatedTransaction.ts index 36c51421678b..fc5a7ea10b78 100644 --- a/src/libs/TransactionUtils/getUpdatedTransaction.ts +++ b/src/libs/TransactionUtils/getUpdatedTransaction.ts @@ -31,7 +31,6 @@ import getDistanceInMeters from './getDistanceInMeters'; // The functions imported here are pure helpers that aren't called at initialization time. // eslint-disable-next-line import/no-cycle import { - getCategoryTaxDetails, getCurrency, hasAppliedCommuterExclusion, hasLocallyKnownDistance, @@ -136,7 +135,6 @@ function getUpdatedTransaction({ policies = undefined, isSplitTransaction = false, personalPolicyOutputCurrency, - getCurrencyDecimals, getCurrencySymbol, }: { transaction: Transaction; @@ -147,7 +145,6 @@ function getUpdatedTransaction({ policies?: OnyxCollection; isSplitTransaction?: boolean; personalPolicyOutputCurrency: string | undefined; - getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol']; }): Transaction { const isUnReportedExpense = transaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; @@ -402,18 +399,6 @@ function getUpdatedTransaction({ if (Object.hasOwn(transactionChanges, 'category') && typeof transactionChanges.category === 'string') { updatedTransaction.category = transactionChanges.category; - - // On a server backed edit, clearing the category leaves the stored tax rate untouched, so predicting a - // change here only writes a rate that the response immediately overwrites. Split drafts have no such - // response and send whatever tax the draft holds, so they keep recalculating. - if (transactionChanges.category || isSplitTransaction) { - const {categoryTaxCode, categoryTaxAmount, categoryTaxValue} = getCategoryTaxDetails(transactionChanges.category, transaction, policy, getCurrencyDecimals); - if (categoryTaxCode && categoryTaxAmount !== undefined && categoryTaxValue) { - updatedTransaction.taxCode = categoryTaxCode; - updatedTransaction.taxAmount = categoryTaxAmount; - updatedTransaction.taxValue = categoryTaxValue; - } - } } if (Object.hasOwn(transactionChanges, 'tag') && typeof transactionChanges.tag === 'string') { diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 2c8630740931..60d32142c790 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -64,7 +64,6 @@ import {getClearedPendingFields, getDistanceMerchantForTransaction, getUpdatedTr import { calculateTaxAmount, getCalculatedTaxAmount, - getCategoryTaxDetails, getDefaultTaxCode, getDistanceRateTaxUpdates, getEnabledTaxRateCount, @@ -1967,7 +1966,6 @@ export { getAllSortedTransactions, getFormattedPostedDate, getPostedDate, - getCategoryTaxDetails, isPerDiemRequest, isViolationDismissed, isPartialTransaction, diff --git a/src/libs/TransactionUtils/tax.ts b/src/libs/TransactionUtils/tax.ts index 519fad0d41ad..eaefd6fc3356 100644 --- a/src/libs/TransactionUtils/tax.ts +++ b/src/libs/TransactionUtils/tax.ts @@ -5,7 +5,6 @@ */ import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList'; -import {getCategoryDefaultTaxRate} from '@libs/CategoryUtils'; import {convertToBackendAmount} from '@libs/CurrencyUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import {translateLocal} from '@libs/Localize'; @@ -206,28 +205,9 @@ function getTaxRateTitle(policy: OnyxEntry, transaction: OnyxEntry, policy: OnyxEntry, getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']) { - const taxRules = policy?.rules?.expenseRules?.filter((rule) => rule.tax); - if (!taxRules || taxRules?.length === 0 || isDistanceRequest(transaction)) { - return {categoryTaxCode: undefined, categoryTaxAmount: undefined, categoryTaxValue: undefined}; - } - - const defaultTaxCode = getDefaultTaxCode(policy, transaction, getCurrency(transaction)); - const categoryTaxCode = getCategoryDefaultTaxRate(taxRules, category, defaultTaxCode); - const categoryTaxPercentage = getTaxValue(policy, transaction, categoryTaxCode ?? ''); - let categoryTaxAmount; - - if (categoryTaxPercentage) { - categoryTaxAmount = convertToBackendAmount(calculateTaxAmount(categoryTaxPercentage, getAmount(transaction), getCurrencyDecimals(getCurrency(transaction)))); - } - - return {categoryTaxCode, categoryTaxAmount, categoryTaxValue: categoryTaxPercentage}; -} - export { calculateTaxAmount, getCalculatedTaxAmount, - getCategoryTaxDetails, getDefaultTaxCode, getDistanceRateTaxUpdates, getEnabledTaxRateCount, diff --git a/src/libs/actions/IOU/BulkEdit.ts b/src/libs/actions/IOU/BulkEdit.ts index f3847deb5ad5..0eb70bba0937 100644 --- a/src/libs/actions/IOU/BulkEdit.ts +++ b/src/libs/actions/IOU/BulkEdit.ts @@ -525,7 +525,6 @@ function updateMultipleMoneyRequests({ policy: transactionPolicy, policies: allPolicies, personalPolicyOutputCurrency, - getCurrencyDecimals, getCurrencySymbol, }); const isTransactionOnHold = isOnHold(transaction); diff --git a/src/libs/actions/IOU/MoneyRequest.ts b/src/libs/actions/IOU/MoneyRequest.ts index c0b85bba9bab..928af6d01e2b 100644 --- a/src/libs/actions/IOU/MoneyRequest.ts +++ b/src/libs/actions/IOU/MoneyRequest.ts @@ -17,7 +17,6 @@ import type {OptionData} from '@libs/ReportUtils'; import {startSpan} from '@libs/telemetry/activeSpans'; import {logReceiptSubmitted} from '@libs/telemetry/ReceiptObservability'; import { - getCategoryTaxDetails, getDefaultTaxCode, getDistanceInMeters, getDistanceRateTaxUpdates, @@ -698,13 +697,7 @@ function setMoneyRequestTaxRateValues(transactionID: string, taxRateValues: TaxR * @param policy - The policy object, or undefined for P2P transactions where tax info should be cleared * @param isMovingFromTrackExpense - If the expense is moved from Track Expense */ -function setMoneyRequestCategory( - transactionID: string, - category: string, - policy: OnyxEntry, - getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals'], - isMovingFromTrackExpense?: boolean, -) { +function setMoneyRequestCategory(transactionID: string, category: string, policy: OnyxEntry, isMovingFromTrackExpense?: boolean) { Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, { category, }); @@ -717,16 +710,6 @@ function setMoneyRequestCategory( taxAmount: null, taxValue: null, }); - return; - } - const transaction = getAllTransactionDrafts()[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`]; - const {categoryTaxCode, categoryTaxAmount, categoryTaxValue} = getCategoryTaxDetails(category, transaction, policy, getCurrencyDecimals); - if (categoryTaxCode && categoryTaxAmount !== undefined && categoryTaxValue) { - setMoneyRequestTaxRateValues(transactionID, { - taxCode: categoryTaxCode, - taxAmount: categoryTaxAmount, - taxValue: categoryTaxValue, - }); } } diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index f377d1510851..c22e4e16427d 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -1351,7 +1351,6 @@ function setDraftSplitTransaction( policies, isSplitTransaction: true, personalPolicyOutputCurrency, - getCurrencyDecimals, getCurrencySymbol, }) : null; diff --git a/src/libs/actions/IOU/UpdateMoneyRequest.ts b/src/libs/actions/IOU/UpdateMoneyRequest.ts index 121d2995f934..049167ecbcdb 100644 --- a/src/libs/actions/IOU/UpdateMoneyRequest.ts +++ b/src/libs/actions/IOU/UpdateMoneyRequest.ts @@ -1977,7 +1977,6 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U isSplitTransaction, policy, personalPolicyOutputCurrency, - getCurrencyDecimals, getCurrencySymbol, }) : undefined; @@ -2568,7 +2567,7 @@ function getUpdateTrackExpenseParams( distanceOriginalPolicy, currentTransactionViolations, } = params; - const {personalPolicyOutputCurrency, getCurrencyDecimals, getCurrencySymbol} = currencyContext; + const {personalPolicyOutputCurrency, getCurrencySymbol} = currencyContext; const optimisticData: Array< OnyxUpdate< | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS @@ -2608,7 +2607,6 @@ function getUpdateTrackExpenseParams( isFromExpenseReport: false, policy: policyForTransaction, personalPolicyOutputCurrency, - getCurrencyDecimals, getCurrencySymbol, }) : null; diff --git a/src/pages/iou/request/step/DynamicIOURequestStepCategoryCreate.tsx b/src/pages/iou/request/step/DynamicIOURequestStepCategoryCreate.tsx index ecd7cd0c9c76..7d3244db97e1 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepCategoryCreate.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepCategoryCreate.tsx @@ -189,7 +189,7 @@ function DynamicIOURequestStepCategoryCreate({ rules, }); } else { - setMoneyRequestCategory(transactionID, categoryName, policy, getCurrencyDecimals); + setMoneyRequestCategory(transactionID, categoryName, policy); } Navigation.goBack(basePath); diff --git a/src/pages/iou/request/step/DynamicIOURequestStepDestination.tsx b/src/pages/iou/request/step/DynamicIOURequestStepDestination.tsx index eaa76a092e2e..c207f27f2e36 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepDestination.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepDestination.tsx @@ -8,7 +8,6 @@ import type {ListItem, SelectionListWithSectionsHandle} from '@components/Select import WorkspaceEmptyStateSection from '@components/WorkspaceEmptyStateSection'; import useContentHeaderHeight from '@hooks/useContentHeaderHeight'; -import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; @@ -88,7 +87,6 @@ function DynamicIOURequestStepDestination({ explicitPolicyID, ref, }: DynamicIOURequestStepDestinationProps) { - const {getCurrencyDecimals} = useCurrencyListActions(); const isEditPage = name === SCREENS.MONEY_REQUEST.DYNAMIC_STEP_DESTINATION_EDIT; const editBackPath = useDynamicBackPath(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DESTINATION_EDIT.path); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); @@ -158,7 +156,7 @@ function DynamicIOURequestStepDestination({ setTransactionReport(transactionID, {reportID: transactionReportID}, true); setMoneyRequestParticipantsFromReport(transactionID, targetReport, accountID, targetIouType !== CONST.IOU.TYPE.TRACK); setCustomUnitID(transactionID, customUnit.customUnitID); - setMoneyRequestCategory(transactionID, customUnit?.defaultCategory ?? '', undefined, getCurrencyDecimals); + setMoneyRequestCategory(transactionID, customUnit?.defaultCategory ?? '', undefined); } setCustomUnitRateID(transactionID, destination.keyForList ?? '', transaction, policy, false, personalPolicy?.outputCurrency); setMoneyRequestCurrency(transactionID, destination.currency); @@ -220,7 +218,7 @@ function DynamicIOURequestStepDestination({ return; } setCustomUnitID(transactionID, perDiemUnit?.customUnitID ?? CONST.CUSTOM_UNITS.FAKE_P2P_ID); - setMoneyRequestCategory(transactionID, perDiemUnit?.defaultCategory ?? '', undefined, getCurrencyDecimals); + setMoneyRequestCategory(transactionID, perDiemUnit?.defaultCategory ?? '', undefined); // eslint-disable-next-line react-hooks/exhaustive-deps }, [transactionID, policy?.customUnits, transaction?.iouRequestType]); diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation/useParticipantPickerState.ts b/src/pages/iou/request/step/IOURequestStepConfirmation/useParticipantPickerState.ts index fbd31bf151ab..c41e36aa9223 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation/useParticipantPickerState.ts +++ b/src/pages/iou/request/step/IOURequestStepConfirmation/useParticipantPickerState.ts @@ -1,5 +1,4 @@ import type useBlockDistanceRequest from '@hooks/useBlockDistanceRequest'; -import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDefaultParticipants from '@hooks/useDefaultParticipants'; import useOnyx from '@hooks/useOnyx'; @@ -56,7 +55,6 @@ function useParticipantPickerState({ isManualRequest, blockDistanceRequestIfNeeded, }: UseParticipantPickerStateParams) { - const {getCurrencyDecimals} = useCurrencyListActions(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const personalPolicy = usePersonalPolicy(); const selfDMReport = useSelfDMReport(); @@ -216,7 +214,7 @@ function useParticipantPickerState({ }); setCustomUnitRateID(activeTransactionID, p2pRateID, transaction, undefined, false, personalPolicy?.outputCurrency); } - setMoneyRequestCategory(activeTransactionID, '', undefined, getCurrencyDecimals); + setMoneyRequestCategory(activeTransactionID, '', undefined); setMoneyRequestTag(activeTransactionID, ''); } else { const workspacePolicy = selectedPolicy; @@ -241,7 +239,7 @@ function useParticipantPickerState({ // policy so the previous workspace's category-derived tax is cleared along with the category. if (firstParticipant.policyID && firstParticipant.policyID !== policyID) { const defaultCategory = isDistanceRequest ? (getDistanceRateCustomUnit(workspacePolicy)?.defaultCategory ?? '') : ''; - setMoneyRequestCategory(activeTransactionID, defaultCategory, undefined, getCurrencyDecimals); + setMoneyRequestCategory(activeTransactionID, defaultCategory, undefined); setMoneyRequestTag(activeTransactionID, ''); } } diff --git a/src/pages/iou/request/step/IOURequestStepPerDiemWorkspace.tsx b/src/pages/iou/request/step/IOURequestStepPerDiemWorkspace.tsx index 072693c990a9..0651d0d99e26 100644 --- a/src/pages/iou/request/step/IOURequestStepPerDiemWorkspace.tsx +++ b/src/pages/iou/request/step/IOURequestStepPerDiemWorkspace.tsx @@ -1,7 +1,6 @@ import ActivityIndicator from '@components/ActivityIndicator'; import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; -import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy'; import useNetwork from '@hooks/useNetwork'; @@ -39,7 +38,6 @@ type IOURequestStepPerDiemWorkspaceProps = WithFullTransactionOrNotFoundProps { for (const item of transactions) { if (!item.category) { @@ -45,7 +42,7 @@ function CategoryDefaultsSetter({ if (existingCategory) { const isExistingCategoryEnabled = policyCategories?.[existingCategory]?.enabled; if (isExistingCategoryEnabled) { - setMoneyRequestCategory(item.transactionID, existingCategory, policy, getCurrencyDecimals); + setMoneyRequestCategory(item.transactionID, existingCategory, policy); } } continue; @@ -63,7 +60,7 @@ function CategoryDefaultsSetter({ if (!isDistanceRequest || !!item?.category) { continue; } - setMoneyRequestCategory(item.transactionID, defaultCategory, policy, getCurrencyDecimals, isMovingTransactionFromTrackExpense); + setMoneyRequestCategory(item.transactionID, defaultCategory, policy, isMovingTransactionFromTrackExpense); } // Prevent resetting to default when unselect category // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/tests/actions/IOU/MoneyRequestSettersTest.ts b/tests/actions/IOU/MoneyRequestSettersTest.ts index 69f1eec529d2..5f6eeeb3d7f1 100644 --- a/tests/actions/IOU/MoneyRequestSettersTest.ts +++ b/tests/actions/IOU/MoneyRequestSettersTest.ts @@ -29,11 +29,11 @@ import {format} from 'date-fns'; import Onyx from 'react-native-onyx'; import currencyList from '../../unit/currencyList.json'; -import createRandomPolicy, {createCategoryTaxExpenseRules} from '../../utils/collections/policies'; +import createRandomPolicy from '../../utils/collections/policies'; import {createRandomReport} from '../../utils/collections/reports'; import createRandomTransaction from '../../utils/collections/transaction'; import getOnyxValue from '../../utils/getOnyxValue'; -import {getCurrencyDecimalsLocal, getGlobalFetchMock} from '../../utils/TestHelper'; +import {getGlobalFetchMock} from '../../utils/TestHelper'; import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; const topMostReportID = '23423423'; @@ -319,124 +319,6 @@ describe('actions/IOU', () => { }); describe('setMoneyRequestCategory', () => { - it('should set the associated tax for the category based on the tax expense rules', async () => { - // Given a policy with tax expense rules associated with category - const transactionID = '1'; - const category = 'Advertising'; - const policyID = '2'; - const taxCode = 'id_TAX_EXEMPT'; - const ruleTaxCode = 'id_TAX_RATE_1'; - const fakePolicy: Policy = { - ...createRandomPolicy(Number(policyID)), - taxRates: CONST.DEFAULT_TAX, - rules: {expenseRules: createCategoryTaxExpenseRules(category, ruleTaxCode)}, - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, { - taxCode, - taxAmount: 0, - amount: 100, - }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); - - // When setting the money request category - setMoneyRequestCategory(transactionID, category, fakePolicy, getCurrencyDecimalsLocal); - - await waitForBatchedUpdates(); - - // Then the transaction tax rate and amount should be updated based on the expense rules - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, - callback: (transaction) => { - Onyx.disconnect(connection); - expect(transaction?.taxCode).toBe(ruleTaxCode); - expect(transaction?.taxAmount).toBe(5); - resolve(); - }, - }); - }); - }); - - describe('should not change the tax', () => { - it('if the transaction type is distance', async () => { - // Given a policy with tax expense rules associated with category and a distance transaction - const transactionID = '1'; - const category = 'Advertising'; - const policyID = '2'; - const taxCode = 'id_TAX_EXEMPT'; - const ruleTaxCode = 'id_TAX_RATE_1'; - const taxAmount = 0; - const fakePolicy: Policy = { - ...createRandomPolicy(Number(policyID)), - taxRates: CONST.DEFAULT_TAX, - rules: {expenseRules: createCategoryTaxExpenseRules(category, ruleTaxCode)}, - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, { - taxCode, - taxAmount, - amount: 100, - iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, - }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); - - // When setting the money request category - setMoneyRequestCategory(transactionID, category, fakePolicy, getCurrencyDecimalsLocal); - - await waitForBatchedUpdates(); - - // Then the transaction tax rate and amount shouldn't be updated - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, - callback: (transaction) => { - Onyx.disconnect(connection); - expect(transaction?.taxCode).toBe(taxCode); - expect(transaction?.taxAmount).toBe(taxAmount); - resolve(); - }, - }); - }); - }); - - it('if there are no tax expense rules', async () => { - // Given a policy without tax expense rules - const transactionID = '1'; - const category = 'Advertising'; - const policyID = '2'; - const taxCode = 'id_TAX_EXEMPT'; - const taxAmount = 0; - const fakePolicy: Policy = { - ...createRandomPolicy(Number(policyID)), - taxRates: CONST.DEFAULT_TAX, - rules: {}, - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, { - taxCode, - taxAmount, - amount: 100, - }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); - - // When setting the money request category - setMoneyRequestCategory(transactionID, category, fakePolicy, getCurrencyDecimalsLocal); - - await waitForBatchedUpdates(); - - // Then the transaction tax rate and amount shouldn't be updated - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, - callback: (transaction) => { - Onyx.disconnect(connection); - expect(transaction?.taxCode).toBe(taxCode); - expect(transaction?.taxAmount).toBe(taxAmount); - resolve(); - }, - }); - }); - }); - }); - it('should clear the tax when the policyID is empty', async () => { // Given a transaction with a tax const transactionID = '1'; @@ -449,7 +331,7 @@ describe('actions/IOU', () => { }); // When setting the money request category without a policyID - setMoneyRequestCategory(transactionID, '', undefined, getCurrencyDecimalsLocal); + setMoneyRequestCategory(transactionID, '', undefined); await waitForBatchedUpdates(); // Then the transaction tax should be cleared diff --git a/tests/actions/IOUTest/BulkEditTest.ts b/tests/actions/IOUTest/BulkEditTest.ts index c3083d41f924..3ce2af033d45 100644 --- a/tests/actions/IOUTest/BulkEditTest.ts +++ b/tests/actions/IOUTest/BulkEditTest.ts @@ -11,7 +11,7 @@ import type {OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; -import createRandomPolicy, {createCategoryTaxExpenseRules} from '../../utils/collections/policies'; +import createRandomPolicy from '../../utils/collections/policies'; import {createRandomReport} from '../../utils/collections/reports'; import createRandomTransaction from '../../utils/collections/transaction'; import createMock from '../../utils/createMock'; @@ -1876,105 +1876,6 @@ describe('actions/IOU/BulkEdit', () => { canEditFieldSpy.mockRestore(); }); - it('uses per-transaction policy for category tax mapping in cross-policy bulk edit', () => { - // Given: two different policies – transactionPolicy has expense rules mapping "Advertising" → "id_TAX_RATE_1", - // while the shared bulk-edit policy has no expense rules at all. - const transactionID = 'transaction-cross-policy-1'; - const transactionThreadReportID = 'thread-cross-policy-1'; - const iouReportID = 'iou-cross-policy-1'; - - const category = 'Advertising'; - const expectedTaxCode = 'id_TAX_RATE_1'; - - // Transaction's own policy – has tax expense rules - const transactionPolicy: Policy = { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - taxRates: CONST.DEFAULT_TAX, - rules: {expenseRules: createCategoryTaxExpenseRules(category, expectedTaxCode)}, - }; - - // Shared bulk-edit policy – no expense rules, different ID - const sharedBulkEditPolicy: Policy = { - ...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), - taxRates: CONST.DEFAULT_TAX, - // No expense rules — category should NOT resolve to a tax code via this policy - }; - - const iouReport: Report = { - ...createRandomReport(2, undefined), - reportID: iouReportID, - policyID: transactionPolicy.id, - type: CONST.REPORT.TYPE.EXPENSE, - }; - - const reports = { - [`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]: iouReport, - }; - - const transaction: Transaction = { - ...createRandomTransaction(1), - transactionID, - reportID: iouReportID, - transactionThreadReportID, - amount: -1000, - currency: CONST.CURRENCY.USD, - category: '', - }; - const transactions = { - [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, - }; - - // Make the transaction's own policy resolvable via allPolicies - const allPolicies = { - [`${ONYXKEYS.COLLECTION.POLICY}${transactionPolicy.id}`]: transactionPolicy, - [`${ONYXKEYS.COLLECTION.POLICY}${sharedBulkEditPolicy.id}`]: sharedBulkEditPolicy, - }; - - const canEditFieldSpy = jest.spyOn(require('@libs/ReportUtils'), 'canEditFieldOfMoneyRequest').mockReturnValue(true); - // eslint-disable-next-line rulesdir/no-multiple-api-calls - const writeSpy = jest.spyOn(API, 'write').mockImplementation(jest.fn()); - - // When: bulk-editing with the shared policy (different from transaction's policy) - updateMultipleMoneyRequests({ - isVendorMatchingBetaEnabled: false, - personalDetailsList: undefined, - transactionIDs: [transactionID], - changes: {category}, - policy: sharedBulkEditPolicy, - reports, - transactions, - reportActions: {}, - policyCategories: undefined, - policyTags: {}, - violations: undefined, - hash: undefined, - allPolicies, - currentUserAccountID: RORY_ACCOUNT_ID, - delegateAccountID: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, - getCurrencySymbol: getCurrencySymbolLocal, - rules: undefined, - }); - - // Then: the optimistic transaction update should use the transaction's own policy for tax resolution. - // Check the optimistic Onyx data passed to API.write (3rd argument) for the TRANSACTION merge. - const writeCall = writeSpy.mock.calls.at(0); - expect(writeCall).toBeDefined(); - const [, , onyxData] = getRequiredWriteCall(writeSpy.mock.calls, 0); - const transactionOnyxUpdate = getRequiredOnyxUpdates(onyxData, 'optimisticData').find( - (update) => isObject(update) && update.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - ); - expect(transactionOnyxUpdate).toBeDefined(); - - // The tax code should resolve from the transaction's policy (which has the expense rule), - // NOT from the shared bulk-edit policy (which has no expense rules) - const transactionOnyxValue = isObject(transactionOnyxUpdate) && isObject(transactionOnyxUpdate.value) ? transactionOnyxUpdate.value : undefined; - expect(transactionOnyxValue?.taxCode).toBe(expectedTaxCode); - - writeSpy.mockRestore(); - canEditFieldSpy.mockRestore(); - }); - it('passes per-transaction policy to buildOptimisticModifiedExpenseReportAction in cross-policy bulk edit', () => { // Given: two different policies — the transaction belongs to transactionPolicy, // but the shared bulk-edit policy is a different workspace. diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts index 7e50ad882488..b6963141bc0a 100644 --- a/tests/actions/IOUTest/SplitTest.ts +++ b/tests/actions/IOUTest/SplitTest.ts @@ -24,15 +24,7 @@ import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; import updateSplitTransactionsFromSplitExpensesFlow from '@pages/iou/updateSplitTransactionsFromSplitExpensesFlow'; -import { - completeSplitBill, - createDistanceRequest, - resolveOptimisticSplitChatReportID, - setDraftSplitTransaction, - splitBill, - splitBillAndOpenReport, - startSplitBill, -} from '@userActions/IOU/Split'; +import {completeSplitBill, createDistanceRequest, resolveOptimisticSplitChatReportID, splitBill, splitBillAndOpenReport, startSplitBill} from '@userActions/IOU/Split'; import { addSplitExpenseField, evenlyDistributeSplitExpenseAmounts, @@ -69,7 +61,7 @@ import Onyx from 'react-native-onyx'; import type {MockFetch} from '../../utils/TestHelper'; import createPersonalDetails from '../../utils/collections/personalDetails'; -import createRandomPolicy, {createCategoryTaxExpenseRules} from '../../utils/collections/policies'; +import createRandomPolicy from '../../utils/collections/policies'; import createRandomReportAction from '../../utils/collections/reportActions'; import {createRandomReport, createSelfDM} from '../../utils/collections/reports'; import createRandomTransaction from '../../utils/collections/transaction'; @@ -10144,127 +10136,6 @@ describe('updateSplitExpenseAmountField', () => { }); }); -describe('setDraftSplitTransaction', () => { - it('should set the associated tax for the category based on the tax expense rules', async () => { - // Given a policy with tax expense rules associated with category - const transactionID = '1'; - const category = 'Advertising'; - const policyID = '2'; - const taxCode = 'id_TAX_EXEMPT'; - const ruleTaxCode = 'id_TAX_RATE_1'; - const fakePolicy: Policy = { - ...createRandomPolicy(Number(policyID)), - taxRates: CONST.DEFAULT_TAX, - rules: {expenseRules: createCategoryTaxExpenseRules(category, ruleTaxCode)}, - }; - const draftTransaction: Transaction = { - ...createRandomTransaction(1), - taxCode, - taxAmount: 0, - amount: 100, - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, draftTransaction); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); - - // When setting a category of a draft split transaction - setDraftSplitTransaction(transactionID, draftTransaction, {category}, getCurrencyDecimalsLocal, getCurrencySymbolLocal, fakePolicy); - - await waitForBatchedUpdates(); - - // Then the transaction tax rate and amount should be updated based on the expense rules - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, - callback: (transaction) => { - Onyx.disconnect(connection); - expect(transaction?.taxCode).toBe(ruleTaxCode); - expect(transaction?.taxAmount).toBe(5); - resolve(); - }, - }); - }); - }); - - describe('should not change the tax', () => { - it('if there are no tax expense rules', async () => { - // Given a policy without tax expense rules - const transactionID = '1'; - const category = 'Advertising'; - const policyID = '2'; - const taxCode = 'id_TAX_EXEMPT'; - const taxAmount = 0; - const fakePolicy: Policy = { - ...createRandomPolicy(Number(policyID)), - taxRates: CONST.DEFAULT_TAX, - rules: {}, - }; - const draftTransaction: Transaction = { - ...createRandomTransaction(1), - taxCode, - taxAmount, - amount: 100, - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, draftTransaction); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); - - // When setting a category of a draft split transaction - setDraftSplitTransaction(transactionID, draftTransaction, {category}, getCurrencyDecimalsLocal, getCurrencySymbolLocal, fakePolicy); - - await waitForBatchedUpdates(); - - // Then the transaction tax rate and amount shouldn't be updated - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, - callback: (transaction) => { - Onyx.disconnect(connection); - expect(transaction?.taxCode).toBe(taxCode); - expect(transaction?.taxAmount).toBe(taxAmount); - resolve(); - }, - }); - }); - }); - - it('if we are not updating category', async () => { - // Given a policy with tax expense rules associated with category - const transactionID = '1'; - const category = 'Advertising'; - const policyID = '2'; - const ruleTaxCode = 'id_TAX_RATE_1'; - const fakePolicy: Policy = { - ...createRandomPolicy(Number(policyID)), - taxRates: CONST.DEFAULT_TAX, - rules: {expenseRules: createCategoryTaxExpenseRules(category, ruleTaxCode)}, - }; - const draftTransaction: Transaction = { - ...createRandomTransaction(1), - amount: 100, - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, draftTransaction); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); - - // When setting a draft split transaction without category update - setDraftSplitTransaction(transactionID, draftTransaction, {}, getCurrencyDecimalsLocal, getCurrencySymbolLocal, fakePolicy); - - await waitForBatchedUpdates(); - - // Then the transaction tax rate and amount shouldn't be updated - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, - callback: (transaction) => { - Onyx.disconnect(connection); - expect(transaction?.taxCode).toBeUndefined(); - expect(transaction?.taxAmount).toBeUndefined(); - resolve(); - }, - }); - }); - }); - }); -}); - describe('initSplitExpenseItemData', () => { it('should create split expense item data from transaction', () => { const transaction: Transaction = { diff --git a/tests/actions/IOUTest/UpdateMoneyRequestTest.ts b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts index ef04c2a94672..e261985c5065 100644 --- a/tests/actions/IOUTest/UpdateMoneyRequestTest.ts +++ b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts @@ -17,7 +17,7 @@ import {editTransactionMerchantInline} from '@libs/actions/TransactionInlineEdit import * as API from '@libs/API'; import {WRITE_COMMANDS} from '@libs/API/types'; import type * as PolicyUtils from '@libs/PolicyUtils'; -import {getOriginalMessage, isActionOfType} from '@libs/ReportActionsUtils'; +import {isActionOfType} from '@libs/ReportActionsUtils'; import {buildOptimisticIOUReportAction} from '@libs/ReportUtils'; import CONST from '@src/CONST'; @@ -41,7 +41,7 @@ import type {MockFetch} from '../../utils/TestHelper'; import currencyList from '../../unit/currencyList.json'; import createPersonalDetails from '../../utils/collections/personalDetails'; -import createRandomPolicy, {createCategoryTaxExpenseRules} from '../../utils/collections/policies'; +import createRandomPolicy from '../../utils/collections/policies'; import {createRandomReport} from '../../utils/collections/reports'; import createRandomTransaction from '../../utils/collections/transaction'; import createMock from '../../utils/createMock'; @@ -163,215 +163,6 @@ describe('actions/IOU/UpdateMoneyRequest', () => { }); describe('updateMoneyRequestCategory', () => { - it('should update the tax when there are tax expense rules', async () => { - // Given a policy with tax expense rules associated with category - const transactionID = '1'; - const policyID = '2'; - const transactionThreadReportID = '3'; - const transactionThreadReport = {reportID: transactionThreadReportID}; - const category = 'Advertising'; - const taxCode = 'id_TAX_EXEMPT'; - const ruleTaxCode = 'id_TAX_RATE_1'; - const fakePolicy: Policy = { - ...createRandomPolicy(Number(policyID)), - taxRates: CONST.DEFAULT_TAX, - rules: { - expenseRules: createCategoryTaxExpenseRules(category, ruleTaxCode), - }, - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { - taxCode, - taxAmount: 0, - amount: 100, - }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, transactionThreadReport); - - // When updating a money request category - updateMoneyRequestCategory({ - isVendorMatchingBetaEnabled: false, - rules: undefined, - transactionID, - transactionThreadReport, - parentReport: undefined, - iouReportOwnerLogin: undefined, - reportPolicyTags: undefined, - category, - policy: fakePolicy, - policyTagList: undefined, - policyCategories: undefined, - policyRecentlyUsedCategories: [], - currentUserAccountIDParam: 123, - currentUserEmailParam: 'existing@example.com', - isASAPSubmitBetaEnabled: false, - delegateAccountID: undefined, - isTrackIntentUser: false, - violations: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, - getCurrencySymbol: getCurrencySymbolLocal, - }); - - await waitForBatchedUpdates(); - - // Then the transaction tax rate and amount should be updated based on the expense rules - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - callback: (transaction) => { - Onyx.disconnect(connection); - expect(transaction?.taxCode).toBe(ruleTaxCode); - expect(transaction?.taxAmount).toBe(5); - resolve(); - }, - }); - }); - - // But the original message should only contains the old and new category data - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, - callback: (reportActions) => { - Onyx.disconnect(connection); - const reportAction = Object.values(reportActions ?? {}).at(0); - if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE)) { - const originalMessage = getOriginalMessage(reportAction); - expect(originalMessage?.oldCategory).toBe(''); - expect(originalMessage?.category).toBe(category); - expect(originalMessage?.oldTaxRate).toBeUndefined(); - expect(originalMessage?.oldTaxAmount).toBeUndefined(); - resolve(); - } - }, - }); - }); - }); - - describe('should not update the tax', () => { - it('if the transaction type is distance', async () => { - // Given a policy with tax expense rules associated with category and a distance transaction - const transactionID = '1'; - const policyID = '2'; - const category = 'Advertising'; - const taxCode = 'id_TAX_EXEMPT'; - const taxAmount = 0; - const ruleTaxCode = 'id_TAX_RATE_1'; - const fakePolicy: Policy = { - ...createRandomPolicy(Number(policyID)), - taxRates: CONST.DEFAULT_TAX, - rules: { - expenseRules: createCategoryTaxExpenseRules(category, ruleTaxCode), - }, - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { - taxCode, - taxAmount, - amount: 100, - iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, - comment: { - type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, - customUnit: { - name: CONST.CUSTOM_UNITS.NAME_DISTANCE, - }, - }, - }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); - - // When updating a money request category - updateMoneyRequestCategory({ - isVendorMatchingBetaEnabled: false, - rules: undefined, - transactionID, - transactionThreadReport: {reportID: '3'}, - parentReport: undefined, - iouReportOwnerLogin: undefined, - reportPolicyTags: undefined, - category, - policy: fakePolicy, - policyTagList: undefined, - policyCategories: undefined, - policyRecentlyUsedCategories: [], - currentUserAccountIDParam: 123, - currentUserEmailParam: 'existing@example.com', - isASAPSubmitBetaEnabled: false, - delegateAccountID: undefined, - isTrackIntentUser: false, - violations: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, - getCurrencySymbol: getCurrencySymbolLocal, - }); - - await waitForBatchedUpdates(); - - // Then the transaction tax rate and amount shouldn't be updated - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - callback: (transaction) => { - Onyx.disconnect(connection); - expect(transaction?.taxCode).toBe(taxCode); - expect(transaction?.taxAmount).toBe(taxAmount); - resolve(); - }, - }); - }); - }); - - it('if there are no tax expense rules', async () => { - // Given a policy without tax expense rules - const transactionID = '1'; - const policyID = '2'; - const category = 'Advertising'; - const fakePolicy: Policy = { - ...createRandomPolicy(Number(policyID)), - taxRates: CONST.DEFAULT_TAX, - rules: {}, - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { - amount: 100, - }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); - - // When updating the money request category - updateMoneyRequestCategory({ - isVendorMatchingBetaEnabled: false, - rules: undefined, - transactionID, - transactionThreadReport: {reportID: '3'}, - parentReport: undefined, - iouReportOwnerLogin: undefined, - reportPolicyTags: undefined, - category, - policy: fakePolicy, - policyTagList: undefined, - policyCategories: undefined, - policyRecentlyUsedCategories: [], - currentUserAccountIDParam: 123, - currentUserEmailParam: 'existing@example.com', - isASAPSubmitBetaEnabled: false, - delegateAccountID: undefined, - isTrackIntentUser: false, - violations: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, - getCurrencySymbol: getCurrencySymbolLocal, - }); - - await waitForBatchedUpdates(); - - // Then the transaction tax rate and amount shouldn't be updated - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - callback: (transaction) => { - Onyx.disconnect(connection); - expect(transaction?.taxCode).toBeUndefined(); - expect(transaction?.taxAmount).toBeUndefined(); - resolve(); - }, - }); - }); - }); - }); - it('should remove all existing category violations when the transaction Category is unset', async () => { const transactionID = '1'; const policyID = '2'; diff --git a/tests/unit/CategoryUtilsTest.ts b/tests/unit/CategoryUtilsTest.ts index 67535d533fa3..4b1fbef6cf77 100644 --- a/tests/unit/CategoryUtilsTest.ts +++ b/tests/unit/CategoryUtilsTest.ts @@ -1,7 +1,6 @@ import { formatRequireItemizedReceiptsOverText, getAvailableNonPersonalPolicyCategories, - getCategoryDefaultTaxRate, getCategoryDescriptionHint, getCategoryGLCode, getCategoryNameError, @@ -17,7 +16,6 @@ import { import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, PolicyCategories} from '@src/types/onyx'; -import type {ExpenseRule} from '@src/types/onyx/Policy'; import type {OnyxCollection} from 'react-native-onyx'; @@ -621,30 +619,3 @@ describe('getCategoryNameError', () => { expect(getCategoryNameError(categories, '<>', 'Food')).toBeUndefined(); }); }); - -describe('getCategoryDefaultTaxRate', () => { - const buildCategoryTaxRule = (categoryName: string, taxID: string): ExpenseRule => ({ - applyWhen: [{condition: CONST.POLICY.RULE_CONDITIONS.MATCHES, field: CONST.POLICY.FIELDS.CATEGORY, value: categoryName}], - // eslint-disable-next-line @typescript-eslint/naming-convention - tax: {field_id_TAX: {externalID: taxID}}, - }); - - it("returns the category's own rate", () => { - expect(getCategoryDefaultTaxRate([buildCategoryTaxRule('Travel', 'id_TAX_A')], 'Travel', 'id_TAX_DEFAULT')).toBe('id_TAX_A'); - }); - - it('falls back to the workspace rate when the category has no rule', () => { - expect(getCategoryDefaultTaxRate([buildCategoryTaxRule('Travel', 'id_TAX_A')], 'Meals', 'id_TAX_DEFAULT')).toBe('id_TAX_DEFAULT'); - }); - - it('ignores a rule that carries the name on some other condition', () => { - // Matching on the value alone would read a rule that a save or delete never targets, handing the expense a - // rate the admin never set for this category. - const tagNamedAfterTheCategory: ExpenseRule = { - applyWhen: [{condition: CONST.POLICY.RULE_CONDITIONS.MATCHES, field: CONST.POLICY.FIELDS.TAG, value: 'Travel'}], - // eslint-disable-next-line @typescript-eslint/naming-convention - tax: {field_id_TAX: {externalID: 'id_TAX_B'}}, - }; - expect(getCategoryDefaultTaxRate([tagNamedAfterTheCategory], 'Travel', 'id_TAX_DEFAULT')).toBe('id_TAX_DEFAULT'); - }); -}); diff --git a/tests/unit/ExpenseDefaultRuleUtilsTest.ts b/tests/unit/ExpenseDefaultRuleUtilsTest.ts index aec2b93c4067..19713e7f3017 100644 --- a/tests/unit/ExpenseDefaultRuleUtilsTest.ts +++ b/tests/unit/ExpenseDefaultRuleUtilsTest.ts @@ -558,6 +558,56 @@ describe('ExpenseDefaultRuleUtils', () => { // Then nothing is returned, because a deleted rule no longer applies and must not be offered for editing expect(getCategoryTaxDefaultRule(collection, POLICY_ID, 'Coffee')).toBeUndefined(); }); + + it('finds a rule that also matches on a merchant, which category settings can create', () => { + // Given a rule the category settings flow made that matches the category and a merchant + const ruleWithMerchant: ExpenseDefaultRule = { + ...categoryTaxRuleBody, + filters: { + operator: AND, + left: {left: FIELD.CATEGORY, operator: EQUAL_TO, right: 'Coffee'}, + right: {left: FIELD.MERCHANT, operator: CONTAINS, right: 'Starbucks'}, + }, + }; + const collection = {[`${ONYXKEYS.COLLECTION.RULE}1`]: asStoredRule(ruleWithMerchant)}; + + // When the category's tax default is looked up + // Then it is found, because hiding it would offer to add a second rule for the same category + expect(getCategoryTaxDefaultRule(collection, POLICY_ID, 'Coffee')?.ruleID).toBe('1'); + }); + + it('finds a rule that sets no tax, which is the rule a duplicate used to be created against', () => { + // Given a rule matching the category that sets a merchant rather than a tax + const ruleWithoutTax: ExpenseDefaultRule = { + ...categoryTaxRuleBody, + actions: toIndexMap([{name: ACTION.SET, field: FIELD.MERCHANT, value: 'Starbucks Coffee'} as ExpenseDefaultAction]), + }; + const collection = {[`${ONYXKEYS.COLLECTION.RULE}1`]: asStoredRule(ruleWithoutTax)}; + + // When the category's tax default is looked up + // Then it is found, so category settings edits it instead of adding another rule + expect(getCategoryTaxDefaultRule(collection, POLICY_ID, 'Coffee')?.ruleID).toBe('1'); + }); + + it('prefers the rule that matches on the category alone', () => { + // Given a rule matching the category and a merchant, stored ahead of one matching the category alone + const ruleWithMerchant: ExpenseDefaultRule = { + ...categoryTaxRuleBody, + filters: { + operator: AND, + left: {left: FIELD.CATEGORY, operator: EQUAL_TO, right: 'Coffee'}, + right: {left: FIELD.MERCHANT, operator: CONTAINS, right: 'Starbucks'}, + }, + }; + const collection = { + [`${ONYXKEYS.COLLECTION.RULE}1`]: asStoredRule(ruleWithMerchant), + [`${ONYXKEYS.COLLECTION.RULE}2`]: asStoredRule(categoryTaxRuleBody), + }; + + // When the category's tax default is looked up + // Then the category-only rule wins, rather than whichever the collection happens to yield first + expect(getCategoryTaxDefaultRule(collection, POLICY_ID, 'Coffee')?.ruleID).toBe('2'); + }); }); describe('hasExpenseDefaultRuleErrors', () => { diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index a0a4655bbc11..eff314c6f8b5 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -19,7 +19,7 @@ import type {Routes} from '../../src/types/onyx/Transaction'; import type {TransactionViolation} from '../../src/types/onyx/TransactionViolation'; import * as TransactionUtils from '../../src/libs/TransactionUtils'; -import createRandomPolicy, {createCategoryTaxExpenseRules} from '../utils/collections/policies'; +import createRandomPolicy from '../utils/collections/policies'; import {createRandomReport} from '../utils/collections/reports'; import createRandomTransaction from '../utils/collections/transaction'; import createMock from '../utils/createMock'; @@ -334,122 +334,6 @@ describe('TransactionUtils', () => { }); }); - describe('getCategoryTaxDetails', () => { - it('should return the associated tax when the category matches the tax expense rules', () => { - // Given a policy with tax expense rules associated with a category - const category = 'Advertising'; - const fakePolicy: Policy = { - ...createRandomPolicy(0), - taxRates: CONST.DEFAULT_TAX, - rules: {expenseRules: createCategoryTaxExpenseRules(category, 'id_TAX_RATE_1')}, - }; - const transaction = generateTransaction(); - - // When retrieving the tax from the associated category - const {categoryTaxCode, categoryTaxAmount, categoryTaxValue} = TransactionUtils.getCategoryTaxDetails(category, transaction, fakePolicy, getCurrencyDecimalsLocal); - - // Then it should return the associated tax code, amount, and value - expect(categoryTaxCode).toBe('id_TAX_RATE_1'); - expect(categoryTaxAmount).toBe(5); - expect(categoryTaxValue).toBe('5%'); - }); - - it("should return the default tax when the category doesn't match the tax expense rules", () => { - // Given a policy with tax expense rules associated with a category - const ruleCategory = 'Advertising'; - const selectedCategory = 'Benefits'; - const fakePolicy: Policy = { - ...createRandomPolicy(0), - taxRates: CONST.DEFAULT_TAX, - rules: {expenseRules: createCategoryTaxExpenseRules(ruleCategory, 'id_TAX_RATE_1')}, - }; - const transaction = generateTransaction(); - - // When retrieving the tax from a category that is not associated with the tax expense rules - const {categoryTaxCode, categoryTaxAmount, categoryTaxValue} = TransactionUtils.getCategoryTaxDetails(selectedCategory, transaction, fakePolicy, getCurrencyDecimalsLocal); - - // Then it should return the default tax code, amount, and value - expect(categoryTaxCode).toBe('id_TAX_EXEMPT'); - expect(categoryTaxAmount).toBe(0); - expect(categoryTaxValue).toBe('0%'); - }); - - it("should return the foreign default tax when the category doesn't match the tax expense rules and using a foreign currency", () => { - // Given a policy with tax expense rules associated with a category and a transaction with a foreign currency - const ruleCategory = 'Advertising'; - const selectedCategory = 'Benefits'; - const fakePolicy: Policy = { - ...createRandomPolicy(0), - taxRates: { - ...CONST.DEFAULT_TAX, - foreignTaxDefault: 'id_TAX_RATE_2', - taxes: { - ...CONST.DEFAULT_TAX.taxes, - // eslint-disable-next-line @typescript-eslint/naming-convention - id_TAX_RATE_2: { - name: 'Tax rate 2', - value: '10%', - }, - }, - }, - outputCurrency: 'IDR', - rules: {expenseRules: createCategoryTaxExpenseRules(ruleCategory, 'id_TAX_RATE_1')}, - }; - const transaction = generateTransaction(); - - // When retrieving the tax from a category that is not associated with the tax expense rules - const {categoryTaxCode, categoryTaxAmount, categoryTaxValue} = TransactionUtils.getCategoryTaxDetails(selectedCategory, transaction, fakePolicy, getCurrencyDecimalsLocal); - - // Then it should return the default tax code, amount, and value - expect(categoryTaxCode).toBe('id_TAX_RATE_2'); - expect(categoryTaxAmount).toBe(9); - expect(categoryTaxValue).toBe('10%'); - }); - - describe('should return undefined tax', () => { - it('if the transaction type is distance', () => { - // Given a policy with tax expense rules associated with a category - const category = 'Advertising'; - const fakePolicy: Policy = { - ...createRandomPolicy(0), - taxRates: CONST.DEFAULT_TAX, - rules: {expenseRules: createCategoryTaxExpenseRules(category, 'id_TAX_RATE_1')}, - }; - const transaction: Transaction = { - ...generateTransaction(), - iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, - }; - - // When retrieving the tax from the associated category - const {categoryTaxCode, categoryTaxAmount, categoryTaxValue} = TransactionUtils.getCategoryTaxDetails(category, transaction, fakePolicy, getCurrencyDecimalsLocal); - - // Then it should return undefined for the tax code, amount, and value - expect(categoryTaxCode).toBe(undefined); - expect(categoryTaxAmount).toBe(undefined); - expect(categoryTaxValue).toBe(undefined); - }); - - it('if there are no policy tax expense rules', () => { - // Given a policy without tax expense rules - const category = 'Advertising'; - const fakePolicy: Policy = { - ...createRandomPolicy(0), - taxRates: CONST.DEFAULT_TAX, - rules: {}, - }; - const transaction = generateTransaction(); - - // When retrieving the tax from a category - const {categoryTaxCode, categoryTaxAmount, categoryTaxValue} = TransactionUtils.getCategoryTaxDetails(category, transaction, fakePolicy, getCurrencyDecimalsLocal); - - // Then it should return undefined for the tax code, amount, and value - expect(categoryTaxCode).toBe(undefined); - expect(categoryTaxAmount).toBe(undefined); - expect(categoryTaxValue).toBe(undefined); - }); - }); - }); - describe('getDefaultTaxCode for distance requests', () => { const RATE_ID = 'rate_1'; @@ -547,7 +431,6 @@ describe('TransactionUtils', () => { isFromExpenseReport: true, transactionChanges: {merchant: 'Zero Scan edited'}, personalPolicyOutputCurrency: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, getCurrencySymbol: getCurrencySymbolLocal, }); updatedTransaction.pendingFields = {merchant: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}; @@ -573,7 +456,6 @@ describe('TransactionUtils', () => { isFromExpenseReport: true, transactionChanges: {merchant: 'Failed Scan edited'}, personalPolicyOutputCurrency: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, getCurrencySymbol: getCurrencySymbolLocal, }); updatedTransaction.pendingFields = {merchant: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}; @@ -583,94 +465,6 @@ describe('TransactionUtils', () => { expect(TransactionUtils.isFailedScanAmountPlaceholder(updatedTransaction)).toBe(true); }); - it('should return updated category and tax when updating category with a category tax rules', () => { - // Given a policy with tax expense rules associated with a category - const category = 'Advertising'; - const taxCode = 'id_TAX_RATE_1'; - const fakePolicy: Policy = { - ...createRandomPolicy(0), - taxRates: CONST.DEFAULT_TAX, - rules: {expenseRules: createCategoryTaxExpenseRules(category, taxCode)}, - }; - const transaction = generateTransaction(); - - // When updating the transaction category to the category associated with the rule - const updatedTransaction = TransactionUtils.getUpdatedTransaction({ - transaction, - isFromExpenseReport: true, - policy: fakePolicy, - transactionChanges: {category}, - personalPolicyOutputCurrency: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, - getCurrencySymbol: getCurrencySymbolLocal, - }); - - // Then the updated transaction should contain the tax from the matched rule - expect(updatedTransaction.category).toBe(category); - expect(updatedTransaction.taxCode).toBe(taxCode); - expect(updatedTransaction.taxAmount).toBe(5); - expect(updatedTransaction.taxValue).toBe('5%'); - }); - - it('should keep the existing tax when clearing the category on a server backed edit', () => { - // Given an expense carrying the tax of a category that has its own rule - const category = 'Advertising'; - const taxCode = 'id_TAX_RATE_1'; - const fakePolicy: Policy = { - ...createRandomPolicy(0), - taxRates: CONST.DEFAULT_TAX, - rules: {expenseRules: createCategoryTaxExpenseRules(category, taxCode)}, - }; - const transaction = generateTransaction({category, taxCode, taxAmount: 5, taxValue: '5%'}); - - // When the category is cleared - const updatedTransaction = TransactionUtils.getUpdatedTransaction({ - transaction, - isFromExpenseReport: true, - policy: fakePolicy, - transactionChanges: {category: ''}, - personalPolicyOutputCurrency: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, - getCurrencySymbol: getCurrencySymbolLocal, - }); - - // Then the tax is left untouched, because the API is not told to change it and the server keeps its own - expect(updatedTransaction.category).toBe(''); - expect(updatedTransaction.taxCode).toBe(taxCode); - expect(updatedTransaction.taxAmount).toBe(5); - expect(updatedTransaction.taxValue).toBe('5%'); - }); - - it('should reset to the workspace default tax when clearing the category on a split draft', () => { - // Given a split draft carrying the tax of a category that has its own rule - const category = 'Advertising'; - const taxCode = 'id_TAX_RATE_1'; - const fakePolicy: Policy = { - ...createRandomPolicy(0), - taxRates: CONST.DEFAULT_TAX, - rules: {expenseRules: createCategoryTaxExpenseRules(category, taxCode)}, - }; - const transaction = generateTransaction({category, taxCode, taxAmount: 5, taxValue: '5%'}); - - // When the category is cleared on the draft - const updatedTransaction = TransactionUtils.getUpdatedTransaction({ - transaction, - isFromExpenseReport: false, - isSplitTransaction: true, - policy: fakePolicy, - transactionChanges: {category: ''}, - personalPolicyOutputCurrency: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, - getCurrencySymbol: getCurrencySymbolLocal, - }); - - // Then the draft falls back to the workspace default, since the draft tax is what gets sent - expect(updatedTransaction.category).toBe(''); - expect(updatedTransaction.taxCode).toBe('id_TAX_EXEMPT'); - expect(updatedTransaction.taxAmount).toBe(0); - expect(updatedTransaction.taxValue).toBe('0%'); - }); - it('should update transaction when distance is changed', () => { // Given: a policy with a mileage rate const fakePolicy: Policy = { @@ -712,7 +506,6 @@ describe('TransactionUtils', () => { policy: fakePolicy, transactionChanges: {distance: newDistance}, personalPolicyOutputCurrency: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, getCurrencySymbol: getCurrencySymbolLocal, }); @@ -776,7 +569,6 @@ describe('TransactionUtils', () => { policy: undefined, transactionChanges: {distance: 20}, personalPolicyOutputCurrency: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, getCurrencySymbol: getCurrencySymbolLocal, }); @@ -823,7 +615,6 @@ describe('TransactionUtils', () => { policy: fakePolicy, transactionChanges: {customUnitRateID: 'rate2'}, personalPolicyOutputCurrency: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, getCurrencySymbol: getCurrencySymbolLocal, }); @@ -871,7 +662,6 @@ describe('TransactionUtils', () => { policy: fakePolicy, transactionChanges: {customUnitRateID: 'rate2'}, personalPolicyOutputCurrency: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, getCurrencySymbol: getCurrencySymbolLocal, }); @@ -934,7 +724,6 @@ describe('TransactionUtils', () => { policy: fakePolicy, transactionChanges: {customUnitRateID: 'rate2'}, personalPolicyOutputCurrency: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, getCurrencySymbol: getCurrencySymbolLocal, }); @@ -1000,7 +789,6 @@ describe('TransactionUtils', () => { ...(rateUnitChanged ? {customUnitRateID: 'ID2'} : {}), }, personalPolicyOutputCurrency: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, getCurrencySymbol: getCurrencySymbolLocal, }); @@ -1066,7 +854,6 @@ describe('TransactionUtils', () => { policy: fakePolicy, transactionChanges: {distance: 20}, personalPolicyOutputCurrency: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, getCurrencySymbol: getCurrencySymbolLocal, }); @@ -1144,7 +931,6 @@ describe('TransactionUtils', () => { policy: fakePolicy, transactionChanges: {selectedRouteKey: 'route1'}, personalPolicyOutputCurrency: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, getCurrencySymbol: getCurrencySymbolLocal, }); @@ -1216,7 +1002,6 @@ describe('TransactionUtils', () => { policies, transactionChanges: {customUnitRateID: 'ID2'}, personalPolicyOutputCurrency: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, getCurrencySymbol: getCurrencySymbolLocal, }); @@ -1295,7 +1080,6 @@ describe('TransactionUtils', () => { policy: undefined, transactionChanges: {distance: 20}, personalPolicyOutputCurrency: 'EUR', - getCurrencyDecimals: getCurrencyDecimalsLocal, getCurrencySymbol: getCurrencySymbolLocal, }); @@ -1350,7 +1134,6 @@ describe('TransactionUtils', () => { }, }, personalPolicyOutputCurrency: 'EUR', - getCurrencyDecimals: getCurrencyDecimalsLocal, getCurrencySymbol: getCurrencySymbolLocal, }); @@ -1411,7 +1194,6 @@ describe('TransactionUtils', () => { }, }, personalPolicyOutputCurrency: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, getCurrencySymbol: getCurrencySymbolLocal, }); @@ -1461,7 +1243,6 @@ describe('TransactionUtils', () => { routes: {route0: {distance: 32186.88, geometry: {coordinates: [[0, 0]], type: 'LineString'}}}, }, personalPolicyOutputCurrency: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, getCurrencySymbol: getCurrencySymbolLocal, }); @@ -1516,7 +1297,6 @@ describe('TransactionUtils', () => { routes: {route0: {distance: 32186.88, geometry: {coordinates: [[0, 0]], type: 'LineString'}}}, }, personalPolicyOutputCurrency: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, getCurrencySymbol: getCurrencySymbolLocal, }); @@ -1536,7 +1316,6 @@ describe('TransactionUtils', () => { isFromExpenseReport: true, transactionChanges: {amount: newAmount}, personalPolicyOutputCurrency: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, getCurrencySymbol: getCurrencySymbolLocal, }); @@ -1552,7 +1331,6 @@ describe('TransactionUtils', () => { isFromExpenseReport: false, transactionChanges: {amount: newAmount}, personalPolicyOutputCurrency: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, getCurrencySymbol: getCurrencySymbolLocal, }); @@ -1567,7 +1345,6 @@ describe('TransactionUtils', () => { isFromExpenseReport: true, transactionChanges: {taxCode: 'id_TAX_RATE_1', taxAmount: 50, taxValue: '5%'}, personalPolicyOutputCurrency: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, getCurrencySymbol: getCurrencySymbolLocal, }); @@ -1584,7 +1361,6 @@ describe('TransactionUtils', () => { isFromExpenseReport: false, transactionChanges: {taxValue: '5%'}, personalPolicyOutputCurrency: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, getCurrencySymbol: getCurrencySymbolLocal, }); diff --git a/tests/unit/hooks/useUpdateTransactionCategory.test.ts b/tests/unit/hooks/useUpdateTransactionCategory.test.ts index b134b020ee8e..102f6da2c138 100644 --- a/tests/unit/hooks/useUpdateTransactionCategory.test.ts +++ b/tests/unit/hooks/useUpdateTransactionCategory.test.ts @@ -99,7 +99,7 @@ describe('useUpdateTransactionCategory', () => { // Then it lands on the money request draft, and says so: the category step reads this to decide whether // it may carry forward into confirmation. expect(setMoneyRequestCategory).toHaveBeenCalledTimes(1); - expect(setMoneyRequestCategory).toHaveBeenCalledWith(TRANSACTION_ID, CATEGORY, policy, expect.any(Function)); + expect(setMoneyRequestCategory).toHaveBeenCalledWith(TRANSACTION_ID, CATEGORY, policy); expect(setDraftSplitTransaction).not.toHaveBeenCalled(); expect(updateMoneyRequestCategory).not.toHaveBeenCalled(); expect(result.current.isDraftUpdate).toBe(true); @@ -140,6 +140,6 @@ describe('useUpdateTransactionCategory', () => { result.current.updateCategory(''); // Then the empty value is written through rather than dropped - expect(setMoneyRequestCategory).toHaveBeenCalledWith(TRANSACTION_ID, '', policy, expect.any(Function)); + expect(setMoneyRequestCategory).toHaveBeenCalledWith(TRANSACTION_ID, '', policy); }); }); diff --git a/tests/utils/collections/policies.ts b/tests/utils/collections/policies.ts index 67867e3a9dc3..e1bb23b46b61 100644 --- a/tests/utils/collections/policies.ts +++ b/tests/utils/collections/policies.ts @@ -39,20 +39,3 @@ export default function createRandomPolicy(index: number, type?: ValueOf