Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
8 changes: 8 additions & 0 deletions src/ROUTES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/SCREENS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof CONST.POLICY.EXPENSE_DEFAULTS_SECTION>;

type ExpenseDefaultTableItem = TableData & {
ruleID: string;
isMerchantType: boolean;
section: ExpenseDefaultsSection;
isRename: boolean;
groupID?: string;
typeLabel: string;
Expand All @@ -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<ExpenseDefaultsSection, TranslationPaths>;

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<ExpenseDefaultsSection, string>;

type WorkspaceExpenseDefaultsTableRowProps = {
item: ExpenseDefaultTableItem;
rowIndex: number;
Expand All @@ -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 ? (
<Tooltip text={translate('workspace.rules.spendRules.defaultRulesCannotBeDeleted')}>
<View>
<Icon
Expand All @@ -79,7 +98,7 @@ function WorkspaceExpenseDefaultsTableRow({item, rowIndex, shouldUseNarrowTableL
{!!showSectionHeader && (
<View style={[styles.mh5, styles.pv2, styles.ph3, StyleUtils.getBackgroundColorStyle(theme.hoverComponentBG), rowIndex === 0 ? styles.borderBottom : styles.borderTop]}>
<TextWithTooltip
text={item.isMerchantType ? translate('workspace.rules.spendRules.merchantTypes') : translate('workspace.rules.spendRules.merchants')}
text={translate(SECTION_HEADER_TRANSLATION_KEYS[item.section])}
style={[styles.textMicroBoldSupporting, styles.lh14]}
/>
</View>
Expand All @@ -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,
Expand Down
10 changes: 8 additions & 2 deletions src/components/Tables/WorkspaceExpenseDefaultsTable/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -62,8 +67,9 @@ function WorkspaceExpenseDefaultsTable({rulesData, selectionEnabled, selectedKey
const compareItems: CompareItemsCallback<ExpenseDefaultTableItem, ExpenseDefaultsTableColumnKey> = (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') {
Expand Down
12 changes: 12 additions & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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}"`,
},
Expand Down
109 changes: 109 additions & 0 deletions src/libs/CategoryTaxRulesUtils.ts
Original file line number Diff line number Diff line change
@@ -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 <name>` 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};
8 changes: 5 additions & 3 deletions src/libs/MerchantTypeRulesUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1117,6 +1117,8 @@ const SettingsModalStackNavigator = createModalStackNavigator<SettingsNavigatorP
[SCREENS.WORKSPACE.RULES_MERCHANT_BILLABLE]: () => require<ReactComponentModule>('../../../../pages/workspace/rules/MerchantRules/AddBillablePage').default,
[SCREENS.WORKSPACE.RULES_MERCHANT_PREVIEW_MATCHES]: () => require<ReactComponentModule>('../../../../pages/workspace/rules/MerchantRules/PreviewMatchesPage').default,
[SCREENS.WORKSPACE.RULES_MERCHANT_EDIT]: () => require<ReactComponentModule>('../../../../pages/workspace/rules/MerchantRules/EditMerchantRulePage').default,
[SCREENS.WORKSPACE.RULES_CATEGORY_TO_MATCH]: () => require<ReactComponentModule>('../../../../pages/workspace/rules/MerchantRules/AddCategoryToMatchPage').default,
[SCREENS.WORKSPACE.RULES_CATEGORY_TAX_EDIT]: () => require<ReactComponentModule>('../../../../pages/workspace/rules/MerchantRules/EditCategoryTaxRulePage').default,
[SCREENS.WORKSPACE.RULES_AGENT_NEW]: () => require<ReactComponentModule>('../../../../pages/workspace/rules/AgentRules/AddAgentRulePage').default,
[SCREENS.WORKSPACE.RULES_AGENT_EDIT]: () => require<ReactComponentModule>('../../../../pages/workspace/rules/AgentRules/EditAgentRulePage').default,
[SCREENS.WORKSPACE.PER_DIEM_IMPORT]: () => require<ReactComponentModule>('../../../../pages/workspace/perDiem/ImportPerDiemPage').default,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,8 @@ const WORKSPACE_TO_RHP: Partial<Record<keyof WorkspaceSplitNavigatorParamList, s
SCREENS.WORKSPACE.RULES_MERCHANT_BILLABLE,
SCREENS.WORKSPACE.RULES_MERCHANT_PREVIEW_MATCHES,
SCREENS.WORKSPACE.RULES_MERCHANT_EDIT,
SCREENS.WORKSPACE.RULES_CATEGORY_TO_MATCH,
SCREENS.WORKSPACE.RULES_CATEGORY_TAX_EDIT,
SCREENS.WORKSPACE.RULES_AGENT_NEW,
SCREENS.WORKSPACE.RULES_AGENT_EDIT,
],
Expand Down
6 changes: 6 additions & 0 deletions src/libs/Navigation/linkingConfig/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1385,6 +1385,12 @@ const config: LinkingOptions<RootNavigatorParamList>['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,
},
Expand Down
Loading
Loading