From 6871001f271ca4f0b92c656d9f0490c8c34fb73b Mon Sep 17 00:00:00 2001 From: Nicolas Bonet Date: Wed, 2 Sep 2026 20:41:57 -0500 Subject: [PATCH 1/2] Add apply to existing unsubmitted expenses toggle to personal expense rules --- .../settings/Personal-Expense-Rules.md | 12 ++++++---- src/languages/de.ts | 2 +- src/languages/el.ts | 2 +- src/languages/en.ts | 2 +- src/languages/es.ts | 2 +- src/languages/fr.ts | 2 +- src/languages/it.ts | 2 +- src/languages/ja.ts | 2 +- src/languages/nl.ts | 2 +- src/languages/pl.ts | 2 +- src/languages/pt-BR.ts | 2 +- src/languages/zh-hans.ts | 2 +- .../SetPersonalExpenseRulesParams.ts | 12 ++++++++++ src/libs/API/parameters/index.ts | 1 + src/libs/API/types.ts | 2 ++ src/libs/actions/User.ts | 23 +++++++++++++------ src/pages/settings/Rules/RulePageBase.tsx | 21 ++++++++++++++++- 17 files changed, 69 insertions(+), 24 deletions(-) create mode 100644 src/libs/API/parameters/SetPersonalExpenseRulesParams.ts diff --git a/docs/articles/new-expensify/settings/Personal-Expense-Rules.md b/docs/articles/new-expensify/settings/Personal-Expense-Rules.md index 238758b818aa..4818d58c3a3c 100644 --- a/docs/articles/new-expensify/settings/Personal-Expense-Rules.md +++ b/docs/articles/new-expensify/settings/Personal-Expense-Rules.md @@ -50,7 +50,8 @@ To create a new Expense Rule: - Reimbursable - Billable - Add to a report -5. Click or tap **Save rule** +5. (Optional) Enable **Apply to existing unsubmitted expenses** to also apply the rule to matching expenses that haven't been submitted yet +6. Click or tap **Save rule** **Note:** Merchant names are case-sensitive. @@ -63,9 +64,10 @@ To modify an existing Expense Rule: 1. Go to **Account > Expense Rules** 2. Click or tap the rule you want to update 3. Edit the fields you want to change -4. Click or tap **Save rule** +4. (Optional) Enable **Apply to existing unsubmitted expenses** to also apply the updated rule to matching expenses that haven't been submitted yet +5. Click or tap **Save rule** -Changes apply to future matching expenses. +Changes apply to future matching expenses, plus existing unsubmitted expenses when the toggle is enabled. --- @@ -92,7 +94,7 @@ Expense Rules work as follows: - If multiple rules match, they apply from top to bottom - Personal Expense Rules override [Workspace Merchant Rules](https://help.expensify.com/articles/new-expensify/workspaces/Workspace-Merchant-Rules) -Rules do not apply retroactively unless you manually update existing expenses. +Rules apply retroactively to unsubmitted expenses only when you enable **Apply to existing unsubmitted expenses** while saving a rule. Submitted expenses need to be edited manually. --- @@ -118,7 +120,7 @@ Personal Expense Rules are account-based. If you’ve used both experiences, you ## Can I apply an Expense Rule to existing expenses? -No. Expense Rules apply when expenses are created. They do not automatically update past expenses. You’ll need to manually edit existing expenses if needed. +Yes, for expenses that haven't been submitted yet. When creating or editing a rule, enable **Apply to existing unsubmitted expenses** before saving. Expenses that have already been submitted are not updated — you'll need to edit those manually if needed. ## What happens if my personal rule conflicts with a Workspace Merchant Rule? diff --git a/src/languages/de.ts b/src/languages/de.ts index 2c93c7abd284..68e1d0973389 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -3148,7 +3148,7 @@ ${amount} für ${merchant} – ${date}`, merchantHint: 'Gib . ein, um eine Regel zu erstellen, die für alle Händler gilt', addToReport: 'Zu einem Bericht mit dem Namen hinzufügen', createReport: 'Bericht bei Bedarf erstellen', - applyToExistingExpenses: 'Auf passende vorhandene Ausgaben anwenden', + applyToExistingExpenses: 'Auf bestehende nicht eingereichte Ausgaben anwenden', confirmError: 'Gib ein Händlerunternehmen ein und nimm mindestens eine Aktualisierung vor', confirmErrorMerchant: 'Bitte Händler eingeben', confirmErrorUpdate: 'Bitte wende mindestens ein Update an', diff --git a/src/languages/el.ts b/src/languages/el.ts index 2ff8ab3222b0..16b25301ce2b 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -3202,7 +3202,7 @@ ${amount} για ${merchant} - ${date}`, merchantHint: 'Πληκτρολογήστε . για να δημιουργήσετε έναν κανόνα που ισχύει για όλους τους εμπόρους', addToReport: 'Προσθήκη σε αναφορά με όνομα', createReport: 'Δημιουργήστε αναφορά αν χρειάζεται', - applyToExistingExpenses: 'Εφαρμογή σε υπάρχουσες αντιστοιχισμένες δαπάνες', + applyToExistingExpenses: 'Εφαρμογή σε υφιστάμενες μη υποβληθείσες δαπάνες', confirmError: 'Εισαγάγετε τον έμπορο και εφαρμόστε τουλάχιστον μία ενημέρωση', confirmErrorMerchant: 'Παρακαλούμε εισαγάγετε τον έμπορο', confirmErrorUpdate: 'Παρακαλούμε εφαρμόστε τουλάχιστον μία ενημέρωση', diff --git a/src/languages/en.ts b/src/languages/en.ts index 4c914fe4878d..af64f6701462 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -3246,7 +3246,7 @@ const translations = { merchantHint: 'Type . to create a rule that applies to all merchants', addToReport: 'Add to a report named', createReport: 'Create report if necessary', - applyToExistingExpenses: 'Apply to existing matching expenses', + applyToExistingExpenses: 'Apply to existing unsubmitted expenses', confirmError: 'Enter merchant and apply at least one update', confirmErrorMerchant: 'Please enter merchant', confirmErrorUpdate: 'Please apply at least one update', diff --git a/src/languages/es.ts b/src/languages/es.ts index b96a50164be2..4b8d6c76598f 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -3056,7 +3056,7 @@ ${amount} para ${merchant} - ${date}`, merchantHint: 'Escribe . para crear una regla que se aplique a todos los comercios', addToReport: 'Añadir a un informe llamado', createReport: 'Crear informe si es necesario', - applyToExistingExpenses: 'Aplicar a gastos existentes que coincidan', + applyToExistingExpenses: 'Aplicar a gastos existentes no enviados', confirmError: 'Introduce el comercio y aplica al menos una actualización', confirmErrorMerchant: 'Por favor, introduce el comercio', confirmErrorUpdate: 'Por favor, aplica al menos una actualización', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 164dd624b775..6b42ccc2a83c 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -3155,7 +3155,7 @@ ${amount} pour ${merchant} - ${date}`, merchantHint: 'Tapez . pour créer une règle qui s’applique à tous les marchands', addToReport: 'Ajouter à une note de frais nommée', createReport: 'Créer une note de frais si nécessaire', - applyToExistingExpenses: 'Appliquer aux dépenses correspondantes existantes', + applyToExistingExpenses: 'Appliquer aux dépenses non soumises existantes', confirmError: 'Saisissez un commerçant et appliquez au moins une mise à jour', confirmErrorMerchant: 'Veuillez saisir le commerçant', confirmErrorUpdate: 'Veuillez appliquer au moins une mise à jour', diff --git a/src/languages/it.ts b/src/languages/it.ts index f370c3da64c3..9423927e867b 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -3138,7 +3138,7 @@ ${amount} per ${merchant} - ${date}`, merchantHint: 'Digita . per creare una regola che si applica a tutti gli esercenti', addToReport: 'Aggiungi a un report chiamato', createReport: 'Crea il report se necessario', - applyToExistingExpenses: 'Applica alle spese corrispondenti esistenti', + applyToExistingExpenses: 'Applica alle spese esistenti non inviate', confirmError: 'Inserisci l’esercente e applica almeno un aggiornamento', confirmErrorMerchant: 'Inserisci l’esercente', confirmErrorUpdate: 'Applica almeno un aggiornamento', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 4fa6bb19967a..60366660fcc4 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -3110,7 +3110,7 @@ ${date} の ${merchant} への ${amount}`, merchantHint: '. を入力して、すべての加盟店に適用されるルールを作成します', addToReport: '名前が次のレポートに追加', createReport: '必要に応じてレポートを作成', - applyToExistingExpenses: '既存の一致する経費に適用', + applyToExistingExpenses: '既存の未提出経費に適用', confirmError: '店舗名を入力し、少なくとも1つの更新を適用してください', confirmErrorMerchant: '加盟店を入力してください', confirmErrorUpdate: '少なくとも 1 つの更新を適用してください', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 8cd3319d0f02..41abc5ab01fc 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -3140,7 +3140,7 @@ ${amount} voor ${merchant} - ${date}`, merchantHint: 'Typ . om een regel te maken die van toepassing is op alle aanbieders', addToReport: 'Toevoegen aan een rapport met de naam', createReport: 'Maak indien nodig een rapport', - applyToExistingExpenses: 'Toepassen op bestaande overeenkomende uitgaven', + applyToExistingExpenses: 'Toepassen op bestaande niet-ingediende uitgaven', confirmError: 'Voer handelaar in en pas minstens één wijziging toe', confirmErrorMerchant: 'Voer handelaar in', confirmErrorUpdate: 'Pas ten minste één wijziging toe', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index a0d06ef5a1d7..be4fa6c0702b 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -3167,7 +3167,7 @@ ${amount} dla ${merchant} - ${date}`, merchantHint: 'Wpisz . , aby utworzyć regułę, która będzie miała zastosowanie do wszystkich sprzedawców', addToReport: 'Dodaj do raportu o nazwie', createReport: 'Utwórz raport w razie potrzeby', - applyToExistingExpenses: 'Zastosuj do pasujących istniejących wydatków', + applyToExistingExpenses: 'Zastosuj do istniejących niewysłanych wydatków', confirmError: 'Wprowadź sprzedawcę i zastosuj co najmniej jedną zmianę', confirmErrorMerchant: 'Wprowadź sprzedawcę', confirmErrorUpdate: 'Zastosuj co najmniej jedną aktualizację', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 42124ce76aac..18c56f6c2c22 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -3130,7 +3130,7 @@ ${amount} para ${merchant} - ${date}`, merchantHint: 'Digite . para criar uma regra que se aplique a todos os estabelecimentos', addToReport: 'Adicionar a um relatório chamado', createReport: 'Criar relatório se necessário', - applyToExistingExpenses: 'Aplicar às despesas correspondentes existentes', + applyToExistingExpenses: 'Aplicar às despesas existentes não enviadas', confirmError: 'Insira o comerciante e aplique pelo menos uma atualização', confirmErrorMerchant: 'Insira o comerciante', confirmErrorUpdate: 'Aplicar pelo menos uma atualização', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 252384f412e2..ba8ce5504d4a 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -3033,7 +3033,7 @@ ${amount},商户:${merchant} - 日期:${date}`, merchantHint: '输入 . 可创建适用于所有商家的规则', addToReport: '添加到报表,名称为', createReport: '如有必要则创建报表', - applyToExistingExpenses: '应用到现有匹配报销费用', + applyToExistingExpenses: '应用到现有未提交的报销费用', confirmError: '输入商户并应用至少一项更新', confirmErrorMerchant: '请输入商户', confirmErrorUpdate: '请至少应用一个更新', diff --git a/src/libs/API/parameters/SetPersonalExpenseRulesParams.ts b/src/libs/API/parameters/SetPersonalExpenseRulesParams.ts new file mode 100644 index 000000000000..1d7f2dc30daa --- /dev/null +++ b/src/libs/API/parameters/SetPersonalExpenseRulesParams.ts @@ -0,0 +1,12 @@ +type SetPersonalExpenseRulesParams = { + /** Stringified JSON of the full personal expense rules array */ + value: string; + + /** Whether the saved rule should be applied to the user's existing unsubmitted expenses */ + shouldUpdateMatchingTransactions: boolean; + + /** Stringified JSON of the created or edited rule to apply (empty when nothing should be applied) */ + ruleToApply: string; +}; + +export default SetPersonalExpenseRulesParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 839171906e8a..22b387e46c29 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -251,6 +251,7 @@ export type {default as EditTaskParams} from './EditTaskParams'; export type {default as ReopenTaskParams} from './ReopenTaskParams'; export type {default as CompleteTaskParams} from './CompleteTaskParams'; export type {default as SetNameValuePairParams} from './SetNameValuePairParams'; +export type {default as SetPersonalExpenseRulesParams} from './SetPersonalExpenseRulesParams'; export type {default as SetNameValuePairsParams} from './SetNameValuePairsParams'; export type {default as SetReportFieldParams} from './SetReportFieldParams'; export type {default as SetReportNameParams} from './SetReportNameParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 3e2d0b204053..408004289de6 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -213,6 +213,7 @@ const WRITE_COMMANDS = { COMPLETE_GUIDED_SETUP: 'CompleteGuidedSetup', SET_NAME_VALUE_PAIR: 'SetNameValuePair', SET_NAME_VALUE_PAIRS: 'SetNameValuePairs', + SET_PERSONAL_EXPENSE_RULES: 'SetPersonalExpenseRules', SET_REPORT_DETAILS_COLUMNS: 'SetReportDetailsColumns', SET_REPORT_FIELD: 'Report_SetFields', DELETE_REPORT_FIELD: 'RemoveReportField', @@ -914,6 +915,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.COMPLETE_GUIDED_SETUP]: Parameters.CompleteGuidedSetupParams; [WRITE_COMMANDS.SET_NAME_VALUE_PAIR]: Parameters.SetNameValuePairParams; [WRITE_COMMANDS.SET_NAME_VALUE_PAIRS]: Parameters.SetNameValuePairsParams; + [WRITE_COMMANDS.SET_PERSONAL_EXPENSE_RULES]: Parameters.SetPersonalExpenseRulesParams; [WRITE_COMMANDS.SET_REPORT_DETAILS_COLUMNS]: Parameters.SetReportDetailsColumnsParams; [WRITE_COMMANDS.SET_REPORT_FIELD]: Parameters.SetReportFieldParams; [WRITE_COMMANDS.SET_REPORT_NAME]: Parameters.SetReportNameParams; diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index b03c801ce809..df22cc4d7d30 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -12,6 +12,7 @@ import type { RevokeDeviceParams, SetContactMethodAsDefaultParams, SetNameValuePairParams, + SetPersonalExpenseRulesParams, TogglePlatformMuteParams, UpdateChatPriorityModeParams, UpdateNewsletterSubscriptionParams, @@ -1689,9 +1690,10 @@ function deleteExpenseRules(expenseRules: ExpenseRule[], selectedRuleKeys: strin return rule; }); - const parameters: SetNameValuePairParams = { - name: ONYXKEYS.NVP_EXPENSE_RULES, + const parameters: SetPersonalExpenseRulesParams = { value: JSON.stringify(rulesForAPI), + shouldUpdateMatchingTransactions: false, + ruleToApply: '', }; const optimisticData: Array> = [ @@ -1718,7 +1720,7 @@ function deleteExpenseRules(expenseRules: ExpenseRule[], selectedRuleKeys: strin }, ]; - API.write(WRITE_COMMANDS.SET_NAME_VALUE_PAIR, parameters, { + API.write(WRITE_COMMANDS.SET_PERSONAL_EXPENSE_RULES, parameters, { optimisticData, successData, failureData, @@ -1748,7 +1750,13 @@ function clearExpenseRuleErrors(expenseRules: ExpenseRule[], selectedRuleKey: st Onyx.set(ONYXKEYS.NVP_EXPENSE_RULES, updatedExpenseRules); } -function saveExpenseRule(expenseRules: ExpenseRule[], newRule: ExpenseRule, existingRuleKey: string | undefined, getKeyForRule: (rule: ExpenseRule) => string) { +function saveExpenseRule( + expenseRules: ExpenseRule[], + newRule: ExpenseRule, + existingRuleKey: string | undefined, + getKeyForRule: (rule: ExpenseRule) => string, + shouldUpdateMatchingTransactions = false, +) { const isEditing = !!existingRuleKey; const pendingAction = isEditing ? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE : CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD; @@ -1802,9 +1810,10 @@ function saveExpenseRule(expenseRules: ExpenseRule[], newRule: ExpenseRule, exis .filter((rule) => rule.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) .map(({pendingAction: _pendingAction, errors: _errors, ...rule}) => rule); - const parameters: SetNameValuePairParams = { - name: ONYXKEYS.NVP_EXPENSE_RULES, + const parameters: SetPersonalExpenseRulesParams = { value: JSON.stringify(rulesForAPI), + shouldUpdateMatchingTransactions, + ruleToApply: shouldUpdateMatchingTransactions ? JSON.stringify(newRule) : '', }; const optimisticData: Array> = [ @@ -1831,7 +1840,7 @@ function saveExpenseRule(expenseRules: ExpenseRule[], newRule: ExpenseRule, exis }, ]; - API.write(WRITE_COMMANDS.SET_NAME_VALUE_PAIR, parameters, { + API.write(WRITE_COMMANDS.SET_PERSONAL_EXPENSE_RULES, parameters, { optimisticData, successData, failureData, diff --git a/src/pages/settings/Rules/RulePageBase.tsx b/src/pages/settings/Rules/RulePageBase.tsx index 7c0b79def208..a763605ab220 100644 --- a/src/pages/settings/Rules/RulePageBase.tsx +++ b/src/pages/settings/Rules/RulePageBase.tsx @@ -5,6 +5,7 @@ import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import RuleNotFoundPageWrapper from '@components/Rule/RuleNotFoundPageWrapper'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; +import Switch from '@components/Switch'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; @@ -101,6 +102,7 @@ function RulePageBase({titleKey, testID, hash}: RulePageBaseProps) { // Cannot use useRef because react compiler fails const [isSaving, setIsSaving] = useState(false); const [shouldShowError, setShouldShowError] = useState(false); + const [shouldUpdateMatchingTransactions, setShouldUpdateMatchingTransactions] = useState(false); const {isLoading, startWithLoading} = usePressLoading({isLoading: isSaving}); const styles = useThemeStyles(); @@ -149,7 +151,7 @@ function RulePageBase({titleKey, testID, hash}: RulePageBaseProps) { setIsSaving(true); const newRule = extractRuleFromForm(form, selectedTaxRate); - saveExpenseRule(expenseRules, newRule, hash, getKeyForRule); + saveExpenseRule(expenseRules, newRule, hash, getKeyForRule, shouldUpdateMatchingTransactions); Navigation.goBack(); }); @@ -290,6 +292,23 @@ function RulePageBase({titleKey, testID, hash}: RulePageBaseProps) { isLoading={isLoading} shouldShowLoadingImmediatelyOnPress={false} enabledWhenOffline + shouldRenderFooterAboveSubmit + footerContent={ + + + {translate('expenseRulesPage.addRule.applyToExistingExpenses')} + + + + } /> From ad91dfe1d058e12f3a385b6c5bdc8b858b8e8576 Mon Sep 17 00:00:00 2001 From: Nicolas Bonet Date: Wed, 16 Sep 2026 10:32:01 -0500 Subject: [PATCH 2/2] refactor: reuse ToggleSettingOptionRow for apply-to-existing toggle Co-Authored-By: Claude Opus 5 (1M context) --- src/pages/settings/Rules/RulePageBase.tsx | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/src/pages/settings/Rules/RulePageBase.tsx b/src/pages/settings/Rules/RulePageBase.tsx index a763605ab220..4583e8c64115 100644 --- a/src/pages/settings/Rules/RulePageBase.tsx +++ b/src/pages/settings/Rules/RulePageBase.tsx @@ -5,7 +5,6 @@ import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import RuleNotFoundPageWrapper from '@components/Rule/RuleNotFoundPageWrapper'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; -import Switch from '@components/Switch'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; @@ -294,20 +293,13 @@ function RulePageBase({titleKey, testID, hash}: RulePageBaseProps) { enabledWhenOffline shouldRenderFooterAboveSubmit footerContent={ - - - {translate('expenseRulesPage.addRule.applyToExistingExpenses')} - - - + } />