diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 67c883682c2d..6002835b466a 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -4117,6 +4117,12 @@ const CONST = { FIELD_LIST_TITLE: 'text_title', TAX: 'tax', }, + /** Subsections of the Rules > Expense defaults table, listed in the order they render. */ + EXPENSE_DEFAULTS_SECTION: { + CATEGORIES: 'categories', + MERCHANTS: 'merchants', + MERCHANT_TYPES: 'merchantTypes', + }, DEFAULT_REPORT_NAME_PATTERN: '{report:type} {report:startdate}', DEFAULT_FIELD_LIST_TYPE: 'formula', DEFAULT_FIELD_LIST_TARGET: 'expense', @@ -9191,6 +9197,7 @@ const CONST = { FLAG_FOR_REVIEW_RULE_CATEGORY: 'WorkspaceRules-FlagForReviewRuleCategory', FLAG_FOR_REVIEW_RULE_AMOUNT: 'WorkspaceRules-FlagForReviewRuleAmount', FLAG_FOR_REVIEW_RULE_EXPENSE_LIMIT_TYPE: 'WorkspaceRules-FlagForReviewRuleExpenseLimitType', + CATEGORY_TAX_RULE_ITEM: 'WorkspaceRules-CategoryTaxRuleItem', MERCHANT_TYPE_RULE_ITEM: 'WorkspaceRules-MerchantTypeRuleItem', MERCHANT_TYPE_RULE_SAVE: 'WorkspaceRules-MerchantTypeRuleSave', MERCHANT_TYPE_RULE_CATEGORY: 'WorkspaceRules-MerchantTypeRuleCategory', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index f8e54cd28e95..e31f59597475 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -3890,6 +3890,14 @@ const ROUTES = { route: 'workspaces/:policyID/rules/merchant-rules/:ruleID/preview-matches', getRoute: (policyID: string, ruleID?: string) => `workspaces/${policyID}/rules/merchant-rules/${ruleID ?? 'new'}/preview-matches` as const, }, + RULES_CATEGORY_TO_MATCH: { + route: 'workspaces/:policyID/rules/merchant-rules/:ruleID/category-to-match', + getRoute: (policyID: string, ruleID?: string) => `workspaces/${policyID}/rules/merchant-rules/${ruleID ?? 'new'}/category-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, + }, RULES_AGENT_NEW: { route: 'workspaces/:policyID/rules/agent-rules/new', getRoute: (policyID: string) => `workspaces/${policyID}/rules/agent-rules/new` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 014a36128658..f1173860e4ed 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -946,6 +946,8 @@ const SCREENS = { RULES_MERCHANT_REIMBURSABLE: 'Rules_Merchant_Reimbursable', RULES_MERCHANT_BILLABLE: 'Rules_Merchant_Billable', RULES_MERCHANT_PREVIEW_MATCHES: 'Rules_Merchant_Preview_Matches', + RULES_CATEGORY_TO_MATCH: 'Rules_Category_To_Match', + RULES_CATEGORY_TAX_EDIT: 'Rules_Category_Tax_Edit', RULES_MERCHANT_EDIT: 'Rules_Merchant_Edit', RULES_SPEND_MERCHANTS: 'Rules_Spend_Merchants', RULES_SPEND_MERCHANT_EDIT: 'Rules_Spend_Merchant_Edit', diff --git a/src/components/Tables/WorkspaceExpenseDefaultsTable/WorkspaceExpenseDefaultsTableRow.tsx b/src/components/Tables/WorkspaceExpenseDefaultsTable/WorkspaceExpenseDefaultsTableRow.tsx index 55872885f466..348893765da7 100644 --- a/src/components/Tables/WorkspaceExpenseDefaultsTable/WorkspaceExpenseDefaultsTableRow.tsx +++ b/src/components/Tables/WorkspaceExpenseDefaultsTable/WorkspaceExpenseDefaultsTableRow.tsx @@ -16,14 +16,19 @@ import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon'; +import type {ValueOf} from 'type-fest'; + import React from 'react'; import {View} from 'react-native'; +type ExpenseDefaultsSection = ValueOf; + type ExpenseDefaultTableItem = TableData & { ruleID: string; - isMerchantType: boolean; + section: ExpenseDefaultsSection; isRename: boolean; groupID?: string; typeLabel: string; @@ -36,6 +41,18 @@ type ExpenseDefaultTableItem = TableData & { action: () => void; }; +const SECTION_HEADER_TRANSLATION_KEYS = { + [CONST.POLICY.EXPENSE_DEFAULTS_SECTION.CATEGORIES]: 'workspace.rules.spendRules.categories', + [CONST.POLICY.EXPENSE_DEFAULTS_SECTION.MERCHANTS]: 'workspace.rules.spendRules.merchants', + [CONST.POLICY.EXPENSE_DEFAULTS_SECTION.MERCHANT_TYPES]: 'workspace.rules.spendRules.merchantTypes', +} as const satisfies Record; + +const SECTION_SENTRY_LABELS = { + [CONST.POLICY.EXPENSE_DEFAULTS_SECTION.CATEGORIES]: CONST.SENTRY_LABEL.WORKSPACE.RULES.CATEGORY_TAX_RULE_ITEM, + [CONST.POLICY.EXPENSE_DEFAULTS_SECTION.MERCHANTS]: CONST.SENTRY_LABEL.WORKSPACE.RULES.MERCHANT_RULE_ITEM, + [CONST.POLICY.EXPENSE_DEFAULTS_SECTION.MERCHANT_TYPES]: CONST.SENTRY_LABEL.WORKSPACE.RULES.MERCHANT_TYPE_RULE_ITEM, +} as const satisfies Record; + type WorkspaceExpenseDefaultsTableRowProps = { item: ExpenseDefaultTableItem; rowIndex: number; @@ -58,10 +75,12 @@ function WorkspaceExpenseDefaultsTableRow({item, rowIndex, shouldUseNarrowTableL const badgeColors = item.isRename ? theme.reportStatusBadge.approved : theme.reportStatusBadge.draft; const prevItem = rowIndex > 0 ? processedData.at(rowIndex - 1) : undefined; - const hasMultipleSections = processedData.some((rule) => rule.isMerchantType) && processedData.some((rule) => !rule.isMerchantType); - const showSectionHeader = hasMultipleSections && (rowIndex === 0 || !!prevItem?.isMerchantType !== !!item.isMerchantType); + const isMerchantType = item.section === CONST.POLICY.EXPENSE_DEFAULTS_SECTION.MERCHANT_TYPES; + // A single section stays a flat list — the headers only earn their place once there is more than one group to tell apart. + const hasMultipleSections = new Set(processedData.map((rule) => rule.section)).size > 1; + const showSectionHeader = hasMultipleSections && (rowIndex === 0 || prevItem?.section !== item.section); - const lockIcon = item.isMerchantType ? ( + const lockIcon = isMerchantType ? ( @@ -89,7 +108,7 @@ function WorkspaceExpenseDefaultsTableRow({item, rowIndex, shouldUseNarrowTableL rowIndex={rowIndex} disabled={isDeleting} accessibilityLabel={accessibilityLabel} - sentryLabel={item.isMerchantType ? CONST.SENTRY_LABEL.WORKSPACE.RULES.MERCHANT_TYPE_RULE_ITEM : CONST.SENTRY_LABEL.WORKSPACE.RULES.MERCHANT_RULE_ITEM} + sentryLabel={SECTION_SENTRY_LABELS[item.section]} offlineWithFeedback={{ pendingAction: item.pendingAction, shouldHideOnDelete: false, diff --git a/src/components/Tables/WorkspaceExpenseDefaultsTable/index.tsx b/src/components/Tables/WorkspaceExpenseDefaultsTable/index.tsx index b3f8dd438502..f3b37dd4340a 100644 --- a/src/components/Tables/WorkspaceExpenseDefaultsTable/index.tsx +++ b/src/components/Tables/WorkspaceExpenseDefaultsTable/index.tsx @@ -9,6 +9,8 @@ import tokenizedSearch from '@libs/tokenizedSearch'; import variables from '@styles/variables'; +import CONST from '@src/CONST'; + import type {ListRenderItemInfo} from '@shopify/flash-list'; import React from 'react'; @@ -19,6 +21,9 @@ import WorkspaceExpenseDefaultsTableRow from './WorkspaceExpenseDefaultsTableRow type ExpenseDefaultsTableColumnKey = 'type' | 'condition' | 'rule' | 'actions'; +/** The order the `Expense defaults` subsections render in, per the design. */ +const SECTION_ORDER = [CONST.POLICY.EXPENSE_DEFAULTS_SECTION.CATEGORIES, CONST.POLICY.EXPENSE_DEFAULTS_SECTION.MERCHANTS, CONST.POLICY.EXPENSE_DEFAULTS_SECTION.MERCHANT_TYPES] as const; + type WorkspaceExpenseDefaultsTableProps = { rulesData: ExpenseDefaultTableItem[]; selectionEnabled: boolean; @@ -62,8 +67,9 @@ function WorkspaceExpenseDefaultsTable({rulesData, selectionEnabled, selectedKey const compareItems: CompareItemsCallback = (a, b, activeSorting) => { const orderMultiplier = activeSorting.order === 'asc' ? 1 : -1; - if (a.isMerchantType !== b.isMerchantType) { - return a.isMerchantType ? 1 : -1; + // Sections stay grouped and in a fixed order whatever the column sort is — sorting only reorders rows inside a section. + if (a.section !== b.section) { + return SECTION_ORDER.indexOf(a.section) - SECTION_ORDER.indexOf(b.section); } if (activeSorting.columnKey === 'type') { diff --git a/src/languages/en.ts b/src/languages/en.ts index 5f4dd01605c9..b7054f515d09 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -8099,6 +8099,17 @@ const translations = { expenseDefaultsSubtitle: 'Update fields without submitter doing anything', ifAnyExpenseMatches: 'If any expense matches:', thenApplyFollowingDefaults: 'Then apply the following defaults:', + confirmErrorCategory: 'Please select a category', + confirmErrorCategoryTax: 'Please select a tax rate', + oneConditionPerRuleTitle: 'Only one condition per rule', + alreadyMatchesMerchantPrompt: 'This rule already matches on a merchant. Reset the rule to match on a category instead.', + alreadyMatchesCategoryPrompt: 'This rule already matches on a category. Reset the rule to match on a merchant instead.', + turnOnTaxesFirstTitle: 'Turn on taxes first', + turnOnTaxesFirstPrompt: 'Category rules set a default tax rate. Turn on taxes in your workspace settings to use them.', + onlyTaxForCategoryRulesTitle: 'Only tax is available for category rules', + onlyTaxForCategoryRulesPrompt: 'Category rules can set a default tax rate. To set other defaults, match on a merchant instead.', + 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.", }, newRule: { title: 'New rule', @@ -8121,6 +8132,7 @@ const translations = { findRule: 'Find rule', rename: 'Rename', update: 'Update', + categoryIs: (category: string) => `Category is "${category}"`, merchantIs: (merchant: string) => `Merchant is "${merchant}"`, merchantTypeIs: (merchantType: string) => `Merchant type is "${merchantType}"`, }, diff --git a/src/libs/CategoryTaxRulesUtils.ts b/src/libs/CategoryTaxRulesUtils.ts new file mode 100644 index 000000000000..681adc652026 --- /dev/null +++ b/src/libs/CategoryTaxRulesUtils.ts @@ -0,0 +1,109 @@ +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} from '@src/types/onyx'; +import type {ExpenseRule} from '@src/types/onyx/Policy'; + +import {clearPolicyCategoryTaxErrors} from './actions/Policy/Category'; +import {getDecodedCategoryName} from './CategoryUtils'; + +const CATEGORY_TAX_RULE_KEY_PREFIX = 'category-tax:'; + +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. `applyWhen` is an array on the backend, but a category tax default only + * ever carries the single `category matches ` condition, so there is exactly one name to read. + */ +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 the rules that carry an explicit tax default. `getCategoryDefaultTaxRate` can't be used here because it falls + * back to the workspace default, which would make every category look like it has a rule of its own. + */ +function getCategoryTaxRules(expenseRules: ExpenseRule[] | undefined): ExpenseRule[] { + return (expenseRules ?? []).filter((rule) => !!rule.tax?.field_id_TAX?.externalID && !!getRuleCategoryName(rule)); +} + +function getCategoryTaxRule(expenseRules: ExpenseRule[] | undefined, categoryName: string): ExpenseRule | undefined { + return getCategoryTaxRules(expenseRules).find((rule) => getRuleCategoryName(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)` label used for a tax rate everywhere in the rules UI. A tax rate deleted from the workspace + * leaves a rule pointing at nothing, so fall back to the raw ID rather than rendering an empty label. + */ +function getTaxRateDisplayName(policy: Policy | undefined, taxID: string | undefined): string { + if (!taxID) { + return ''; + } + const taxRate = policy?.taxRates?.taxes?.[taxID]; + return taxRate ? `${taxRate.name} (${taxRate.value})` : taxID; +} + +function getCategoryTaxRulesTableData({ + policy, + translate, + onNavigate, +}: { + policy: Policy | undefined; + translate: LocaleContextProps['translate']; + 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(); + + return getCategoryTaxRules(policy.rules?.expenseRules).map((rule) => { + // `getCategoryTaxRules` already dropped the rules without a category, so this is always set. + const categoryName = getRuleCategoryName(rule) ?? ''; + const decodedCategoryName = getDecodedCategoryName(categoryName); + const taxDisplayName = getTaxRateDisplayName(policy, rule.tax?.field_id_TAX?.externalID); + const conditionText = translate('workspace.rules.expenseDefaultsTable.categoryIs', decodedCategoryName); + const ruleDescription = translate('workspace.rules.merchantRules.ruleSummarySubtitleUpdateField', fieldLabel, taxDisplayName); + + return { + keyForList: getCategoryTaxRuleKey(categoryName), + ruleID: getCategoryTaxRuleKey(categoryName), + section: CONST.POLICY.EXPENSE_DEFAULTS_SECTION.CATEGORIES, + isRename: false, + // Deleting a category tax default needs a backend command that doesn't exist yet, so these rows can't take + // part in the table's bulk delete. + isSelectionDisabled: true, + typeLabel, + conditionText, + ruleDescription, + searchTokens: [decodedCategoryName, conditionText, ruleDescription, taxDisplayName], + pendingAction: rule.pendingAction, + errors: rule.errors ?? undefined, + onCloseError: () => clearPolicyCategoryTaxErrors(policy, categoryName), + disabled: rule.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + action: () => onNavigate(ROUTES.RULES_CATEGORY_TAX_EDIT.getRoute(policyID, categoryName)), + }; + }); +} + +export {categoryHasTaxRule, getCategoryTaxRule, getCategoryTaxRulesTableData, getCategoryTaxRuleTaxID, getTaxRateDisplayName, isCategoryTaxRuleKey}; diff --git a/src/libs/MerchantTypeRulesUtils.ts b/src/libs/MerchantTypeRulesUtils.ts index a94f03df2e00..4bcfaf68e338 100644 --- a/src/libs/MerchantTypeRulesUtils.ts +++ b/src/libs/MerchantTypeRulesUtils.ts @@ -12,6 +12,7 @@ import type {CodingRule} from '@src/types/onyx/Policy'; import {DEFAULT_MCC_GROUP, isDefaultMccGroupID} from './actions/Policy/Category'; import {setWorkspaceDefaultSpendCategory} from './actions/Policy/Policy'; import {clearPolicyCodingRuleErrors} from './actions/Policy/Rules'; +import {getCategoryTaxRulesTableData} from './CategoryTaxRulesUtils'; import {getDecodedCategoryName} from './CategoryUtils'; import Parser from './Parser'; import {getMccGroupDisplayName} from './PolicyRulesUtils'; @@ -82,7 +83,7 @@ function getMerchantTypeRulesTableData({ keyForList: getMerchantTypeRuleKey(groupID), ruleID: getMerchantTypeRuleKey(groupID), groupID, - isMerchantType: true, + section: CONST.POLICY.EXPENSE_DEFAULTS_SECTION.MERCHANT_TYPES, isRename: false, isSelectionDisabled: true, typeLabel, @@ -172,7 +173,7 @@ function getMerchantCodingRulesTableData({ return { keyForList: ruleID, ruleID, - isMerchantType: false, + section: CONST.POLICY.EXPENSE_DEFAULTS_SECTION.MERCHANTS, isRename: hasOnlyMerchantRename, typeLabel, conditionText: translate('workspace.rules.expenseDefaultsTable.merchantIs', merchantName), @@ -200,10 +201,11 @@ function getExpenseDefaultsTableData({ isOffline: boolean; onNavigate: (route: Route) => void; }): ExpenseDefaultTableItem[] { + const categoryTaxRules = getCategoryTaxRulesTableData({policy, translate, onNavigate}); const merchantRules = getMerchantCodingRulesTableData({policy, policyID, translate, isOffline, onNavigate}); const merchantTypeRules = getMerchantTypeRulesTableData({policy, translate, onNavigate}); - return [...merchantRules, ...merchantTypeRules]; + return [...categoryTaxRules, ...merchantRules, ...merchantTypeRules]; } export { diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index efc70173d067..5e63517c1838 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -1117,6 +1117,8 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/rules/MerchantRules/AddBillablePage').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.RULES_CATEGORY_TO_MATCH]: () => require('../../../../pages/workspace/rules/MerchantRules/AddCategoryToMatchPage').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, [SCREENS.WORKSPACE.PER_DIEM_IMPORT]: () => require('../../../../pages/workspace/perDiem/ImportPerDiemPage').default, diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts index 833550693c94..6b1719dfaa44 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts @@ -444,6 +444,8 @@ const WORKSPACE_TO_RHP: Partial['config'] = { [SCREENS.WORKSPACE.RULES_MERCHANT_BILLABLE]: { path: ROUTES.RULES_MERCHANT_BILLABLE.route, }, + [SCREENS.WORKSPACE.RULES_CATEGORY_TO_MATCH]: { + path: ROUTES.RULES_CATEGORY_TO_MATCH.route, + }, + [SCREENS.WORKSPACE.RULES_CATEGORY_TAX_EDIT]: { + path: ROUTES.RULES_CATEGORY_TAX_EDIT.route, + }, [SCREENS.WORKSPACE.RULES_MERCHANT_EDIT]: { path: ROUTES.RULES_MERCHANT_EDIT.route, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 02da79612e4d..172b5467dbdf 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1792,6 +1792,14 @@ type SettingsNavigatorParamList = { policyID: string; ruleID: string; }; + [SCREENS.WORKSPACE.RULES_CATEGORY_TO_MATCH]: { + policyID: string; + ruleID: string; + }; + [SCREENS.WORKSPACE.RULES_CATEGORY_TAX_EDIT]: { + policyID: string; + categoryName: string; + }; [SCREENS.WORKSPACE.PER_DIEM_IMPORT]: { policyID: string; }; diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts index 97108c62f66c..1362cfcbe601 100644 --- a/src/libs/actions/Policy/Category.ts +++ b/src/libs/actions/Policy/Category.ts @@ -1833,14 +1833,23 @@ function setPolicyCategoryApprover(policyID: string, categoryName: string, appro API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_APPROVER, parameters, onyxData); } -function setPolicyCategoryTax(policy: OnyxEntry, categoryName: string, taxID: string) { +/** + * Sets a category's default tax rate. Returns the rules array as it stands after this write. + * + * `baseExpenseRules` lets a caller saving several categories in a row thread the accumulated array through each call. + * Without it every call would read the same stale `policy` prop and, because Onyx replaces arrays wholesale on merge, + * each write would clobber the one before it. + */ +function setPolicyCategoryTax(policy: OnyxEntry, categoryName: string, taxID: string, baseExpenseRules?: ExpenseRule[]): ExpenseRule[] | undefined { if (!policy?.id) { return; } const policyID = policy.id; - const expenseRules = policy?.rules?.expenseRules ?? []; + const expenseRules = baseExpenseRules ?? policy?.rules?.expenseRules ?? []; const updatedExpenseRules: ExpenseRule[] = lodashCloneDeep(expenseRules); const existingCategoryExpenseRule = updatedExpenseRules.find((rule) => rule.applyWhen.some((when) => when.value === categoryName)); + const isEditing = !!existingCategoryExpenseRule; + const pendingAction = isEditing ? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE : CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD; if (!existingCategoryExpenseRule) { updatedExpenseRules.push({ @@ -1857,6 +1866,7 @@ function setPolicyCategoryTax(policy: OnyxEntry, categoryName: string, t value: categoryName, }, ], + pendingAction, }); } else { const indexToUpdate = updatedExpenseRules.indexOf(existingCategoryExpenseRule); @@ -1864,9 +1874,23 @@ function setPolicyCategoryTax(policy: OnyxEntry, categoryName: string, t if (expenseRule && indexToUpdate !== -1) { expenseRule.tax.field_id_TAX.externalID = taxID; + expenseRule.pendingAction = pendingAction; + expenseRule.errors = null; } } + // `expenseRules` is an array rather than a collection keyed by ID, and Onyx replaces arrays wholesale on merge, so + // every stage below has to write the complete array instead of patching the one rule that changed. + const withRuleStateForCategory = (rules: ExpenseRule[], state: Pick): ExpenseRule[] => + rules.map((rule) => (rule.applyWhen.some((when) => when.value === categoryName) ? {...rule, ...state} : rule)); + + const failureErrors = ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'); + // An edit reverts to the stored tax rate so a failed save doesn't leave the new value showing, while an add has no + // previous value to fall back to and keeps its row so the error has somewhere to render. + const failureExpenseRules = isEditing + ? withRuleStateForCategory(expenseRules, {pendingAction: null, errors: failureErrors}) + : withRuleStateForCategory(updatedExpenseRules, {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, errors: failureErrors}); + const onyxData: OnyxData = { optimisticData: [ { @@ -1879,13 +1903,27 @@ function setPolicyCategoryTax(policy: OnyxEntry, categoryName: string, t }, }, ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + rules: { + expenseRules: withRuleStateForCategory(updatedExpenseRules, {pendingAction: null, errors: null}), + }, + }, + }, + ], failureData: [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { rules: { - expenseRules, + // Keep the rule in place carrying the error rather than reverting the array, so the failure is + // visible and can be dismissed instead of the row silently disappearing. An add keeps its ADD + // pending action so dismissing the error knows to drop the row entirely. + expenseRules: failureExpenseRules, }, }, }, @@ -1899,6 +1937,52 @@ function setPolicyCategoryTax(policy: OnyxEntry, categoryName: string, t }; API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_TAX, parameters, onyxData); + + return updatedExpenseRules; +} + +/** + * Sets the same default tax rate on several categories at once. The command is per-category, so this issues one write + * each, threading the accumulated rules array through so the writes build on each other instead of overwriting. + * + * A partial failure is imperfect: an earlier write's failureData was built before the later categories were added, so + * rolling it back can drop them from the array until the next fetch. Acceptable for an admin action that either + * succeeds or fails as a whole in practice. + */ +function setPolicyCategoryTaxes(policy: OnyxEntry, categoryNames: string[], taxID: string) { + let workingExpenseRules = policy?.rules?.expenseRules ?? []; + + for (const categoryName of categoryNames) { + workingExpenseRules = setPolicyCategoryTax(policy, categoryName, taxID, workingExpenseRules) ?? workingExpenseRules; + } +} + +/** + * Dismisses the error on a category's tax default. A rule whose add never landed is dropped outright, since there is no + * stored rule left to show — matching how a failed coding rule is cleared. + */ +function clearPolicyCategoryTaxErrors(policy: OnyxEntry, categoryName: string) { + if (!policy?.id) { + return; + } + const expenseRules = policy.rules?.expenseRules ?? []; + const matchesCategory = (rule: ExpenseRule) => rule.applyWhen.some((when) => when.value === categoryName); + const failedRule = expenseRules.find(matchesCategory); + + if (!failedRule) { + return; + } + + const updatedExpenseRules = + failedRule.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD + ? expenseRules.filter((rule) => !matchesCategory(rule)) + : expenseRules.map((rule) => (matchesCategory(rule) ? {...rule, errors: null} : rule)); + + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, { + rules: { + expenseRules: updatedExpenseRules, + }, + }); } function setPolicyCategoryAttendeesRequired(policyID: string, categoryName: string, areAttendeesRequired: boolean, policyCategories: PolicyCategories = {}) { @@ -1979,6 +2063,7 @@ export { DEFAULT_MCC_GROUP, isDefaultMccGroupID, clearCategoryErrors, + clearPolicyCategoryTaxErrors, createPolicyCategory, deleteWorkspaceCategories, downloadCategoriesCSV, @@ -2000,6 +2085,7 @@ export { setPolicyCategoryReceiptsRequired, setPolicyCategoryItemizedReceiptsRequired, setPolicyCategoryTax, + setPolicyCategoryTaxes, setPolicyCustomUnitDefaultCategory, setWorkspaceCategoryDescriptionHint, setWorkspaceCategoryEnabled, diff --git a/src/pages/workspace/rules/MerchantRules/AddCategoryToMatchPage.tsx b/src/pages/workspace/rules/MerchantRules/AddCategoryToMatchPage.tsx new file mode 100644 index 000000000000..422ac1bc2d53 --- /dev/null +++ b/src/pages/workspace/rules/MerchantRules/AddCategoryToMatchPage.tsx @@ -0,0 +1,213 @@ +import ActivityIndicator from '@components/ActivityIndicator'; +import BlockingView from '@components/BlockingViews/BlockingView'; +import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import RuleCategoriesDisabledEmptyState from '@components/Rule/RuleCategoriesDisabledEmptyState'; +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 useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import usePolicy from '@hooks/usePolicy'; +import useSearchResults from '@hooks/useSearchResults'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {openPolicyCategoriesPage} from '@libs/actions/Policy/Category'; +import {updateDraftMerchantRule} from '@libs/actions/User'; +import {categoryHasTaxRule} from '@libs/CategoryTaxRulesUtils'; +import {getDecodedCategoryName} from '@libs/CategoryUtils'; +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 moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; + +import variables from '@styles/variables'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; + +import {useFocusEffect} from '@react-navigation/native'; +import React, {useState} from 'react'; +import {View} from 'react-native'; + +type AddCategoryToMatchPageProps = PlatformStackScreenProps; + +type CategoryListItem = ListItem & { + value: string; +}; + +function AddCategoryToMatchPage({route}: AddCategoryToMatchPageProps) { + const {policyID} = route.params; + const styles = useThemeStyles(); + const {translate, localeCompare} = useLocalize(); + const illustrations = useMemoizedLazyIllustrations(['Telescope']); + const policy = usePolicy(policyID); + + const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); + const areCategoriesEnabled = !!policy?.areCategoriesEnabled; + + const draftCategories = form?.categoriesToMatch ?? []; + const [selectedCategories, setSelectedCategories] = useState(draftCategories); + const initialSelectedCategories = useInitialSelection(draftCategories, {resetOnFocus: true}); + + const fetchPolicyCategories = () => { + if (!areCategoriesEnabled || policyCategories !== undefined) { + return; + } + openPolicyCategoriesPage(policyID); + }; + + const {isOffline} = useNetwork({onReconnect: fetchPolicyCategories}); + + useFocusEffect(() => { + fetchPolicyCategories(); + }); + + // Only spin while a fetch can actually resolve. Offline there is nothing to wait for, so fall through to the + // list instead of a spinner that never goes away. + const arePolicyCategoriesLoading = areCategoriesEnabled && policyCategories === undefined && !isOffline; + + const categoryItems: CategoryListItem[] = Object.values(policyCategories ?? {}) + .filter((category) => { + if (!category.enabled) { + return false; + } + + // Match the rules table: keep pending-delete categories visible while offline. + if (!isOffline && category.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + return false; + } + + // Keep the current selections available so they stay visible and removable, but don't offer a category that + // already has a tax default — saving over it would silently replace the existing rule. + if (selectedCategories.includes(category.name)) { + return true; + } + + return !categoryHasTaxRule(policy?.rules?.expenseRules, category.name); + }) + .map((category) => ({ + keyForList: category.name, + text: getDecodedCategoryName(category.name), + value: category.name, + isSelected: selectedCategories.includes(category.name), + })); + + const filterCategory = (item: CategoryListItem, searchInput: string) => (item.text ?? '').toLowerCase().includes(searchInput.toLowerCase()); + + // Pin the initially selected categories to the top of the FULL sorted list, then let the search filter run over the + // already-pinned list so pinned rows stay at the top even while searching. + const sortedCategoryItems = moveInitialSelectionToTop( + [...categoryItems].sort((a, b) => localeCompare(a.text ?? '', b.text ?? '')), + initialSelectedCategories, + ); + + const [inputValue, setInputValue, filteredCategoryItems] = useSearchResults(sortedCategoryItems, filterCategory); + + const toggleCategory = (item: CategoryListItem) => { + setSelectedCategories((prev) => (prev.includes(item.value) ? prev.filter((categoryName) => categoryName !== item.value) : [...prev, item.value])); + }; + + const toggleSelectAll = () => { + const visibleValues = filteredCategoryItems.map((item) => item.value); + const allVisibleSelected = visibleValues.length > 0 && visibleValues.every((value) => selectedCategories.includes(value)); + + if (allVisibleSelected) { + const visibleSet = new Set(visibleValues); + setSelectedCategories((prev) => prev.filter((value) => !visibleSet.has(value))); + return; + } + + setSelectedCategories((prev) => Array.from(new Set([...prev, ...visibleValues]))); + }; + + const handleSave = () => { + updateDraftMerchantRule({categoriesToMatch: selectedCategories}); + Navigation.goBack(undefined, {shouldSkipFocusRestore: true}); + }; + + if (!areCategoriesEnabled) { + return ( + + Navigation.goBack()} + /> + + + ); + } + + return ( + + Navigation.goBack()} + /> + {arePolicyCategoriesLoading ? ( + + + + ) : ( + 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 AddCategoryToMatchPage; diff --git a/src/pages/workspace/rules/MerchantRules/EditCategoryTaxRulePage.tsx b/src/pages/workspace/rules/MerchantRules/EditCategoryTaxRulePage.tsx new file mode 100644 index 000000000000..aab12dd81edd --- /dev/null +++ b/src/pages/workspace/rules/MerchantRules/EditCategoryTaxRulePage.tsx @@ -0,0 +1,25 @@ +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; + +import type SCREENS from '@src/SCREENS'; + +import React from 'react'; + +import MerchantRulePageBase from './MerchantRulePageBase'; + +type EditCategoryTaxRulePageProps = PlatformStackScreenProps; + +function EditCategoryTaxRulePage({route}: EditCategoryTaxRulePageProps) { + return ( + + ); +} + +EditCategoryTaxRulePage.displayName = 'EditCategoryTaxRulePage'; + +export default EditCategoryTaxRulePage; diff --git a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx index f44c082310c6..8aad4fd6afa0 100644 --- a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx +++ b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx @@ -8,6 +8,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import Switch from '@components/Switch'; import Text from '@components/Text'; +import TextLink from '@components/TextLink'; import useConfirmModal from '@hooks/useConfirmModal'; import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; @@ -22,11 +23,12 @@ import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import usePressLoading from '@hooks/usePressLoading'; import useThemeStyles from '@hooks/useThemeStyles'; -import {openPolicyCategoriesPage} from '@libs/actions/Policy/Category'; +import {openPolicyCategoriesPage, setPolicyCategoryTaxes} from '@libs/actions/Policy/Category'; import {deletePolicyCodingRule, setPolicyCodingRule} 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} from '@libs/CategoryTaxRulesUtils'; import {getDecodedCategoryName} from '@libs/CategoryUtils'; import Navigation from '@libs/Navigation/Navigation'; import {hasEnabledOptions} from '@libs/OptionsListUtils'; @@ -62,6 +64,12 @@ 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; titleKey: TranslationPaths; testID: string; }; @@ -74,6 +82,8 @@ type SectionItemType = { onPress: () => void; shouldRenderAsHTML?: boolean; icon?: IconAsset; + /** Renders the lock icon in place of the chevron. `onPress` then opens the explainer rather than a picker. */ + isLocked?: boolean; }; type SectionType = { @@ -88,8 +98,19 @@ 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, form?: MerchantRuleForm) => { + if (!form?.categoriesToMatch?.length) { + return translate('workspace.rules.merchantRules.confirmErrorCategory'); + } + if (!form?.tax) { + return translate('workspace.rules.merchantRules.confirmErrorCategoryTax'); + } + return ''; +}; + const getErrorMessage = (translate: LocalizedTranslate, form?: MerchantRuleForm) => { - const matchingCriteriaFields = new Set([MERCHANT_RULE_INPUT_IDS.MERCHANT_TO_MATCH, MERCHANT_RULE_INPUT_IDS.MATCH_TYPE]); + const matchingCriteriaFields = new Set([MERCHANT_RULE_INPUT_IDS.MERCHANT_TO_MATCH, MERCHANT_RULE_INPUT_IDS.MATCH_TYPE, MERCHANT_RULE_INPUT_IDS.CATEGORIES_TO_MATCH]); const hasAtLeastOneUpdate = Object.entries(form ?? {}).some(([key, value]) => { if (matchingCriteriaFields.has(key)) { return false; @@ -111,7 +132,7 @@ const getErrorMessage = (translate: LocalizedTranslate, form?: MerchantRuleForm) return translate('workspace.rules.merchantRules.confirmError'); }; -function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, testID}: MerchantRulePageBaseProps) { +function MerchantRulePageBase({policyID, ruleID, initialCategoryName, editCategoryTaxRuleFor, titleKey, testID}: MerchantRulePageBaseProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const policy = usePolicy(policyID); @@ -119,10 +140,11 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, const [isDeleting, setIsDeleting] = useState(false); const {isLoading, startWithLoading} = usePressLoading(); const isEditing = !!ruleID; + const isEditingCategoryTaxRule = !!editCategoryTaxRuleFor; const isInLandscapeMode = useIsInLandscapeMode(); const {isBetaEnabled} = usePermissions(); const isRulesRevampEnabled = isBetaEnabled(CONST.BETAS.RULES_REVAMP); - const icons = useMemoizedLazyExpensifyIcons(['Basket', 'Folder', 'Pencil', 'InvoiceGeneric', 'Tag', 'Paycheck']); + const icons = useMemoizedLazyExpensifyIcons(['Basket', 'Folder', 'Pencil', 'InvoiceGeneric', 'Tag', 'Paycheck', 'Lock']); const getItemIcon = (icon: IconAsset) => (isRulesRevampEnabled ? icon : undefined); const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM); @@ -145,9 +167,18 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, // Get the existing rule from the policy (for edit mode) const existingRule = ruleID ? policy?.rules?.codingRules?.[ruleID] : undefined; + const existingCategoryTaxID = editCategoryTaxRuleFor ? getCategoryTaxRuleTaxID(policy?.rules?.expenseRules, editCategoryTaxRuleFor) : undefined; // Initialize the form with existing rule data (for edit mode), or a pre-scoped category for create useEffect(() => { + if (isEditingCategoryTaxRule) { + if (!existingCategoryTaxID) { + return; + } + setDraftMerchantRule({categoriesToMatch: [editCategoryTaxRuleFor], tax: existingCategoryTaxID}); + return; + } + if (isEditing) { if (!existingRule) { return; @@ -178,7 +209,7 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, didInitializeCreateDraftRef.current = true; setDraftMerchantRule({category: initialCategoryName}); - }, [isEditing, existingRule, initialCategoryName]); + }, [isEditing, existingRule, initialCategoryName, isEditingCategoryTaxRule, editCategoryTaxRuleFor, existingCategoryTaxID]); // Clear the form on unmount useEffect(() => () => clearDraftMerchantRule(), []); @@ -231,6 +262,50 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, 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. + // Setting either condition locks the other, and a category condition also narrows the defaults down to tax alone. + const areTaxesEnabled = hasTaxes(); + const categoriesToMatch = form?.categoriesToMatch ?? []; + const hasCategoryCondition = categoriesToMatch.length > 0; + const hasMerchantCondition = !!form?.merchantToMatch; + const isCategoryRule = hasCategoryCondition || isEditingCategoryTaxRule; + const isMerchantConditionLocked = hasCategoryCondition; + // A category rule sets a tax rate, so with taxes off there is nothing for it to configure. + const isCategoryConditionLocked = isEditingCategoryTaxRule || hasMerchantCondition || !areTaxesEnabled; + + const showExplainer = (explainerTitleKey: TranslationPaths, explainerPromptKey: TranslationPaths) => { + showConfirmModal({ + title: translate(explainerTitleKey), + prompt: translate(explainerPromptKey), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + }); + }; + + const showCategoryConditionExplainer = () => { + if (!areTaxesEnabled) { + showExplainer('workspace.rules.merchantRules.turnOnTaxesFirstTitle', 'workspace.rules.merchantRules.turnOnTaxesFirstPrompt'); + return; + } + showExplainer('workspace.rules.merchantRules.oneConditionPerRuleTitle', 'workspace.rules.merchantRules.alreadyMatchesMerchantPrompt'); + }; + + const showMerchantConditionExplainer = () => showExplainer('workspace.rules.merchantRules.oneConditionPerRuleTitle', 'workspace.rules.merchantRules.alreadyMatchesCategoryPrompt'); + + const showCategoryOnlyTaxExplainer = () => showExplainer('workspace.rules.merchantRules.onlyTaxForCategoryRulesTitle', 'workspace.rules.merchantRules.onlyTaxForCategoryRulesPrompt'); + + const showCategoryRulesApplyGoingForwardExplainer = () => + showExplainer('workspace.rules.merchantRules.categoryRulesApplyGoingForwardTitle', 'workspace.rules.merchantRules.categoryRulesApplyGoingForwardPrompt'); + + /** Clears both conditions and every default, unlocking every row. */ + const resetRule = () => { + setDraftMerchantRule({}); + setShouldShowError(false); + setShouldUpdateMatchingTransactions(false); + }; + + // 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 categoryDisplayName = form?.category ? getDecodedCategoryName(form.category) : undefined; const taxDisplayName = () => { if (!form?.tax || !policy?.taxRates?.taxes) { @@ -281,7 +356,12 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, }); }; - const errorMessage = getErrorMessage(translate, form); + const errorMessage = isCategoryRule ? getCategoryRuleErrorMessage(translate, form) : getErrorMessage(translate, form); + + const goBackToExpenseDefaults = () => { + Tab.setSelectedTab(CONST.TAB.RULES_TAB_TYPE, CONST.TAB.RULES.EXPENSE_DEFAULTS); + Navigation.goBack(ROUTES.WORKSPACE_RULES.getRoute(policyID)); + }; /** * Saves the rule to the backend and navigates back. @@ -290,10 +370,27 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, if (!form) { 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) { + const taxID = form.tax; + if (!hasCategoryCondition || !taxID) { + return; + } + // The command is per-category, so a bulk selection saves one rule for each category picked. + setPolicyCategoryTaxes(policy, categoriesToMatch, taxID); + if (isEditingCategoryTaxRule) { + Navigation.goBack(); + } else { + goBackToExpenseDefaults(); + } + return; + } + setPolicyCodingRule(policyID, form, policy, ruleID, shouldUpdateMatchingTransactions); if (!isEditing && isRulesRevampEnabled) { - Tab.setSelectedTab(CONST.TAB.RULES_TAB_TYPE, CONST.TAB.RULES.EXPENSE_DEFAULTS); - Navigation.goBack(ROUTES.WORKSPACE_RULES.getRoute(policyID)); + goBackToExpenseDefaults(); } else { Navigation.goBack(); } @@ -311,6 +408,13 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, 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(policy?.rules?.codingRules, form.merchantToMatch, form.matchType); if (hasDuplicate) { @@ -355,6 +459,14 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, }); }; + /** Locks a default row that a category rule can't set, so selecting it explains why instead of opening a picker. */ + const withCategoryRuleLock = (item: SectionItemType): SectionItemType => { + if (!isCategoryRule) { + return item; + } + return {...item, title: undefined, isLocked: true, onPress: showCategoryOnlyTaxExplainer}; + }; + const sections: SectionType[] = [ { titleTranslationKey: 'workspace.rules.merchantRules.expensesWith', @@ -362,11 +474,23 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, { key: 'merchantToMatch', description: translate('common.merchant'), - required: true, + // Exactly one condition is required, so neither row can be marked required on its own. + required: !isRulesRevampEnabled, title: form?.merchantToMatch, - onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_MERCHANT_TO_MATCH.getRoute(policyID, ruleID)), + isLocked: isMerchantConditionLocked, + onPress: isMerchantConditionLocked ? showMerchantConditionExplainer : () => Navigation.navigate(ROUTES.RULES_MERCHANT_MERCHANT_TO_MATCH.getRoute(policyID, ruleID)), icon: getItemIcon(icons.Basket), }, + isRulesRevampEnabled + ? { + key: 'categoriesToMatch', + description: translate('common.category'), + title: categoriesToMatchDisplayName, + isLocked: isCategoryConditionLocked, + onPress: isCategoryConditionLocked ? showCategoryConditionExplainer : () => Navigation.navigate(ROUTES.RULES_CATEGORY_TO_MATCH.getRoute(policyID, ruleID)), + icon: getItemIcon(icons.Folder), + } + : undefined, ], }, { @@ -444,7 +568,8 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, icon: getItemIcon(icons.Paycheck), } : undefined, - ], + // Tax is the only default a category rule can set, so every other row locks behind the explainer. + ].map((item) => (!item || item.key === 'tax' ? item : withCategoryRuleLock(item))), }, ]; @@ -461,6 +586,10 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, return ; } + if (isEditingCategoryTaxRule && !existingCategoryTaxID) { + return ; + } + if (!isEditing && !!policy && !canWriteRules) { return ; } @@ -487,20 +616,27 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, > {translate('workspace.rules.merchantRules.applyToExistingUnsubmittedExpenses')} + {/* A category tax default only applies to expenses created after the rule is saved, so the switch + is locked off. `disabled` draws the lock inside the thumb and routes the press to the explainer. */} - + {/* There is no set of existing expenses for a category rule to preview, so the button is hidden rather than locked. */} + {!isCategoryRule && ( + + )} {isEditing && (