From d2653365faa1553f364142f955f4865f2eac83ca Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 12:05:23 -0800 Subject: [PATCH 01/29] Add CodingRule type and update rules in Policy.ts --- src/types/onyx/Policy.ts | 61 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index e1b8f6a400e9..8eeda5963e98 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -1685,6 +1685,61 @@ type ExpenseRule = { id?: string; }; +/** Coding rule filter condition */ +type CodingRuleFilter = { + /** The left side of the filter condition (e.g., 'merchant') */ + left: string; + + /** The operator for the filter (e.g., 'eq', 'contains') */ + operator: string; + + /** The right side of the filter condition (e.g., 'Snoop') */ + right: string; +}; + +/** Tax configuration for coding rule */ +type CodingRuleTax = { + /** Object wrapping the tax field */ + // eslint-disable-next-line @typescript-eslint/naming-convention + field_id_TAX: { + /** The external ID of the tax rate */ + externalID: string; + + /** The display value of the tax rate */ + value: string; + }; +}; + +/** Coding rule data model for workspace merchant rules */ +type CodingRule = { + /** Filter conditions for when this rule applies */ + filters: CodingRuleFilter; + + /** The merchant name to set on matching expenses */ + merchant?: string; + + /** Whether the expense should be billable */ + billable?: boolean; + + /** The category to set on matching expenses */ + category?: string; + + /** The comment/description to set on matching expenses */ + comment?: string; + + /** Whether the expense should be reimbursable */ + reimbursable?: boolean; + + /** The tag to set on matching expenses */ + tag?: string; + + /** Tax configuration for the expense */ + tax?: CodingRuleTax; + + /** When this rule was created */ + created?: string; +}; + /** Model of policy data */ type Policy = OnyxCommon.OnyxValueWithOfflineFeedback< { @@ -1892,6 +1947,9 @@ type Policy = OnyxCommon.OnyxValueWithOfflineFeedback< /** A set of rules related to the workspace expenses */ expenseRules?: ExpenseRule[]; + + /** A set of coding rules for automatic expense field population based on merchant matching */ + codingRules?: Record; }; /** A set of custom rules defined with natural language */ @@ -2098,6 +2156,9 @@ export type { ACHAccount, ApprovalRule, ExpenseRule, + CodingRule, + CodingRuleFilter, + CodingRuleTax, NetSuiteConnectionConfig, MccGroup, Subrate, From 80f3783185b990cfc508eabc156edafe0910e199 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 12:05:58 -0800 Subject: [PATCH 02/29] Add translations for merchant rules section --- src/languages/en.ts | 17 +++++++++++++++++ src/languages/es.ts | 17 +++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/languages/en.ts b/src/languages/en.ts index 593ef87cf92b..2a74989be693 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -6165,6 +6165,23 @@ const translations = { unlockFeatureEnableWorkflowsSubtitle: (featureName: string) => `Add ${featureName} to unlock this feature.`, enableFeatureSubtitle: (featureName: string, moreFeaturesLink?: string) => `Go to [more features](${moreFeaturesLink}) and enable ${featureName} to unlock this feature.`, }, + merchantRules: { + title: 'Merchant rules', + subtitle: 'Automatically update expense fields based on merchant name.', + emptyTitle: 'No merchant rules', + emptySubtitle: 'Create rules to automatically fill in expense details like category, tag, and more when a merchant name matches.', + addRule: 'Add rule', + merchantToMatch: 'Merchant to match', + setCategory: 'Set category', + setTag: 'Set tag', + setDescription: 'Set description', + setReimbursable: 'Set reimbursable', + setBillable: 'Set billable', + setTax: 'Set tax', + renameMerchant: 'Rename merchant to', + ruleAppliesWhen: ({merchantName}: {merchantName: string}) => `When merchant contains "${merchantName}"`, + ruleAppliesWhenExact: ({merchantName}: {merchantName: string}) => `When merchant is "${merchantName}"`, + }, categoryRules: { title: 'Category rules', approver: 'Approver', diff --git a/src/languages/es.ts b/src/languages/es.ts index c615533a3828..a4fbaeb7618b 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -5940,6 +5940,23 @@ ${amount} para ${merchant} - ${date}`, unlockFeatureEnableWorkflowsSubtitle: (featureName) => `Añade ${featureName} para desbloquear esta función.`, enableFeatureSubtitle: (featureName, moreFeaturesLink) => `Ir a [más características](${moreFeaturesLink}) y habilita ${featureName} para desbloquear esta función.`, }, + merchantRules: { + title: 'Reglas de comerciante', + subtitle: 'Actualiza automáticamente los campos de gasto según el nombre del comerciante.', + emptyTitle: 'Sin reglas de comerciante', + emptySubtitle: 'Crea reglas para completar automáticamente los detalles del gasto como categoría, etiqueta y más cuando el nombre del comerciante coincida.', + addRule: 'Añadir regla', + merchantToMatch: 'Comerciante a coincidir', + setCategory: 'Establecer categoría', + setTag: 'Establecer etiqueta', + setDescription: 'Establecer descripción', + setReimbursable: 'Establecer reembolsable', + setBillable: 'Establecer facturable', + setTax: 'Establecer impuesto', + renameMerchant: 'Renombrar comerciante a', + ruleAppliesWhen: ({merchantName}) => `Cuando el comerciante contiene "${merchantName}"`, + ruleAppliesWhenExact: ({merchantName}) => `Cuando el comerciante es "${merchantName}"`, + }, categoryRules: { title: 'Reglas de categoría', approver: 'Aprobador', From fee939927fac248335b16f300f06f3f76d45f960 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 12:06:40 -0800 Subject: [PATCH 03/29] Create MerchantRulesSection component --- .../workspace/rules/MerchantRulesSection.tsx | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 src/pages/workspace/rules/MerchantRulesSection.tsx diff --git a/src/pages/workspace/rules/MerchantRulesSection.tsx b/src/pages/workspace/rules/MerchantRulesSection.tsx new file mode 100644 index 000000000000..ee7b1b497a42 --- /dev/null +++ b/src/pages/workspace/rules/MerchantRulesSection.tsx @@ -0,0 +1,124 @@ +import React, {useMemo} from 'react'; +import {View} from 'react-native'; +import EmptyStateComponent from '@components/EmptyStateComponent'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import Section from '@components/Section'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import usePolicy from '@hooks/usePolicy'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; +import type {CodingRule} from '@src/types/onyx/Policy'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +type MerchantRulesSectionProps = { + policyID: string; +}; + +/** + * Generates a human-readable description of what a coding rule does + */ +function getRuleDescription(rule: CodingRule, translate: ReturnType['translate']): string { + const actions: string[] = []; + + if (rule.category) { + actions.push(`${translate('workspace.rules.merchantRules.setCategory')}: ${rule.category}`); + } + if (rule.tag) { + actions.push(`${translate('workspace.rules.merchantRules.setTag')}: ${rule.tag}`); + } + if (rule.merchant) { + actions.push(`${translate('workspace.rules.merchantRules.renameMerchant')}: ${rule.merchant}`); + } + if (rule.comment) { + actions.push(`${translate('workspace.rules.merchantRules.setDescription')}: ${rule.comment}`); + } + if (rule.reimbursable !== undefined) { + actions.push(`${translate('workspace.rules.merchantRules.setReimbursable')}: ${rule.reimbursable ? translate('common.yes') : translate('common.no')}`); + } + if (rule.billable !== undefined) { + actions.push(`${translate('workspace.rules.merchantRules.setBillable')}: ${rule.billable ? translate('common.yes') : translate('common.no')}`); + } + if (rule.tax?.field_id_TAX?.value) { + actions.push(`${translate('workspace.rules.merchantRules.setTax')}: ${rule.tax.field_id_TAX.value}`); + } + + return actions.join(', ') || ''; +} + +function MerchantRulesSection({policyID}: MerchantRulesSectionProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const policy = usePolicy(policyID); + + const codingRules = policy?.rules?.codingRules; + const hasRules = !isEmptyObject(codingRules); + + // Sort rules by creation date (newest first) and convert to array for rendering + const sortedRules = useMemo(() => { + if (!codingRules) { + return []; + } + + return Object.entries(codingRules) + .map(([ruleID, rule]) => ({ruleID, ...rule})) + .sort((a, b) => { + // Sort by created date, newest first + if (a.created && b.created) { + return new Date(b.created).getTime() - new Date(a.created).getTime(); + } + return 0; + }); + }, [codingRules]); + + return ( +
+ + {!hasRules ? ( + + ) : ( + sortedRules.map((rule) => { + const merchantName = rule.filters?.right ?? ''; + const isExactMatch = rule.filters?.operator === 'eq'; + const matchDescription = isExactMatch + ? translate('workspace.rules.merchantRules.ruleAppliesWhenExact', {merchantName}) + : translate('workspace.rules.merchantRules.ruleAppliesWhen', {merchantName}); + const ruleDescription = getRuleDescription(rule, translate); + + return ( + + + + ); + }) + )} + {hasRules && ( + + {translate('workspace.rules.merchantRules.subtitle')} + + )} + +
+ ); +} + +MerchantRulesSection.displayName = 'MerchantRulesSection'; + +export default MerchantRulesSection; From c0f919f5c94b1721317ec6282c15601eb636feb0 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 12:06:58 -0800 Subject: [PATCH 04/29] Add MerchantRulesSection to PolicyRulesPage --- src/pages/workspace/rules/PolicyRulesPage.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pages/workspace/rules/PolicyRulesPage.tsx b/src/pages/workspace/rules/PolicyRulesPage.tsx index 2dfef01ad9ea..04dc9e1da121 100644 --- a/src/pages/workspace/rules/PolicyRulesPage.tsx +++ b/src/pages/workspace/rules/PolicyRulesPage.tsx @@ -12,6 +12,7 @@ import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSection import CONST from '@src/CONST'; import type SCREENS from '@src/SCREENS'; import IndividualExpenseRulesSection from './IndividualExpenseRulesSection'; +import MerchantRulesSection from './MerchantRulesSection'; type PolicyRulesPageProps = PlatformStackScreenProps; @@ -49,6 +50,7 @@ function PolicyRulesPage({route}: PolicyRulesPageProps) { > + From 8f9979200e52519c462a38daae7eee725bbe9708 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 13:44:26 -0800 Subject: [PATCH 05/29] Update MerchantRulesSection to use new rule summary translations --- src/languages/es.ts | 23 +++----- .../workspace/rules/MerchantRulesSection.tsx | 54 +++++++------------ 2 files changed, 26 insertions(+), 51 deletions(-) diff --git a/src/languages/es.ts b/src/languages/es.ts index a4fbaeb7618b..a7298a950123 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -5941,21 +5941,14 @@ ${amount} para ${merchant} - ${date}`, enableFeatureSubtitle: (featureName, moreFeaturesLink) => `Ir a [más características](${moreFeaturesLink}) y habilita ${featureName} para desbloquear esta función.`, }, merchantRules: { - title: 'Reglas de comerciante', - subtitle: 'Actualiza automáticamente los campos de gasto según el nombre del comerciante.', - emptyTitle: 'Sin reglas de comerciante', - emptySubtitle: 'Crea reglas para completar automáticamente los detalles del gasto como categoría, etiqueta y más cuando el nombre del comerciante coincida.', - addRule: 'Añadir regla', - merchantToMatch: 'Comerciante a coincidir', - setCategory: 'Establecer categoría', - setTag: 'Establecer etiqueta', - setDescription: 'Establecer descripción', - setReimbursable: 'Establecer reembolsable', - setBillable: 'Establecer facturable', - setTax: 'Establecer impuesto', - renameMerchant: 'Renombrar comerciante a', - ruleAppliesWhen: ({merchantName}) => `Cuando el comerciante contiene "${merchantName}"`, - ruleAppliesWhenExact: ({merchantName}) => `Cuando el comerciante es "${merchantName}"`, + title: 'Comerciante', + subtitle: 'Configura las reglas de comerciante para que los gastos lleguen correctamente codificados y requieran menos limpieza.', + addRule: 'Añadir regla de comerciante', + ruleSummaryTitle: (merchantName: string) => `Si el comerciante contiene "${merchantName}"`, + ruleSumarySubtitleMerchant: (merchantName: string) => `Renombrar comerciante a "${merchantName}"`, + ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `Actualizar ${fieldName} a "${fieldValue}"`, + ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `Marcar como "${reimbursable ? 'reembolsable' : 'no reembolsable'}"`, + ruleSummarySubtitleBillable: (billable: boolean) => `Marcar como "${billable ? 'facturable' : 'no facturable'}"`, }, categoryRules: { title: 'Reglas de categoría', diff --git a/src/pages/workspace/rules/MerchantRulesSection.tsx b/src/pages/workspace/rules/MerchantRulesSection.tsx index ee7b1b497a42..3fdf1fd77f1c 100644 --- a/src/pages/workspace/rules/MerchantRulesSection.tsx +++ b/src/pages/workspace/rules/MerchantRulesSection.tsx @@ -1,13 +1,10 @@ import React, {useMemo} from 'react'; import {View} from 'react-native'; -import EmptyStateComponent from '@components/EmptyStateComponent'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import Section from '@components/Section'; -import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; -import CONST from '@src/CONST'; import type {CodingRule} from '@src/types/onyx/Policy'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -21,26 +18,26 @@ type MerchantRulesSectionProps = { function getRuleDescription(rule: CodingRule, translate: ReturnType['translate']): string { const actions: string[] = []; + if (rule.merchant) { + actions.push(translate('workspace.rules.merchantRules.ruleSumarySubtitleMerchant', rule.merchant)); + } if (rule.category) { - actions.push(`${translate('workspace.rules.merchantRules.setCategory')}: ${rule.category}`); + actions.push(translate('workspace.rules.merchantRules.ruleSummarySubtitleUpdateField', translate('common.category').toLowerCase(), rule.category)); } if (rule.tag) { - actions.push(`${translate('workspace.rules.merchantRules.setTag')}: ${rule.tag}`); - } - if (rule.merchant) { - actions.push(`${translate('workspace.rules.merchantRules.renameMerchant')}: ${rule.merchant}`); + actions.push(translate('workspace.rules.merchantRules.ruleSummarySubtitleUpdateField', translate('common.tag').toLowerCase(), rule.tag)); } if (rule.comment) { - actions.push(`${translate('workspace.rules.merchantRules.setDescription')}: ${rule.comment}`); + actions.push(translate('workspace.rules.merchantRules.ruleSummarySubtitleUpdateField', translate('common.description').toLowerCase(), rule.comment)); + } + if (rule.tax?.field_id_TAX?.value) { + actions.push(translate('workspace.rules.merchantRules.ruleSummarySubtitleUpdateField', translate('common.tax').toLowerCase(), rule.tax.field_id_TAX.value)); } if (rule.reimbursable !== undefined) { - actions.push(`${translate('workspace.rules.merchantRules.setReimbursable')}: ${rule.reimbursable ? translate('common.yes') : translate('common.no')}`); + actions.push(translate('workspace.rules.merchantRules.ruleSummarySubtitleReimbursable', rule.reimbursable)); } if (rule.billable !== undefined) { - actions.push(`${translate('workspace.rules.merchantRules.setBillable')}: ${rule.billable ? translate('common.yes') : translate('common.no')}`); - } - if (rule.tax?.field_id_TAX?.value) { - actions.push(`${translate('workspace.rules.merchantRules.setTax')}: ${rule.tax.field_id_TAX.value}`); + actions.push(translate('workspace.rules.merchantRules.ruleSummarySubtitleBillable', rule.billable)); } return actions.join(', ') || ''; @@ -79,21 +76,11 @@ function MerchantRulesSection({policyID}: MerchantRulesSectionProps) { titleStyles={styles.accountSettingsSectionTitle} subtitleMuted > - - {!hasRules ? ( - - ) : ( - sortedRules.map((rule) => { + {hasRules && ( + + {sortedRules.map((rule) => { const merchantName = rule.filters?.right ?? ''; - const isExactMatch = rule.filters?.operator === 'eq'; - const matchDescription = isExactMatch - ? translate('workspace.rules.merchantRules.ruleAppliesWhenExact', {merchantName}) - : translate('workspace.rules.merchantRules.ruleAppliesWhen', {merchantName}); + const matchDescription = translate('workspace.rules.merchantRules.ruleSummaryTitle', merchantName); const ruleDescription = getRuleDescription(rule, translate); return ( @@ -107,14 +94,9 @@ function MerchantRulesSection({policyID}: MerchantRulesSectionProps) { /> ); - }) - )} - {hasRules && ( - - {translate('workspace.rules.merchantRules.subtitle')} - - )} - + })} + + )} ); } From 391cdbc997a56eac38efede015c4f62ede784719 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 13:49:54 -0800 Subject: [PATCH 06/29] Lowercase subsequent rules in rule description --- src/pages/workspace/rules/MerchantRulesSection.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/pages/workspace/rules/MerchantRulesSection.tsx b/src/pages/workspace/rules/MerchantRulesSection.tsx index 3fdf1fd77f1c..0fe31a516bc1 100644 --- a/src/pages/workspace/rules/MerchantRulesSection.tsx +++ b/src/pages/workspace/rules/MerchantRulesSection.tsx @@ -40,7 +40,10 @@ function getRuleDescription(rule: CodingRule, translate: ReturnType (index === 0 ? action : action.charAt(0).toLowerCase() + action.slice(1))) + .join(', '); } function MerchantRulesSection({policyID}: MerchantRulesSectionProps) { From d1259b71c23233dcad6579f2cce4e08cd2333ada Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 13:50:44 -0800 Subject: [PATCH 07/29] update copy --- src/languages/en.ts | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/src/languages/en.ts b/src/languages/en.ts index 2a74989be693..66f837251e8e 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -6166,21 +6166,14 @@ const translations = { enableFeatureSubtitle: (featureName: string, moreFeaturesLink?: string) => `Go to [more features](${moreFeaturesLink}) and enable ${featureName} to unlock this feature.`, }, merchantRules: { - title: 'Merchant rules', - subtitle: 'Automatically update expense fields based on merchant name.', - emptyTitle: 'No merchant rules', - emptySubtitle: 'Create rules to automatically fill in expense details like category, tag, and more when a merchant name matches.', - addRule: 'Add rule', - merchantToMatch: 'Merchant to match', - setCategory: 'Set category', - setTag: 'Set tag', - setDescription: 'Set description', - setReimbursable: 'Set reimbursable', - setBillable: 'Set billable', - setTax: 'Set tax', - renameMerchant: 'Rename merchant to', - ruleAppliesWhen: ({merchantName}: {merchantName: string}) => `When merchant contains "${merchantName}"`, - ruleAppliesWhenExact: ({merchantName}: {merchantName: string}) => `When merchant is "${merchantName}"`, + title: 'Merchant', + subtitle: 'Set the merchant rules so expenses arrive correctly coded and require less cleanup.', + addRule: 'Add merchant rule', + ruleSummaryTitle: (merchantName: string) => `If merchant contains "${merchantName}"`, + ruleSumarySubtitleMerchant: (merchantName: string) => `Rename merchant to "${merchantName}"`, + ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `Update ${fieldName} to "${fieldValue}"`, + ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `Mark as "${reimbursable ? 'reimbursable' : 'non-reimbursable'}"`, + ruleSummarySubtitleBillable: (billable: boolean) => `Mark as "${billable ? 'billable' : 'non-billable'}"`, }, categoryRules: { title: 'Category rules', From 06b25cdfba3a006c0a37418c46bb7b38c1a034fb Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 13:54:44 -0800 Subject: [PATCH 08/29] Add New feature badge to merchant rules section title --- src/languages/en.ts | 1 + src/languages/es.ts | 1 + .../workspace/rules/MerchantRulesSection.tsx | 22 +++++++++++++++---- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/languages/en.ts b/src/languages/en.ts index 66f837251e8e..3ff95e6f744c 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -245,6 +245,7 @@ const translations = { in: 'In', optional: 'Optional', new: 'New', + newFeature: 'New feature', search: 'Search', reports: 'Reports', find: 'Find', diff --git a/src/languages/es.ts b/src/languages/es.ts index a7298a950123..144e725be798 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -28,6 +28,7 @@ const translations: TranslationDeepObject = { in: 'En', optional: 'Opcional', new: 'Nuevo', + newFeature: 'Nueva función', center: 'Centrar', search: 'Buscar', reports: 'Informes', diff --git a/src/pages/workspace/rules/MerchantRulesSection.tsx b/src/pages/workspace/rules/MerchantRulesSection.tsx index 0fe31a516bc1..f4a76443dffa 100644 --- a/src/pages/workspace/rules/MerchantRulesSection.tsx +++ b/src/pages/workspace/rules/MerchantRulesSection.tsx @@ -1,10 +1,13 @@ import React, {useMemo} from 'react'; import {View} from 'react-native'; +import Badge from '@components/Badge'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import Section from '@components/Section'; +import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; +import useTheme from '@hooks/useTheme'; import type {CodingRule} from '@src/types/onyx/Policy'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -49,12 +52,12 @@ function getRuleDescription(rule: CodingRule, translate: ReturnType { if (!codingRules) { return []; @@ -63,7 +66,6 @@ function MerchantRulesSection({policyID}: MerchantRulesSectionProps) { return Object.entries(codingRules) .map(([ruleID, rule]) => ({ruleID, ...rule})) .sort((a, b) => { - // Sort by created date, newest first if (a.created && b.created) { return new Date(b.created).getTime() - new Date(a.created).getTime(); } @@ -71,12 +73,24 @@ function MerchantRulesSection({policyID}: MerchantRulesSectionProps) { }); }, [codingRules]); + const renderTitle = () => ( + + + {translate('workspace.rules.merchantRules.title')} + + + + ); + return (
{hasRules && ( From 87f6b5a07a19979297d000e8ddb0c7d85e9f8bef Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 13:57:32 -0800 Subject: [PATCH 09/29] Ensure title uses dark text color --- src/pages/workspace/rules/MerchantRulesSection.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/rules/MerchantRulesSection.tsx b/src/pages/workspace/rules/MerchantRulesSection.tsx index f4a76443dffa..ff8863917d4a 100644 --- a/src/pages/workspace/rules/MerchantRulesSection.tsx +++ b/src/pages/workspace/rules/MerchantRulesSection.tsx @@ -75,7 +75,7 @@ function MerchantRulesSection({policyID}: MerchantRulesSectionProps) { const renderTitle = () => ( - + {translate('workspace.rules.merchantRules.title')} Date: Mon, 26 Jan 2026 13:58:30 -0800 Subject: [PATCH 10/29] Make New feature badge smaller --- src/pages/workspace/rules/MerchantRulesSection.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/workspace/rules/MerchantRulesSection.tsx b/src/pages/workspace/rules/MerchantRulesSection.tsx index ff8863917d4a..aaee5814cf04 100644 --- a/src/pages/workspace/rules/MerchantRulesSection.tsx +++ b/src/pages/workspace/rules/MerchantRulesSection.tsx @@ -80,8 +80,8 @@ function MerchantRulesSection({policyID}: MerchantRulesSectionProps) { ); From 30a10b668cd5df11b50820b406e0157edd323853 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 14:02:03 -0800 Subject: [PATCH 11/29] fix label colors --- src/pages/workspace/rules/MerchantRulesSection.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/pages/workspace/rules/MerchantRulesSection.tsx b/src/pages/workspace/rules/MerchantRulesSection.tsx index aaee5814cf04..83862b1cc9ed 100644 --- a/src/pages/workspace/rules/MerchantRulesSection.tsx +++ b/src/pages/workspace/rules/MerchantRulesSection.tsx @@ -80,8 +80,7 @@ function MerchantRulesSection({policyID}: MerchantRulesSectionProps) { ); From c1bfa9d0f1d1e63985c35aa6243c2cfd62f6a217 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 14:03:45 -0800 Subject: [PATCH 12/29] Add 'Add merchant rule' button to MerchantRulesSection --- src/pages/workspace/rules/MerchantRulesSection.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/pages/workspace/rules/MerchantRulesSection.tsx b/src/pages/workspace/rules/MerchantRulesSection.tsx index 83862b1cc9ed..ac78ea001ef9 100644 --- a/src/pages/workspace/rules/MerchantRulesSection.tsx +++ b/src/pages/workspace/rules/MerchantRulesSection.tsx @@ -1,13 +1,15 @@ import React, {useMemo} from 'react'; import {View} from 'react-native'; import Badge from '@components/Badge'; +import MenuItem from '@components/MenuItem'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import Section from '@components/Section'; import Text from '@components/Text'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import usePolicy from '@hooks/usePolicy'; -import useThemeStyles from '@hooks/useThemeStyles'; import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; import type {CodingRule} from '@src/types/onyx/Policy'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -54,6 +56,7 @@ function MerchantRulesSection({policyID}: MerchantRulesSectionProps) { const styles = useThemeStyles(); const theme = useTheme(); const policy = usePolicy(policyID); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Plus']); const codingRules = policy?.rules?.codingRules; const hasRules = !isEmptyObject(codingRules); @@ -113,6 +116,15 @@ function MerchantRulesSection({policyID}: MerchantRulesSectionProps) { })} )} + {}} + />
); } From bef93e716f3f174ba2065b472ce4626211aa3b1a Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 14:13:56 -0800 Subject: [PATCH 13/29] update rules styles --- .../workspace/rules/MerchantRulesSection.tsx | 50 +++++++++++++++++-- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/src/pages/workspace/rules/MerchantRulesSection.tsx b/src/pages/workspace/rules/MerchantRulesSection.tsx index ac78ea001ef9..5e356c37a145 100644 --- a/src/pages/workspace/rules/MerchantRulesSection.tsx +++ b/src/pages/workspace/rules/MerchantRulesSection.tsx @@ -58,7 +58,50 @@ function MerchantRulesSection({policyID}: MerchantRulesSectionProps) { const policy = usePolicy(policyID); const expensifyIcons = useMemoizedLazyExpensifyIcons(['Plus']); - const codingRules = policy?.rules?.codingRules; + // TODO: Remove mock data before merging + const mockCodingRules = { + '123456789': { + filters: { + left: 'merchant', + operator: 'contains', + right: 'Starbucks', + }, + category: 'Meals & Entertainment', + tag: 'Client Meeting', + reimbursable: true, + created: '2026-01-20 10:00:00', + }, + '987654321': { + filters: { + left: 'merchant', + operator: 'eq', + right: 'Uber', + }, + category: 'Transportation', + merchant: 'Uber Technologies', + billable: false, + created: '2026-01-15 14:30:00', + }, + '456789123': { + filters: { + left: 'merchant', + operator: 'contains', + right: 'Amazon', + }, + category: 'Office Supplies', + comment: 'Office purchase - requires manager approval', + tax: { + // eslint-disable-next-line @typescript-eslint/naming-convention + field_id_TAX: { + externalID: 'tax_1', + value: '8.5%', + }, + }, + created: '2026-01-10 09:15:00', + }, + }; + + const codingRules = mockCodingRules; // policy?.rules?.codingRules; const hasRules = !isEmptyObject(codingRules); const sortedRules = useMemo(() => { @@ -108,8 +151,9 @@ function MerchantRulesSection({policyID}: MerchantRulesSectionProps) { description={matchDescription} title={ruleDescription} wrapperStyle={[styles.sectionMenuItemTopDescription]} - numberOfLinesTitle={2} - interactive={false} + descriptionTextStyle={[styles.textStrong, {color: theme.text}]} + titleStyle={[styles.textMicroSupporting]} + shouldShowRightIcon /> ); From da5a910c00604ee47ac8d1f6106e7e930e91fc5d Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 14:14:33 -0800 Subject: [PATCH 14/29] update more styles --- src/pages/workspace/rules/MerchantRulesSection.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/rules/MerchantRulesSection.tsx b/src/pages/workspace/rules/MerchantRulesSection.tsx index 5e356c37a145..d3b21b35b8d2 100644 --- a/src/pages/workspace/rules/MerchantRulesSection.tsx +++ b/src/pages/workspace/rules/MerchantRulesSection.tsx @@ -166,7 +166,7 @@ function MerchantRulesSection({policyID}: MerchantRulesSectionProps) { icon={expensifyIcons.Plus} iconHeight={20} iconWidth={20} - style={[styles.sectionMenuItemTopDescription, styles.mt6, styles.mbn3]} + style={[styles.sectionMenuItemTopDescription, !hasRules && styles.mt6, styles.mbn3]} onPress={() => {}} /> From a40309fde25191bcc58362f46ca5c06559f9ed6d Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 14:23:34 -0800 Subject: [PATCH 15/29] Add badgeNewFeature style for compact new feature badges --- src/styles/index.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/styles/index.ts b/src/styles/index.ts index 08336c5bf230..044d9b93c104 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -1043,6 +1043,12 @@ const staticStyles = (theme: ThemeColors) => minHeight: 28, }, + badgeNewFeature: { + minHeight: 20, + height: 20, + paddingHorizontal: 8, + }, + badgeText: { color: theme.text, fontSize: variables.fontSizeSmall, From 7d2e255d699071ac0c4e3eedeb6193be7c8906a4 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 14:28:51 -0800 Subject: [PATCH 16/29] update menu item styles --- src/pages/workspace/rules/MerchantRulesSection.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/pages/workspace/rules/MerchantRulesSection.tsx b/src/pages/workspace/rules/MerchantRulesSection.tsx index d3b21b35b8d2..97610da780c5 100644 --- a/src/pages/workspace/rules/MerchantRulesSection.tsx +++ b/src/pages/workspace/rules/MerchantRulesSection.tsx @@ -126,6 +126,7 @@ function MerchantRulesSection({policyID}: MerchantRulesSectionProps) { @@ -151,8 +152,8 @@ function MerchantRulesSection({policyID}: MerchantRulesSectionProps) { description={matchDescription} title={ruleDescription} wrapperStyle={[styles.sectionMenuItemTopDescription]} - descriptionTextStyle={[styles.textStrong, {color: theme.text}]} - titleStyle={[styles.textMicroSupporting]} + descriptionTextStyle={[styles.textStrong, styles.themeTextColor]} + titleStyle={[styles.textLabelSupporting]} shouldShowRightIcon /> From fd8f46cb511acec866220c882db2085f80ba87d8 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 14:30:12 -0800 Subject: [PATCH 17/29] update gap --- src/pages/workspace/rules/MerchantRulesSection.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/rules/MerchantRulesSection.tsx b/src/pages/workspace/rules/MerchantRulesSection.tsx index 97610da780c5..cf9dfd35fe90 100644 --- a/src/pages/workspace/rules/MerchantRulesSection.tsx +++ b/src/pages/workspace/rules/MerchantRulesSection.tsx @@ -120,7 +120,7 @@ function MerchantRulesSection({policyID}: MerchantRulesSectionProps) { }, [codingRules]); const renderTitle = () => ( - + {translate('workspace.rules.merchantRules.title')} From 41c6f92d6488ab2912b9ed6b4437788cb1ac89fe Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 14:33:21 -0800 Subject: [PATCH 18/29] Remove mock data and use real policy codingRules --- .../workspace/rules/MerchantRulesSection.tsx | 45 +------------------ 1 file changed, 1 insertion(+), 44 deletions(-) diff --git a/src/pages/workspace/rules/MerchantRulesSection.tsx b/src/pages/workspace/rules/MerchantRulesSection.tsx index cf9dfd35fe90..5dd3a58c390f 100644 --- a/src/pages/workspace/rules/MerchantRulesSection.tsx +++ b/src/pages/workspace/rules/MerchantRulesSection.tsx @@ -58,50 +58,7 @@ function MerchantRulesSection({policyID}: MerchantRulesSectionProps) { const policy = usePolicy(policyID); const expensifyIcons = useMemoizedLazyExpensifyIcons(['Plus']); - // TODO: Remove mock data before merging - const mockCodingRules = { - '123456789': { - filters: { - left: 'merchant', - operator: 'contains', - right: 'Starbucks', - }, - category: 'Meals & Entertainment', - tag: 'Client Meeting', - reimbursable: true, - created: '2026-01-20 10:00:00', - }, - '987654321': { - filters: { - left: 'merchant', - operator: 'eq', - right: 'Uber', - }, - category: 'Transportation', - merchant: 'Uber Technologies', - billable: false, - created: '2026-01-15 14:30:00', - }, - '456789123': { - filters: { - left: 'merchant', - operator: 'contains', - right: 'Amazon', - }, - category: 'Office Supplies', - comment: 'Office purchase - requires manager approval', - tax: { - // eslint-disable-next-line @typescript-eslint/naming-convention - field_id_TAX: { - externalID: 'tax_1', - value: '8.5%', - }, - }, - created: '2026-01-10 09:15:00', - }, - }; - - const codingRules = mockCodingRules; // policy?.rules?.codingRules; + const codingRules = policy?.rules?.codingRules; const hasRules = !isEmptyObject(codingRules); const sortedRules = useMemo(() => { From e34e177d43b757e5aa63578ed4d898188e9888fd Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 14:34:33 -0800 Subject: [PATCH 19/29] Only show MerchantRulesSection in development environment --- src/pages/workspace/rules/MerchantRulesSection.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/pages/workspace/rules/MerchantRulesSection.tsx b/src/pages/workspace/rules/MerchantRulesSection.tsx index 5dd3a58c390f..d0aae7347061 100644 --- a/src/pages/workspace/rules/MerchantRulesSection.tsx +++ b/src/pages/workspace/rules/MerchantRulesSection.tsx @@ -5,6 +5,7 @@ import MenuItem from '@components/MenuItem'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import Section from '@components/Section'; import Text from '@components/Text'; +import useEnvironment from '@hooks/useEnvironment'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import usePolicy from '@hooks/usePolicy'; @@ -57,6 +58,7 @@ function MerchantRulesSection({policyID}: MerchantRulesSectionProps) { const theme = useTheme(); const policy = usePolicy(policyID); const expensifyIcons = useMemoizedLazyExpensifyIcons(['Plus']); + const {isDevelopment} = useEnvironment(); const codingRules = policy?.rules?.codingRules; const hasRules = !isEmptyObject(codingRules); @@ -76,6 +78,11 @@ function MerchantRulesSection({policyID}: MerchantRulesSectionProps) { }); }, [codingRules]); + // Only show in development environment + if (!isDevelopment) { + return null; + } + const renderTitle = () => ( From 75da9ddd2c6ec95dd2283ad29ab5302d6ccd0da3 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 14:35:30 -0800 Subject: [PATCH 20/29] add dev beta --- src/pages/workspace/rules/MerchantRulesSection.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/workspace/rules/MerchantRulesSection.tsx b/src/pages/workspace/rules/MerchantRulesSection.tsx index d0aae7347061..acdc951e5df1 100644 --- a/src/pages/workspace/rules/MerchantRulesSection.tsx +++ b/src/pages/workspace/rules/MerchantRulesSection.tsx @@ -78,7 +78,6 @@ function MerchantRulesSection({policyID}: MerchantRulesSectionProps) { }); }, [codingRules]); - // Only show in development environment if (!isDevelopment) { return null; } From a75f310bef8801beaa44422839f72f9d5b2bd548 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 14:38:28 -0800 Subject: [PATCH 21/29] fix prettier --- src/pages/workspace/rules/MerchantRulesSection.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/pages/workspace/rules/MerchantRulesSection.tsx b/src/pages/workspace/rules/MerchantRulesSection.tsx index acdc951e5df1..b103055beed3 100644 --- a/src/pages/workspace/rules/MerchantRulesSection.tsx +++ b/src/pages/workspace/rules/MerchantRulesSection.tsx @@ -47,9 +47,7 @@ function getRuleDescription(rule: CodingRule, translate: ReturnType (index === 0 ? action : action.charAt(0).toLowerCase() + action.slice(1))) - .join(', '); + return actions.map((action, index) => (index === 0 ? action : action.charAt(0).toLowerCase() + action.slice(1))).join(', '); } function MerchantRulesSection({policyID}: MerchantRulesSectionProps) { @@ -84,9 +82,7 @@ function MerchantRulesSection({policyID}: MerchantRulesSectionProps) { const renderTitle = () => ( - - {translate('workspace.rules.merchantRules.title')} - + {translate('workspace.rules.merchantRules.title')} Date: Mon, 26 Jan 2026 14:39:00 -0800 Subject: [PATCH 22/29] fix spellcheck --- src/languages/en.ts | 2 +- src/languages/es.ts | 2 +- src/pages/workspace/rules/MerchantRulesSection.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/languages/en.ts b/src/languages/en.ts index 3ff95e6f744c..a8b4098c2780 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -6171,7 +6171,7 @@ const translations = { subtitle: 'Set the merchant rules so expenses arrive correctly coded and require less cleanup.', addRule: 'Add merchant rule', ruleSummaryTitle: (merchantName: string) => `If merchant contains "${merchantName}"`, - ruleSumarySubtitleMerchant: (merchantName: string) => `Rename merchant to "${merchantName}"`, + ruleSummarySubtitleMerchant: (merchantName: string) => `Rename merchant to "${merchantName}"`, ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `Update ${fieldName} to "${fieldValue}"`, ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `Mark as "${reimbursable ? 'reimbursable' : 'non-reimbursable'}"`, ruleSummarySubtitleBillable: (billable: boolean) => `Mark as "${billable ? 'billable' : 'non-billable'}"`, diff --git a/src/languages/es.ts b/src/languages/es.ts index 144e725be798..6b62d6109ddc 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -5946,7 +5946,7 @@ ${amount} para ${merchant} - ${date}`, subtitle: 'Configura las reglas de comerciante para que los gastos lleguen correctamente codificados y requieran menos limpieza.', addRule: 'Añadir regla de comerciante', ruleSummaryTitle: (merchantName: string) => `Si el comerciante contiene "${merchantName}"`, - ruleSumarySubtitleMerchant: (merchantName: string) => `Renombrar comerciante a "${merchantName}"`, + ruleSummarySubtitleMerchant: (merchantName: string) => `Renombrar comerciante a "${merchantName}"`, ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `Actualizar ${fieldName} a "${fieldValue}"`, ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `Marcar como "${reimbursable ? 'reembolsable' : 'no reembolsable'}"`, ruleSummarySubtitleBillable: (billable: boolean) => `Marcar como "${billable ? 'facturable' : 'no facturable'}"`, diff --git a/src/pages/workspace/rules/MerchantRulesSection.tsx b/src/pages/workspace/rules/MerchantRulesSection.tsx index b103055beed3..deef0d21e154 100644 --- a/src/pages/workspace/rules/MerchantRulesSection.tsx +++ b/src/pages/workspace/rules/MerchantRulesSection.tsx @@ -25,7 +25,7 @@ function getRuleDescription(rule: CodingRule, translate: ReturnType Date: Mon, 26 Jan 2026 14:41:40 -0800 Subject: [PATCH 23/29] apply translations --- src/languages/de.ts | 11 +++++++++++ src/languages/fr.ts | 11 +++++++++++ src/languages/it.ts | 11 +++++++++++ src/languages/ja.ts | 11 +++++++++++ src/languages/nl.ts | 11 +++++++++++ src/languages/pl.ts | 11 +++++++++++ src/languages/pt-BR.ts | 11 +++++++++++ src/languages/zh-hans.ts | 11 +++++++++++ 8 files changed, 88 insertions(+) diff --git a/src/languages/de.ts b/src/languages/de.ts index e0ec4822cea9..6d54352c36f8 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -640,6 +640,7 @@ const translations: TranslationDeepObject = { originalAmount: 'Ursprünglicher Betrag', insights: 'Einblicke', duplicateExpense: 'Doppelte Ausgabe', + newFeature: 'Neue Funktion', }, supportalNoAccess: { title: 'Nicht so schnell', @@ -6316,6 +6317,16 @@ Fordere Spesendetails wie Belege und Beschreibungen an, lege Limits und Standard title: 'Spesenrichtlinie', cardSubtitle: 'Hier befindet sich die Spesenrichtlinie eures Teams, damit alle genau wissen, was abgedeckt ist.', }, + merchantRules: { + title: 'Händler', + subtitle: 'Legen Sie Händlerregeln fest, damit Ausgaben korrekt codiert ankommen und weniger Nachbearbeitung erfordern.', + addRule: 'Händlerregel hinzufügen', + ruleSummaryTitle: (merchantName: string) => `Wenn Händler „${merchantName}“ enthält`, + ruleSummarySubtitleMerchant: (merchantName: string) => `Händler in „${merchantName}“ umbenennen`, + ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `Aktualisiere ${fieldName} zu „${fieldValue}“`, + ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `Als "${reimbursable ? 'erstattungsfähig' : 'nicht erstattungsfähig'}" markieren`, + ruleSummarySubtitleBillable: (billable: boolean) => `Als „${billable ? 'Abrechenbar' : 'nicht abrechenbar'}“ markieren`, + }, }, planTypePage: { planTypes: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 8cbc089fa00a..7a781bc2dacf 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -642,6 +642,7 @@ const translations: TranslationDeepObject = { originalAmount: 'Montant d’origine', insights: 'Analyses', duplicateExpense: 'Note de frais en double', + newFeature: 'Nouvelle fonctionnalité', }, supportalNoAccess: { title: 'Pas si vite', @@ -6327,6 +6328,16 @@ Exigez des informations de dépense comme les reçus et les descriptions, défin title: 'Politique de dépenses', cardSubtitle: 'Voici l’endroit où se trouve la politique de dépenses de votre équipe, afin que tout le monde soit sur la même longueur d’onde concernant ce qui est couvert.', }, + merchantRules: { + title: 'Commerçant', + subtitle: 'Définissez les règles de marchand afin que les dépenses arrivent correctement codées et nécessitent moins de nettoyage.', + addRule: 'Ajouter une règle de commerçant', + ruleSummaryTitle: (merchantName: string) => `Si le commerçant contient « ${merchantName} »`, + ruleSummarySubtitleMerchant: (merchantName: string) => `Renommer le marchand en « ${merchantName} »`, + ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `Mettre à jour ${fieldName} sur « ${fieldValue} »`, + ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `Marquer comme « ${reimbursable ? 'remboursable' : 'non remboursable'} »`, + ruleSummarySubtitleBillable: (billable: boolean) => `Marquer comme « ${billable ? 'facturable' : 'non facturable'} »`, + }, }, planTypePage: { planTypes: { diff --git a/src/languages/it.ts b/src/languages/it.ts index deb488917bbc..97ce434bb2db 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -641,6 +641,7 @@ const translations: TranslationDeepObject = { originalAmount: 'Importo originale', insights: 'Analisi', duplicateExpense: 'Spesa duplicata', + newFeature: 'Nuova funzionalità', }, supportalNoAccess: { title: 'Non così in fretta', @@ -6300,6 +6301,16 @@ Richiedi dettagli di spesa come ricevute e descrizioni, imposta limiti e valori title: 'Policy di spesa', cardSubtitle: 'Qui è dove si trova la policy di spesa del tuo team, così tutti sono allineati su cosa è coperto.', }, + merchantRules: { + title: 'Esercente', + subtitle: 'Imposta le regole per gli esercenti in modo che le spese arrivino già codificate correttamente e richiedano meno correzioni.', + addRule: 'Aggiungi regola esercente', + ruleSummaryTitle: (merchantName: string) => `Se l’esercente contiene "${merchantName}"`, + ruleSummarySubtitleMerchant: (merchantName: string) => `Rinomina esercente in "${merchantName}"`, + ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `Aggiorna ${fieldName} a "${fieldValue}"`, + ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `Segna come "${reimbursable ? 'rimborsabile' : 'non rimborsabile'}"`, + ruleSummarySubtitleBillable: (billable: boolean) => `Contrassegna come "${billable ? 'fatturabile' : 'non fatturabile'}"`, + }, }, planTypePage: { planTypes: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index d3cfcd265892..0a70a59323df 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -640,6 +640,7 @@ const translations: TranslationDeepObject = { originalAmount: '元の金額', insights: 'インサイト', duplicateExpense: '重複した経費', + newFeature: '新機能', }, supportalNoAccess: { title: 'ちょっと待ってください', @@ -6257,6 +6258,16 @@ ${reportName} title: '経費ポリシー', cardSubtitle: 'ここにはチームの経費ポリシーが保存されています。これにより、何が対象になるかについて全員が同じ認識を持てます。', }, + merchantRules: { + title: '加盟店', + subtitle: '取引先ルールを設定して、経費が正しくコード化された状態で届くようにし、後処理を最小限に抑えましょう。', + addRule: '店舗ルールを追加', + ruleSummaryTitle: (merchantName: string) => `もし取引先に「${merchantName}」が含まれている場合`, + ruleSummarySubtitleMerchant: (merchantName: string) => `支払先名を「${merchantName}」に変更`, + ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `${fieldName} を「${fieldValue}」に更新`, + ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `「${reimbursable ? '払い戻し対象' : '精算対象外'}」としてマーク`, + ruleSummarySubtitleBillable: (billable: boolean) => `「${billable ? '請求可能' : '請求対象外'}」としてマーク`, + }, }, planTypePage: { planTypes: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 90027e14228e..a4421c1d3ab6 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -641,6 +641,7 @@ const translations: TranslationDeepObject = { originalAmount: 'Oorspronkelijk bedrag', insights: 'Inzichten', duplicateExpense: 'Dubbele uitgave', + newFeature: 'Nieuwe functie', }, supportalNoAccess: { title: 'Niet zo snel', @@ -6286,6 +6287,16 @@ Vraag verplichte uitgavedetails zoals bonnetjes en beschrijvingen, stel limieten title: 'Onkostennota-beleid', cardSubtitle: 'Hier staat het onkostebeleid van je team, zodat iedereen goed weet wat wel en niet wordt vergoed.', }, + merchantRules: { + title: 'Handelaar', + subtitle: 'Stel de merchantregels zo in dat onkosten met de juiste codering binnenkomen en er minder nabewerking nodig is.', + addRule: 'Merchantregel toevoegen', + ruleSummaryTitle: (merchantName: string) => `Als handelaar "${merchantName}" bevat`, + ruleSummarySubtitleMerchant: (merchantName: string) => `Naam handelaar wijzigen in "${merchantName}"`, + ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `Werk ${fieldName} bij naar "${fieldValue}"`, + ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `Markeren als "${reimbursable ? 'Vergoedbaar' : 'niet-vergoedbaar'}"`, + ruleSummarySubtitleBillable: (billable: boolean) => `Markeren als "${billable ? 'factureerbaar' : 'niet-factureerbaar'}"`, + }, }, planTypePage: { planTypes: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index b39f9c4345c4..28a17b01098f 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -641,6 +641,7 @@ const translations: TranslationDeepObject = { originalAmount: 'Kwota pierwotna', insights: 'Analizy', duplicateExpense: 'Zduplikowany wydatek', + newFeature: 'Nowa funkcja', }, supportalNoAccess: { title: 'Nie tak szybko', @@ -6280,6 +6281,16 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i title: 'Polityka wydatków', cardSubtitle: 'Tutaj znajduje się polityka wydatków Twojego zespołu, aby wszyscy mieli jasność, co jest objęte.', }, + merchantRules: { + title: 'Sprzedawca', + subtitle: 'Skonfiguruj reguły dla sprzedawców, aby wydatki trafiały z poprawnym kodowaniem i wymagały mniej poprawek.', + addRule: 'Dodaj regułę sprzedawcy', + ruleSummaryTitle: (merchantName: string) => `Jeśli sprzedawca zawiera „${merchantName}”`, + ruleSummarySubtitleMerchant: (merchantName: string) => `Zmień sprzedawcę na „${merchantName}”`, + ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `Zaktualizuj ${fieldName} na „${fieldValue}”`, + ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `Oznacz jako "${reimbursable ? 'kwalifikujący się do zwrotu kosztów' : 'niepodlegający zwrotowi'}"`, + ruleSummarySubtitleBillable: (billable: boolean) => `Oznacz jako „${billable ? 'fakturowalne' : 'poza fakturą'}”`, + }, }, planTypePage: { planTypes: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 334edd7e3663..ef12d476e5b3 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -640,6 +640,7 @@ const translations: TranslationDeepObject = { originalAmount: 'Valor original', insights: 'Insights', duplicateExpense: 'Despesa duplicada', + newFeature: 'Novo recurso', }, supportalNoAccess: { title: 'Não tão rápido', @@ -6281,6 +6282,16 @@ Exija detalhes de despesas como recibos e descrições, defina limites e padrõe title: 'Política de despesas', cardSubtitle: 'É aqui que fica a política de despesas da sua equipe, para que todos estejam alinhados sobre o que é coberto.', }, + merchantRules: { + title: 'Estabelecimento', + subtitle: 'Defina as regras de comerciante para que as despesas cheguem corretamente categorizadas e exijam menos retrabalho.', + addRule: 'Adicionar regra de estabelecimento', + ruleSummaryTitle: (merchantName: string) => `Se o comerciante contiver "${merchantName}"`, + ruleSummarySubtitleMerchant: (merchantName: string) => `Renomear comerciante para "${merchantName}"`, + ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `Atualizar ${fieldName} para "${fieldValue}"`, + ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `Marcar como "${reimbursable ? 'reembolsável' : 'não reembolsável'}"`, + ruleSummarySubtitleBillable: (billable: boolean) => `Marcar como "${billable ? 'faturável' : 'não faturável'}"`, + }, }, planTypePage: { planTypes: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 4ea00ed4b59c..5c997de1759e 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -637,6 +637,7 @@ const translations: TranslationDeepObject = { originalAmount: '原始金额', insights: '洞察', duplicateExpense: '重复报销', + newFeature: '新功能', }, supportalNoAccess: { title: '先别急', @@ -6147,6 +6148,16 @@ ${reportName} title: '报销政策', cardSubtitle: '这里是你们团队报销政策所在的位置,让所有人都能清楚了解哪些费用包含在内。', }, + merchantRules: { + title: '商家', + subtitle: '设置商家规则,让报销费用自动按正确科目归类,减少后期清理工作。', + addRule: '添加商家规则', + ruleSummaryTitle: (merchantName: string) => `如果商户包含“${merchantName}”`, + ruleSummarySubtitleMerchant: (merchantName: string) => `将商家重命名为 “${merchantName}”`, + ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `将 ${fieldName} 更新为“${fieldValue}”`, + ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `标记为“${reimbursable ? '可报销' : '不予报销'}”`, + ruleSummarySubtitleBillable: (billable: boolean) => `标记为“${billable ? '可计费' : '不可计费'}”`, + }, }, planTypePage: { planTypes: { From 1d4729ee01ad4dbbff00f57451e8c99362a8ae55 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 16:34:18 -0800 Subject: [PATCH 24/29] Perf: Hoist iterator-independent translations in MerchantRulesSection - Move common field label translations outside the map loop - Pass pre-computed labels to getRuleDescription to avoid redundant translate() calls --- .../workspace/rules/MerchantRulesSection.tsx | 30 +++++++++++++++---- src/types/onyx/Policy.ts | 2 +- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/pages/workspace/rules/MerchantRulesSection.tsx b/src/pages/workspace/rules/MerchantRulesSection.tsx index deef0d21e154..5dc5ff618654 100644 --- a/src/pages/workspace/rules/MerchantRulesSection.tsx +++ b/src/pages/workspace/rules/MerchantRulesSection.tsx @@ -18,26 +18,33 @@ type MerchantRulesSectionProps = { policyID: string; }; +type FieldLabels = { + category: string; + tag: string; + description: string; + tax: string; +}; + /** * Generates a human-readable description of what a coding rule does */ -function getRuleDescription(rule: CodingRule, translate: ReturnType['translate']): string { +function getRuleDescription(rule: CodingRule, translate: ReturnType['translate'], labels: FieldLabels): string { const actions: string[] = []; if (rule.merchant) { actions.push(translate('workspace.rules.merchantRules.ruleSummarySubtitleMerchant', rule.merchant)); } if (rule.category) { - actions.push(translate('workspace.rules.merchantRules.ruleSummarySubtitleUpdateField', translate('common.category').toLowerCase(), rule.category)); + actions.push(translate('workspace.rules.merchantRules.ruleSummarySubtitleUpdateField', labels.category, rule.category)); } if (rule.tag) { - actions.push(translate('workspace.rules.merchantRules.ruleSummarySubtitleUpdateField', translate('common.tag').toLowerCase(), rule.tag)); + actions.push(translate('workspace.rules.merchantRules.ruleSummarySubtitleUpdateField', labels.tag, rule.tag)); } if (rule.comment) { - actions.push(translate('workspace.rules.merchantRules.ruleSummarySubtitleUpdateField', translate('common.description').toLowerCase(), rule.comment)); + actions.push(translate('workspace.rules.merchantRules.ruleSummarySubtitleUpdateField', labels.description, rule.comment)); } if (rule.tax?.field_id_TAX?.value) { - actions.push(translate('workspace.rules.merchantRules.ruleSummarySubtitleUpdateField', translate('common.tax').toLowerCase(), rule.tax.field_id_TAX.value)); + actions.push(translate('workspace.rules.merchantRules.ruleSummarySubtitleUpdateField', labels.tax, rule.tax.field_id_TAX.value)); } if (rule.reimbursable !== undefined) { actions.push(translate('workspace.rules.merchantRules.ruleSummarySubtitleReimbursable', rule.reimbursable)); @@ -58,6 +65,17 @@ function MerchantRulesSection({policyID}: MerchantRulesSectionProps) { const expensifyIcons = useMemoizedLazyExpensifyIcons(['Plus']); const {isDevelopment} = useEnvironment(); + // Hoist iterator-independent translations to avoid redundant calls in the loop + const fieldLabels: FieldLabels = useMemo( + () => ({ + category: translate('common.category').toLowerCase(), + tag: translate('common.tag').toLowerCase(), + description: translate('common.description').toLowerCase(), + tax: translate('common.tax').toLowerCase(), + }), + [translate], + ); + const codingRules = policy?.rules?.codingRules; const hasRules = !isEmptyObject(codingRules); @@ -103,7 +121,7 @@ function MerchantRulesSection({policyID}: MerchantRulesSectionProps) { {sortedRules.map((rule) => { const merchantName = rule.filters?.right ?? ''; const matchDescription = translate('workspace.rules.merchantRules.ruleSummaryTitle', merchantName); - const ruleDescription = getRuleDescription(rule, translate); + const ruleDescription = getRuleDescription(rule, translate, fieldLabels); return ( diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index 8eeda5963e98..8760f63d37f5 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -1700,7 +1700,7 @@ type CodingRuleFilter = { /** Tax configuration for coding rule */ type CodingRuleTax = { /** Object wrapping the tax field */ - // eslint-disable-next-line @typescript-eslint/naming-convention + // eslint-disable-next-line @typescript-eslint/naming-convention - field_id_TAX matches the backend API format and cannot be changed field_id_TAX: { /** The external ID of the tax rate */ externalID: string; From 582dfd719b21e19bdbd4e276b5d572200a237ee4 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 16:35:46 -0800 Subject: [PATCH 25/29] Decode category and clean tag names in MerchantRulesSection - Use getDecodedCategoryName to properly decode HTML entities in category names - Use getCleanedTagName to handle escaped characters in tag names - Matches the pattern used in ExpenseRuleUtils --- src/pages/workspace/rules/MerchantRulesSection.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/pages/workspace/rules/MerchantRulesSection.tsx b/src/pages/workspace/rules/MerchantRulesSection.tsx index 5dc5ff618654..5356052f28eb 100644 --- a/src/pages/workspace/rules/MerchantRulesSection.tsx +++ b/src/pages/workspace/rules/MerchantRulesSection.tsx @@ -11,6 +11,8 @@ import useLocalize from '@hooks/useLocalize'; import usePolicy from '@hooks/usePolicy'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import {getDecodedCategoryName} from '@libs/CategoryUtils'; +import {getCleanedTagName} from '@libs/PolicyUtils'; import type {CodingRule} from '@src/types/onyx/Policy'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -35,10 +37,10 @@ function getRuleDescription(rule: CodingRule, translate: ReturnType Date: Mon, 26 Jan 2026 16:39:34 -0800 Subject: [PATCH 26/29] Simplify date comparison in MerchantRulesSection sort ISO 8601 timestamps are lexicographically sortable, so string comparison works correctly --- src/pages/workspace/rules/MerchantRulesSection.tsx | 2 +- src/types/onyx/Policy.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/workspace/rules/MerchantRulesSection.tsx b/src/pages/workspace/rules/MerchantRulesSection.tsx index 5356052f28eb..c07048146de2 100644 --- a/src/pages/workspace/rules/MerchantRulesSection.tsx +++ b/src/pages/workspace/rules/MerchantRulesSection.tsx @@ -90,7 +90,7 @@ function MerchantRulesSection({policyID}: MerchantRulesSectionProps) { .map(([ruleID, rule]) => ({ruleID, ...rule})) .sort((a, b) => { if (a.created && b.created) { - return new Date(b.created).getTime() - new Date(a.created).getTime(); + return a.created < b.created ? 1 : -1; } return 0; }); diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index 8760f63d37f5..e9ad13e203e6 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -1690,7 +1690,7 @@ type CodingRuleFilter = { /** The left side of the filter condition (e.g., 'merchant') */ left: string; - /** The operator for the filter (e.g., 'eq', 'contains') */ + /** The operator for the filter, defined in CONST.SEARCH.SYNTAX_OPERATORS */ operator: string; /** The right side of the filter condition (e.g., 'Snoop') */ @@ -1710,7 +1710,7 @@ type CodingRuleTax = { }; }; -/** Coding rule data model for workspace merchant rules */ +/** Policy coding rule data model */ type CodingRule = { /** Filter conditions for when this rule applies */ filters: CodingRuleFilter; From 3a245595855dc720acc5263ad95a77af1d2dc280 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 16:58:18 -0800 Subject: [PATCH 27/29] Fix eslint-disable comment format in Policy.ts Move the explanation to the JSDoc comment instead of inline with the rule name --- src/types/onyx/Policy.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index e9ad13e203e6..02fb9be8dbed 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -1699,8 +1699,8 @@ type CodingRuleFilter = { /** Tax configuration for coding rule */ type CodingRuleTax = { - /** Object wrapping the tax field */ - // eslint-disable-next-line @typescript-eslint/naming-convention - field_id_TAX matches the backend API format and cannot be changed + // eslint-disable-next-line @typescript-eslint/naming-convention + /** Object wrapping the tax field - field_id_TAX matches the backend API format */ field_id_TAX: { /** The external ID of the tax rate */ externalID: string; From 7645ee7fe26057fdc1bd555d456f01f32aff0fd6 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 16:58:33 -0800 Subject: [PATCH 28/29] fix linter --- src/types/onyx/Policy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index 02fb9be8dbed..bb251657ff0d 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -1699,8 +1699,8 @@ type CodingRuleFilter = { /** Tax configuration for coding rule */ type CodingRuleTax = { + // Object wrapping the tax field - field_id_TAX matches the backend API format // eslint-disable-next-line @typescript-eslint/naming-convention - /** Object wrapping the tax field - field_id_TAX matches the backend API format */ field_id_TAX: { /** The external ID of the tax rate */ externalID: string; From 2db82fb9bb44be5ded511eba9905063acd7e4c42 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 26 Jan 2026 17:05:56 -0800 Subject: [PATCH 29/29] fix eslint --- src/types/onyx/Policy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index bb251657ff0d..cad8dd9ec6a0 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -1699,7 +1699,7 @@ type CodingRuleFilter = { /** Tax configuration for coding rule */ type CodingRuleTax = { - // Object wrapping the tax field - field_id_TAX matches the backend API format + /** Object wrapping the tax field - field_id_TAX matches the backend API format */ // eslint-disable-next-line @typescript-eslint/naming-convention field_id_TAX: { /** The external ID of the tax rate */