diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 126425377a3a..9a388f22ff53 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -1709,8 +1709,8 @@ "../../src/pages/workspace/rules/ExpenseReportRulesSection.tsx" "@typescript-eslint/no-deprecated/MenuItemWithTopDescription" 3 "../../src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx" "@typescript-eslint/no-deprecated/MenuItemWithTopDescription" 1 "../../src/pages/workspace/rules/IndividualExpenseRulesSectionRevamp.tsx" "@typescript-eslint/no-deprecated/MenuItem" 1 +"../../src/pages/workspace/rules/MerchantRules/ExpenseDefaultRulePageBase.tsx" "@typescript-eslint/no-deprecated/MenuItemWithTopDescription" 1 "../../src/pages/workspace/rules/MerchantRules/ExpenseDefaultTypePage.tsx" "@typescript-eslint/no-deprecated/MenuItem" 1 -"../../src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx" "@typescript-eslint/no-deprecated/MenuItemWithTopDescription" 1 "../../src/pages/workspace/rules/MerchantTypeRules/MerchantTypeRulePageBase.tsx" "@typescript-eslint/no-deprecated/MenuItemWithTopDescription" 1 "../../src/pages/workspace/rules/RulesNewPage.tsx" "@typescript-eslint/no-deprecated/MenuItem" 1 "../../src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx" "@typescript-eslint/no-deprecated/MenuItemWithTopDescription" 1 diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 59f9a6dd758f..f610110015a1 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -4918,11 +4918,6 @@ const CONST = { MERCHANTS: 'merchants', MERCHANT_TYPES: 'merchantTypes', }, - /** What an expense default rule matches on, chosen before the editor opens and kept in its draft. */ - EXPENSE_DEFAULT_RULE_TYPE: { - MERCHANT: 'merchant', - CATEGORY: 'category', - }, DEFAULT_REPORT_NAME_PATTERN: '{report:type} {report:startdate}', DEFAULT_FIELD_LIST_TYPE: 'formula', DEFAULT_FIELD_LIST_TARGET: 'expense', @@ -9351,6 +9346,7 @@ const CONST = { EXPENSE_DEFAULT: { /** Expense fields a `Set` action can write to */ FIELD: { + AMOUNT: 'amount', BILLABLE: 'billable', CATEGORY: 'category', COMMENT: 'comment', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 7100763e297f..eec422193a23 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -104,6 +104,14 @@ const ENABLE_GLOBAL_REIMBURSEMENTS_ENTRY_SCREENS = [ SCREENS.SETTINGS.WALLET.ROOT, ] as const; +// Every step of the expense default editor is reachable from the workspace Rules flow and from category settings, +// which enters through its own dynamic route, so each step has to accept both as an entry. +const EXPENSE_DEFAULT_RULE_STEP_ENTRY_SCREENS = [ + SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW, + SCREENS.WORKSPACE.DYNAMIC_CATEGORY_TAX_RULE_NEW, + SCREENS.WORKSPACE.DYNAMIC_CATEGORY_TAX_RULE_EDIT, +] as const; + const DYNAMIC_ROUTES = { VERIFY_ACCOUNT: { path: 'verify-account', @@ -1101,10 +1109,6 @@ const DYNAMIC_ROUTES = { path: 'gl-code', entryScreens: [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_SETTINGS], }, - WORKSPACE_CATEGORY_DEFAULT_TAX_RATE: { - path: 'tax-rate', - entryScreens: [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_SETTINGS, SCREENS.SETTINGS_CATEGORIES.DYNAMIC_SETTINGS_CATEGORY_SETTINGS], - }, WORKSPACE_CATEGORY_FLAG_AMOUNTS_OVER: { path: 'flag-amounts', entryScreens: [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_SETTINGS, SCREENS.SETTINGS_CATEGORIES.DYNAMIC_SETTINGS_CATEGORY_SETTINGS], @@ -1169,7 +1173,7 @@ const DYNAMIC_ROUTES = { }, RULES_MERCHANT_MERCHANT_TO_MATCH_FROM_EXPENSE: { path: 'rule-merchant-to-match', - entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + entryScreens: EXPENSE_DEFAULT_RULE_STEP_ENTRY_SCREENS, }, RULES_MERCHANT_MATCH_TYPE_FROM_EXPENSE: { path: 'rule-match-type', @@ -1177,40 +1181,52 @@ const DYNAMIC_ROUTES = { }, RULES_MERCHANT_MERCHANT_FROM_EXPENSE: { path: 'rule-merchant', - entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + entryScreens: EXPENSE_DEFAULT_RULE_STEP_ENTRY_SCREENS, }, RULES_MERCHANT_CATEGORY_FROM_EXPENSE: { path: 'rule-category', - entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + entryScreens: EXPENSE_DEFAULT_RULE_STEP_ENTRY_SCREENS, }, RULES_MERCHANT_TAG_FROM_EXPENSE: { path: 'rule-tag/:orderWeight', - entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + entryScreens: EXPENSE_DEFAULT_RULE_STEP_ENTRY_SCREENS, getRoute: (orderWeight: number) => `rule-tag/${orderWeight}` as const, }, RULES_MERCHANT_TAX_FROM_EXPENSE: { path: 'rule-tax', - entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW, SCREENS.WORKSPACE.DYNAMIC_CATEGORY_TAX_RULE_NEW, SCREENS.WORKSPACE.DYNAMIC_CATEGORY_TAX_RULE_EDIT], + entryScreens: EXPENSE_DEFAULT_RULE_STEP_ENTRY_SCREENS, }, RULES_MERCHANT_VENDOR_FROM_EXPENSE: { path: 'rule-vendor', - entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + entryScreens: EXPENSE_DEFAULT_RULE_STEP_ENTRY_SCREENS, }, RULES_MERCHANT_DESCRIPTION_FROM_EXPENSE: { path: 'rule-description', - entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + entryScreens: EXPENSE_DEFAULT_RULE_STEP_ENTRY_SCREENS, }, RULES_MERCHANT_REIMBURSABLE_FROM_EXPENSE: { path: 'rule-reimbursable', - entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + entryScreens: EXPENSE_DEFAULT_RULE_STEP_ENTRY_SCREENS, }, RULES_MERCHANT_BILLABLE_FROM_EXPENSE: { path: 'rule-billable', - entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + entryScreens: EXPENSE_DEFAULT_RULE_STEP_ENTRY_SCREENS, + }, + RULES_MERCHANT_AMOUNT_FROM_EXPENSE: { + path: 'rule-amount', + entryScreens: EXPENSE_DEFAULT_RULE_STEP_ENTRY_SCREENS, + }, + RULES_CATEGORY_TO_MATCH_FROM_EXPENSE: { + path: 'rule-category-to-match', + entryScreens: EXPENSE_DEFAULT_RULE_STEP_ENTRY_SCREENS, + }, + RULES_TAG_TO_MATCH_FROM_EXPENSE: { + path: 'rule-tag-to-match', + entryScreens: EXPENSE_DEFAULT_RULE_STEP_ENTRY_SCREENS, }, RULES_MERCHANT_PREVIEW_MATCHES_FROM_EXPENSE: { path: 'rule-matches', - entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + entryScreens: EXPENSE_DEFAULT_RULE_STEP_ENTRY_SCREENS, }, NOTIFICATION_PREFERENCES: { // `reportID` is intentionally carried as a distinct path param (`notificationReportID`) rather than @@ -4424,10 +4440,6 @@ const ROUTES = { route: 'workspaces/:policyID/rules/new', getRoute: (policyID: string, categoryName?: string) => `workspaces/${policyID}/rules/new${getOptionalCategoryNameQuery(categoryName)}` as const, }, - RULES_EXPENSE_DEFAULT_TYPE: { - route: 'workspaces/:policyID/rules/expense-defaults/new', - getRoute: (policyID: string) => `workspaces/${policyID}/rules/expense-defaults/new` as const, - }, RULES_MERCHANT_NEW: { route: 'workspaces/:policyID/rules/merchant-rules/new', getRoute: (policyID: string, categoryName?: string) => `workspaces/${policyID}/rules/merchant-rules/new${getOptionalCategoryNameQuery(categoryName)}` as const, @@ -4515,6 +4527,10 @@ const ROUTES = { route: 'workspaces/:policyID/rules/merchant-rules/:ruleID/billable', getRoute: (policyID: string, ruleID?: string) => `workspaces/${policyID}/rules/merchant-rules/${ruleID ?? 'new'}/billable` as const, }, + RULES_MERCHANT_AMOUNT: { + route: 'workspaces/:policyID/rules/merchant-rules/:ruleID/amount', + getRoute: (policyID: string, ruleID?: string) => `workspaces/${policyID}/rules/merchant-rules/${ruleID ?? 'new'}/amount` as const, + }, RULES_MERCHANT_EDIT: { route: 'workspaces/:policyID/rules/merchant-rules/:ruleID', getRoute: (policyID: string, ruleID: string) => `workspaces/${policyID}/rules/merchant-rules/${ruleID}` as const, @@ -4529,6 +4545,10 @@ const ROUTES = { getRoute: (policyID: string, ruleID?: string, categoryName?: string) => `workspaces/${policyID}/rules/merchant-rules/${ruleID ?? 'new'}/category-to-match${getOptionalCategoryNameQuery(categoryName)}` as const, }, + RULES_TAG_TO_MATCH: { + route: 'workspaces/:policyID/rules/merchant-rules/:ruleID/tag-to-match', + getRoute: (policyID: string, ruleID?: string) => `workspaces/${policyID}/rules/merchant-rules/${ruleID ?? 'new'}/tag-to-match` as const, + }, RULES_CATEGORY_TAX_EDIT: { route: 'workspaces/:policyID/rules/category-tax-rules/edit/:categoryName', getRoute: (policyID: string, categoryName: string) => `workspaces/${policyID}/rules/category-tax-rules/edit/${encodeURIComponent(categoryName)}` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index c1da1aad7140..7757f1f34b06 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -943,7 +943,6 @@ const SCREENS = { DYNAMIC_CATEGORY_PAYROLL_CODE: 'Dynamic_Category_Payroll_Code', DYNAMIC_CATEGORY_GL_CODE: 'Dynamic_Category_GL_Code', DYNAMIC_CATEGORY_SETTINGS: 'Dynamic_Category_Settings', - DYNAMIC_CATEGORY_DEFAULT_TAX_RATE: 'Dynamic_Category_Default_Tax_Rate', DYNAMIC_CATEGORY_FLAG_AMOUNTS_OVER: 'Dynamic_Category_Flag_Amounts_Over', DYNAMIC_EXPENSE_LIMIT_TYPE_SELECTOR: 'Dynamic_Expense_Limit_Type_Selector', DYNAMIC_IMPORTED_MEMBERS_ROLE: 'Dynamic_Imported_Members_Role', @@ -1027,7 +1026,6 @@ const SCREENS = { RULES_CUSTOM: 'Rules_Custom', RULES_PROHIBITED_DEFAULT: 'Rules_Prohibited_Default', RULES_NEW: 'Rules_New', - RULES_EXPENSE_DEFAULT_TYPE: 'Rules_Expense_Default_Type', RULES_MERCHANT_NEW: 'Rules_Merchant_New', RULES_MERCHANT_IMPORT: 'Rules_Merchant_Import', RULES_MERCHANT_IMPORTED: 'Rules_Merchant_Imported', @@ -1061,8 +1059,10 @@ const SCREENS = { RULES_MERCHANT_DESCRIPTION: 'Rules_Merchant_Description', RULES_MERCHANT_REIMBURSABLE: 'Rules_Merchant_Reimbursable', RULES_MERCHANT_BILLABLE: 'Rules_Merchant_Billable', + RULES_MERCHANT_AMOUNT: 'Rules_Merchant_Amount', RULES_MERCHANT_PREVIEW_MATCHES: 'Rules_Merchant_Preview_Matches', RULES_CATEGORY_TO_MATCH: 'Rules_Category_To_Match', + RULES_TAG_TO_MATCH: 'Rules_Tag_To_Match', RULES_CATEGORY_TAX_EDIT: 'Rules_Category_Tax_Edit', RULES_MERCHANT_EDIT: 'Rules_Merchant_Edit', DYNAMIC_RULES_MERCHANT_NEW: 'Dynamic_Rules_Merchant_New', @@ -1076,6 +1076,9 @@ const SCREENS = { DYNAMIC_RULES_MERCHANT_DESCRIPTION: 'Dynamic_Rules_Merchant_Description', DYNAMIC_RULES_MERCHANT_REIMBURSABLE: 'Dynamic_Rules_Merchant_Reimbursable', DYNAMIC_RULES_MERCHANT_BILLABLE: 'Dynamic_Rules_Merchant_Billable', + DYNAMIC_RULES_MERCHANT_AMOUNT: 'Dynamic_Rules_Merchant_Amount', + DYNAMIC_RULES_CATEGORY_TO_MATCH: 'Dynamic_Rules_Category_To_Match', + DYNAMIC_RULES_TAG_TO_MATCH: 'Dynamic_Rules_Tag_To_Match', DYNAMIC_RULES_MERCHANT_PREVIEW_MATCHES: 'Dynamic_Rules_Merchant_Preview_Matches', RULES_SPEND_MERCHANTS: 'Rules_Spend_Merchants', RULES_SPEND_MERCHANT_EDIT: 'Rules_Spend_Merchant_Edit', diff --git a/src/components/MoneyRequestConfirmationList/FieldAutoSelector.tsx b/src/components/MoneyRequestConfirmationList/FieldAutoSelector.tsx index 0ea87f49b00f..783a3e701027 100644 --- a/src/components/MoneyRequestConfirmationList/FieldAutoSelector.tsx +++ b/src/components/MoneyRequestConfirmationList/FieldAutoSelector.tsx @@ -66,7 +66,7 @@ function FieldAutoSelector({ if (!transactionID || iouCategory || !shouldShowCategories || enabledCategories.length !== 1 || !isCategoryRequired) { return; } - setMoneyRequestCategory(transactionID, enabledCategories.at(0)?.name ?? '', policy, getCurrencyDecimals, isMovingTransactionFromTrackExpense); + setMoneyRequestCategory(transactionID, enabledCategories.at(0)?.name ?? '', policy, isMovingTransactionFromTrackExpense); // Keep 'transaction' out to ensure that we auto select the option only once // eslint-disable-next-line react-hooks/exhaustive-deps }, [shouldShowCategories, policyCategories, isCategoryRequired, policy?.id, getCurrencyDecimals]); diff --git a/src/hooks/useMerchantRuleSuggestion.ts b/src/hooks/useMerchantRuleSuggestion.ts index 3f4475094325..3f6e9caff15b 100644 --- a/src/hooks/useMerchantRuleSuggestion.ts +++ b/src/hooks/useMerchantRuleSuggestion.ts @@ -65,7 +65,7 @@ function useMerchantRuleSuggestion(reportID: string | undefined, policyID: strin // Built here rather than only on press, so an offer that would apply nothing never appears. Clearing a field is // still an edit worth recording, but the rule it would make sets that field to empty, which changes nothing. const draft = suggestion && transaction ? getMerchantRuleDraftFromTransaction(transaction, fields, policy, editedTagLevels) : undefined; - const hasUpdatesToApply = !!draft && Object.keys(draft).some((key) => key !== INPUT_IDS.MERCHANT_TO_MATCH && key !== INPUT_IDS.RULE_TYPE); + const hasUpdatesToApply = !!draft && Object.keys(draft).some((key) => key !== INPUT_IDS.MERCHANT_TO_MATCH); // A rule matches on merchant, so an expense without one (a receipt still scanning) can't seed one. Nor can an // offer with nothing recorded, which is how an expense reads once its fields are cleared. diff --git a/src/hooks/useParticipantSubmission.ts b/src/hooks/useParticipantSubmission.ts index ae493d6a0d66..239ab8fef9b8 100644 --- a/src/hooks/useParticipantSubmission.ts +++ b/src/hooks/useParticipantSubmission.ts @@ -394,13 +394,7 @@ function useParticipantSubmission({ const policyDistance = Object.values(policy?.customUnits ?? {}).find((customUnit) => customUnit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); const defaultCategory = isDistanceRequest(transaction) && policyDistance?.defaultCategory ? policyDistance?.defaultCategory : ''; const category = isMovingTransactionFromTrackExpense ? (transaction?.category ?? '') : defaultCategory; - setMoneyRequestCategory( - transaction.transactionID, - category, - isMovingTransactionFromTrackExpense ? movingPolicy : undefined, - getCurrencyDecimals, - isMovingTransactionFromTrackExpense, - ); + setMoneyRequestCategory(transaction.transactionID, category, isMovingTransactionFromTrackExpense ? movingPolicy : undefined, isMovingTransactionFromTrackExpense); if (shouldUpdateTransactionReportID) { setTransactionReport(transaction.transactionID, {reportID: transactionReportID}, true); diff --git a/src/hooks/useUpdateTransactionCategory.ts b/src/hooks/useUpdateTransactionCategory.ts index 11105fd909f7..2604244b4ef2 100644 --- a/src/hooks/useUpdateTransactionCategory.ts +++ b/src/hooks/useUpdateTransactionCategory.ts @@ -123,7 +123,7 @@ function useUpdateTransactionCategory({ return; } - setMoneyRequestCategory(transactionID, category, policy, getCurrencyDecimals); + setMoneyRequestCategory(transactionID, category, policy); }; return {updateCategory, isDraftUpdate: !isSplitUpdate && !isExistingExpenseUpdate}; diff --git a/src/languages/de.ts b/src/languages/de.ts index 6cac84e85b3d..d74aceb7d3cb 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -8584,18 +8584,8 @@ Fordern Sie Spesendetails wie Belege und Beschreibungen an, legen Sie Limits und thenApplyFollowingDefaults: 'Wenden Sie dann die folgenden Standardwerte an:', vendorUnavailable: 'Anbieter nicht verfügbar', confirmErrorCategory: 'Bitte wählen Sie eine Kategorie aus', - confirmErrorCategoryTax: 'Bitte wählen Sie einen Steuersatz aus', - confirmErrorCondition: 'Bitte geben Sie einen Händler ein', - confirmErrorConditionAndDefault: 'Geben Sie einen Händler ein und wenden Sie mindestens eine Standardeinstellung an', - turnOnTaxesFirstTitle: 'Schalten Sie zuerst Steuern ein', - turnOnTaxesFirstPrompt: 'Kategorienregeln legen einen Standardsteuersatz fest. Aktivieren Sie Steuern in Ihren Workspace-Einstellungen, um sie zu verwenden.', - categoryRulesApplyGoingForwardTitle: 'Kategorienregeln gelten ab jetzt', - categoryRulesApplyGoingForwardPrompt: 'Ein Standardsatz für Steuern gilt für neue Ausgaben in dieser Kategorie. Bereits vorhandene Ausgaben werden nicht geändert.', - confirmErrorCategoryTaxMoveIsWorkspaceDefault: - 'Der ausgewählte Steuersatz ist jetzt die Voreinstellung für Ihren Workspace, daher ist diese Regel nicht mehr gültig. Wählen Sie einen anderen Steuersatz.', - addTaxRateFirstTitle: 'Fügen Sie zuerst einen Steuersatz hinzu', - addTaxRateFirstPrompt: - 'Kategorienregeln legen einen standardmäßigen Steuersatz fest. Fügen Sie einen anderen Steuersatz als den Standard Ihres Arbeitsbereichs hinzu, um sie zu verwenden.', + confirmErrorCondition: 'Bitte fügen Sie mindestens eine Bedingung hinzu', + confirmErrorConditionAndDefault: 'Fügen Sie mindestens eine Bedingung hinzu und wenden Sie mindestens eine Voreinstellung an', createRuleFromExpenseAction: 'Regel erstellen', createRuleFromExpensePrompt: 'um Ihre Änderungen auf alle Ausgaben anzuwenden, die Ihren Kriterien entsprechen.', }, @@ -8926,9 +8916,11 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc findRule: 'Regel suchen', rename: 'Umbenennen', update: 'Aktualisieren', - merchantIs: (merchant: string) => `Händler ist „${merchant}“`, merchantTypeIs: (merchantType: string) => `Händlertyp ist „${merchantType}"`, categoryIs: (category: string) => `Kategorie ist „${category}“`, + tagIs: (tag: string) => `Tag ist „${tag}“`, + merchantContains: (merchant: string) => `Händler enthält „${merchant}“`, + merchantExactlyMatches: (merchant: string) => `Händler stimmt genau mit „${merchant}“ überein`, }, merchantTypeRule: {merchantType: 'Händlerart', saveRule: 'Regel speichern', confirmErrorCategory: 'Bitte wählen Sie eine Kategorie aus.'}, requireFieldsTable: { @@ -8998,14 +8990,6 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc cta: 'KI-Regel hinzufügen', }, categoriesDisabledEmptyState: {title: 'Kategorien sind nicht aktiviert', subtitle: 'Aktivieren Sie Kategorien, um Ihre Ausgaben besser zu kontrollieren.'}, - expenseDefaultType: { - title: 'Standardspesen übernehmen', - subtitle: 'Worauf soll diese Regel angewendet werden?', - merchant: 'Händler', - merchantDescription: 'Felder bei Ausgaben eines bestimmten Händlers aktualisieren', - category: 'Kategorie', - categoryDescription: 'Legen Sie einen Standardsteuersatz für eine bestimmte Kategorie fest', - }, taxesDisabledEmptyState: { title: 'Steuern sind nicht aktiviert', subtitle: 'Aktivieren Sie Steuern, um anfallende Steuern zu dokumentieren und zurückzufordern.', diff --git a/src/languages/el.ts b/src/languages/el.ts index 52df4e2a35c7..175125489a4f 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -8956,20 +8956,8 @@ ${reportName}`, importColumnUpdatedVendor: 'Ενημερώθηκε ο προμηθευτής', vendorUnavailable: 'Ο προμηθευτής δεν είναι διαθέσιμος', confirmErrorCategory: 'Παρακαλώ επιλέξτε κατηγορία', - confirmErrorCategoryTax: 'Επιλέξτε έναν φορολογικό συντελεστή', - confirmErrorCondition: 'Παρακαλούμε εισαγάγετε έναν έμπορο', - confirmErrorConditionAndDefault: 'Προσθέστε έναν έμπορο και ορίστε τουλάχιστον μία προεπιλογή', - turnOnTaxesFirstTitle: 'Ενεργοποιήστε πρώτα τους φόρους', - turnOnTaxesFirstPrompt: - 'Οι κανόνες κατηγορίας ορίζουν έναν προεπιλεγμένο φορολογικό συντελεστή. Ενεργοποιήστε τους φόρους στις ρυθμίσεις του χώρου εργασίας σας για να τους χρησιμοποιήσετε.', - categoryRulesApplyGoingForwardTitle: 'Οι κανόνες κατηγορίας ισχύουν από εδώ και στο εξής', - categoryRulesApplyGoingForwardPrompt: - 'Ένας προεπιλεγμένος φορολογικός συντελεστής εφαρμόζεται σε νέες δαπάνες σε αυτή την κατηγορία. Οι δαπάνες που υπάρχουν ήδη δεν θα αλλάξουν.', - confirmErrorCategoryTaxMoveIsWorkspaceDefault: - 'Ο επιλεγμένος φορολογικός συντελεστής είναι πλέον η προεπιλογή για τον χώρο εργασίας σας, επομένως αυτός ο κανόνας δεν είναι πλέον έγκυρος. Επιλέξτε έναν διαφορετικό φορολογικό συντελεστή.', - addTaxRateFirstTitle: 'Προσθέστε πρώτα έναν φορολογικό συντελεστή', - addTaxRateFirstPrompt: - 'Οι κανόνες κατηγορίας ορίζουν έναν προεπιλεγμένο φορολογικό συντελεστή. Προσθέστε έναν φορολογικό συντελεστή διαφορετικό από την προεπιλογή του χώρου εργασίας σας για να τους χρησιμοποιήσετε.', + confirmErrorCondition: 'Παρακαλούμε προσθέστε τουλάχιστον μία συνθήκη', + confirmErrorConditionAndDefault: 'Προσθέστε τουλάχιστον μία συνθήκη και ορίστε τουλάχιστον μία προεπιλογή', createRuleFromExpenseAction: 'Δημιουργία κανόνα', createRuleFromExpensePrompt: 'για να εφαρμόσετε τις αλλαγές σας σε όλες τις δαπάνες που ταιριάζουν με τα κριτήριά σας.', }, @@ -9016,9 +9004,11 @@ ${reportName}`, findRule: 'Εύρεση κανόνα', rename: 'Μετονομασία', update: 'Ενημέρωση', - merchantIs: (merchant: string) => `Ο έμπορος είναι «${merchant}»`, merchantTypeIs: (merchantType: string) => `Ο τύπος εμπόρου είναι «${merchantType}»`, categoryIs: (category: string) => `Η κατηγορία είναι «${category}»`, + tagIs: (tag: string) => `Η ετικέτα είναι «${tag}»`, + merchantContains: (merchant: string) => `Ο έμπορος περιέχει «${merchant}»`, + merchantExactlyMatches: (merchant: string) => `Το κατάστημα ταιριάζει ακριβώς με το «${merchant}»`, }, merchantTypeRule: { merchantType: 'Τύπος εμπόρου', @@ -9317,14 +9307,6 @@ ${reportName}`, subtitle: 'Δημιουργήστε έναν κανόνα για την αυτοματοποίηση των πολιτικών του χώρου εργασίας σας.', cta: 'Προσθήκη κανόνα AI', }, - expenseDefaultType: { - title: 'Εφαρμογή προεπιλογών εξόδων', - subtitle: 'Σε τι πρέπει να αντιστοιχεί αυτός ο κανόνας;', - merchant: 'Έμπορος', - merchantDescription: 'Ενημέρωση πεδίων σε δαπάνες από συγκεκριμένο έμπορο', - category: 'Κατηγορία', - categoryDescription: 'Ορίστε προεπιλεγμένο φορολογικό συντελεστή για μια συγκεκριμένη κατηγορία', - }, taxesDisabledEmptyState: { title: 'Οι φόροι δεν είναι ενεργοποιημένοι', subtitle: 'Ενεργοποιήστε τους φόρους για να τεκμηριώσετε και να διεκδικήσετε τους επιλέξιμους φόρους.', diff --git a/src/languages/en.ts b/src/languages/en.ts index f8b224aaa791..3e89daa91e5d 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -9202,24 +9202,8 @@ const translations = { ifAnyExpenseMatches: 'If any expense matches:', thenApplyFollowingDefaults: 'Then apply the following defaults:', confirmErrorCategory: 'Please select a category', - confirmErrorCategoryTax: 'Please select a tax rate', - confirmErrorCategoryTaxMoveIsWorkspaceDefault: 'The selected tax rate is now your workspace default, so this rule is no longer valid. Choose a different tax rate.', - confirmErrorCondition: 'Please enter a merchant', - confirmErrorConditionAndDefault: 'Enter a merchant, and apply at least one default', - turnOnTaxesFirstTitle: 'Turn on taxes first', - turnOnTaxesFirstPrompt: 'Category rules set a default tax rate. Turn on taxes in your workspace settings to use them.', - addTaxRateFirstTitle: 'Add a tax rate first', - addTaxRateFirstPrompt: 'Category rules set a default tax rate. Add a tax rate other than your workspace default to use them.', - categoryRulesApplyGoingForwardTitle: 'Category rules apply going forward', - categoryRulesApplyGoingForwardPrompt: "A default tax rate applies to new expenses in this category. Expenses that already exist won't change.", - }, - expenseDefaultType: { - title: 'Apply expense defaults', - subtitle: 'What should this rule match on?', - merchant: 'Merchant', - merchantDescription: 'Update fields on expenses from a specific merchant', - category: 'Category', - categoryDescription: 'Set a default tax rate for a specific category', + confirmErrorCondition: 'Please add at least one condition', + confirmErrorConditionAndDefault: 'Add at least one condition, and apply at least one default', }, newRule: { title: 'New rule', @@ -9265,7 +9249,9 @@ const translations = { rename: 'Rename', update: 'Update', categoryIs: (category: string) => `Category is "${category}"`, - merchantIs: (merchant: string) => `Merchant is "${merchant}"`, + merchantContains: (merchant: string) => `Merchant contains "${merchant}"`, + merchantExactlyMatches: (merchant: string) => `Merchant exactly matches "${merchant}"`, + tagIs: (tag: string) => `Tag is "${tag}"`, merchantTypeIs: (merchantType: string) => `Merchant type is "${merchantType}"`, }, merchantTypeRule: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 73cd968041b1..1a05f3dbc0c5 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -8618,19 +8618,8 @@ ${reportName}`, ifAnyExpenseMatches: 'Si algún gasto coincide con:', thenApplyFollowingDefaults: 'Luego aplica las siguientes opciones predeterminadas:', confirmErrorCategory: 'Selecciona una categoría', - confirmErrorCategoryTax: 'Selecciona una tasa de impuesto', - confirmErrorCondition: 'Por favor, introduce un comercio', - confirmErrorConditionAndDefault: 'Introduce un comercio y aplica al menos un valor predeterminado', - turnOnTaxesFirstTitle: 'Activa primero los impuestos', - turnOnTaxesFirstPrompt: - 'Las reglas de categoría establecen una tasa de impuesto predeterminada. Activa los impuestos en la configuración de tu espacio de trabajo para usarlos.', - categoryRulesApplyGoingForwardTitle: 'Las reglas de categoría se aplican de ahora en adelante', - categoryRulesApplyGoingForwardPrompt: 'Se aplica una tasa de impuesto predeterminada a los nuevos gastos de esta categoría. Los gastos que ya existen no cambiarán.', - confirmErrorCategoryTaxMoveIsWorkspaceDefault: - 'El tipo impositivo seleccionado es ahora el predeterminado de tu espacio de trabajo, por lo que esta regla ya no es válida. Elige un tipo impositivo diferente.', - addTaxRateFirstTitle: 'Añade primero una tasa de impuesto', - addTaxRateFirstPrompt: - 'Las reglas de categoría establecen una tasa de impuestos predeterminada. Añade una tasa de impuestos distinta de la predeterminada de tu espacio de trabajo para poder usarlas.', + confirmErrorCondition: 'Por favor, añade al menos una condición', + confirmErrorConditionAndDefault: 'Añade al menos una condición y aplica al menos un valor predeterminado', createRuleFromExpenseAction: 'Crear una norma', createRuleFromExpensePrompt: 'para aplicar tus cambios a todos los gastos que coincidan con tus criterios.', }, @@ -8934,9 +8923,11 @@ ${reportName}`, findRule: 'Buscar regla', rename: 'Cambiar nombre', update: 'Actualizar', - merchantIs: (merchant: string) => `El comercio es «${merchant}»`, merchantTypeIs: (merchantType: string) => `El tipo de comercio es "${merchantType}"`, categoryIs: (category: string) => `La categoría es «${category}»`, + tagIs: (tag: string) => `La etiqueta es "${tag}"`, + merchantContains: (merchant: string) => `El comercio contiene «${merchant}»`, + merchantExactlyMatches: (merchant: string) => `El comercio coincide exactamente con "${merchant}"`, }, merchantTypeRule: {merchantType: 'Tipo de comercio', saveRule: 'Guardar regla', confirmErrorCategory: 'Por favor, selecciona una categoría.'}, requireFieldsTable: { @@ -9006,14 +8997,6 @@ ${reportName}`, cta: 'Añadir regla de IA', }, categoriesDisabledEmptyState: {title: 'Las categorías no están habilitadas', subtitle: 'Habilita las categorías para tener más control sobre tus gastos.'}, - expenseDefaultType: { - title: 'Aplicar valores predeterminados de gastos', - subtitle: '¿Con qué debería coincidir esta regla?', - merchant: 'Comercio', - merchantDescription: 'Actualizar campos en gastos de un comerciante específico', - category: 'Categoría', - categoryDescription: 'Establece una tasa de impuesto predeterminada para una categoría específica', - }, taxesDisabledEmptyState: { title: 'Los impuestos no están habilitados', subtitle: 'Habilita los impuestos para documentar y recuperar los impuestos que sean deducibles.', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 6b4e962aacc0..bf55375b7f8c 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -8606,19 +8606,8 @@ Rendez obligatoires des informations de dépense comme les reçus et les descrip thenApplyFollowingDefaults: 'Appliquez ensuite les valeurs par défaut suivantes :', vendorUnavailable: 'Fournisseur indisponible', confirmErrorCategory: 'Veuillez sélectionner une catégorie', - confirmErrorCategoryTax: 'Veuillez sélectionner un taux de taxe', - confirmErrorCondition: 'Veuillez saisir un commerçant', - confirmErrorConditionAndDefault: 'Saisissez un commerçant et appliquez au moins un paramètre par défaut', - turnOnTaxesFirstTitle: 'Activer d’abord les taxes', - turnOnTaxesFirstPrompt: 'Les règles de catégorie définissent un taux de taxe par défaut. Activez les taxes dans les paramètres de votre espace de travail pour les utiliser.', - categoryRulesApplyGoingForwardTitle: 'Les règles de catégorie s’appliquent à partir de maintenant', - categoryRulesApplyGoingForwardPrompt: - 'Un taux de taxe par défaut s’applique aux nouvelles dépenses de cette catégorie. Les dépenses déjà existantes ne seront pas modifiées.', - confirmErrorCategoryTaxMoveIsWorkspaceDefault: - 'Le taux de taxe sélectionné est désormais le taux par défaut de votre espace de travail, cette règle n’est donc plus valide. Choisissez un autre taux de taxe.', - addTaxRateFirstTitle: 'Ajoutez d’abord un taux de taxe', - addTaxRateFirstPrompt: - 'Les règles de catégorie définissent un taux de taxe par défaut. Ajoutez un taux de taxe autre que le taux par défaut de votre espace de travail pour les utiliser.', + confirmErrorCondition: 'Veuillez ajouter au moins une condition', + confirmErrorConditionAndDefault: 'Ajoutez au moins une condition et appliquez au moins un par défaut', createRuleFromExpenseAction: 'Créer une règle', createRuleFromExpensePrompt: 'pour appliquer vos modifications à toutes les dépenses qui correspondent à vos critères.', }, @@ -8949,9 +8938,11 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e findRule: 'Trouver une règle', rename: 'Renommer', update: 'Mettre à jour', - merchantIs: (merchant: string) => `Le commerçant est « ${merchant} »`, merchantTypeIs: (merchantType: string) => `Le type de commerçant est « ${merchantType} »`, categoryIs: (category: string) => `La catégorie est « ${category} »`, + tagIs: (tag: string) => `Le tag est « ${tag} »`, + merchantContains: (merchant: string) => `Le commerçant contient « ${merchant} »`, + merchantExactlyMatches: (merchant: string) => `Le commerçant correspond exactement à « ${merchant} »`, }, merchantTypeRule: {merchantType: 'Type de marchand', saveRule: 'Enregistrer la règle', confirmErrorCategory: 'Veuillez sélectionner une catégorie.'}, requireFieldsTable: { @@ -9021,14 +9012,6 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e cta: 'Ajouter une règle IA', }, categoriesDisabledEmptyState: {title: 'Les catégories ne sont pas activées', subtitle: 'Activez les catégories pour mieux contrôler vos dépenses.'}, - expenseDefaultType: { - title: 'Appliquer les valeurs de dépense par défaut', - subtitle: 'Sur quoi cette règle doit-elle faire correspondre ?', - merchant: 'Commerçant', - merchantDescription: 'Mettre à jour les champs des dépenses d’un commerçant spécifique', - category: 'Catégorie', - categoryDescription: 'Définir un taux de taxe par défaut pour une catégorie spécifique', - }, taxesDisabledEmptyState: {title: 'Les taxes ne sont pas activées', subtitle: 'Activez les taxes pour documenter et récupérer les taxes admissibles.', cta: 'Activer les taxes'}, }, planTypePage: { diff --git a/src/languages/it.ts b/src/languages/it.ts index 1de03c49f542..f99be1e937cf 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -8538,18 +8538,8 @@ Richiedi dettagli sulle spese come ricevute e descrizioni, imposta limiti e valo thenApplyFollowingDefaults: 'Quindi applica le seguenti impostazioni predefinite:', vendorUnavailable: 'Fornitore non disponibile', confirmErrorCategory: 'Seleziona una categoria', - confirmErrorCategoryTax: "Seleziona un'aliquota fiscale", - confirmErrorCondition: 'Inserisci un esercente', - confirmErrorConditionAndDefault: 'Inserisci un esercente e applica almeno un valore predefinito', - turnOnTaxesFirstTitle: 'Attiva prima le tasse', - turnOnTaxesFirstPrompt: 'Le regole di categoria impostano un’aliquota fiscale predefinita. Attiva le imposte nelle impostazioni dello spazio di lavoro per usarle.', - categoryRulesApplyGoingForwardTitle: 'Le regole di categoria si applicano da ora in poi', - categoryRulesApplyGoingForwardPrompt: 'Un’aliquota fiscale predefinita viene applicata alle nuove spese in questa categoria. Le spese già esistenti non verranno modificate.', - confirmErrorCategoryTaxMoveIsWorkspaceDefault: - 'L’aliquota fiscale selezionata è ora quella predefinita del tuo workspace, quindi questa regola non è più valida. Scegli un’altra aliquota fiscale.', - addTaxRateFirstTitle: "Aggiungi prima un'aliquota fiscale", - addTaxRateFirstPrompt: - 'Le regole di categoria impostano un’aliquota fiscale predefinita. Aggiungi un’aliquota diversa da quella predefinita dello spazio di lavoro per poterle usare.', + confirmErrorCondition: 'Aggiungi almeno una condizione', + confirmErrorConditionAndDefault: 'Aggiungi almeno una condizione e applica almeno un valore predefinito', createRuleFromExpenseAction: 'Crea una regola', createRuleFromExpensePrompt: 'per applicare le modifiche a tutte le spese che corrispondono ai tuoi criteri.', }, @@ -8880,9 +8870,11 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, findRule: 'Trova regola', rename: 'Rinomina', update: 'Aggiorna', - merchantIs: (merchant: string) => `L'esercente è "${merchant}"`, merchantTypeIs: (merchantType: string) => `Il tipo di esercente è "${merchantType}"`, categoryIs: (category: string) => `La categoria è "${category}"`, + tagIs: (tag: string) => `Il tag è "${tag}"`, + merchantContains: (merchant: string) => `L'esercente contiene "${merchant}"`, + merchantExactlyMatches: (merchant: string) => `L’esercente corrisponde esattamente a "${merchant}"`, }, merchantTypeRule: {merchantType: 'Tipo di esercente', saveRule: 'Salva regola', confirmErrorCategory: 'Seleziona una categoria.'}, requireFieldsTable: { @@ -8948,14 +8940,6 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, }, agentRulesEmptyState: {title: 'Nessuna regola agente aggiunta', subtitle: 'Crea una regola per automatizzare le policy del tuo workspace.', cta: 'Aggiungi regola IA'}, categoriesDisabledEmptyState: {title: 'Le categorie non sono abilitate', subtitle: 'Attiva le categorie per avere un maggiore controllo sulle tue spese.'}, - expenseDefaultType: { - title: 'Applica impostazioni predefinite spesa', - subtitle: 'Su cosa dovrebbe corrispondere questa regola?', - merchant: 'Commerciante', - merchantDescription: 'Aggiorna i campi delle spese di un determinato esercente', - category: 'Categoria', - categoryDescription: "Imposta un'aliquota fiscale predefinita per una categoria specifica", - }, taxesDisabledEmptyState: {title: 'Le imposte non sono abilitate', subtitle: 'Abilita le imposte per documentare e recuperare quelle ammissibili.', cta: 'Abilita imposte'}, }, planTypePage: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 84c2cc557cdf..4b42cefebf1d 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -8428,16 +8428,8 @@ ${reportName}`, thenApplyFollowingDefaults: '次に、以下のデフォルトを適用します。', vendorUnavailable: 'ベンダーを利用できません', confirmErrorCategory: 'カテゴリを選択してください', - confirmErrorCategoryTax: '税率を選択してください', - confirmErrorCondition: '店舗名を入力してください', - confirmErrorConditionAndDefault: '取引先を入力し、少なくとも 1 つのデフォルトを適用してください', - turnOnTaxesFirstTitle: '先に税金を有効にしてください', - turnOnTaxesFirstPrompt: 'カテゴリルールでは、デフォルトの税率を設定できます。利用するには、ワークスペース設定で税金を有効にしてください。', - categoryRulesApplyGoingForwardTitle: 'カテゴリルールは今後に適用されます', - categoryRulesApplyGoingForwardPrompt: 'このカテゴリーの新しい経費には、デフォルトの税率が適用されます。既存の経費は変更されません。', - confirmErrorCategoryTaxMoveIsWorkspaceDefault: '選択した税率がワークスペースのデフォルトになっているため、このルールは無効になりました。別の税率を選択してください。', - addTaxRateFirstTitle: '先に税率を追加してください', - addTaxRateFirstPrompt: 'カテゴリルールではデフォルトの税率が設定されます。ワークスペースのデフォルト以外の税率を追加してから、カテゴリルールを使用してください。', + confirmErrorCondition: '少なくとも 1 つ条件を追加してください', + confirmErrorConditionAndDefault: '少なくとも 1 つ条件を追加し、少なくとも 1 つデフォルトを適用してください', createRuleFromExpenseAction: 'ルールを作成', createRuleFromExpensePrompt: '条件に一致するすべての経費に変更を適用します。', }, @@ -8766,9 +8758,11 @@ ${reportName}`, findRule: 'ルールを検索', rename: '名前を変更', update: '更新', - merchantIs: (merchant: string) => `加盟店名は「${merchant}」です`, merchantTypeIs: (merchantType: string) => `加盟店タイプ: 「${merchantType}」`, categoryIs: (category: string) => `カテゴリは「${category}」です`, + tagIs: (tag: string) => `タグは「${tag}」です`, + merchantContains: (merchant: string) => `加盟店名に「${merchant}」を含む`, + merchantExactlyMatches: (merchant: string) => `支出先が「${merchant}」と完全一致します`, }, merchantTypeRule: {merchantType: '加盟店種別', saveRule: 'ルールを保存', confirmErrorCategory: 'カテゴリを選択してください。'}, requireFieldsTable: { @@ -8834,14 +8828,6 @@ ${reportName}`, }, agentRulesEmptyState: {title: 'エージェントルールが追加されていません', subtitle: 'ワークスペースのポリシーを自動化するルールを作成します。', cta: 'AIルールを追加'}, categoriesDisabledEmptyState: {title: 'カテゴリが有効になっていません', subtitle: 'カテゴリを有効にして、支出をより細かく管理しましょう。'}, - expenseDefaultType: { - title: '経費のデフォルトを適用', - subtitle: 'このルールは何に一致させますか?', - merchant: '加盟店', - merchantDescription: '特定の加盟店の経費フィールドを更新', - category: 'カテゴリ', - categoryDescription: '特定のカテゴリにデフォルトの税率を設定する', - }, taxesDisabledEmptyState: {title: '税金は有効になっていません', subtitle: '税金を有効にして、対象となる税金を記録し還付請求できるようにします。', cta: '税金を有効にする'}, }, planTypePage: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 906008ef2daf..9df3dfde2e05 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -8514,17 +8514,8 @@ Vereis onkostendetails zoals bonnen en beschrijvingen, stel limieten en standaar thenApplyFollowingDefaults: 'Pas vervolgens de volgende standaardinstellingen toe:', vendorUnavailable: 'Leverancier niet beschikbaar', confirmErrorCategory: 'Selecteer een categorie', - confirmErrorCategoryTax: 'Selecteer een belastingtarief', - confirmErrorCondition: 'Voer een leverancier in', - confirmErrorConditionAndDefault: 'Voer een leverancier in en stel minstens één standaard in', - turnOnTaxesFirstTitle: 'Zet eerst belastingen aan', - turnOnTaxesFirstPrompt: 'Categorischregels stellen een standaardbelastingtarief in. Schakel belastingen in bij de instellingen van je workspace om ze te gebruiken.', - categoryRulesApplyGoingForwardTitle: 'Categoriegregels gelden vanaf nu', - categoryRulesApplyGoingForwardPrompt: 'Een standaardbelastingtarief is van toepassing op nieuwe uitgaven in deze categorie. Bestaande uitgaven veranderen niet.', - confirmErrorCategoryTaxMoveIsWorkspaceDefault: - 'Het geselecteerde belastingtarief is nu de standaard voor je werkruimte, waardoor deze regel niet meer geldig is. Kies een ander belastingtarief.', - addTaxRateFirstTitle: 'Voeg eerst een belastingtarief toe', - addTaxRateFirstPrompt: 'Categorielimieten stellen een standaardbelastingtarief in. Voeg een ander belastingtarief toe dan de standaard van je werkruimte om ze te gebruiken.', + confirmErrorCondition: 'Voeg minstens één voorwaarde toe', + confirmErrorConditionAndDefault: 'Voeg minstens één voorwaarde toe en pas minstens één standaardinstelling toe', createRuleFromExpenseAction: 'Maak een regel', createRuleFromExpensePrompt: 'om je wijzigingen toe te passen op alle onkosten die aan je criteria voldoen.', }, @@ -8854,9 +8845,11 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, findRule: 'Regel zoeken', rename: 'Naam wijzigen', update: 'Bijwerken', - merchantIs: (merchant: string) => `Handelaar is "${merchant}"`, merchantTypeIs: (merchantType: string) => `Type leverancier is "${merchantType}"`, categoryIs: (category: string) => `Categorie is "${category}"`, + tagIs: (tag: string) => `Tag is "${tag}"`, + merchantContains: (merchant: string) => `Handelaar bevat "${merchant}"`, + merchantExactlyMatches: (merchant: string) => `Handelaar komt exact overeen met "${merchant}"`, }, merchantTypeRule: {merchantType: 'Type handelaar', saveRule: 'Regel opslaan', confirmErrorCategory: 'Selecteer een categorie.'}, requireFieldsTable: { @@ -8922,14 +8915,6 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, }, agentRulesEmptyState: {title: 'Geen agentregels toegevoegd', subtitle: 'Maak een regel om je werkruimtebeleid te automatiseren.', cta: 'AI-regel toevoegen'}, categoriesDisabledEmptyState: {title: 'Categorieën zijn niet ingeschakeld', subtitle: 'Schakel categorieën in om meer controle te krijgen over je uitgaven.'}, - expenseDefaultType: { - title: 'Standaardwaarden voor uitgaven toepassen', - subtitle: 'Waar moet deze regel op overeenkomen?', - merchant: 'Winkelier', - merchantDescription: 'Veldwaarden bij uitgaven van een specifieke verkoper bijwerken', - category: 'Categorie', - categoryDescription: 'Stel een standaardbelastingtarief in voor een specifieke categorie', - }, taxesDisabledEmptyState: { title: 'Belastingen zijn niet ingeschakeld', subtitle: 'Activeer belastingen om in aanmerking komende belastingen vast te leggen en terug te vorderen.', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 40a1b1a4aa14..3290f2909000 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -8540,17 +8540,8 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i thenApplyFollowingDefaults: 'Następnie zastosuj następujące domyślne ustawienia:', vendorUnavailable: 'Dostawca niedostępny', confirmErrorCategory: 'Wybierz kategorię', - confirmErrorCategoryTax: 'Wybierz stawkę podatku', - confirmErrorCondition: 'Wpisz sprzedawcę', - confirmErrorConditionAndDefault: 'Wpisz sprzedawcę i zastosuj co najmniej jedną domyślną opcję', - turnOnTaxesFirstTitle: 'Najpierw włącz podatki', - turnOnTaxesFirstPrompt: 'Reguły kategorii ustawiają domyślną stawkę podatku. Włącz podatki w ustawieniach swojego workspace, aby z nich korzystać.', - categoryRulesApplyGoingForwardTitle: 'Reguły kategorii będą stosowane od teraz', - categoryRulesApplyGoingForwardPrompt: 'Domyślna stawka podatku będzie stosowana do nowych wydatków w tej kategorii. Istniejące wydatki nie zostaną zmienione.', - confirmErrorCategoryTaxMoveIsWorkspaceDefault: - 'Wybrana stawka podatku jest teraz domyślną stawką w twoim zespole, więc ta reguła nie jest już prawidłowa. Wybierz inną stawkę podatku.', - addTaxRateFirstTitle: 'Najpierw dodaj stawkę podatku', - addTaxRateFirstPrompt: 'Reguły kategorii ustawiają domyślną stawkę podatku. Dodaj stawkę podatku inną niż domyślna w twoim obszarze roboczym, żeby z nich korzystać.', + confirmErrorCondition: 'Dodaj co najmniej jeden warunek', + confirmErrorConditionAndDefault: 'Dodaj co najmniej jeden warunek i zastosuj co najmniej jedną domyślną opcję', createRuleFromExpenseAction: 'Utwórz regułę', createRuleFromExpensePrompt: 'aby zastosować zmiany do wszystkich wydatków spełniających twoje kryteria.', }, @@ -8881,9 +8872,11 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, findRule: 'Znajdź regułę', rename: 'Zmień nazwę', update: 'Zaktualizuj', - merchantIs: (merchant: string) => `Sprzedawca to „${merchant}”`, merchantTypeIs: (merchantType: string) => `Typ sprzedawcy to „${merchantType}"`, categoryIs: (category: string) => `Kategoria to „${category}”`, + tagIs: (tag: string) => `Tag to „${tag}”`, + merchantContains: (merchant: string) => `Sprzedawca zawiera „${merchant}”`, + merchantExactlyMatches: (merchant: string) => `Sprzedawca dokładnie odpowiada „${merchant}”`, }, merchantTypeRule: {merchantType: 'Typ sprzedawcy', saveRule: 'Zapisz regułę', confirmErrorCategory: 'Wybierz kategorię.'}, requireFieldsTable: { @@ -8949,14 +8942,6 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, }, agentRulesEmptyState: {title: 'Nie dodano reguł agenta', subtitle: 'Utwórz regułę, żeby zautomatyzować zasady swojego workspace’u.', cta: 'Dodaj regułę AI'}, categoriesDisabledEmptyState: {title: 'Kategorie nie są włączone', subtitle: 'Włącz kategorie, żeby mieć większą kontrolę nad wydatkami.'}, - expenseDefaultType: { - title: 'Zastosuj domyślne ustawienia wydatków', - subtitle: 'Do czego ta reguła powinna pasować?', - merchant: 'Sprzedawca', - merchantDescription: 'Zaktualizuj pola w wydatkach od konkretnego sprzedawcy', - category: 'Kategoria', - categoryDescription: 'Ustaw domyślną stawkę podatku dla konkretnej kategorii', - }, taxesDisabledEmptyState: {title: 'Podatki nie są włączone', subtitle: 'Włącz podatki, aby udokumentować i odzyskać kwalifikujące się podatki.', cta: 'Włącz podatki'}, }, planTypePage: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 97386f2c1da7..287b1f1cef9c 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -8517,17 +8517,8 @@ Exija dados de despesas como recibos e descrições, defina limites e padrões e thenApplyFollowingDefaults: 'Em seguida, aplica os seguintes padrões:', vendorUnavailable: 'Fornecedor indisponível', confirmErrorCategory: 'Selecione uma categoria', - confirmErrorCategoryTax: 'Selecione uma alíquota de imposto', - confirmErrorCondition: 'Insira um estabelecimento', - confirmErrorConditionAndDefault: 'Insira um comerciante e aplique pelo menos um padrão', - turnOnTaxesFirstTitle: 'Ative os impostos primeiro', - turnOnTaxesFirstPrompt: 'As regras de categoria definem uma alíquota de imposto padrão. Ative os impostos nas configurações do seu workspace para usá-los.', - categoryRulesApplyGoingForwardTitle: 'As regras de categoria se aplicam daqui em diante', - categoryRulesApplyGoingForwardPrompt: 'Uma taxa de imposto padrão se aplica às novas despesas desta categoria. As despesas que já existem não serão alteradas.', - confirmErrorCategoryTaxMoveIsWorkspaceDefault: - 'A alíquota de imposto selecionada agora é o padrão do seu workspace, então esta regra não é mais válida. Escolha uma alíquota de imposto diferente.', - addTaxRateFirstTitle: 'Adicione uma alíquota de imposto primeiro', - addTaxRateFirstPrompt: 'As regras de categoria definem uma taxa de imposto padrão. Adicione uma taxa diferente da padrão do seu espaço de trabalho para usá-las.', + confirmErrorCondition: 'Adicione pelo menos uma condição', + confirmErrorConditionAndDefault: 'Adicione pelo menos uma condição e aplique pelo menos um padrão', createRuleFromExpenseAction: 'Criar regra', createRuleFromExpensePrompt: 'para aplicar suas alterações a todas as despesas que correspondem aos seus critérios.', }, @@ -8858,9 +8849,11 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, findRule: 'Encontrar regra', rename: 'Renomear', update: 'Atualizar', - merchantIs: (merchant: string) => `O comerciante é "${merchant}"`, merchantTypeIs: (merchantType: string) => `O tipo de estabelecimento é "${merchantType}"`, categoryIs: (category: string) => `A categoria é "${category}"`, + tagIs: (tag: string) => `A tag é "${tag}"`, + merchantContains: (merchant: string) => `Estabelecimento contém "${merchant}"`, + merchantExactlyMatches: (merchant: string) => `Estabelecimento corresponde exatamente a "${merchant}"`, }, merchantTypeRule: {merchantType: 'Tipo de comerciante', saveRule: 'Salvar regra', confirmErrorCategory: 'Selecione uma categoria.'}, requireFieldsTable: { @@ -8926,14 +8919,6 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, }, agentRulesEmptyState: {title: 'Nenhuma regra de agente adicionada', subtitle: 'Crie uma regra para automatizar as políticas do seu workspace.', cta: 'Adicionar regra de IA'}, categoriesDisabledEmptyState: {title: 'Categorias não estão ativadas', subtitle: 'Ative categorias para ter mais controle sobre seus gastos.'}, - expenseDefaultType: { - title: 'Aplicar padrões de despesa', - subtitle: 'Com o que esta regra deve corresponder?', - merchant: 'Comerciante', - merchantDescription: 'Atualizar campos em despesas de um comerciante específico', - category: 'Categoria', - categoryDescription: 'Definir uma taxa de imposto padrão para uma categoria específica', - }, taxesDisabledEmptyState: {title: 'Impostos não estão ativados', subtitle: 'Ative impostos para documentar e recuperar impostos elegíveis.', cta: 'Ativar impostos'}, }, planTypePage: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 1c8d93405ab2..5008440fedf8 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -8223,16 +8223,8 @@ ${reportName}`, thenApplyFollowingDefaults: '然后应用以下默认设置:', vendorUnavailable: '供应商不可用', confirmErrorCategory: '请选择一个类别', - confirmErrorCategoryTax: '请选择税率', - confirmErrorCondition: '请输入商家', - confirmErrorConditionAndDefault: '输入商家,并至少应用一个默认设置', - turnOnTaxesFirstTitle: '请先启用税费', - turnOnTaxesFirstPrompt: '类别规则会设置默认税率。请在工作区设置中启用税费以使用此功能。', - categoryRulesApplyGoingForwardTitle: '类别规则将从现在起生效', - categoryRulesApplyGoingForwardPrompt: '此类别中的新报销将应用默认税率,已存在的报销不会改变。', - confirmErrorCategoryTaxMoveIsWorkspaceDefault: '所选税率现在已成为您的工作区默认税率,因此此规则不再有效。请选择其他税率。', - addTaxRateFirstTitle: '请先添加税率', - addTaxRateFirstPrompt: '类别规则会设置一个默认税率。请添加一个不同于工作区默认值的税率以使用类别规则。', + confirmErrorCondition: '请至少添加一个条件', + confirmErrorConditionAndDefault: '请至少添加一个条件,并应用至少一个默认设置', createRuleFromExpenseAction: '创建规则', createRuleFromExpensePrompt: '将您的更改应用于所有符合条件的报销。', }, @@ -8547,9 +8539,11 @@ ${reportName}`, findRule: '查找规则', rename: '重命名', update: '更新', - merchantIs: (merchant: string) => `商户为“${merchant}”`, merchantTypeIs: (merchantType: string) => `商户类型为"${merchantType}"`, categoryIs: (category: string) => `类别为“${category}”`, + tagIs: (tag: string) => `标签为“${tag}”`, + merchantContains: (merchant: string) => `商户包含“${merchant}”`, + merchantExactlyMatches: (merchant: string) => `商家与“${merchant}”完全匹配`, }, merchantTypeRule: {merchantType: '商户类型', saveRule: '保存规则', confirmErrorCategory: '请选择一个类别。'}, requireFieldsTable: { @@ -8607,14 +8601,6 @@ ${reportName}`, }, agentRulesEmptyState: {title: '未添加代理规则', subtitle: '创建规则以自动化您的工作区策略。', cta: '添加 AI 规则'}, categoriesDisabledEmptyState: {title: '类别未启用', subtitle: '启用类别以更好地控制您的支出。'}, - expenseDefaultType: { - title: '应用报销默认值', - subtitle: '此规则应匹配什么内容?', - merchant: '商户', - merchantDescription: '更新来自特定商家的报销项目字段', - category: '类别', - categoryDescription: '为特定类别设置默认税率', - }, taxesDisabledEmptyState: {title: '税费未启用', subtitle: '启用税费以记录并追回符合条件的税款。', cta: '启用税费'}, }, planTypePage: { diff --git a/src/libs/CategoryContextualRulesUtils.ts b/src/libs/CategoryContextualRulesUtils.ts index 26f8091f479d..a1c7f300d60d 100644 --- a/src/libs/CategoryContextualRulesUtils.ts +++ b/src/libs/CategoryContextualRulesUtils.ts @@ -9,10 +9,13 @@ import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList'; import CONST from '@src/CONST'; import type {DynamicRouteSuffix} from '@src/ROUTES'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; -import type {Policy, PolicyCategories, PolicyCategory} from '@src/types/onyx'; +import type {Policy, PolicyCategories, PolicyCategory, Rule} from '@src/types/onyx'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; -import {getCategoryTaxRuleTaxID, getRuleDeletionPendingAction, getTaxRateDisplayName} from './CategoryTaxRulesUtils'; +import type {OnyxCollection} from 'react-native-onyx'; + +import {getRuleDeletionPendingAction, getTaxRateDisplayName} from './CategoryTaxRulesUtils'; +import {getCategoryTaxDefaultID} from './ExpenseDefaultRuleUtils'; import {hasExplicitFlagAmount} from './FlagForReviewRulesUtils'; import {getTaxByID} from './PolicyUtils'; import { @@ -59,6 +62,7 @@ function getFlagForReviewContextualSummary( function getCategoryContextualRules({ policy, policyCategories, + policyRules, category, categoryName, translate, @@ -68,6 +72,8 @@ function getCategoryContextualRules({ policy: Policy | undefined; /** Read for the pending delete of the category a tax default depends on. */ policyCategories: PolicyCategories | undefined; + /** Holds the rule that acts as this category's tax default. */ + policyRules?: OnyxCollection; category: PolicyCategory | undefined; categoryName: string; translate: LocaleContextProps['translate']; @@ -115,9 +121,9 @@ function getCategoryContextualRules({ } } - // A tax default lives in `policy.rules.expenseRules` rather than on the category, and carries no pending state of - // its own, so it follows the Expense defaults table and borrows the delete of the category or rate it depends on. - const taxID = getCategoryTaxRuleTaxID(policy.rules?.expenseRules, categoryName); + // A tax default is an ordinary rule matching on this category alone, so it is looked up rather than read off + // the category. It borrows the delete of the category or rate it depends on, the way the table does. + const taxID = getCategoryTaxDefaultID(policyRules, policy.id, categoryName); if (taxID) { const taxPendingAction = getRuleDeletionPendingAction(policy, policyCategories, categoryName, taxID); const isTaxPendingDelete = taxPendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; diff --git a/src/libs/CategoryTaxRulesUtils.ts b/src/libs/CategoryTaxRulesUtils.ts index d6a04f73f2b0..2735bd4593a8 100644 --- a/src/libs/CategoryTaxRulesUtils.ts +++ b/src/libs/CategoryTaxRulesUtils.ts @@ -1,109 +1,24 @@ /** - * Helpers for reading, shaping and identifying the category tax default rules stored in `policy.rules.expenseRules`. + * Tax helpers shared by the rules pages: which rates a rule may apply, how a rate reads, and when a rule row borrows + * the pending delete of something it depends on. */ -import type {LocaleContextProps} from '@components/LocaleContextProvider'; -import type {ExpenseDefaultTableItem} from '@components/Tables/WorkspaceExpenseDefaultsTable'; - import CONST from '@src/CONST'; -import ROUTES from '@src/ROUTES'; -import type {Route} from '@src/ROUTES'; -import type {MerchantRuleForm} from '@src/types/form'; import type {Policy, PolicyCategories, TaxRate} from '@src/types/onyx'; -import type {GeneratedRuleValues} from '@src/types/onyx/GeneratedRule'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; -import type {ExpenseRule} from '@src/types/onyx/Policy'; - -import {getDecodedCategoryName} from './CategoryUtils'; -import {getTaxByID} from './PolicyUtils'; - -const CATEGORY_TAX_RULE_KEY_PREFIX = 'category-tax:'; - -/** - * Whether the rule being written is a category tax default rather than a merchant rule. - * - * The editor and every picker it opens have to agree on this — the editor picks its rows from it, the tax picker which - * rates it may offer — so they read it here rather than each rebuilding the same disjunction. A saved rule arrives by - * category name, an unsaved one carries the type the chooser put in its draft, and a draft that already holds a - * category counts either way. - */ -function isCategoryRuleDraft(form: MerchantRuleForm | undefined, editingCategoryName?: string): boolean { - return !!editingCategoryName || form?.ruleType === CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.CATEGORY || !!form?.categoriesToMatch?.length; -} /** Whether a rule has a tax rate to apply. Tracking being on isn't enough on its own, it needs a rate to choose from. */ function hasUsableTaxRates(policy: Policy | undefined): boolean { return !!policy?.tax?.trackingEnabled && Object.keys(policy?.taxRates?.taxes ?? {}).length > 0; } -/** - * Whether a rule may pick this rate. A disabled rate, or one on its way out, is not an option for any rule, and the - * workspace default is not one for a category rule, since writing that rate is what deletes the rule. - * - * The tax picker and the chooser that decides whether to offer a category rule at all both read this, so a locked - * option and an empty picker can't disagree about what counts as a usable rate. - */ -function isSelectableTaxRate(policy: Policy | undefined, taxID: string, taxRate: TaxRate, isCategoryRule: boolean): boolean { - return !taxRate.isDisabled && taxRate.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !(isCategoryRule && taxID === policy?.taxRates?.defaultExternalID); -} - -/** Returns the category tax rule draft for generated values. It keeps the generated tax rate only when a category rule can use it. */ -function getCategoryTaxRuleDraftFromGeneratedValues({tax, ...values}: GeneratedRuleValues, policy: Policy | undefined): Partial { - const taxRate = tax ? policy?.taxRates?.taxes?.[tax] : undefined; - const selectableTax = tax && taxRate && isSelectableTaxRate(policy, tax, taxRate, true) ? tax : undefined; - return {...values, tax: selectableTax, ruleType: CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.CATEGORY}; -} - -/** - * Whether a new category tax default has any rate to choose. Holding rates is not enough: they can all be disabled, or - * the workspace default can be the only one left, which a category rule can't use. - */ -function hasSelectableCategoryTaxRate(policy: Policy | undefined): boolean { - if (!hasUsableTaxRates(policy)) { - return false; - } - return Object.entries(policy?.taxRates?.taxes ?? {}).some(([taxID, taxRate]) => isSelectableTaxRate(policy, taxID, taxRate, true)); -} - -function getCategoryTaxRuleKey(categoryName: string) { - return `${CATEGORY_TAX_RULE_KEY_PREFIX}${categoryName}`; -} - -function isCategoryTaxRuleKey(key: string) { - return key.startsWith(CATEGORY_TAX_RULE_KEY_PREFIX); -} - -/** The category a rule matches on. A category tax default only ever carries the one `category matches ` condition. */ -function getRuleCategoryName(rule: ExpenseRule): string | undefined { - return rule.applyWhen?.find(({condition, field}) => condition === CONST.POLICY.RULE_CONDITIONS.MATCHES && field === CONST.POLICY.FIELDS.CATEGORY)?.value; -} - -/** Only rules with an explicit tax default. `getCategoryDefaultTaxRate` falls back to the workspace default, which - * would make every category look covered. */ -function getCategoryTaxRules(expenseRules: ExpenseRule[] | undefined): ExpenseRule[] { - return (expenseRules ?? []).filter((rule) => !!rule.tax?.field_id_TAX?.externalID && !!getRuleCategoryName(rule)); -} - -/** Whether an expense rule is this category's tax default. Unlike `getCategoryTaxRule` it doesn't require a rate yet, so - * a write can find the rule it is about to set one on. */ -function matchesCategoryTaxRule(rule: ExpenseRule, categoryName: string): boolean { - return getRuleCategoryName(rule) === categoryName; -} - -function getCategoryTaxRule(expenseRules: ExpenseRule[] | undefined, categoryName: string): ExpenseRule | undefined { - return getCategoryTaxRules(expenseRules).find((rule) => matchesCategoryTaxRule(rule, categoryName)); -} - -function categoryHasTaxRule(expenseRules: ExpenseRule[] | undefined, categoryName: string): boolean { - return !!getCategoryTaxRule(expenseRules, categoryName); -} - -function getCategoryTaxRuleTaxID(expenseRules: ExpenseRule[] | undefined, categoryName: string): string | undefined { - return getCategoryTaxRule(expenseRules, categoryName)?.tax?.field_id_TAX?.externalID; +/** Whether a rule may pick this rate. A disabled rate, or one on its way out, is not an option for any rule. */ +function isSelectableTaxRate(taxRate: TaxRate): boolean { + return !taxRate.isDisabled && taxRate.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; } /** The `Name (Value)` tax label. Prefers the workspace rate so renames read correctly, then the label the rule saved * inline, then the raw ID. Callers with no inline label should check `getTaxByID` first: an ID is a fallback for - * a merchant rule that carries its own name, not something to show an admin. */ + * a rule that carries its own name, not something to show an admin. */ function getTaxRateDisplayName(policy: Policy | undefined, taxID: string | undefined, savedTaxRate?: {name?: string; value?: string}): string { if (!taxID) { return ''; @@ -122,8 +37,8 @@ function getTaxRateDisplayName(policy: Policy | undefined, taxID: string | undef * Marks a rule row as deleting while the category or tax rate it depends on is itself being deleted. * * The backend drops the rule along with them, so this only borrows their pending state instead of writing one of its - * own — nothing to roll back, and the row clears when the delete lands. Only deletion is borrowed: a disabled category - * or tax keeps its rule, which fires again once it is re-enabled. + * own. There is nothing to roll back, and the row clears when the delete lands. Only deletion is borrowed: a disabled + * category or tax keeps its rule, which fires again once it is re-enabled. */ function getRuleDeletionPendingAction( policy: Policy | undefined, @@ -136,83 +51,4 @@ function getRuleDeletionPendingAction( return isCategoryDeleting || isTaxDeleting ? CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE : undefined; } -function getCategoryTaxRulesTableData({ - policy, - policyCategories, - translate, - isOffline, - onNavigate, -}: { - policy: Policy | undefined; - policyCategories: PolicyCategories | undefined; - translate: LocaleContextProps['translate']; - isOffline: boolean; - onNavigate: (route: Route) => void; -}): ExpenseDefaultTableItem[] { - if (!policy?.id) { - return []; - } - - const policyID = policy.id; - const typeLabel = translate('workspace.rules.expenseDefaultsTable.update'); - const fieldLabel = translate('common.tax').toLowerCase(); - // The rule is both its category and its tax rate, so deleting either one takes it down. - const getPendingAction = (rule: ExpenseRule) => getRuleDeletionPendingAction(policy, policyCategories, getRuleCategoryName(rule), rule.tax?.field_id_TAX?.externalID); - - return ( - getCategoryTaxRules(policy.rules?.expenseRules) - // Only about the category or tax rate going away, never the rule's own delete: `expenseRules` is a plain - // array, so a rule carries no pending state of its own and `deletePolicyCategoryTaxes` drops it outright. - // While its category or rate is being deleted, online the delete resolves in a moment, so the row goes - // rather than flashing greyed; offline it stays, styled as deleting, since there is nothing to wait for. - .filter((rule) => isOffline || getPendingAction(rule) !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) - .map((rule) => { - // `getCategoryTaxRules` already dropped the rules without a category, so this is always set. - const categoryName = getRuleCategoryName(rule) ?? ''; - const decodedCategoryName = getDecodedCategoryName(categoryName); - const taxID = rule.tax?.field_id_TAX?.externalID; - // Blank rather than the raw ID once the rate is gone from the workspace, matching the editor this row - // opens. The rule still holds the ID, so it starts naming the rate again if the rate comes back. - const taxDisplayName = taxID && getTaxByID(policy, taxID) ? getTaxRateDisplayName(policy, taxID) : ''; - const conditionText = translate('workspace.rules.expenseDefaultsTable.categoryIs', decodedCategoryName); - const ruleDescription = translate('workspace.rules.merchantRules.ruleSummarySubtitleUpdateField', fieldLabel, taxDisplayName); - const pendingAction = getPendingAction(rule); - - return { - keyForList: getCategoryTaxRuleKey(categoryName), - ruleID: getCategoryTaxRuleKey(categoryName), - section: CONST.POLICY.EXPENSE_DEFAULTS_SECTION.CATEGORIES, - isRename: false, - typeLabel, - conditionText, - ruleDescription, - searchTokens: [decodedCategoryName, conditionText, ruleDescription, taxDisplayName], - pendingAction, - disabled: pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - action: () => onNavigate(ROUTES.RULES_CATEGORY_TAX_EDIT.getRoute(policyID, categoryName)), - }; - }) - ); -} - -/** The category a rule key from the Expense defaults table refers to. */ -function getCategoryNameFromTaxRuleKey(key: string): string { - return key.slice(CATEGORY_TAX_RULE_KEY_PREFIX.length); -} - -export { - categoryHasTaxRule, - getCategoryTaxRuleDraftFromGeneratedValues, - getCategoryNameFromTaxRuleKey, - getCategoryTaxRulesTableData, - getCategoryTaxRuleTaxID, - getRuleCategoryName, - getRuleDeletionPendingAction, - getTaxRateDisplayName, - hasSelectableCategoryTaxRate, - hasUsableTaxRates, - isCategoryRuleDraft, - isCategoryTaxRuleKey, - isSelectableTaxRate, - matchesCategoryTaxRule, -}; +export {getRuleDeletionPendingAction, getTaxRateDisplayName, hasUsableTaxRates, isSelectableTaxRate}; diff --git a/src/libs/CategoryUtils.ts b/src/libs/CategoryUtils.ts index c4574a7ac310..b1467d1b044b 100644 --- a/src/libs/CategoryUtils.ts +++ b/src/libs/CategoryUtils.ts @@ -4,8 +4,8 @@ import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy, PolicyCategories, TaxRate, TaxRatesWithDefault} from '@src/types/onyx'; -import type {ApprovalRule, ExpenseRule, MccGroup} from '@src/types/onyx/Policy'; +import type {Policy, PolicyCategories} from '@src/types/onyx'; +import type {ApprovalRule, MccGroup} from '@src/types/onyx/Policy'; import type {OnyxCollection} from 'react-native-onyx'; @@ -14,26 +14,6 @@ import {Str} from 'expensify-common'; import StringUtils from './StringUtils'; import {containsHtmlTag} from './ValidationUtils'; -function formatDefaultTaxRateText(translate: LocaleContextProps['translate'], taxID: string, taxRate: TaxRate, policyTaxRates?: TaxRatesWithDefault) { - const taxRateText = `${taxRate.name} ${CONST.DOT_SEPARATOR} ${taxRate.value}`; - - if (!policyTaxRates) { - return taxRateText; - } - - const {defaultExternalID, foreignTaxDefault} = policyTaxRates; - let suffix; - - if (taxID === defaultExternalID && taxID === foreignTaxDefault) { - suffix = translate('common.default'); - } else if (taxID === defaultExternalID) { - suffix = translate('workspace.taxes.workspaceDefault'); - } else if (taxID === foreignTaxDefault) { - suffix = translate('workspace.taxes.foreignDefault'); - } - return `${taxRateText}${suffix ? ` ${CONST.DOT_SEPARATOR} ${suffix}` : ``}`; -} - function formatRequireItemizedReceiptsOverText( translate: LocaleContextProps['translate'], policy: Policy, @@ -68,27 +48,6 @@ function getCategoryApproverRule(approvalRules: ApprovalRule[], categoryName: st return approverRule; } -function getCategoryExpenseRule(expenseRules: ExpenseRule[], categoryName: string) { - const expenseRule = expenseRules?.find((rule) => - rule.applyWhen.find(({condition, field, value}) => condition === CONST.POLICY.RULE_CONDITIONS.MATCHES && field === CONST.POLICY.FIELDS.CATEGORY && value === categoryName), - ); - return expenseRule; -} - -function getCategoryDefaultTaxRate(expenseRules: ExpenseRule[], categoryName: string, defaultTaxRate?: string) { - // Matched the same way the rules are written: on a `category matches ` condition rather than on the value - // alone. Matching any condition carrying the name could read a rule that a save or delete never targets, so the - // rate an expense picks up would not be the one the admin set. - const categoryDefaultTaxRate = getCategoryExpenseRule(expenseRules, categoryName)?.tax?.field_id_TAX?.externalID; - - // If the default taxRate is not found in expenseRules, use the default value for policy - if (!categoryDefaultTaxRate) { - return defaultTaxRate; - } - - return categoryDefaultTaxRate; -} - function updateCategoryInMccGroup(mccGroups: Record, oldCategoryName: string, newCategoryName: string, shouldClearPendingAction?: boolean) { if (oldCategoryName === newCategoryName) { return mccGroups; @@ -303,10 +262,8 @@ function hasAnyCategoryRules(categories: PolicyCategories | undefined): boolean } export { - formatDefaultTaxRateText, formatRequireItemizedReceiptsOverText, getCategoryApproverRule, - getCategoryDefaultTaxRate, updateCategoryInMccGroup, getEnabledCategoriesCount, isCategoryMissing, diff --git a/src/libs/ExpenseDefaultRuleUtils.ts b/src/libs/ExpenseDefaultRuleUtils.ts index 386b4fa554b6..c2a218b179b3 100644 --- a/src/libs/ExpenseDefaultRuleUtils.ts +++ b/src/libs/ExpenseDefaultRuleUtils.ts @@ -5,7 +5,6 @@ */ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {MerchantRuleForm} from '@src/types/form'; import type {Policy, Rule} from '@src/types/onyx'; import type {ApprovalWorkflowAction} from '@src/types/onyx/ApprovalWorkflowRules'; import type { @@ -14,9 +13,9 @@ import type { ExpenseDefaultActions, ExpenseDefaultRule, ExpenseDefaultTaxValue, + ExpenseDefaultTrigger, ExpenseDefaultTriggers, } from '@src/types/onyx/ExpenseDefaultRules'; -import type {GeneratedRuleValues} from '@src/types/onyx/GeneratedRule'; import type {RuleFilterComparison, RuleFilterNode} from '@src/types/onyx/RuleFilters'; import type {OnyxCollection} from 'react-native-onyx'; @@ -27,12 +26,18 @@ import Parser from './Parser'; import {fromIndexMap, getRuleFilterLeaves, isExpenseDefaultRule, isRuleFilterComparison, isRuleFilterNode, toIndexMap} from './RuleUtils'; /** The form shape the merchant rule editor round-trips a rule through. */ -type MerchantRuleFormValues = { +type ExpenseDefaultFormValues = { /** The merchant string an expense has to match */ - merchantToMatch: string; + merchantToMatch?: string; /** Whether the merchant has to match exactly (`eq`) or partially (`contains`) */ - matchType: ValueOf; + matchType?: ValueOf; + + /** The categories an expense has to be in. The list is matched as an OR, so any one of them matches */ + categoriesToMatch?: string[]; + + /** The tags an expense has to carry. The list is matched as an OR, so any one of them matches */ + tagsToMatch?: string[]; /** The merchant the expense is renamed to */ merchant?: string; @@ -46,6 +51,9 @@ type MerchantRuleFormValues = { /** The external ID of the tax rate set on the expense */ tax?: string; + /** The amount set on the expense, as positive whole cents. A form input can only hold a string, and the rule stores the value negated */ + amount?: string; + /** The external ID of the vendor set on the expense */ vendorID?: string; @@ -70,10 +78,22 @@ type RuleWithID = { const {FIELD} = CONST.RULES.EXPENSE_DEFAULT; const {TRIGGERS: TRIGGER, ACTIONS: ACTION} = CONST.RULES; -const {EQUAL_TO, CONTAINS} = CONST.SEARCH.SYNTAX_OPERATORS; +const {AND, EQUAL_TO, CONTAINS} = CONST.SEARCH.SYNTAX_OPERATORS; + +/** + * The expense fields a rule can match on. + * + * These are search syntax filter keys, which is what the rules engine's filter tree speaks. They are not the + * action field names, even where the two happen to spell a field the same way. + */ +const CONDITION = { + MERCHANT: CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT, + CATEGORY: CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY, + TAG: CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG, +} as const; /** The order actions are written in, which fixes the numeric keys a built rule uses. */ -const ACTION_FIELD_ORDER = [FIELD.MERCHANT, FIELD.CATEGORY, FIELD.TAG, FIELD.TAX, FIELD.VENDOR_ID, FIELD.COMMENT, FIELD.REIMBURSABLE, FIELD.BILLABLE] as const; +const ACTION_FIELD_ORDER = [FIELD.MERCHANT, FIELD.CATEGORY, FIELD.TAG, FIELD.TAX, FIELD.AMOUNT, FIELD.VENDOR_ID, FIELD.COMMENT, FIELD.REIMBURSABLE, FIELD.BILLABLE] as const; /** Merchant match types the editor can represent. Any other operator on the merchant node makes a rule read-only. */ const SUPPORTED_MERCHANT_MATCH_TYPES = new Set>([EQUAL_TO, CONTAINS]); @@ -90,13 +110,13 @@ const STRING_ACTION_FIELD_TO_FORM_KEY = { [FIELD.TAG]: 'tag', [FIELD.VENDOR_ID]: 'vendorID', [FIELD.COMMENT]: 'comment', -} as const satisfies Partial>; +} as const satisfies Partial>; /** See `STRING_ACTION_FIELD_TO_FORM_KEY`. */ const BOOLEAN_ACTION_FIELD_TO_FORM_KEY = { [FIELD.REIMBURSABLE]: 'reimbursable', [FIELD.BILLABLE]: 'billable', -} as const satisfies Partial>; +} as const satisfies Partial>; function isStringActionField(field: ExpenseDefaultActionField): field is keyof typeof STRING_ACTION_FIELD_TO_FORM_KEY { return field in STRING_ACTION_FIELD_TO_FORM_KEY; @@ -174,36 +194,102 @@ function buildTaxActionValue(taxKey: string | undefined, policy: Policy | undefi } /** - * A merchant rule as this editor builds it. Stored rules may carry a whole filter tree, but the editor only ever - * produces one merchant comparison, so callers converting a freshly built rule can read the filter directly. + * Builds the `amount` action value. + * + * The rules engine stores an expense amount negated, so $30.00 is saved as -3000. The form holds what the admin + * typed, and the sign is applied here so only one place knows the convention. */ -type BuiltMerchantRule = Omit & { - /** The single `merchant eq|contains ` comparison this editor matches on. */ - filters: RuleFilterComparison; +function buildAmountActionValue(amount: string | undefined): number | undefined { + const amountInCents = Number(amount); + if (!amount || !Number.isFinite(amountInCents) || amountInCents === 0) { + return undefined; + } + + return -Math.abs(amountInCents); +} + +/** A merchant rule as this editor builds it, which is the stored shape minus the fields the server fills in. */ +type BuiltExpenseDefaultRule = Omit & { + /** The conditions the expense has to meet, ANDed together. */ + filters: RuleFilterNode; }; -/** Builds the filter tree for a merchant rule: a single `merchant eq|contains ` comparison. */ -function buildMerchantRuleFilters(formValues: Partial): RuleFilterComparison | undefined { +/** Drops the blanks a multi-select leaves behind, so an emptied picker reads as no condition at all. */ +function getNonEmptyValues(values: string[] | undefined): string[] { + return (values ?? []).map((value) => value.trim()).filter(Boolean); +} + +/** + * Builds the filter tree for a merchant rule, ANDing every condition the form set. + * + * The tree is left nested, so three conditions become `and(and(a, b), c)`. Each node only takes two children, + * and nesting to the left keeps the order the form reads them back in. + */ +function buildExpenseDefaultFilters(formValues: Partial): RuleFilterNode | undefined { + const comparisons: RuleFilterComparison[] = []; + const merchantToMatch = formValues.merchantToMatch?.trim(); - if (!merchantToMatch) { + if (merchantToMatch) { + comparisons.push({left: CONDITION.MERCHANT, operator: formValues.matchType ?? CONTAINS, right: merchantToMatch}); + } + + const categoriesToMatch = getNonEmptyValues(formValues.categoriesToMatch); + if (categoriesToMatch.length > 0) { + comparisons.push({left: CONDITION.CATEGORY, operator: EQUAL_TO, right: categoriesToMatch}); + } + + const tagsToMatch = getNonEmptyValues(formValues.tagsToMatch); + if (tagsToMatch.length > 0) { + comparisons.push({left: CONDITION.TAG, operator: EQUAL_TO, right: tagsToMatch}); + } + + const [firstComparison, ...rest] = comparisons; + if (!firstComparison) { return undefined; } - return { - left: FIELD.MERCHANT, - operator: formValues.matchType ?? CONTAINS, - right: merchantToMatch, - }; + return rest.reduce((left, right) => ({operator: AND, left, right}), firstComparison); +} + +/** Whether the form matches on anything. A rule with no conditions would apply to every expense, and the API rejects it. */ +function hasExpenseDefaultConditions(formValues: Partial): boolean { + return !!buildExpenseDefaultFilters(formValues); +} + +/** Compares two multi-select conditions, which are ORed lists rather than ordered ones. */ +function haveSameConditionValues(first: string[] | undefined, second: string[] | undefined): boolean { + const sortedFirst = getNonEmptyValues(first).sort(); + const sortedSecond = getNonEmptyValues(second).sort(); + + return sortedFirst.length === sortedSecond.length && sortedFirst.every((value, index) => value === sortedSecond.at(index)); +} + +/** Whether two rules match on the same expenses, which is what makes one a duplicate of the other. */ +function haveSameConditions(first: Partial, second: Partial): boolean { + const firstMerchant = first.merchantToMatch?.trim().toLowerCase() ?? ''; + const secondMerchant = second.merchantToMatch?.trim().toLowerCase() ?? ''; + + if (firstMerchant !== secondMerchant) { + return false; + } + + // The match type only tells two rules apart when they both match on a merchant. + if (firstMerchant && (first.matchType ?? CONTAINS) !== (second.matchType ?? CONTAINS)) { + return false; + } + + return haveSameConditionValues(first.categoriesToMatch, second.categoriesToMatch) && haveSameConditionValues(first.tagsToMatch, second.tagsToMatch); } /** Builds the `Set` actions for a merchant rule, keyed by a stringified index in a fixed field order. */ -function buildMerchantRuleActions(formValues: Partial, policy: Policy | undefined): ExpenseDefaultActions { +function buildExpenseDefaultActions(formValues: Partial, policy: Policy | undefined): ExpenseDefaultActions { const comment = emptyToUndefined(formValues.comment); const valuesByField: Partial> = { [FIELD.MERCHANT]: emptyToUndefined(formValues.merchant), [FIELD.CATEGORY]: emptyToUndefined(formValues.category), [FIELD.TAG]: emptyToUndefined(formValues.tag), [FIELD.TAX]: buildTaxActionValue(formValues.tax, policy), + [FIELD.AMOUNT]: buildAmountActionValue(formValues.amount), [FIELD.VENDOR_ID]: emptyToUndefined(formValues.vendorID), [FIELD.COMMENT]: comment ? Parser.replace(comment) : undefined, [FIELD.REIMBURSABLE]: formValues.reimbursable, @@ -228,15 +314,17 @@ function buildMerchantRuleActions(formValues: Partial, p * as the `value` param of `SetRule`. * Returns undefined when the form has nothing to match on or nothing to set, which the API rejects. */ -function buildMerchantRule(formValues: Partial, policy: Policy | undefined): BuiltMerchantRule | undefined { - const filters = buildMerchantRuleFilters(formValues); - const actions = buildMerchantRuleActions(formValues, policy); +function buildExpenseDefaultRule(formValues: Partial, policy: Policy | undefined, existingTriggers?: Record): BuiltExpenseDefaultRule | undefined { + const filters = buildExpenseDefaultFilters(formValues); + const actions = buildExpenseDefaultActions(formValues, policy); if (!filters || Object.keys(actions).length === 0) { return undefined; } - const triggers: ExpenseDefaultTriggers = toIndexMap([TRIGGER.CREATE_TRANSACTION, TRIGGER.UPDATE_TRANSACTION]); + // A rule the backend wrote can fire on more than one event, and the form has no input for that, so an edit + // hands back the triggers the rule already carries instead of rebuilding them. + const triggers: ExpenseDefaultTriggers = getEditableTriggers(existingTriggers) ?? toIndexMap([TRIGGER.CREATE_TRANSACTION, TRIGGER.UPDATE_TRANSACTION]); return {triggers, filters, actions}; } @@ -251,30 +339,103 @@ function isExpenseDefaultTaxValue(value: unknown): value is ExpenseDefaultTaxVal return !!taxField && typeof taxField === 'object' && 'externalID' in taxField && typeof taxField.externalID === 'string'; } +function isExpenseDefaultTrigger(trigger: string): trigger is ExpenseDefaultTrigger { + return trigger === TRIGGER.CREATE_TRANSACTION || trigger === TRIGGER.UPDATE_TRANSACTION; +} + +/** + * The rule's triggers when the editor knows every one of them, otherwise undefined. + * + * The form has no trigger input, so it can only save a rule whose triggers it is able to hand back untouched. + */ +function getEditableTriggers(triggers: Record | undefined): ExpenseDefaultTriggers | undefined { + const triggerValues = fromIndexMap(triggers); + const editableTriggers = triggerValues.filter(isExpenseDefaultTrigger); + if (editableTriggers.length === 0 || editableTriggers.length !== triggerValues.length) { + return undefined; + } + return toIndexMap(editableTriggers); +} + /** Every trigger has to be one the editor knows about, otherwise saving the form would drop the rest. */ function areTriggersEditable(triggers: Record | undefined): boolean { - const triggerValues = fromIndexMap(triggers); - return triggerValues.length > 0 && triggerValues.every((trigger) => trigger === TRIGGER.CREATE_TRANSACTION || trigger === TRIGGER.UPDATE_TRANSACTION); + return !!getEditableTriggers(triggers); } -/** The editor matches on exactly one merchant condition, so anything else (a tree, another field, a list) can't be shown in the form. */ -function getEditableMerchantMatch(filters: RuleFilterNode | undefined): Pick | undefined { - if (!filters || !isRuleFilterNode(filters) || !isRuleFilterComparison(filters)) { +/** The conditions the editor renders, read back off a rule's filter tree. */ +type EditableConditions = Pick; + +/** + * Reads one leaf comparison into the conditions it sets. + * + * Returns undefined when the form can't represent the leaf, which makes the whole rule read-only. + */ +function getConditionFromLeaf(leaf: RuleFilterComparison): EditableConditions | undefined { + // The backend ORs a list of values together, and stores a single value unwrapped. + const values: string[] = []; + for (const value of [leaf.right].flat()) { + if (typeof value !== 'string' || !value) { + return undefined; + } + values.push(value); + } + + if (values.length === 0) { return undefined; } - if (filters.left !== FIELD.MERCHANT || !SUPPORTED_MERCHANT_MATCH_TYPES.has(filters.operator)) { + if (leaf.left === CONDITION.MERCHANT) { + const merchantToMatch = values.at(0); + // The form has a single merchant input, so only one value round-trips. + if (values.length !== 1 || !merchantToMatch || !SUPPORTED_MERCHANT_MATCH_TYPES.has(leaf.operator)) { + return undefined; + } + return {merchantToMatch, matchType: leaf.operator}; + } + + if (leaf.operator !== EQUAL_TO) { + return undefined; + } + + if (leaf.left === CONDITION.CATEGORY) { + return {categoriesToMatch: values}; + } + + if (leaf.left === CONDITION.TAG) { + return {tagsToMatch: values}; + } + + return undefined; +} + +/** The conditions the editor can show for a rule, or undefined when its filter tree can't be represented. */ +function getEditableConditions(filters: RuleFilterNode | undefined): EditableConditions | undefined { + // Rules arrive from the server, so a node can be malformed even though the type says otherwise. + if (!filters || !isRuleFilterNode(filters)) { + return undefined; + } + + if (isRuleFilterComparison(filters)) { + return getConditionFromLeaf(filters); + } + + // The form lists conditions that all have to match, so only an `and` tree round-trips. + if (filters.operator !== AND) { + return undefined; + } + + const left = getEditableConditions(filters.left); + const right = getEditableConditions(filters.right); + if (!left || !right) { return undefined; } - // The backend ORs a list of values together. The form only has one merchant input, so only a single value round-trips. - const rightValues = [filters.right].flat(); - const merchantToMatch = rightValues.at(0); - if (rightValues.length !== 1 || typeof merchantToMatch !== 'string' || !merchantToMatch) { + // The form has one input per field, so a field matched on twice would lose a condition on save. + if (Object.keys(left).some((field) => field in right)) { return undefined; } - return {merchantToMatch, matchType: filters.operator}; + return {...left, ...right}; } /** @@ -285,17 +446,17 @@ function getEditableMerchantMatch(filters: RuleFilterNode | undefined): Pick= 0) { + return undefined; + } + formValues.amount = String(Math.abs(value)); + continue; + } + if (field === FIELD.TAX) { if (!isExpenseDefaultTaxValue(value)) { return undefined; @@ -345,19 +515,96 @@ function getMerchantRuleFormValues(rule: Rule | ExpenseDefaultRule | undefined): return formValues; } +/** A merchant a rule matches on, with the way it matches, since a rule can match a merchant exactly or by substring. */ +type MerchantCondition = { + /** The merchant text the rule matches against */ + value: string; + + /** Whether the merchant has to match in full rather than only contain the text */ + isExactMatch: boolean; +}; + +/** The values a rule matches on, grouped by field. */ +type ExpenseDefaultConditionSummary = { + /** The merchants the rule matches on */ + merchants: MerchantCondition[]; + + /** The categories the rule matches on, as they are stored on the policy */ + categories: string[]; + + /** The tags the rule matches on, as they are stored on the policy */ + tags: string[]; +}; + /** - * Summarizes the merchants a rule matches on, for the condition text and the search index. + * Groups a rule's conditions by field, for the rules table's condition text and its search tokens. * - * Derived from the filter tree rather than from `getMerchantRuleFormValues`, so a rule the editor can't - * represent still shows what it matches and can still be found by merchant while staying read-only. + * Derived from the filter tree rather than from `getExpenseDefaultFormValues`, so a rule the editor can't + * represent still shows what it matches and can still be found by it while staying read-only. */ -function getRuleMerchantMatchSummary(filters: RuleFilterNode | undefined): {merchants: string; isExactMatch: boolean} { - const merchantLeaves = getRuleFilterLeaves(filters).filter((leaf) => leaf.left === FIELD.MERCHANT); +function getExpenseDefaultConditionSummary(filters: RuleFilterNode | undefined): ExpenseDefaultConditionSummary { + const summary: ExpenseDefaultConditionSummary = {merchants: [], categories: [], tags: []}; + + for (const leaf of getRuleFilterLeaves(filters)) { + const values = [leaf.right].flat().filter((value): value is string => typeof value === 'string'); + + if (leaf.left === CONDITION.MERCHANT) { + summary.merchants.push(...values.map((value) => ({value, isExactMatch: leaf.operator === EQUAL_TO}))); + } else if (leaf.left === CONDITION.CATEGORY) { + summary.categories.push(...values); + } else if (leaf.left === CONDITION.TAG) { + summary.tags.push(...values); + } + } - return { - merchants: merchantLeaves.flatMap((leaf) => [leaf.right].flat()).join(', '), - isExactMatch: merchantLeaves.length > 0 && merchantLeaves.every((leaf) => leaf.operator === EQUAL_TO), - }; + return summary; +} + +/** + * The rule that holds a category's tax default: any rule the policy has that matches on that category. + * + * Category settings addresses this rule by category name rather than by ID, because a category holds one tax + * default, so the ID is resolved here instead of being carried on every route. + * + * A rule reached from category settings can also match on a merchant or a tag and can leave the tax unset, so + * narrowing to a category-only rule that sets a tax would hide it and offer to add a second one. + */ +function getCategoryTaxDefaultRule(rulesCollection: OnyxCollection | undefined, policyID: string | undefined, categoryName: string | undefined): RuleWithID | undefined { + if (!categoryName) { + return undefined; + } + + const matchingRules = getPolicyExpenseDefaultRules(rulesCollection, policyID).filter(({rule}) => { + // A rule deleted offline sticks around until the request goes through, and category settings would keep + // offering it to edit. + if (rule.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + return false; + } + + return getExpenseDefaultConditionSummary(rule.filters).categories.includes(categoryName); + }); + + // More than one rule can match a category, so the one that matches on nothing else is the category's own + // default. Without this the category would show whichever rule the collection happened to yield first. + const categoryOnlyRule = matchingRules.find(({rule}) => { + const {merchants, categories, tags} = getExpenseDefaultConditionSummary(rule.filters); + return merchants.length === 0 && tags.length === 0 && categories.length === 1; + }); + + return categoryOnlyRule ?? matchingRules.at(0); +} + +/** The tax rate a category's tax default applies. See `getCategoryTaxDefaultRule`. */ +function getCategoryTaxDefaultID(rulesCollection: OnyxCollection | undefined, policyID: string | undefined, categoryName: string | undefined): string | undefined { + const rule = getCategoryTaxDefaultRule(rulesCollection, policyID, categoryName)?.rule; + + for (const action of getRuleActions(rule)) { + if (action?.name === ACTION.SET && 'field' in action && action.field === FIELD.TAX && isExpenseDefaultTaxValue(action.value)) { + return action.value.field_id_TAX.externalID; + } + } + + return undefined; } /** A single field a rule sets, normalized for display. */ @@ -435,9 +682,9 @@ function hasExpenseDefaultRuleErrors(rules: OnyxCollection | undefined, po return getPolicyExpenseDefaultRules(rules, policyID).some(({rule}) => Object.keys(rule.errors ?? {}).length > 0); } -/** Whether the merchant rule editor can safely open this rule. See `getMerchantRuleFormValues`. */ -function isEditableMerchantRule(rule: Rule | ExpenseDefaultRule | undefined): boolean { - return !!getMerchantRuleFormValues(rule); +/** Whether the merchant rule editor can safely open this rule. See `getExpenseDefaultFormValues`. */ +function isEditableExpenseDefaultRule(rule: Rule | ExpenseDefaultRule | undefined): boolean { + return !!getExpenseDefaultFormValues(rule); } /** @@ -449,27 +696,26 @@ function isEditableMerchantRule(rule: Rule | ExpenseDefaultRule | undefined): bo * refused rather than opened. A rule the form can edit is already an expense default rule, because the form only * accepts `CreateTransaction` and `UpdateTransaction` triggers and `Set` actions. */ -function canEditMerchantRule(rule: Rule | undefined, policyID: string | undefined): boolean { - return isPolicyScopedRule(rule, policyID) && isEditableMerchantRule(rule); -} - -/** Returns the merchant rule draft for generated values */ -function getMerchantRuleDraftFromGeneratedValues(values: GeneratedRuleValues): Partial { - return {...values, ruleType: CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.MERCHANT}; +function canEditExpenseDefaultRule(rule: Rule | undefined, policyID: string | undefined): boolean { + return isPolicyScopedRule(rule, policyID) && isEditableExpenseDefaultRule(rule); } -export type {MerchantRuleFormValues}; +export type {ExpenseDefaultFormValues}; export { buildCopiedExpenseDefaultRules, - buildMerchantRule, - canEditMerchantRule, + buildExpenseDefaultFilters, + buildExpenseDefaultRule, + canEditExpenseDefaultRule, + getCategoryTaxDefaultID, + getCategoryTaxDefaultRule, + getExpenseDefaultConditionSummary, getExpenseDefaultRuleCount, getExpenseDefaultRuleSummaryFields, - getMerchantRuleDraftFromGeneratedValues, - getMerchantRuleFormValues, + getExpenseDefaultFormValues, getPolicyExpenseDefaultRules, - getRuleMerchantMatchSummary, + hasExpenseDefaultConditions, hasExpenseDefaultRuleErrors, - isEditableMerchantRule, + haveSameConditions, + isEditableExpenseDefaultRule, isExpenseDefaultTaxValue, }; diff --git a/src/libs/MerchantRuleSuggestionUtils.ts b/src/libs/MerchantRuleSuggestionUtils.ts index 239c11e38f80..8349d460a1e7 100644 --- a/src/libs/MerchantRuleSuggestionUtils.ts +++ b/src/libs/MerchantRuleSuggestionUtils.ts @@ -102,7 +102,7 @@ function getMerchantRuleDraftFromTransaction( // The callout only ever creates a merchant rule, so it names the type itself. Without it the editor bounces to the // type chooser, which would drop the user a step back from where the callout promised to take them. - const draft: Partial = {ruleType: CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.MERCHANT, merchantToMatch: getMerchant(transaction)}; + const draft: Partial = {merchantToMatch: getMerchant(transaction)}; // Each field sets a different property, so edit order does not matter. for (const field of fields) { diff --git a/src/libs/MerchantTypeRulesUtils.ts b/src/libs/MerchantTypeRulesUtils.ts index f069592a4f14..a21b552e5393 100644 --- a/src/libs/MerchantTypeRulesUtils.ts +++ b/src/libs/MerchantTypeRulesUtils.ts @@ -6,16 +6,23 @@ import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import INPUT_IDS from '@src/types/form/MerchantTypeRuleForm'; import type {MerchantTypeRuleForm} from '@src/types/form/MerchantTypeRuleForm'; -import type {Policy, PolicyCategories, PolicyTagLists, Rule} from '@src/types/onyx'; +import type {Policy, PolicyTagLists, Rule} from '@src/types/onyx'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {DEFAULT_MCC_GROUP, isDefaultMccGroupID} from './actions/Policy/OptimisticPolicyCategoriesAndMccGroups'; import {setWorkspaceDefaultSpendCategory} from './actions/Policy/Policy'; import {clearMerchantRuleErrors} from './actions/Policy/Rules'; -import {getCategoryTaxRulesTableData, getTaxRateDisplayName} from './CategoryTaxRulesUtils'; +import {getTaxRateDisplayName} from './CategoryTaxRulesUtils'; import {getDecodedCategoryName} from './CategoryUtils'; -import {getExpenseDefaultRuleSummaryFields, getPolicyExpenseDefaultRules, getRuleMerchantMatchSummary, isEditableMerchantRule, isExpenseDefaultTaxValue} from './ExpenseDefaultRuleUtils'; +import {convertAmountToDisplayString} from './CurrencyUtils'; +import { + getExpenseDefaultConditionSummary, + getExpenseDefaultRuleSummaryFields, + getPolicyExpenseDefaultRules, + isEditableExpenseDefaultRule, + isExpenseDefaultTaxValue, +} from './ExpenseDefaultRuleUtils'; import {getMccGroupDisplayName} from './PolicyRulesUtils'; import {getCommaSeparatedTagNameWithSanitizedColons, getVendorRuleDisplayValue, isTagInPolicy} from './PolicyUtils'; @@ -126,6 +133,7 @@ function getMerchantRulesTableData({ tag: translate('common.tag').toLowerCase(), description: translate('common.description').toLowerCase(), tax: translate('common.tax').toLowerCase(), + amount: translate('iou.amount').toLowerCase(), vendor: translate('common.vendor').toLowerCase(), }; const {FIELD} = CONST.RULES.EXPENSE_DEFAULT; @@ -149,8 +157,38 @@ function getMerchantRulesTableData({ const summaryFields = getExpenseDefaultRuleSummaryFields(rule).filter( ({field, value}) => field !== FIELD.TAG || !policyTags || typeof value !== 'string' || isTagInPolicy(value, policyTags), ); - const {merchants: merchantName} = getRuleMerchantMatchSummary(rule.filters); + const {merchants, categories, tags} = getExpenseDefaultConditionSummary(rule.filters); + const merchantNames = merchants.map(({value}) => value).join(', '); + const containedMerchantNames = merchants + .filter(({isExactMatch}) => !isExactMatch) + .map(({value}) => value) + .join(', '); + const exactMerchantNames = merchants + .filter(({isExactMatch}) => isExactMatch) + .map(({value}) => value) + .join(', '); + const categoryNames = categories.map(getDecodedCategoryName).join(', '); + const tagNames = tags.map(getCommaSeparatedTagNameWithSanitizedColons).join(', '); + const conditions: string[] = []; + // Spelled out rather than a single "Merchant is", so a contains rule and an exact match rule on the + // same merchant don't read as duplicates. + if (containedMerchantNames) { + conditions.push(translate('workspace.rules.expenseDefaultsTable.merchantContains', containedMerchantNames)); + } + if (exactMerchantNames) { + conditions.push(translate('workspace.rules.expenseDefaultsTable.merchantExactlyMatches', exactMerchantNames)); + } + if (categoryNames) { + conditions.push(translate('workspace.rules.expenseDefaultsTable.categoryIs', categoryNames)); + } + if (tagNames) { + conditions.push(translate('workspace.rules.expenseDefaultsTable.tagIs', tagNames)); + } + + // The Categories section lists the rules a category alone decides. One that also matches on a + // merchant or a tag reads as a merchant rule, so it stays with them. + const isCategoryOnlyRule = categories.length > 0 && merchants.length === 0 && tags.length === 0; const hasOnlyMerchantRename = summaryFields.length === 1 && summaryFields.at(0)?.field === FIELD.MERCHANT; const typeLabel = hasOnlyMerchantRename ? translate('workspace.rules.expenseDefaultsTable.rename') : translate('workspace.rules.expenseDefaultsTable.update'); @@ -175,6 +213,15 @@ function getMerchantRulesTableData({ getTaxRateDisplayName(policy, value.field_id_TAX.externalID, value.field_id_TAX), ), ); + } else if (field === FIELD.AMOUNT && typeof value === 'number') { + // The rule stores the amount negated, the way an expense carries it. + actions.push( + translate( + 'workspace.rules.merchantRules.ruleSummarySubtitleUpdateField', + fieldLabels.amount, + convertAmountToDisplayString(Math.abs(value), policy?.outputCurrency), + ), + ); } else if (field === FIELD.VENDOR_ID && typeof value === 'string') { const unavailableLabel = translate('workspace.rules.merchantRules.vendorUnavailable'); actions.push( @@ -190,9 +237,9 @@ function getMerchantRulesTableData({ const pendingAction = rule.pendingAction; // A rule the editor can't represent would lose whatever the form can't show if it were saved back, - // so the row summarizes it but doesn't open it. See `getMerchantRuleFormValues`. It stays selectable + // so the row summarizes it but doesn't open it. See `getExpenseDefaultFormValues`. It stays selectable // either way, because deleting it is the only thing an admin can still do with it. - const isEditable = isEditableMerchantRule(rule); + const isEditable = isEditableExpenseDefaultRule(rule); const openEditor = () => { if (!isEditable) { return; @@ -203,12 +250,13 @@ function getMerchantRulesTableData({ return { keyForList: ruleID, ruleID, - section: CONST.POLICY.EXPENSE_DEFAULTS_SECTION.MERCHANTS, + section: isCategoryOnlyRule ? CONST.POLICY.EXPENSE_DEFAULTS_SECTION.CATEGORIES : CONST.POLICY.EXPENSE_DEFAULTS_SECTION.MERCHANTS, isRename: hasOnlyMerchantRename, typeLabel, - conditionText: translate('workspace.rules.expenseDefaultsTable.merchantIs', merchantName), + // A rule matches on every condition it carries, so the row reads them as one sentence. + conditionText: conditions.join(` ${translate('common.and')} `), ruleDescription, - searchTokens: [merchantName, ruleDescription], + searchTokens: [merchantNames, categoryNames, tagNames, ruleDescription].filter(Boolean), pendingAction, errors: rule.errors, onCloseError: () => clearMerchantRuleErrors(ruleID, rule), @@ -223,7 +271,6 @@ function getExpenseDefaultsTableData({ policy, policyID, rules, - policyCategories, policyTags, translate, isOffline, @@ -232,19 +279,16 @@ function getExpenseDefaultsTableData({ policy: Policy | undefined; policyID: string; rules: OnyxCollection | undefined; - /** Read for the pending state of a category a rule depends on, so the rule shows as deleting alongside it. */ - policyCategories: PolicyCategories | undefined; /** Read to hide a deleted tag from a merchant rule's summary. */ policyTags: OnyxEntry; translate: LocaleContextProps['translate']; isOffline: boolean; onNavigate: (route: Route) => void; }): ExpenseDefaultTableItem[] { - const categoryTaxRules = getCategoryTaxRulesTableData({policy, policyCategories, translate, isOffline, onNavigate}); const merchantRules = getMerchantRulesTableData({policy, policyID, rules, policyTags, translate, isOffline, onNavigate}); const merchantTypeRules = getMerchantTypeRulesTableData({policy, translate, onNavigate}); - return [...categoryTaxRules, ...merchantRules, ...merchantTypeRules]; + return [...merchantRules, ...merchantTypeRules]; } export { diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index dd6d600dca96..cdd661170833 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -628,7 +628,6 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/categories/DynamicEditCategoryPage').default, [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_PAYROLL_CODE]: () => require('../../../../pages/workspace/categories/CategoryPayrollCodePage').default, [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_GL_CODE]: () => require('../../../../pages/workspace/categories/CategoryGLCodePage').default, - [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_DEFAULT_TAX_RATE]: () => require('../../../../pages/workspace/categories/DynamicCategoryDefaultTaxRatePage').default, [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_FLAG_AMOUNTS_OVER]: () => require('../../../../pages/workspace/categories/DynamicCategoryFlagAmountsOverPage').default, [SCREENS.WORKSPACE.DYNAMIC_EXPENSE_LIMIT_TYPE_SELECTOR]: () => require('../../../../pages/workspace/categories/DynamicExpenseLimitTypeSelectorPage').default, [SCREENS.WORKSPACE.DYNAMIC_IMPORTED_MEMBERS_ROLE]: () => require('../../../../pages/workspace/members/DynamicImportedMembersRoleSelectionPage').default, @@ -1245,7 +1244,6 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/rules/RulesCustomPage').default, [SCREENS.WORKSPACE.RULES_PROHIBITED_DEFAULT]: () => require('../../../../pages/workspace/rules/RulesProhibitedDefaultPage').default, [SCREENS.WORKSPACE.RULES_NEW]: () => require('../../../../pages/workspace/rules/RulesNewPage').default, - [SCREENS.WORKSPACE.RULES_EXPENSE_DEFAULT_TYPE]: () => require('../../../../pages/workspace/rules/MerchantRules/ExpenseDefaultTypePage').default, [SCREENS.WORKSPACE.RULES_MERCHANT_NEW]: () => require('../../../../pages/workspace/rules/MerchantRules/AddMerchantRulePage').default, [SCREENS.WORKSPACE.RULES_MERCHANT_IMPORT]: () => require('../../../../pages/workspace/rules/MerchantRules/ImportMerchantRulesPage').default, [SCREENS.WORKSPACE.RULES_MERCHANT_IMPORTED]: () => require('../../../../pages/workspace/rules/MerchantRules/ImportedMerchantRulesPage').default, @@ -1287,6 +1285,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/rules/MerchantRules/AddDescriptionPage').default, [SCREENS.WORKSPACE.RULES_MERCHANT_REIMBURSABLE]: () => require('../../../../pages/workspace/rules/MerchantRules/AddReimbursablePage').default, [SCREENS.WORKSPACE.RULES_MERCHANT_BILLABLE]: () => require('../../../../pages/workspace/rules/MerchantRules/AddBillablePage').default, + [SCREENS.WORKSPACE.RULES_MERCHANT_AMOUNT]: () => require('../../../../pages/workspace/rules/MerchantRules/AddAmountPage').default, [SCREENS.WORKSPACE.RULES_MERCHANT_PREVIEW_MATCHES]: () => require('../../../../pages/workspace/rules/MerchantRules/PreviewMatchesPage').default, [SCREENS.WORKSPACE.RULES_MERCHANT_EDIT]: () => require('../../../../pages/workspace/rules/MerchantRules/EditMerchantRulePage').default, [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW]: () => require('../../../../pages/workspace/rules/MerchantRules/AddMerchantRulePage').default, @@ -1300,8 +1299,12 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/rules/MerchantRules/AddDescriptionPage').default, [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_REIMBURSABLE]: () => require('../../../../pages/workspace/rules/MerchantRules/AddReimbursablePage').default, [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_BILLABLE]: () => require('../../../../pages/workspace/rules/MerchantRules/AddBillablePage').default, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_AMOUNT]: () => require('../../../../pages/workspace/rules/MerchantRules/AddAmountPage').default, + [SCREENS.WORKSPACE.DYNAMIC_RULES_CATEGORY_TO_MATCH]: () => require('../../../../pages/workspace/rules/MerchantRules/AddCategoryToMatchPage').default, + [SCREENS.WORKSPACE.DYNAMIC_RULES_TAG_TO_MATCH]: () => require('../../../../pages/workspace/rules/MerchantRules/AddTagToMatchPage').default, [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_PREVIEW_MATCHES]: () => require('../../../../pages/workspace/rules/MerchantRules/PreviewMatchesPage').default, [SCREENS.WORKSPACE.RULES_CATEGORY_TO_MATCH]: () => require('../../../../pages/workspace/rules/MerchantRules/AddCategoryToMatchPage').default, + [SCREENS.WORKSPACE.RULES_TAG_TO_MATCH]: () => require('../../../../pages/workspace/rules/MerchantRules/AddTagToMatchPage').default, [SCREENS.WORKSPACE.RULES_CATEGORY_TAX_EDIT]: () => require('../../../../pages/workspace/rules/MerchantRules/EditCategoryTaxRulePage').default, [SCREENS.WORKSPACE.RULES_AGENT_NEW]: () => require('../../../../pages/workspace/rules/AgentRules/AddAgentRulePage').default, [SCREENS.WORKSPACE.RULES_AGENT_EDIT]: () => require('../../../../pages/workspace/rules/AgentRules/EditAgentRulePage').default, diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts index 15ce62c1bd74..f09f686c7ab6 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts @@ -364,7 +364,6 @@ const WORKSPACE_TO_RHP: Partial['config'] = { [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_EDIT]: DYNAMIC_ROUTES.WORKSPACE_CATEGORY_EDIT.path, [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_PAYROLL_CODE]: DYNAMIC_ROUTES.WORKSPACE_CATEGORY_PAYROLL_CODE.path, [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_GL_CODE]: DYNAMIC_ROUTES.WORKSPACE_CATEGORY_GL_CODE.path, - [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_DEFAULT_TAX_RATE]: DYNAMIC_ROUTES.WORKSPACE_CATEGORY_DEFAULT_TAX_RATE.path, [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_FLAG_AMOUNTS_OVER]: DYNAMIC_ROUTES.WORKSPACE_CATEGORY_FLAG_AMOUNTS_OVER.path, [SCREENS.WORKSPACE.DYNAMIC_EXPENSE_LIMIT_TYPE_SELECTOR]: DYNAMIC_ROUTES.EXPENSE_LIMIT_TYPE_SELECTOR.path, [SCREENS.WORKSPACE.DYNAMIC_IMPORTED_MEMBERS_ROLE]: DYNAMIC_ROUTES.IMPORTED_MEMBERS_ROLE.path, @@ -1434,9 +1433,6 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.RULES_NEW]: { path: ROUTES.RULES_NEW.route, }, - [SCREENS.WORKSPACE.RULES_EXPENSE_DEFAULT_TYPE]: { - path: ROUTES.RULES_EXPENSE_DEFAULT_TYPE.route, - }, [SCREENS.WORKSPACE.RULES_MERCHANT_NEW]: { path: ROUTES.RULES_MERCHANT_NEW.route, }, @@ -1451,6 +1447,9 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_DESCRIPTION]: DYNAMIC_ROUTES.RULES_MERCHANT_DESCRIPTION_FROM_EXPENSE.path, [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_REIMBURSABLE]: DYNAMIC_ROUTES.RULES_MERCHANT_REIMBURSABLE_FROM_EXPENSE.path, [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_BILLABLE]: DYNAMIC_ROUTES.RULES_MERCHANT_BILLABLE_FROM_EXPENSE.path, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_AMOUNT]: DYNAMIC_ROUTES.RULES_MERCHANT_AMOUNT_FROM_EXPENSE.path, + [SCREENS.WORKSPACE.DYNAMIC_RULES_CATEGORY_TO_MATCH]: DYNAMIC_ROUTES.RULES_CATEGORY_TO_MATCH_FROM_EXPENSE.path, + [SCREENS.WORKSPACE.DYNAMIC_RULES_TAG_TO_MATCH]: DYNAMIC_ROUTES.RULES_TAG_TO_MATCH_FROM_EXPENSE.path, [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_PREVIEW_MATCHES]: DYNAMIC_ROUTES.RULES_MERCHANT_PREVIEW_MATCHES_FROM_EXPENSE.path, [SCREENS.WORKSPACE.RULES_MERCHANT_IMPORT]: { path: ROUTES.RULES_MERCHANT_IMPORT.route, @@ -1557,9 +1556,15 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.RULES_MERCHANT_BILLABLE]: { path: ROUTES.RULES_MERCHANT_BILLABLE.route, }, + [SCREENS.WORKSPACE.RULES_MERCHANT_AMOUNT]: { + path: ROUTES.RULES_MERCHANT_AMOUNT.route, + }, [SCREENS.WORKSPACE.RULES_CATEGORY_TO_MATCH]: { path: ROUTES.RULES_CATEGORY_TO_MATCH.route, }, + [SCREENS.WORKSPACE.RULES_TAG_TO_MATCH]: { + path: ROUTES.RULES_TAG_TO_MATCH.route, + }, [SCREENS.WORKSPACE.RULES_CATEGORY_TAX_EDIT]: { path: ROUTES.RULES_CATEGORY_TAX_EDIT.route, }, diff --git a/src/libs/Navigation/paramLists/WorkspaceSettingsParamList.ts b/src/libs/Navigation/paramLists/WorkspaceSettingsParamList.ts index dc38564d79a3..d2ae18247841 100644 --- a/src/libs/Navigation/paramLists/WorkspaceSettingsParamList.ts +++ b/src/libs/Navigation/paramLists/WorkspaceSettingsParamList.ts @@ -113,10 +113,6 @@ type WorkspaceSettingsParamList = { // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md backTo?: Routes; }; - [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_DEFAULT_TAX_RATE]: { - policyID: string; - categoryName: string; - }; [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_FLAG_AMOUNTS_OVER]: { policyID: string; categoryName: string; @@ -1536,9 +1532,6 @@ type WorkspaceSettingsParamList = { policyID: string; categoryName?: string; }; - [SCREENS.WORKSPACE.RULES_EXPENSE_DEFAULT_TYPE]: { - policyID: string; - }; [SCREENS.WORKSPACE.RULES_MERCHANT_NEW]: { policyID: string; categoryName?: string; @@ -1590,6 +1583,19 @@ type WorkspaceSettingsParamList = { policyID: string; ruleID?: undefined; }; + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_AMOUNT]: { + policyID: string; + ruleID?: undefined; + }; + [SCREENS.WORKSPACE.DYNAMIC_RULES_CATEGORY_TO_MATCH]: { + policyID: string; + ruleID?: undefined; + categoryName?: undefined; + }; + [SCREENS.WORKSPACE.DYNAMIC_RULES_TAG_TO_MATCH]: { + policyID: string; + ruleID?: undefined; + }; [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_PREVIEW_MATCHES]: { policyID: string; ruleID?: undefined; @@ -1743,6 +1749,10 @@ type WorkspaceSettingsParamList = { policyID: string; ruleID: string; }; + [SCREENS.WORKSPACE.RULES_MERCHANT_AMOUNT]: { + policyID: string; + ruleID: string; + }; [SCREENS.WORKSPACE.RULES_MERCHANT_EDIT]: { policyID: string; ruleID: string; @@ -1756,6 +1766,10 @@ type WorkspaceSettingsParamList = { ruleID: string; categoryName?: string; }; + [SCREENS.WORKSPACE.RULES_TAG_TO_MATCH]: { + policyID: string; + ruleID: string; + }; [SCREENS.WORKSPACE.RULES_CATEGORY_TAX_EDIT]: { policyID: string; categoryName: string; diff --git a/src/libs/RuleUtils.ts b/src/libs/RuleUtils.ts index 89f873647671..0efbb3a2e8ab 100644 --- a/src/libs/RuleUtils.ts +++ b/src/libs/RuleUtils.ts @@ -44,14 +44,11 @@ function isApprovalWorkflowRule(rule: Rule): rule is Rule & ApprovalWorkflowRule } /** - * An expense default rule fires on transaction creation, matches on a merchant, and sets at least one field. + * An expense default rule fires on transaction creation, matches on at least one condition, and sets at least one field. * * This mirrors the rules engine's own definition. It is a heuristic over triggers, filters and action names rather * than a full check of every action's shape, so callers that go on to read the values still validate them field by * field. - * - * The merchant condition is what keeps category rules out. They are moving into the same collection, and a client - * that has no way to edit them must not list them either. */ function isExpenseDefaultRule(rule: Rule | undefined): rule is Rule & ExpenseDefaultRule { if (!rule) { @@ -63,9 +60,9 @@ function isExpenseDefaultRule(rule: Rule | undefined): rule is Rule & ExpenseDef const actions = fromIndexMap(actionsByIndex); const hasCreateTransactionTrigger = getRuleTriggers(rule).some((trigger) => trigger === CONST.RULES.TRIGGERS.CREATE_TRANSACTION); const hasSetAction = actions.some((action) => action?.name === CONST.RULES.ACTIONS.SET); - const hasMerchantFilter = getRuleFilterLeaves(rule.filters).some((leaf) => leaf.left === CONST.RULES.EXPENSE_DEFAULT.FIELD.MERCHANT); + const hasCondition = getRuleFilterLeaves(rule.filters).length > 0; - return hasCreateTransactionTrigger && hasMerchantFilter && hasSetAction; + return hasCreateTransactionTrigger && hasCondition && hasSetAction; } /** Whether an unknown value read off a rule is shaped like a node of its filter tree. */ diff --git a/src/libs/TransactionUtils/getUpdatedTransaction.ts b/src/libs/TransactionUtils/getUpdatedTransaction.ts index 36c51421678b..fc5a7ea10b78 100644 --- a/src/libs/TransactionUtils/getUpdatedTransaction.ts +++ b/src/libs/TransactionUtils/getUpdatedTransaction.ts @@ -31,7 +31,6 @@ import getDistanceInMeters from './getDistanceInMeters'; // The functions imported here are pure helpers that aren't called at initialization time. // eslint-disable-next-line import/no-cycle import { - getCategoryTaxDetails, getCurrency, hasAppliedCommuterExclusion, hasLocallyKnownDistance, @@ -136,7 +135,6 @@ function getUpdatedTransaction({ policies = undefined, isSplitTransaction = false, personalPolicyOutputCurrency, - getCurrencyDecimals, getCurrencySymbol, }: { transaction: Transaction; @@ -147,7 +145,6 @@ function getUpdatedTransaction({ policies?: OnyxCollection; isSplitTransaction?: boolean; personalPolicyOutputCurrency: string | undefined; - getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol']; }): Transaction { const isUnReportedExpense = transaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; @@ -402,18 +399,6 @@ function getUpdatedTransaction({ if (Object.hasOwn(transactionChanges, 'category') && typeof transactionChanges.category === 'string') { updatedTransaction.category = transactionChanges.category; - - // On a server backed edit, clearing the category leaves the stored tax rate untouched, so predicting a - // change here only writes a rate that the response immediately overwrites. Split drafts have no such - // response and send whatever tax the draft holds, so they keep recalculating. - if (transactionChanges.category || isSplitTransaction) { - const {categoryTaxCode, categoryTaxAmount, categoryTaxValue} = getCategoryTaxDetails(transactionChanges.category, transaction, policy, getCurrencyDecimals); - if (categoryTaxCode && categoryTaxAmount !== undefined && categoryTaxValue) { - updatedTransaction.taxCode = categoryTaxCode; - updatedTransaction.taxAmount = categoryTaxAmount; - updatedTransaction.taxValue = categoryTaxValue; - } - } } if (Object.hasOwn(transactionChanges, 'tag') && typeof transactionChanges.tag === 'string') { diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 2c8630740931..60d32142c790 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -64,7 +64,6 @@ import {getClearedPendingFields, getDistanceMerchantForTransaction, getUpdatedTr import { calculateTaxAmount, getCalculatedTaxAmount, - getCategoryTaxDetails, getDefaultTaxCode, getDistanceRateTaxUpdates, getEnabledTaxRateCount, @@ -1967,7 +1966,6 @@ export { getAllSortedTransactions, getFormattedPostedDate, getPostedDate, - getCategoryTaxDetails, isPerDiemRequest, isViolationDismissed, isPartialTransaction, diff --git a/src/libs/TransactionUtils/tax.ts b/src/libs/TransactionUtils/tax.ts index 519fad0d41ad..eaefd6fc3356 100644 --- a/src/libs/TransactionUtils/tax.ts +++ b/src/libs/TransactionUtils/tax.ts @@ -5,7 +5,6 @@ */ import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList'; -import {getCategoryDefaultTaxRate} from '@libs/CategoryUtils'; import {convertToBackendAmount} from '@libs/CurrencyUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import {translateLocal} from '@libs/Localize'; @@ -206,28 +205,9 @@ function getTaxRateTitle(policy: OnyxEntry, transaction: OnyxEntry, policy: OnyxEntry, getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']) { - const taxRules = policy?.rules?.expenseRules?.filter((rule) => rule.tax); - if (!taxRules || taxRules?.length === 0 || isDistanceRequest(transaction)) { - return {categoryTaxCode: undefined, categoryTaxAmount: undefined, categoryTaxValue: undefined}; - } - - const defaultTaxCode = getDefaultTaxCode(policy, transaction, getCurrency(transaction)); - const categoryTaxCode = getCategoryDefaultTaxRate(taxRules, category, defaultTaxCode); - const categoryTaxPercentage = getTaxValue(policy, transaction, categoryTaxCode ?? ''); - let categoryTaxAmount; - - if (categoryTaxPercentage) { - categoryTaxAmount = convertToBackendAmount(calculateTaxAmount(categoryTaxPercentage, getAmount(transaction), getCurrencyDecimals(getCurrency(transaction)))); - } - - return {categoryTaxCode, categoryTaxAmount, categoryTaxValue: categoryTaxPercentage}; -} - export { calculateTaxAmount, getCalculatedTaxAmount, - getCategoryTaxDetails, getDefaultTaxCode, getDistanceRateTaxUpdates, getEnabledTaxRateCount, diff --git a/src/libs/actions/IOU/BulkEdit.ts b/src/libs/actions/IOU/BulkEdit.ts index f3847deb5ad5..0eb70bba0937 100644 --- a/src/libs/actions/IOU/BulkEdit.ts +++ b/src/libs/actions/IOU/BulkEdit.ts @@ -525,7 +525,6 @@ function updateMultipleMoneyRequests({ policy: transactionPolicy, policies: allPolicies, personalPolicyOutputCurrency, - getCurrencyDecimals, getCurrencySymbol, }); const isTransactionOnHold = isOnHold(transaction); diff --git a/src/libs/actions/IOU/MoneyRequest.ts b/src/libs/actions/IOU/MoneyRequest.ts index c0b85bba9bab..928af6d01e2b 100644 --- a/src/libs/actions/IOU/MoneyRequest.ts +++ b/src/libs/actions/IOU/MoneyRequest.ts @@ -17,7 +17,6 @@ import type {OptionData} from '@libs/ReportUtils'; import {startSpan} from '@libs/telemetry/activeSpans'; import {logReceiptSubmitted} from '@libs/telemetry/ReceiptObservability'; import { - getCategoryTaxDetails, getDefaultTaxCode, getDistanceInMeters, getDistanceRateTaxUpdates, @@ -698,13 +697,7 @@ function setMoneyRequestTaxRateValues(transactionID: string, taxRateValues: TaxR * @param policy - The policy object, or undefined for P2P transactions where tax info should be cleared * @param isMovingFromTrackExpense - If the expense is moved from Track Expense */ -function setMoneyRequestCategory( - transactionID: string, - category: string, - policy: OnyxEntry, - getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals'], - isMovingFromTrackExpense?: boolean, -) { +function setMoneyRequestCategory(transactionID: string, category: string, policy: OnyxEntry, isMovingFromTrackExpense?: boolean) { Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, { category, }); @@ -717,16 +710,6 @@ function setMoneyRequestCategory( taxAmount: null, taxValue: null, }); - return; - } - const transaction = getAllTransactionDrafts()[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`]; - const {categoryTaxCode, categoryTaxAmount, categoryTaxValue} = getCategoryTaxDetails(category, transaction, policy, getCurrencyDecimals); - if (categoryTaxCode && categoryTaxAmount !== undefined && categoryTaxValue) { - setMoneyRequestTaxRateValues(transactionID, { - taxCode: categoryTaxCode, - taxAmount: categoryTaxAmount, - taxValue: categoryTaxValue, - }); } } diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index f377d1510851..c22e4e16427d 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -1351,7 +1351,6 @@ function setDraftSplitTransaction( policies, isSplitTransaction: true, personalPolicyOutputCurrency, - getCurrencyDecimals, getCurrencySymbol, }) : null; diff --git a/src/libs/actions/IOU/UpdateMoneyRequest.ts b/src/libs/actions/IOU/UpdateMoneyRequest.ts index 121d2995f934..049167ecbcdb 100644 --- a/src/libs/actions/IOU/UpdateMoneyRequest.ts +++ b/src/libs/actions/IOU/UpdateMoneyRequest.ts @@ -1977,7 +1977,6 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U isSplitTransaction, policy, personalPolicyOutputCurrency, - getCurrencyDecimals, getCurrencySymbol, }) : undefined; @@ -2568,7 +2567,7 @@ function getUpdateTrackExpenseParams( distanceOriginalPolicy, currentTransactionViolations, } = params; - const {personalPolicyOutputCurrency, getCurrencyDecimals, getCurrencySymbol} = currencyContext; + const {personalPolicyOutputCurrency, getCurrencySymbol} = currencyContext; const optimisticData: Array< OnyxUpdate< | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS @@ -2608,7 +2607,6 @@ function getUpdateTrackExpenseParams( isFromExpenseReport: false, policy: policyForTransaction, personalPolicyOutputCurrency, - getCurrencyDecimals, getCurrencySymbol, }) : null; diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts index 2d0cd1e85419..7bcd411ce945 100644 --- a/src/libs/actions/Policy/Category.ts +++ b/src/libs/actions/Policy/Category.ts @@ -24,7 +24,6 @@ import type { } from '@libs/API/parameters'; import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import * as ApiUtils from '@libs/ApiUtils'; -import {getRuleCategoryName, matchesCategoryTaxRule} from '@libs/CategoryTaxRulesUtils'; import * as CategoryUtils from '@libs/CategoryUtils'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import * as ErrorUtils from '@libs/ErrorUtils'; @@ -43,7 +42,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, PolicyCategories, PolicyCategory, Report, ReportAction} from '@src/types/onyx'; import type {ImportFinalModal} from '@src/types/onyx/ImportedSpreadsheet'; -import type {ApprovalRule, CustomUnit, ExpenseRule, MccGroup} from '@src/types/onyx/Policy'; +import type {ApprovalRule, CustomUnit, MccGroup} from '@src/types/onyx/Policy'; import type {PolicyCategoryExpenseLimitType} from '@src/types/onyx/PolicyCategory'; import type {OnyxData} from '@src/types/onyx/Request'; @@ -854,32 +853,12 @@ function renamePolicyCategory(policyData: PolicyData, policyCategory: {oldName: const policyCategoryApproverRule = CategoryUtils.getCategoryApproverRule(policy?.rules?.approvalRules ?? [], policyCategory.oldName); const approvalRules = policy?.rules?.approvalRules ?? []; - const expenseRules = policy?.rules?.expenseRules ?? []; const mccGroup = policy?.mccGroup ?? {}; const updatedApprovalRules: ApprovalRule[] = lodashCloneDeep(approvalRules); - const updatedExpenseRules: ExpenseRule[] = lodashCloneDeep(expenseRules); const clonedMccGroup: Record = lodashCloneDeep(mccGroup); const updatedMccGroup = CategoryUtils.updateCategoryInMccGroup(clonedMccGroup, policyCategory.oldName, policyCategory.newName); const updatedMccGroupWithClearedPendingAction = CategoryUtils.updateCategoryInMccGroup(clonedMccGroup, policyCategory.oldName, policyCategory.newName, true); - // Found by its category condition rather than by `id`, because a rule created in NewDot has none: comparing ids - // made every rule without one equal, so a rename overwrote whichever sat earliest in the array and destroyed it. - // The match is rewritten as a copy for the same reason — the rule read off the policy is the live object. - const expenseRuleIndex = updatedExpenseRules.findIndex((rule) => matchesCategoryTaxRule(rule, policyCategory.oldName)); - const ruleToRename = updatedExpenseRules.at(expenseRuleIndex); - if (expenseRuleIndex !== -1 && ruleToRename) { - updatedExpenseRules[expenseRuleIndex] = { - ...ruleToRename, - applyWhen: ruleToRename.applyWhen.map((applyWhen) => ({ - ...applyWhen, - ...(applyWhen.field === CONST.POLICY.FIELDS.CATEGORY && - applyWhen.value === policyCategory.oldName && { - value: policyCategory.newName, - }), - })), - }; - } - // Its related by name, so the corresponding rule has to be updated to handle offline scenario if (policyCategoryApproverRule) { const indexToUpdate = updatedApprovalRules.findIndex((rule) => rule.id === policyCategoryApproverRule.id); @@ -898,7 +877,6 @@ function renamePolicyCategory(policyData: PolicyData, policyCategory: {oldName: const policyOptimisticData = { rules: { approvalRules: updatedApprovalRules, - expenseRules: updatedExpenseRules, }, mccGroup: updatedMccGroup, }; @@ -972,7 +950,6 @@ function renamePolicyCategory(policyData: PolicyData, policyCategory: {oldName: value: { rules: { approvalRules, - expenseRules, }, mccGroup, }, @@ -1811,226 +1788,6 @@ function setPolicyCategoryApprover(policyID: string, categoryName: string, appro API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_APPROVER, parameters, onyxData); } -/** - * The categories an expense rule already matches on, for membership tests that would otherwise scan the array once per - * name. A rate isn't required, since a rule is found here to have one written to it. - */ -function getCategoriesWithExpenseRule(expenseRules: ExpenseRule[]): Set { - return new Set(expenseRules.map(getRuleCategoryName)); -} - -/** Builds the expense rule that carries a category's default tax rate. */ -function buildCategoryTaxRule(categoryName: string, taxID: string): ExpenseRule { - return { - tax: { - // eslint-disable-next-line @typescript-eslint/naming-convention - field_id_TAX: {externalID: taxID}, - }, - applyWhen: [ - { - condition: CONST.POLICY.RULE_CONDITIONS.MATCHES, - field: CONST.POLICY.FIELDS.CATEGORY, - value: categoryName, - }, - ], - }; -} - -/** - * Applies a tax rate to each category, returning the whole rules array. - * - * Onyx replaces arrays wholesale, so a write can never patch one rule. Every stage supplies the complete array. - */ -function withCategoryTaxRates(expenseRules: ExpenseRule[], taxRatesByCategory: Map): ExpenseRule[] { - const updated = expenseRules.map((rule) => { - const categoryName = getRuleCategoryName(rule); - const taxID = categoryName ? taxRatesByCategory.get(categoryName) : undefined; - - if (!taxID) { - return rule; - } - // eslint-disable-next-line @typescript-eslint/naming-convention - return {...rule, tax: {field_id_TAX: {...rule.tax?.field_id_TAX, externalID: taxID}}}; - }); - - const categoriesWithExpenseRule = getCategoriesWithExpenseRule(expenseRules); - const added = [...taxRatesByCategory.entries()].flatMap(([categoryName, taxID]) => - !taxID || categoriesWithExpenseRule.has(categoryName) ? [] : [buildCategoryTaxRule(categoryName, taxID)], - ); - - return [...updated, ...added]; -} - -/** - * Sets the same default tax rate on one or more categories. - * - * No `successData`, like `setPolicyCategoryApprover`: it would carry a whole-array snapshot, and `API.write` persists - * those, so queued offline writes would replay stale arrays over each other. The server response is authoritative. - * - * The command is per-category, so a bulk save is one write each, and every rollback is the array as it stood before the - * save. Onyx replaces the array wholesale, so a rollback can't patch a single rule: whichever failure lands last is the - * array. Snapshots that each spared their own siblings therefore contradicted each other once two writes failed, and - * the survivor made a rejected category look saved. One identical baseline makes the order stop mattering. - * - * The cost is that a partial failure discards the writes that succeeded too, so they read stale until the next policy - * read. That beats showing a rate the server rejected, which is what the admin would otherwise save on top of. - */ -function setPolicyCategoryTaxes(policy: OnyxEntry, categoryNames: string[], taxID: string) { - if (!policy?.id || categoryNames.length === 0 || !taxID) { - Log.warn('Invalid params for setPolicyCategoryTaxes'); - return; - } - const policyID = policy.id; - const expenseRules = policy.rules?.expenseRules ?? []; - const requested = new Map(categoryNames.map((categoryName) => [categoryName, taxID])); - - // Every write shares this array. They are all enqueued at once, so the optimistic state has to be the same - // end state for each of them rather than a running total that the last write would truncate. - const optimisticExpenseRules = withCategoryTaxRates(expenseRules, requested); - - const onyxData: OnyxData = { - optimisticData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: {rules: {expenseRules: optimisticExpenseRules}}, - }, - ], - failureData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: {rules: {expenseRules}}, - }, - ], - }; - - for (const categoryName of categoryNames) { - API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_TAX, {policyID, categoryName, taxID}, onyxData); - } -} - -/** Sets a single category's default tax rate. */ -function setPolicyCategoryTax(policy: OnyxEntry, categoryName: string, taxID: string) { - setPolicyCategoryTaxes(policy, [categoryName], taxID); -} - -/** - * Removes the tax defaults for one or more categories. There is no dedicated delete command: writing the workspace's own - * default rate is what clears an override, since `getCategoryDefaultTaxRate` falls back to that same rate when no rule - * exists. The rules are dropped optimistically so the rows go straight away. - * - * Dropped rather than marked as deleting, so no grey pending row offline. A rule can't carry a pending flag: clearing - * one on success means rewriting the whole array, `API.write` persists that snapshot, and queued writes then replay - * stale arrays over each other. That was the state before dc9c34f9a97 removed `pendingAction` from `ExpenseRule`. - * - * Every rollback is the array as it stood before the delete, for the reason in `setPolicyCategoryTaxes`: Onyx replaces - * the array wholesale, so per-category snapshots contradict each other and the last failure to land hides the rest. - */ -function deletePolicyCategoryTaxes(policy: OnyxEntry, categoryNames: string[]) { - const defaultExternalID = policy?.taxRates?.defaultExternalID; - if (!policy?.id || !defaultExternalID || categoryNames.length === 0) { - Log.warn('Invalid params for deletePolicyCategoryTaxes'); - return; - } - const policyID = policy.id; - const expenseRules = policy.rules?.expenseRules ?? []; - const categoriesWithExpenseRule = getCategoriesWithExpenseRule(expenseRules); - const targets = categoryNames.filter((categoryName) => categoriesWithExpenseRule.has(categoryName)); - - if (targets.length === 0) { - return; - } - - // Shared like the save path: the writes are enqueued together, so each needs the same end state. - const targetedCategories = new Set(targets); - const optimisticExpenseRules = expenseRules.filter((rule) => { - const categoryName = getRuleCategoryName(rule); - return !categoryName || !targetedCategories.has(categoryName); - }); - - const onyxData: OnyxData = { - optimisticData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: {rules: {expenseRules: optimisticExpenseRules}}, - }, - ], - failureData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: {rules: {expenseRules}}, - }, - ], - }; - - for (const categoryName of targets) { - API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_TAX, {policyID, categoryName, taxID: defaultExternalID}, onyxData); - } -} - -/** Removes a single category's default tax rate. */ -function deletePolicyCategoryTax(policy: OnyxEntry, categoryName: string) { - deletePolicyCategoryTaxes(policy, [categoryName]); -} - -/** - * Moves a category's default tax rate to another category. - * - * The command is per-category, so a move is two writes: clearing the old category and setting the new one. They share - * one rollback array — the state before the move — because Onyx replaces the array wholesale. Rolling each write back - * against its own baseline instead lets the second failure overwrite what the first restored, dropping both rules. - * - * If exactly one write fails the local array goes back to the state before the move, so the one that succeeded reads - * stale until the next policy read. Two commands with no transaction between them can't do better, and it beats - * leaving the rate on two categories at once. - * - * The server can be left mid-move for the same reason — the rate on both categories, or on neither — and no rollback - * here can undo a request that already landed. A compensating write is not the answer: it can fail too, and offline it - * queues behind the failure it is meant to repair. The next policy read reconciles, and until the API can move a rate - * in one command that is the honest bound on this flow. - */ -function movePolicyCategoryTax(policy: OnyxEntry, fromCategoryName: string, toCategoryName: string, taxID: string) { - const defaultExternalID = policy?.taxRates?.defaultExternalID; - if (!policy?.id || !defaultExternalID || !fromCategoryName || !toCategoryName || !taxID) { - Log.warn('Invalid params for movePolicyCategoryTax'); - return; - } - const policyID = policy.id; - const originalExpenseRules = policy.rules?.expenseRules ?? []; - const withoutOldCategory = originalExpenseRules.filter((rule) => !matchesCategoryTaxRule(rule, fromCategoryName)); - const optimisticExpenseRules = withCategoryTaxRates(withoutOldCategory, new Map([[toCategoryName, taxID]])); - - // Writing the workspace default rate is what clears an override, so the move's first half is a write like any other. - const writes = [ - {categoryName: fromCategoryName, taxID: defaultExternalID}, - {categoryName: toCategoryName, taxID}, - ]; - - const onyxData: OnyxData = { - optimisticData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: {rules: {expenseRules: optimisticExpenseRules}}, - }, - ], - failureData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: {rules: {expenseRules: originalExpenseRules}}, - }, - ], - }; - - for (const write of writes) { - API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_TAX, {policyID, ...write}, onyxData); - } -} - function setPolicyCategoryAttendeesRequired(policyID: string, categoryName: string, areAttendeesRequired: boolean, policyCategories: PolicyCategories = {}) { const policyCategoryToUpdate = policyCategories?.[categoryName]; const originalAreAttendeesRequired = policyCategoryToUpdate?.areAttendeesRequired; @@ -2105,8 +1862,6 @@ function setPolicyCategoryAttendeesRequired(policyID: string, categoryName: stri export { clearCategoryErrors, - deletePolicyCategoryTax, - deletePolicyCategoryTaxes, createPolicyCategory, deleteWorkspaceCategories, downloadCategoriesCSV, @@ -2117,7 +1872,6 @@ export { openPolicyCategoriesPage, removePolicyCategoryReceiptsRequired, removePolicyCategoryItemizedReceiptsRequired, - movePolicyCategoryTax, renamePolicyCategory, setPolicyCategoryApprover, setPolicyCategoryAttendeesRequired, @@ -2128,8 +1882,6 @@ export { setPolicyCategoryReceiptsAndItemizedReceiptRequired, setPolicyCategoryReceiptsRequired, setPolicyCategoryItemizedReceiptsRequired, - setPolicyCategoryTax, - setPolicyCategoryTaxes, setPolicyCustomUnitDefaultCategory, setWorkspaceCategoryDescriptionHint, setWorkspaceCategoryEnabled, diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index c899012c7a64..d354ac96519b 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -26,10 +26,11 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {GeneratedRuleState, GeneratedRuleType} from '@src/types/onyx/GeneratedRule'; import type {ImportFinalModal} from '@src/types/onyx/ImportedSpreadsheet'; import type Policy from '@src/types/onyx/Policy'; -import type {AgentRule, CodingRule, CodingRuleFilter} from '@src/types/onyx/Policy'; +import type {AgentRule, CodingRule} from '@src/types/onyx/Policy'; import type {OnyxData} from '@src/types/onyx/Request'; import type Rule from '@src/types/onyx/Rule'; import type {RuleBody} from '@src/types/onyx/Rule'; +import type {RuleFilterNode} from '@src/types/onyx/RuleFilters'; import type {OnyxUpdate} from 'react-native-onyx'; @@ -269,7 +270,7 @@ async function importMerchantRulesSpreadsheet(policyID: string, rules: Record> = [ { onyxMethod: Onyx.METHOD.MERGE, diff --git a/src/pages/iou/request/step/DynamicIOURequestStepCategoryCreate.tsx b/src/pages/iou/request/step/DynamicIOURequestStepCategoryCreate.tsx index ecd7cd0c9c76..7d3244db97e1 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepCategoryCreate.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepCategoryCreate.tsx @@ -189,7 +189,7 @@ function DynamicIOURequestStepCategoryCreate({ rules, }); } else { - setMoneyRequestCategory(transactionID, categoryName, policy, getCurrencyDecimals); + setMoneyRequestCategory(transactionID, categoryName, policy); } Navigation.goBack(basePath); diff --git a/src/pages/iou/request/step/DynamicIOURequestStepDestination.tsx b/src/pages/iou/request/step/DynamicIOURequestStepDestination.tsx index eaa76a092e2e..c207f27f2e36 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepDestination.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepDestination.tsx @@ -8,7 +8,6 @@ import type {ListItem, SelectionListWithSectionsHandle} from '@components/Select import WorkspaceEmptyStateSection from '@components/WorkspaceEmptyStateSection'; import useContentHeaderHeight from '@hooks/useContentHeaderHeight'; -import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; @@ -88,7 +87,6 @@ function DynamicIOURequestStepDestination({ explicitPolicyID, ref, }: DynamicIOURequestStepDestinationProps) { - const {getCurrencyDecimals} = useCurrencyListActions(); const isEditPage = name === SCREENS.MONEY_REQUEST.DYNAMIC_STEP_DESTINATION_EDIT; const editBackPath = useDynamicBackPath(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DESTINATION_EDIT.path); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); @@ -158,7 +156,7 @@ function DynamicIOURequestStepDestination({ setTransactionReport(transactionID, {reportID: transactionReportID}, true); setMoneyRequestParticipantsFromReport(transactionID, targetReport, accountID, targetIouType !== CONST.IOU.TYPE.TRACK); setCustomUnitID(transactionID, customUnit.customUnitID); - setMoneyRequestCategory(transactionID, customUnit?.defaultCategory ?? '', undefined, getCurrencyDecimals); + setMoneyRequestCategory(transactionID, customUnit?.defaultCategory ?? '', undefined); } setCustomUnitRateID(transactionID, destination.keyForList ?? '', transaction, policy, false, personalPolicy?.outputCurrency); setMoneyRequestCurrency(transactionID, destination.currency); @@ -220,7 +218,7 @@ function DynamicIOURequestStepDestination({ return; } setCustomUnitID(transactionID, perDiemUnit?.customUnitID ?? CONST.CUSTOM_UNITS.FAKE_P2P_ID); - setMoneyRequestCategory(transactionID, perDiemUnit?.defaultCategory ?? '', undefined, getCurrencyDecimals); + setMoneyRequestCategory(transactionID, perDiemUnit?.defaultCategory ?? '', undefined); // eslint-disable-next-line react-hooks/exhaustive-deps }, [transactionID, policy?.customUnits, transaction?.iouRequestType]); diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation/useParticipantPickerState.ts b/src/pages/iou/request/step/IOURequestStepConfirmation/useParticipantPickerState.ts index fbd31bf151ab..c41e36aa9223 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation/useParticipantPickerState.ts +++ b/src/pages/iou/request/step/IOURequestStepConfirmation/useParticipantPickerState.ts @@ -1,5 +1,4 @@ import type useBlockDistanceRequest from '@hooks/useBlockDistanceRequest'; -import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDefaultParticipants from '@hooks/useDefaultParticipants'; import useOnyx from '@hooks/useOnyx'; @@ -56,7 +55,6 @@ function useParticipantPickerState({ isManualRequest, blockDistanceRequestIfNeeded, }: UseParticipantPickerStateParams) { - const {getCurrencyDecimals} = useCurrencyListActions(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const personalPolicy = usePersonalPolicy(); const selfDMReport = useSelfDMReport(); @@ -216,7 +214,7 @@ function useParticipantPickerState({ }); setCustomUnitRateID(activeTransactionID, p2pRateID, transaction, undefined, false, personalPolicy?.outputCurrency); } - setMoneyRequestCategory(activeTransactionID, '', undefined, getCurrencyDecimals); + setMoneyRequestCategory(activeTransactionID, '', undefined); setMoneyRequestTag(activeTransactionID, ''); } else { const workspacePolicy = selectedPolicy; @@ -241,7 +239,7 @@ function useParticipantPickerState({ // policy so the previous workspace's category-derived tax is cleared along with the category. if (firstParticipant.policyID && firstParticipant.policyID !== policyID) { const defaultCategory = isDistanceRequest ? (getDistanceRateCustomUnit(workspacePolicy)?.defaultCategory ?? '') : ''; - setMoneyRequestCategory(activeTransactionID, defaultCategory, undefined, getCurrencyDecimals); + setMoneyRequestCategory(activeTransactionID, defaultCategory, undefined); setMoneyRequestTag(activeTransactionID, ''); } } diff --git a/src/pages/iou/request/step/IOURequestStepPerDiemWorkspace.tsx b/src/pages/iou/request/step/IOURequestStepPerDiemWorkspace.tsx index 072693c990a9..0651d0d99e26 100644 --- a/src/pages/iou/request/step/IOURequestStepPerDiemWorkspace.tsx +++ b/src/pages/iou/request/step/IOURequestStepPerDiemWorkspace.tsx @@ -1,7 +1,6 @@ import ActivityIndicator from '@components/ActivityIndicator'; import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; -import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy'; import useNetwork from '@hooks/useNetwork'; @@ -39,7 +38,6 @@ type IOURequestStepPerDiemWorkspaceProps = WithFullTransactionOrNotFoundProps { for (const item of transactions) { if (!item.category) { @@ -45,7 +42,7 @@ function CategoryDefaultsSetter({ if (existingCategory) { const isExistingCategoryEnabled = policyCategories?.[existingCategory]?.enabled; if (isExistingCategoryEnabled) { - setMoneyRequestCategory(item.transactionID, existingCategory, policy, getCurrencyDecimals); + setMoneyRequestCategory(item.transactionID, existingCategory, policy); } } continue; @@ -63,7 +60,7 @@ function CategoryDefaultsSetter({ if (!isDistanceRequest || !!item?.category) { continue; } - setMoneyRequestCategory(item.transactionID, defaultCategory, policy, getCurrencyDecimals, isMovingTransactionFromTrackExpense); + setMoneyRequestCategory(item.transactionID, defaultCategory, policy, isMovingTransactionFromTrackExpense); } // Prevent resetting to default when unselect category // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/pages/workspace/categories/CategorySettingsPage.tsx b/src/pages/workspace/categories/CategorySettingsPage.tsx index 1c871d4be279..e732964276f6 100644 --- a/src/pages/workspace/categories/CategorySettingsPage.tsx +++ b/src/pages/workspace/categories/CategorySettingsPage.tsx @@ -73,6 +73,7 @@ function CategorySettingsPage({route: {params, name}, navigation}: CategorySetti const {environmentURL} = useEnvironment(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const {isOffline} = useNetwork(); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const policyCategory = policyCategories?.[categoryName] ?? Object.values(policyCategories).find((category) => category.previousCategoryName === categoryName); const decodedCategoryName = getDecodedCategoryName(policyCategory?.name ?? ''); @@ -83,6 +84,7 @@ function CategorySettingsPage({route: {params, name}, navigation}: CategorySetti : getCategoryContextualRules({ policy, policyCategories, + policyRules: rules, category: policyCategory, categoryName: policyCategory.name, translate, diff --git a/src/pages/workspace/categories/DynamicCategoryDefaultTaxRatePage.tsx b/src/pages/workspace/categories/DynamicCategoryDefaultTaxRatePage.tsx deleted file mode 100644 index bc002ed1a679..000000000000 --- a/src/pages/workspace/categories/DynamicCategoryDefaultTaxRatePage.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; -import SelectionList from '@components/SelectionList'; -import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; -import type {ListItem} from '@components/SelectionList/types'; - -import useDynamicBackPath from '@hooks/useDynamicBackPath'; -import useInitialSelection from '@hooks/useInitialSelection'; -import useLocalize from '@hooks/useLocalize'; -import usePolicy from '@hooks/usePolicy'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import {formatDefaultTaxRateText, getCategoryDefaultTaxRate} from '@libs/CategoryUtils'; -import Navigation from '@libs/Navigation/Navigation'; -import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; - -import type {SettingsNavigatorParamList} from '@navigation/types'; - -import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; - -import {setPolicyCategoryTax} from '@userActions/Policy/Category'; - -import CONST from '@src/CONST'; -import {DYNAMIC_ROUTES} from '@src/ROUTES'; -import type SCREENS from '@src/SCREENS'; -import type {TaxRate} from '@src/types/onyx'; - -import React, {useCallback, useMemo, useState} from 'react'; - -type DynamicCategoryDefaultTaxRatePageProps = PlatformStackScreenProps; - -type TaxRateListItem = ListItem & { - value: string; -}; - -function DynamicCategoryDefaultTaxRatePage({ - route: { - params: {policyID, categoryName}, - }, -}: DynamicCategoryDefaultTaxRatePageProps) { - const styles = useThemeStyles(); - const {translate, localeCompare} = useLocalize(); - const policy = usePolicy(policyID); - const categorySettingsBackPath = useDynamicBackPath(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_DEFAULT_TAX_RATE.path); - - const persistedTaxRate = getCategoryDefaultTaxRate(policy?.rules?.expenseRules ?? [], categoryName, policy?.taxRates?.defaultExternalID); - - const [draftTaxRate, setDraftTaxRate] = useState(); - const selectedTaxRate = draftTaxRate ?? persistedTaxRate; - const hasChanges = !!selectedTaxRate && selectedTaxRate !== persistedTaxRate; - const initialSelectedTaxRate = useInitialSelection(selectedTaxRate, {isVisible: !!persistedTaxRate, resetOnFocus: true}); - - const textForDefault = useCallback((taxID: string, taxRate: TaxRate) => formatDefaultTaxRateText(translate, taxID, taxRate, policy?.taxRates), [policy?.taxRates, translate]); - - const taxesList = useMemo(() => { - if (!policy) { - return []; - } - return Object.entries(policy.taxRates?.taxes ?? {}) - .map(([key, value]) => ({ - text: textForDefault(key, value), - value: key, - keyForList: key, - isSelected: key === selectedTaxRate, - isDisabled: value.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - pendingAction: value.pendingAction ?? (Object.keys(value.pendingFields ?? {}).length > 0 ? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE : null), - })) - .sort((a, b) => localeCompare(a.text ?? a.keyForList ?? '', b.text ?? b.keyForList ?? '')); - }, [policy, selectedTaxRate, textForDefault, localeCompare]); - const orderedTaxesList = moveInitialSelectionToTop(taxesList, initialSelectedTaxRate ? [initialSelectedTaxRate] : []); - - const saveAndGoBack = () => { - if (hasChanges) { - setPolicyCategoryTax(policy, categoryName, selectedTaxRate); - Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.goBack(categorySettingsBackPath)); - return; - } - Navigation.goBack(categorySettingsBackPath); - }; - - const confirmButtonOptions = { - showButton: true, - text: translate('common.save'), - onConfirm: saveAndGoBack, - isDisabled: !hasChanges, - }; - - return ( - - - Navigation.goBack(categorySettingsBackPath)} - /> - { - if (!item.keyForList) { - return; - } - setDraftTaxRate(item.keyForList); - }} - confirmButtonOptions={confirmButtonOptions} - shouldSingleExecuteRowSelect - addBottomSafeAreaPadding - initiallyFocusedItemKey={initialSelectedTaxRate} - shouldScrollToFocusedIndexOnMount={false} - shouldUpdateFocusedIndex - style={{containerStyle: styles.pt3}} - /> - - - ); -} - -export default DynamicCategoryDefaultTaxRatePage; diff --git a/src/pages/workspace/rules/DescribeNewRuleForm.tsx b/src/pages/workspace/rules/DescribeNewRuleForm.tsx index 77ca88639147..595111b281a2 100644 --- a/src/pages/workspace/rules/DescribeNewRuleForm.tsx +++ b/src/pages/workspace/rules/DescribeNewRuleForm.tsx @@ -18,8 +18,7 @@ import {setDraftValues} from '@libs/actions/FormActions'; import {openPolicyCategoriesPage} from '@libs/actions/Policy/Category'; import {setDraftFlagForReviewRule, setDraftMerchantRule, setDraftRequireFieldsRule, setDraftSpendRule} from '@libs/actions/User'; import {filterInactiveCards} from '@libs/CardUtils'; -import {categoryHasTaxRule, getCategoryTaxRuleDraftFromGeneratedValues} from '@libs/CategoryTaxRulesUtils'; -import {getMerchantRuleDraftFromGeneratedValues} from '@libs/ExpenseDefaultRuleUtils'; +import {getCategoryTaxDefaultRule} from '@libs/ExpenseDefaultRuleUtils'; import {hasExplicitFlagAmount} from '@libs/FlagForReviewRulesUtils'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; @@ -34,11 +33,10 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import AGENT_RULE_INPUT_IDS from '@src/types/form/AddAgentRuleForm'; -import type {Policy, PolicyCategory} from '@src/types/onyx'; +import type {PolicyCategory} from '@src/types/onyx'; import type {GeneratedRuleState, GeneratedRuleType, GeneratedRuleValues} from '@src/types/onyx/GeneratedRule'; import type {ReactNode} from 'react'; -import type {OnyxEntry} from 'react-native-onyx'; import {useFocusEffect, useIsFocused} from '@react-navigation/native'; import React from 'react'; @@ -55,14 +53,13 @@ type DescribeNewRuleFormProps = { type SeedDraftContext = { policyID: string; - policy: OnyxEntry; /** The cards that a new spend rule can cover, or undefined while the card settings load */ eligibleCardIDs: string[] | undefined; }; /** Seeds the draft of the editor for the generated rule type and opens that editor */ -function seedDraftAndNavigate(values: GeneratedRuleValues, ruleType: GeneratedRuleType, {policyID, policy, eligibleCardIDs}: SeedDraftContext) { +function seedDraftAndNavigate(values: GeneratedRuleValues, ruleType: GeneratedRuleType, {policyID, eligibleCardIDs}: SeedDraftContext) { switch (ruleType) { case CONST.GENERATED_RULE.RULE_TYPE.REQUIRE_FIELDS: setDraftRequireFieldsRule(getRequireFieldsDraftFromGeneratedValues(values).draft); @@ -76,13 +73,10 @@ function seedDraftAndNavigate(values: GeneratedRuleValues, ruleType: GeneratedRu setDraftSpendRule(getSpendRuleDraftFromGeneratedValues(values, eligibleCardIDs)); Navigation.navigate(ROUTES.RULES_SPEND_NEW.getRoute(policyID)); break; + // A category tax default is an expense default that matches on a category, so both open the same editor. case CONST.GENERATED_RULE.RULE_TYPE.EXPENSE_DEFAULTS: - setDraftMerchantRule(getMerchantRuleDraftFromGeneratedValues(values)); - Navigation.navigate(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); - break; - // The expense defaults editor takes its rule type from the draft. Therefore a category draft opens the category tax rate editor. case CONST.GENERATED_RULE.RULE_TYPE.CATEGORY_TAX_RATE: - setDraftMerchantRule(getCategoryTaxRuleDraftFromGeneratedValues(values, policy)); + setDraftMerchantRule(values); Navigation.navigate(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); break; default: @@ -98,6 +92,7 @@ function DescribeNewRuleForm({policyID, children}: DescribeNewRuleFormProps) { const policy = usePolicy(policyID); const icons = useMemoizedLazyExpensifyIcons(['Sparkles']); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const domainAccountID = useDefaultFundID(policyID); const [expensifyCardSettings] = useOnyx(`${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${domainAccountID}`); const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${domainAccountID}_${CONST.EXPENSIFY_CARD.BANK}`, {selector: filterInactiveCards}); @@ -129,7 +124,7 @@ function DescribeNewRuleForm({policyID, children}: DescribeNewRuleFormProps) { case CONST.GENERATED_RULE.RULE_TYPE.FLAG_FOR_REVIEW: return !hasExplicitFlagAmount(category.maxExpenseAmount); case CONST.GENERATED_RULE.RULE_TYPE.CATEGORY_TAX_RATE: - return !categoryHasTaxRule(policy?.rules?.expenseRules, category.name); + return !getCategoryTaxDefaultRule(rules, policyID, category.name); default: return true; } @@ -153,7 +148,7 @@ function DescribeNewRuleForm({policyID, children}: DescribeNewRuleFormProps) { addBottomSafeAreaPadding shouldUseScrollView submitFlexEnabled - onRuleGenerated={(values, generatedRuleType) => seedDraftAndNavigate(values, generatedRuleType, {policyID, policy, eligibleCardIDs})} + onRuleGenerated={(values, generatedRuleType) => seedDraftAndNavigate(values, generatedRuleType, {policyID, eligibleCardIDs})} isCategoryEligible={isCategoryEligible} onAgentRuleFallback={offerAgentRule} > diff --git a/src/pages/workspace/rules/MerchantRules/AddAmountPage.tsx b/src/pages/workspace/rules/MerchantRules/AddAmountPage.tsx new file mode 100644 index 000000000000..92fca2fb3cc4 --- /dev/null +++ b/src/pages/workspace/rules/MerchantRules/AddAmountPage.tsx @@ -0,0 +1,91 @@ +import AmountForm from '@components/AmountForm'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormOnyxValues} from '@components/Form/types'; +import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; +import ScreenWrapper from '@components/ScreenWrapper'; + +import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import {useCurrencyListActions} from '@hooks/useCurrencyList'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePolicy from '@hooks/usePolicy'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {updateDraftMerchantRule} from '@libs/actions/User'; +import {convertToBackendAmount, convertToFrontendAmountAsString} from '@libs/CurrencyUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import MERCHANT_RULE_INPUT_IDS from '@src/types/form/MerchantRuleForm'; + +import React from 'react'; +import {View} from 'react-native'; + +import useMerchantRuleRoute from './useMerchantRuleRoute'; + +type AddAmountPageProps = PlatformStackScreenProps; + +function AddAmountPage({route}: AddAmountPageProps) { + const {policyID, ruleID} = route.params; + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {inputCallbackRef} = useAutoFocusInput(); + const {getCurrencyDecimals} = useCurrencyListActions(); + const policy = usePolicy(policyID); + const {backToRoute} = useMerchantRuleRoute(DYNAMIC_ROUTES.RULES_MERCHANT_AMOUNT_FROM_EXPENSE.path, policyID, ruleID); + + const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM); + const currency = policy?.outputCurrency ?? CONST.CURRENCY.USD; + // The rule stores whole cents and the input works in whole currency, so the two are converted at this boundary. + const defaultValue = form?.amount ? convertToFrontendAmountAsString(Number(form.amount), getCurrencyDecimals(currency)) : ''; + + const goBack = () => Navigation.goBack(backToRoute); + + const onSubmit = ({amount}: FormOnyxValues) => { + const amountInCents = amount ? convertToBackendAmount(Number(amount)) : 0; + updateDraftMerchantRule({amount: amountInCents ? String(amountInCents) : ''}); + goBack(); + }; + + return ( + + + + + + + + + ); +} + +export default AddAmountPage; diff --git a/src/pages/workspace/rules/MerchantRules/AddCategoryTaxRulePage.tsx b/src/pages/workspace/rules/MerchantRules/AddCategoryTaxRulePage.tsx index fc5ce474ced9..95af5097447b 100644 --- a/src/pages/workspace/rules/MerchantRules/AddCategoryTaxRulePage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddCategoryTaxRulePage.tsx @@ -1,33 +1,34 @@ import useCategoryRuleCreateBackPath from '@hooks/useCategoryRuleCreateBackPath'; -import usePolicy from '@hooks/usePolicy'; +import useOnyx from '@hooks/useOnyx'; -import {categoryHasTaxRule} from '@libs/CategoryTaxRulesUtils'; +import {getCategoryTaxDefaultRule} from '@libs/ExpenseDefaultRuleUtils'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES, getWorkspaceCategorySettingsRoute} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import React from 'react'; -import MerchantRulePageBase from './MerchantRulePageBase'; +import ExpenseDefaultRulePageBase from './ExpenseDefaultRulePageBase'; type AddCategoryTaxRulePageProps = PlatformStackScreenProps; function AddCategoryTaxRulePage({route}: AddCategoryTaxRulePageProps) { const {policyID, categoryName} = route.params; - const policy = usePolicy(policyID); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const categorySettingsBackPath = useCategoryRuleCreateBackPath(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_RULES_TAX_NEW.path) ?? getWorkspaceCategorySettingsRoute(policyID, categoryName); // A category holds one tax default, so creating one for a category that already has it is really an edit. Opening // it as one shows the rate already applied and lets the admin delete it. - const hasExistingRule = categoryHasTaxRule(policy?.rules?.expenseRules, categoryName); + const existingRuleID = getCategoryTaxDefaultRule(rules, policyID, categoryName)?.ruleID; return ( - ; +import useMerchantRuleRoute from './useMerchantRuleRoute'; + +type AddCategoryToMatchPageProps = PlatformStackScreenProps< + SettingsNavigatorParamList, + typeof SCREENS.WORKSPACE.RULES_CATEGORY_TO_MATCH | typeof SCREENS.WORKSPACE.DYNAMIC_RULES_CATEGORY_TO_MATCH +>; type CategoryListItem = ListItem & { value: string; }; function AddCategoryToMatchPage({route}: AddCategoryToMatchPageProps) { - const {policyID, categoryName: editingCategoryName} = route.params; + const {policyID, ruleID, categoryName: editingCategoryName} = route.params; // Editing a category tax default edits one rule, so its category is swapped, not added to. const isEditingCategoryTaxRule = !!editingCategoryName; + // A category tax default is addressed by category rather than ruleID, so it returns to its own editor. + const {backToRoute} = useMerchantRuleRoute( + DYNAMIC_ROUTES.RULES_CATEGORY_TO_MATCH_FROM_EXPENSE.path, + policyID, + ruleID, + editingCategoryName ? ROUTES.RULES_CATEGORY_TAX_EDIT.getRoute(policyID, editingCategoryName) : undefined, + ); const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); const illustrations = useMemoizedLazyIllustrations(['Telescope']); - const policy = usePolicy(policyID); const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM); @@ -66,10 +75,8 @@ function AddCategoryToMatchPage({route}: AddCategoryToMatchPageProps) { isLoading: arePolicyCategoriesLoading, } = usePolicyCategoryPickerCategories({ policyID, - // Keep current selections so they stay removable; don't offer a category that already has a tax default. - // The rule being edited stays listed even once the selection moves off it, since its own category still has a - // tax rule and would otherwise drop out of the list with no way to switch back. - isEligible: (category) => selectedCategories.includes(category.name) || category.name === editingCategoryName || !categoryHasTaxRule(policy?.rules?.expenseRules, category.name), + // A rule only matches on the category, so every category is eligible. + isEligible: () => true, }); const categoryItems: CategoryListItem[] = categories.map((category) => ({ @@ -79,6 +86,16 @@ function AddCategoryToMatchPage({route}: AddCategoryToMatchPageProps) { isSelected: selectedCategories.includes(category.name), })); + // A rule keeps matching on a category the workspace has since deleted. Listing it is the only way to see what the + // rule matches on and the only way to take it off, so it is added back rather than left out of the picker. + const listedCategoryNames = new Set(categories.map((category) => category.name)); + for (const categoryName of selectedCategories) { + if (listedCategoryNames.has(categoryName)) { + continue; + } + categoryItems.push({keyForList: categoryName, text: getDecodedCategoryName(categoryName), value: categoryName, isSelected: true}); + } + // Tokenized like the main category picker, so the words can be typed in any order and a nested name is still found // by any part of it. const filterCategory = (item: CategoryListItem, searchInput: string) => tokenizedSearch([item], searchInput, (category) => [category.text ?? '']).length > 0; @@ -114,9 +131,6 @@ function AddCategoryToMatchPage({route}: AddCategoryToMatchPageProps) { setSelectedCategories((prev) => Array.from(new Set([...prev, ...visibleValues]))); }; - // Passed explicitly like every sibling picker, so a deep link has somewhere to pop to. A category tax default is - // addressed by category rather than ruleID. - const backToRoute = editingCategoryName ? ROUTES.RULES_CATEGORY_TAX_EDIT.getRoute(policyID, editingCategoryName) : ROUTES.RULES_MERCHANT_NEW.getRoute(policyID); const goBackToRule = () => Navigation.goBack(backToRoute); const goBackAfterSave = () => Navigation.goBack(backToRoute, {shouldSkipFocusRestore: true}); diff --git a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx index a774872dda81..64bda399915f 100644 --- a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx @@ -1,41 +1,19 @@ -import useOnyx from '@hooks/useOnyx'; - -import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; -import React, {useEffect} from 'react'; +import React from 'react'; -import MerchantRulePageBase from './MerchantRulePageBase'; +import ExpenseDefaultRulePageBase from './ExpenseDefaultRulePageBase'; type AddMerchantRulePageProps = PlatformStackScreenProps; function AddMerchantRulePage({route}: AddMerchantRulePageProps) { const {policyID} = route.params; - const [form, formMetadata] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM); - - // The editor is always scoped to one rule type, which the chooser puts in the draft. A deep link straight here - // carries no draft, so send it to the chooser rather than render an editor offering both conditions at once. - const shouldRedirectToTypePicker = !isLoadingOnyxValue(formMetadata) && !form?.ruleType; - - useEffect(() => { - if (!shouldRedirectToTypePicker) { - return; - } - Navigation.navigate(ROUTES.RULES_EXPENSE_DEFAULT_TYPE.getRoute(policyID), {forceReplace: true}); - }, [shouldRedirectToTypePicker, policyID]); - - if (shouldRedirectToTypePicker) { - return null; - } return ( - ; + +type TagListItem = ListItem & { + value: string; +}; + +function AddTagToMatchPage({route}: AddTagToMatchPageProps) { + const {policyID, ruleID} = route.params; + const {backToRoute} = useMerchantRuleRoute(DYNAMIC_ROUTES.RULES_TAG_TO_MATCH_FROM_EXPENSE.path, policyID, ruleID); + const styles = useThemeStyles(); + const {translate, localeCompare} = useLocalize(); + const illustrations = useMemoizedLazyIllustrations(['Telescope']); + + // Tags are lazy loaded, so a picker that only reads Onyx shows an empty list on an account that has never + // opened the tags page. + const {isLoadingPolicyTags} = useLoadPolicyTags(policyID); + const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM); + const [policyTagLists = getEmptyArray>()] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, {selector: getTagLists}); + + const draftTags = form?.tagsToMatch ?? []; + const [selectedTags, setSelectedTags] = useState(draftTags); + const initialSelectedTags = useInitialSelection(draftTags, {resetOnFocus: true}); + + // The rule matches any one of the tags it carries, so the levels are offered as one flat list rather than a row + // per level. A name shared by two levels is one option, since the rule stores the name and not the level. + const tagItems: TagListItem[] = []; + const seenTagNames = new Set(); + for (const tagList of policyTagLists) { + for (const tag of Object.values(tagList.tags)) { + if (seenTagNames.has(tag.name)) { + continue; + } + // A rule keeps a tag it already matches on even once the tag is disabled or on its way out, so it stays + // removable. + const isTagMatched = selectedTags.includes(tag.name); + if ((!tag.enabled || tag.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) && !isTagMatched) { + continue; + } + seenTagNames.add(tag.name); + tagItems.push({ + keyForList: tag.name, + text: getCleanedTagName(tag.name), + value: tag.name, + isSelected: isTagMatched, + }); + } + } + + // A rule keeps matching on a tag the workspace has since deleted. Listing it is the only way to see what the rule + // matches on and the only way to take it off, so it is added back rather than left out of the picker. + for (const tagName of selectedTags) { + if (seenTagNames.has(tagName)) { + continue; + } + seenTagNames.add(tagName); + tagItems.push({keyForList: tagName, text: getCleanedTagName(tagName), value: tagName, isSelected: true}); + } + + const filterTag = (item: TagListItem, searchInput: string) => tokenizedSearch([item], searchInput, (tag) => [tag.text ?? '']).length > 0; + + // Pin the initially selected tags to the top of the full sorted list, so they stay at the top while searching. + const sortedTagItems = moveInitialSelectionToTop( + [...tagItems].sort((first, second) => localeCompare(first.text ?? '', second.text ?? '')), + initialSelectedTags, + ); + + const [inputValue, setInputValue, filteredTagItems] = useSearchResults(sortedTagItems, filterTag); + + const toggleTag = (item: TagListItem) => { + setSelectedTags((previous) => (previous.includes(item.value) ? previous.filter((tagName) => tagName !== item.value) : [...previous, item.value])); + }; + + const toggleSelectAll = () => { + const visibleValues = filteredTagItems.map((item) => item.value); + const allVisibleSelected = visibleValues.length > 0 && visibleValues.every((value) => selectedTags.includes(value)); + + if (allVisibleSelected) { + const visibleSet = new Set(visibleValues); + setSelectedTags((previous) => previous.filter((value) => !visibleSet.has(value))); + return; + } + + setSelectedTags((previous) => Array.from(new Set([...previous, ...visibleValues]))); + }; + + const goBackToRule = () => Navigation.goBack(backToRoute); + + const handleSave = () => { + updateDraftMerchantRule({tagsToMatch: selectedTags}); + Navigation.goBack(backToRoute, {shouldSkipFocusRestore: true}); + }; + + return ( + + + {isLoadingPolicyTags ? ( + + + + ) : ( + 0 ? toggleSelectAll : undefined} + textInputOptions={{ + value: inputValue, + label: translate('common.search'), + onChangeText: setInputValue, + }} + style={{ + listHeaderWrapperStyle: [styles.pt5, styles.pb2], + listHeaderSelectAllTextStyle: [styles.textLabelSupporting], + }} + listEmptyContent={ + + + + } + footerContent={ + + } + /> + )} + + ); +} + +export default AddTagToMatchPage; diff --git a/src/pages/workspace/rules/MerchantRules/AddTaxPage.tsx b/src/pages/workspace/rules/MerchantRules/AddTaxPage.tsx index a28e5b59dbba..26b448f8783c 100644 --- a/src/pages/workspace/rules/MerchantRules/AddTaxPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddTaxPage.tsx @@ -4,7 +4,7 @@ import RuleTaxesDisabledEmptyState from '@components/Rule/RuleTaxesDisabledEmpty import useOnyx from '@hooks/useOnyx'; import {updateDraftMerchantRule} from '@libs/actions/User'; -import {hasUsableTaxRates, isCategoryRuleDraft, isSelectableTaxRate} from '@libs/CategoryTaxRulesUtils'; +import {hasUsableTaxRates, isSelectableTaxRate} from '@libs/CategoryTaxRulesUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; @@ -37,14 +37,12 @@ function AddTaxPage({route}: AddTaxPageProps) { // showing an empty picker. const areTaxesEnabled = hasUsableTaxRates(policy); - const isCategoryRule = isCategoryRuleDraft(form, categoryName); - const taxes = policy?.taxRates?.taxes ?? {}; const taxItems = Object.entries(taxes) // The rate the rule already holds always stays listed, whatever state it is in now. A rate can be disabled, // or become the workspace default, after a rule chose it, and dropping it here left the picker with nothing // marked selected, so the admin couldn't tell what the rule applies, only that it wasn't any of the options. - .filter(([taxKey, tax]) => taxKey === form?.tax || isSelectableTaxRate(policy, taxKey, tax, isCategoryRule)) + .filter(([taxKey, tax]) => taxKey === form?.tax || isSelectableTaxRate(tax)) .map(([taxKey, tax]) => ({ name: `${tax.name} (${tax.value})`, value: taxKey, @@ -68,10 +66,7 @@ function AddTaxPage({route}: AddTaxPageProps) { items={taxItems} onSave={onSave} backToRoute={backToRoute} - // A category rule exists to set a rate, so it has no "leave the tax alone" state to offer. Reading - // "None" as the current value is wrong too: without a rule the category falls back to the - // workspace default rate, which this list deliberately leaves out. - allowNoneOption={!isCategoryRule} + allowNoneOption /> ) : ( diff --git a/src/pages/workspace/rules/MerchantRules/EditCategoryTaxRulePage.tsx b/src/pages/workspace/rules/MerchantRules/EditCategoryTaxRulePage.tsx index 44a321b4dff0..bef1a62f326f 100644 --- a/src/pages/workspace/rules/MerchantRules/EditCategoryTaxRulePage.tsx +++ b/src/pages/workspace/rules/MerchantRules/EditCategoryTaxRulePage.tsx @@ -1,22 +1,37 @@ +import useDynamicBackPath from '@hooks/useDynamicBackPath'; +import useOnyx from '@hooks/useOnyx'; + +import {getCategoryTaxDefaultRule} from '@libs/ExpenseDefaultRuleUtils'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import React from 'react'; -import MerchantRulePageBase from './MerchantRulePageBase'; +import ExpenseDefaultRulePageBase from './ExpenseDefaultRulePageBase'; type EditCategoryTaxRulePageProps = | PlatformStackScreenProps | PlatformStackScreenProps; function EditCategoryTaxRulePage({route}: EditCategoryTaxRulePageProps) { + const {policyID, categoryName} = route.params; + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); + const isOpenedFromCategorySettings = route.name === SCREENS.WORKSPACE.DYNAMIC_CATEGORY_TAX_RULE_EDIT; + const categorySettingsPath = useDynamicBackPath(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_RULES_TAX_EDIT.path, isOpenedFromCategorySettings); + // Category settings links by category, since a category holds one tax default, so the rule is resolved here. + const ruleID = getCategoryTaxDefaultRule(rules, policyID, categoryName)?.ruleID; + return ( - ); diff --git a/src/pages/workspace/rules/MerchantRules/EditMerchantRulePage.tsx b/src/pages/workspace/rules/MerchantRules/EditMerchantRulePage.tsx index a970d111c1d4..52e289901d64 100644 --- a/src/pages/workspace/rules/MerchantRules/EditMerchantRulePage.tsx +++ b/src/pages/workspace/rules/MerchantRules/EditMerchantRulePage.tsx @@ -5,13 +5,13 @@ import type SCREENS from '@src/SCREENS'; import React from 'react'; -import MerchantRulePageBase from './MerchantRulePageBase'; +import ExpenseDefaultRulePageBase from './ExpenseDefaultRulePageBase'; type EditMerchantRulePageProps = PlatformStackScreenProps; function EditMerchantRulePage({route}: EditMerchantRulePageProps) { return ( - { - if (!form?.categoriesToMatch?.length) { - return translate('workspace.rules.merchantRules.confirmErrorCategory'); - } - if (isMoveBlocked) { - return translate('workspace.rules.merchantRules.confirmErrorCategoryTaxMoveIsWorkspaceDefault'); - } - if (!taxID) { - return translate('workspace.rules.merchantRules.confirmErrorCategoryTax'); - } - return ''; -}; - -/** Only ever a merchant rule: a category rule is scoped before this page opens and validates through `getCategoryRuleErrorMessage`. */ +/** A rule needs something to match on and something to set. */ const getErrorMessage = (translate: LocalizedTranslate, form?: MerchantRuleForm) => { const matchingCriteriaFields = new Set([ MERCHANT_RULE_INPUT_IDS.MERCHANT_TO_MATCH, MERCHANT_RULE_INPUT_IDS.MATCH_TYPE, MERCHANT_RULE_INPUT_IDS.CATEGORIES_TO_MATCH, - // Scoping, not a default the admin set. - MERCHANT_RULE_INPUT_IDS.RULE_TYPE, + MERCHANT_RULE_INPUT_IDS.TAGS_TO_MATCH, ]); const hasAtLeastOneUpdate = Object.entries(form ?? {}).some(([key, value]) => { if (matchingCriteriaFields.has(key)) { @@ -152,30 +132,31 @@ const getErrorMessage = (translate: LocalizedTranslate, form?: MerchantRuleForm) } return value !== undefined && value !== ''; }); - if (form?.merchantToMatch && hasAtLeastOneUpdate) { + const hasCondition = hasExpenseDefaultConditions(form ?? {}); + if (hasCondition && hasAtLeastOneUpdate) { return ''; } if (hasAtLeastOneUpdate) { return translate('workspace.rules.merchantRules.confirmErrorCondition'); } - if (form?.merchantToMatch) { + if (hasCondition) { return translate('workspace.rules.merchantRules.confirmErrorUpdate'); } return translate('workspace.rules.merchantRules.confirmErrorConditionAndDefault'); }; -function MerchantRulePageBase({ +function ExpenseDefaultRulePageBase({ policyID, ruleID, initialCategoryName, - editCategoryTaxRuleFor, - newCategoryTaxRuleFor, + initialCategoryToMatch, isCategoryLocked = false, categorySettingsBackPath, testID, -}: MerchantRulePageBaseProps) { +}: ExpenseDefaultRulePageBaseProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); + const {convertToDisplayString} = useCurrencyListActions(); const policy = usePolicy(policyID); const {canWrite: canWriteRules} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.RULES); // The page is on its way out, so the rule it was editing no longer being there is expected. A category rule is @@ -184,12 +165,10 @@ function MerchantRulePageBase({ const {isLoading, startWithLoading} = usePressLoading(); const isEditing = !!ruleID; const {isCreatedFromExpense, backToRoute, getRuleRoute} = useMerchantRuleRoute(DYNAMIC_ROUTES.RULES_MERCHANT_NEW_FROM_EXPENSE.path, policyID, ruleID); - const isEditingCategoryTaxRule = !!editCategoryTaxRuleFor; - // A category tax default has no ruleID, so neither flag alone means "saved". - const isEditingSavedRule = isEditing || isEditingCategoryTaxRule; + const isEditingSavedRule = isEditing; const isInLandscapeMode = useIsInLandscapeMode(); const {isBetaEnabled} = usePermissions(); - const icons = useMemoizedLazyExpensifyIcons(['Basket', 'Folder', 'Pencil', 'InvoiceGeneric', 'Tag', 'Paycheck']); + const icons = useMemoizedLazyExpensifyIcons(['Basket', 'Cash', 'Folder', 'Pencil', 'InvoiceGeneric', 'Tag', 'Paycheck']); const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); @@ -200,7 +179,6 @@ function MerchantRulePageBase({ const [shouldUpdateMatchingTransactions, setShouldUpdateMatchingTransactions] = useState(false); // The footer remounts when it moves on rotation, so the page owns the modal to keep it open const [isDescribeRuleModalVisible, setIsDescribeRuleModalVisible] = useState(false); - const seededCategoryTaxRuleRef = useRef(undefined); const didSeedInitialCategoryRef = useRef(false); // The "Set vendor to" row gate below reads policy.connections (via hasVendorFeature and @@ -220,26 +198,14 @@ function MerchantRulePageBase({ const areRulesLoading = isLoadingOnyxValue(rulesResult) || isRulesFetchPending; // Get the existing rule from the rules collection (for edit mode) const existingRule = ruleID ? rules?.[`${ONYXKEYS.COLLECTION.RULE}${ruleID}`] : undefined; - const existingCategoryTaxID = editCategoryTaxRuleFor ? getCategoryTaxRuleTaxID(policy?.rules?.expenseRules, editCategoryTaxRuleFor) : undefined; - // Initialize the form with existing rule data (for edit mode) useEffect(() => { - if (isEditingCategoryTaxRule) { - // Seed once per rule, or this overwrites the category picked in the picker. - if (!existingCategoryTaxID || seededCategoryTaxRuleRef.current === editCategoryTaxRuleFor) { - return; - } - seededCategoryTaxRuleRef.current = editCategoryTaxRuleFor; - setDraftMerchantRule({categoriesToMatch: [editCategoryTaxRuleFor], tax: existingCategoryTaxID}); - return; - } - - if (newCategoryTaxRuleFor) { + if (initialCategoryToMatch) { if (didSeedInitialCategoryRef.current) { return; } didSeedInitialCategoryRef.current = true; - setDraftMerchantRule({ruleType: CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.CATEGORY, categoriesToMatch: [newCategoryTaxRuleFor]}); + setDraftMerchantRule({categoriesToMatch: [initialCategoryToMatch]}); return; } @@ -255,12 +221,12 @@ function MerchantRulePageBase({ // An undefined result means the rule uses parts of the format this form can't show. Saving it back would // drop them, so the editor stays empty and the rules list keeps such rules read-only. - const formValues = getMerchantRuleFormValues(existingRule); + const formValues = getExpenseDefaultFormValues(existingRule); if (!formValues) { return; } setDraftMerchantRule(formValues); - }, [isEditing, existingRule, isEditingCategoryTaxRule, editCategoryTaxRuleFor, existingCategoryTaxID, initialCategoryName, newCategoryTaxRuleFor]); + }, [isEditing, existingRule, initialCategoryName, initialCategoryToMatch]); // Clear the form on unmount useEffect(() => () => clearDraftMerchantRule(), []); @@ -296,6 +262,25 @@ function MerchantRulePageBase({ } return policyTags.length > 0; }; + + // `hasCategories` falls back to the category the rule sets, which says nothing about what it matches on, so the + // condition row asks separately. A category the rule already matches on keeps the row even once the workspace has + // none left to pick, otherwise the condition would be invisible and still saved. + const hasCategoriesToMatch = () => { + if (!policy?.areCategoriesEnabled) { + return false; + } + return !!form?.categoriesToMatch?.length || hasEnabledOptions(policyCategories ?? {}); + }; + + // Turning tags on creates a tag list whether or not it holds anything, so the condition row needs a tag to + // actually match on. A tag the rule already matches on keeps the row, the same way. + const hasTagsToMatch = () => { + if (!policy?.areTagsEnabled) { + return false; + } + return !!form?.tagsToMatch?.length || policyTags.some((tagList) => hasEnabledOptions(tagList.tags)); + }; const formTags = getTagArrayFromName(form?.tag ?? ''); const isBillableEnabled = policy?.disabledFields?.defaultBillable !== true; @@ -305,86 +290,55 @@ function MerchantRulePageBase({ const unavailableLabel = translate('workspace.rules.merchantRules.vendorUnavailable'); const vendorDisplayName = form?.vendorID ? getVendorRuleDisplayValue(policy, form.vendorID, unavailableLabel) : undefined; - // `Expense defaults` has not been migrated to the new rules system, so a rule can only carry one condition. The - // type is chosen before this page opens, so the condition and the defaults the type can't carry are simply absent. + // A rule matches on every condition it carries, so all three rows are offered together. const categoriesToMatch = form?.categoriesToMatch ?? []; const hasCategoryCondition = categoriesToMatch.length > 0; const hasMerchantCondition = !!form?.merchantToMatch; - // A saved rule already is one kind or the other, so editing is scoped the same way creating is. Creating reads the - // draft rather than the route, so scoping survives a trip to any picker and every picker routes back to one URL. - // Three branches, and `no-nested-ternary` rules out folding them into one expression. - const getScopedRuleType = (): ExpenseDefaultRuleType | undefined => { - if (isEditingCategoryTaxRule) { - return CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.CATEGORY; - } - if (isEditing) { - return CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.MERCHANT; - } - return form?.ruleType; - }; - const scopedRuleType = getScopedRuleType(); - const isScopedToCategory = scopedRuleType === CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.CATEGORY; - const isCategoryRule = isCategoryRuleDraft(form, editCategoryTaxRuleFor); - // Deleting means writing the workspace default rate back, so without one there is nothing to write. - const canDeleteCategoryTaxRule = isEditingCategoryTaxRule && !!policy?.taxRates?.defaultExternalID; - // A draft carrying the workspace default rate means "no rule", since saving it would delete the rule. - const isDraftTaxTheWorkspaceDefault = isCategoryRule && !isEditingCategoryTaxRule && form?.tax === policy?.taxRates?.defaultExternalID; - const categoryTaxID = isDraftTaxTheWorkspaceDefault ? undefined : form?.tax; - // Safe to show a saved rule holding the default rate, but not to write it: that write is what deletes the rule. - const isSavedTaxTheWorkspaceDefault = isCategoryRule && isEditingCategoryTaxRule && !!form?.tax && form.tax === policy?.taxRates?.defaultExternalID; - // Editing is single-select, so a move has exactly one destination. - const movedToCategory = editCategoryTaxRuleFor && !categoriesToMatch.includes(editCategoryTaxRuleFor) ? categoriesToMatch.at(0) : undefined; - // A move still needs a write the command can't express while the rate is the default, so that's the one case blocked. - const isCategoryTaxRuleMoveBlocked = isSavedTaxTheWorkspaceDefault && !!movedToCategory; - const showCategoryRulesApplyGoingForwardExplainer = () => { - showConfirmModal({ - title: translate('workspace.rules.merchantRules.categoryRulesApplyGoingForwardTitle'), - prompt: translate('workspace.rules.merchantRules.categoryRulesApplyGoingForwardPrompt'), - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - }); - }; - /** Clears the condition and every default, keeping the type the rule was scoped to. */ + /** Clears every condition and every default. */ const resetRule = () => { - setDraftMerchantRule(scopedRuleType ? {ruleType: scopedRuleType} : {}); + setDraftMerchantRule({}); setShouldShowError(false); setShouldUpdateMatchingTransactions(false); }; // One rule per category is saved, so the condition row lists every category the admin picked. const categoriesToMatchDisplayName = hasCategoryCondition ? categoriesToMatch.map(getDecodedCategoryName).join(', ') : undefined; + const tagsToMatch = form?.tagsToMatch ?? []; + const hasTagCondition = tagsToMatch.length > 0; + const tagsToMatchDisplayName = hasTagCondition ? tagsToMatch.map(getCleanedTagName).join(', ') : undefined; + const amountDisplayName = form?.amount ? convertToDisplayString(Number(form.amount), policy?.outputCurrency) : undefined; const categoryDisplayName = form?.category ? getDecodedCategoryName(form.category) : undefined; // Blank rather than the raw ID once the rate is gone from the workspace. - const taxRateID = isCategoryRule ? categoryTaxID : form?.tax; - const taxDisplayName = (taxRateID && getTaxByID(policy, taxRateID) ? getTaxRateDisplayName(policy, taxRateID) : '') || undefined; + const taxDisplayName = (form?.tax && getTaxByID(policy, form.tax) ? getTaxRateDisplayName(policy, form.tax) : '') || undefined; /** - * Checks if there's a duplicate rule with the same merchant name and match type. - * A duplicate is a rule that has the same merchant to match AND the same match type (contains/exact). + * Checks if another rule already matches on the same conditions. * When editing, we exclude the current rule from the comparison. */ - const checkForDuplicateRule = (merchantToMatch: string | undefined, matchType: string | undefined): boolean => { - if (!merchantToMatch) { + const checkForDuplicateRule = (formValues: Partial): boolean => { + if (!hasExpenseDefaultConditions(formValues)) { return false; } - const normalizedMerchant = merchantToMatch.toLowerCase(); - const currentMatchType = matchType ?? CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS; - return getPolicyExpenseDefaultRules(rules, policyID).some(({ruleID: existingRuleID, rule}) => { // Skip the rule being edited if (isEditing && existingRuleID === ruleID) { return false; } + // A rule deleted offline no longer applies to anything, so recreating it is not a duplicate. + if (rule.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + return false; + } + // A rule this form can't represent can't be a duplicate of what this form is about to save. - const existingFormValues = getMerchantRuleFormValues(rule); + const existingFormValues = getExpenseDefaultFormValues(rule); if (!existingFormValues) { return false; } - if (existingFormValues.merchantToMatch.toLowerCase() !== normalizedMerchant || existingFormValues.matchType !== currentMatchType) { + if (!haveSameConditions(existingFormValues, formValues)) { return false; } @@ -398,7 +352,7 @@ function MerchantRulePageBase({ }); }; - const errorMessage = isCategoryRule ? getCategoryRuleErrorMessage(translate, categoryTaxID, isCategoryTaxRuleMoveBlocked, form) : getErrorMessage(translate, form); + const errorMessage = getErrorMessage(translate, form); const goBackToExpenseDefaults = () => { Tab.setSelectedTab(CONST.TAB.RULES_TAB_TYPE, CONST.TAB.RULES.EXPENSE_DEFAULTS); @@ -413,36 +367,12 @@ function MerchantRulePageBase({ return; } - // Category rules are stored as `policy.rules.expenseRules`, the same objects Expensify Classic reads, so that a - // default tax rate set here is the one Classic already understands. - if (isCategoryRule) { - if (!hasCategoryCondition || !categoryTaxID) { - return; - } - // Nothing to write for an unchanged save; a move is blocked earlier by the error message instead. - if (isSavedTaxTheWorkspaceDefault) { - setIsClosing(true); - Navigation.goBack(categorySettingsBackPath); - return; - } - setIsClosing(true); - // A move clears the old category and sets the new one as a pair, sharing one rollback. - if (editCategoryTaxRuleFor && movedToCategory) { - movePolicyCategoryTax(policy, editCategoryTaxRuleFor, movedToCategory, categoryTaxID); - } else { - // The command is per-category, so a bulk selection saves one rule for each category picked. - setPolicyCategoryTaxes(policy, categoriesToMatch, categoryTaxID); - } - if (isEditingCategoryTaxRule || categorySettingsBackPath) { - Navigation.goBack(categorySettingsBackPath); - } else { - goBackToExpenseDefaults(); - } - return; - } - - setRule(policyID, buildMerchantRule(form, policy), CONST.RULES.EXPENSE_DEFAULT.PRIORITY, ruleID, existingRule, shouldUpdateMatchingTransactions); - if (isCreatedFromExpense) { + setRule(policyID, buildExpenseDefaultRule(form, policy, existingRule?.triggers), CONST.RULES.EXPENSE_DEFAULT.PRIORITY, ruleID, existingRule, shouldUpdateMatchingTransactions); + if (categorySettingsBackPath) { + // Category settings enters through its own dynamic route, so the way back is the category the rule was + // opened from rather than anything in the workspace Rules flow. + Navigation.goBack(categorySettingsBackPath); + } else if (isCreatedFromExpense) { // Opened from the callout, so this page is a suffix on the expense's path. Dropping it returns to the // expense instead of the workspace Rules page. Navigation.goBack(backToRoute); @@ -465,19 +395,14 @@ function MerchantRulePageBase({ return; } - // A category rule matches on a category that the picker already excluded if it had a rule, so there is no - // duplicate to warn about. - if (isCategoryRule) { - startWithLoading(() => saveRule()); - return; - } - // Check for duplicate rules - const hasDuplicate = checkForDuplicateRule(form.merchantToMatch, form.matchType); + const hasDuplicate = checkForDuplicateRule(form); if (hasDuplicate) { + // A duplicate does not have to match on a merchant, so the prompt names the first condition the two share. + const duplicateCondition = [form.merchantToMatch, categoriesToMatchDisplayName, tagsToMatchDisplayName].find(Boolean) ?? ''; showConfirmModal({ title: translate('workspace.rules.merchantRules.duplicateRuleTitle'), - prompt: translate('workspace.rules.merchantRules.duplicateRulePrompt', form.merchantToMatch ?? ''), + prompt: translate('workspace.rules.merchantRules.duplicateRulePrompt', duplicateCondition), confirmText: translate('workspace.rules.merchantRules.saveAnyway'), cancelText: translate('common.cancel'), }).then((result) => { @@ -497,18 +422,15 @@ function MerchantRulePageBase({ return false; } setIsClosing(true); - if (editCategoryTaxRuleFor) { - deletePolicyCategoryTax(policy, editCategoryTaxRuleFor); - } else if (ruleID) { + if (ruleID) { deleteRule(ruleID, existingRule); } return true; }; - // A category tax rule is its category, so it carries no pending state of its own. Only a merchant rule can - // already be on its way out. Declared above the not-found returns below, since a hook can't run conditionally. + // Declared above the not-found returns below, since a hook can't run conditionally. const isRuleBeingDeleted = existingRule?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; - const canDeleteRule = canWriteRules && !!policy && !isRuleBeingDeleted && (isEditing || canDeleteCategoryTaxRule); + const canDeleteRule = canWriteRules && !!policy && !isRuleBeingDeleted && isEditing; const {deleteIconButtonProps} = useRuleDeleteHeaderProps({ canDelete: canDeleteRule, @@ -521,31 +443,36 @@ function MerchantRulePageBase({ { titleTranslationKey: 'workspace.rules.merchantRules.expensesWith', items: [ - isScopedToCategory - ? undefined - : { - key: 'merchantToMatch', - description: translate('common.merchant'), - // The rule's only condition, since the type is chosen before this page opens. - required: true, - title: form?.merchantToMatch, - onPress: () => - Navigation.navigate( - getRuleRoute(DYNAMIC_ROUTES.RULES_MERCHANT_MERCHANT_TO_MATCH_FROM_EXPENSE.path, ROUTES.RULES_MERCHANT_MERCHANT_TO_MATCH.getRoute(policyID, ruleID)), - ), - icon: icons.Basket, - }, - isScopedToCategory + { + key: 'merchantToMatch', + description: translate('common.merchant'), + title: form?.merchantToMatch, + onPress: () => + Navigation.navigate( + getRuleRoute(DYNAMIC_ROUTES.RULES_MERCHANT_MERCHANT_TO_MATCH_FROM_EXPENSE.path, ROUTES.RULES_MERCHANT_MERCHANT_TO_MATCH.getRoute(policyID, ruleID)), + ), + icon: icons.Basket, + }, + hasCategoriesToMatch() ? { key: 'categoriesToMatch', description: translate('common.category'), - required: true, title: categoriesToMatchDisplayName, - onPress: () => Navigation.navigate(ROUTES.RULES_CATEGORY_TO_MATCH.getRoute(policyID, ruleID, editCategoryTaxRuleFor)), + onPress: () => + Navigation.navigate(getRuleRoute(DYNAMIC_ROUTES.RULES_CATEGORY_TO_MATCH_FROM_EXPENSE.path, ROUTES.RULES_CATEGORY_TO_MATCH.getRoute(policyID, ruleID))), isLocked: isCategoryLocked, icon: icons.Folder, } : undefined, + !hasTagsToMatch() + ? undefined + : { + key: 'tagsToMatch', + description: translate('common.tag'), + title: tagsToMatchDisplayName, + onPress: () => Navigation.navigate(getRuleRoute(DYNAMIC_ROUTES.RULES_TAG_TO_MATCH_FROM_EXPENSE.path, ROUTES.RULES_TAG_TO_MATCH.getRoute(policyID, ruleID))), + icon: icons.Tag, + }, ], }, { @@ -590,20 +517,22 @@ function MerchantRulePageBase({ }; }) : []), - // Tax is a category rule's only default, so the row stays with taxes off rather than leaving the - // section blank. The picker then explains that taxes are disabled. - hasUsableTaxRates(policy) || isCategoryRule + hasUsableTaxRates(policy) ? { key: 'tax', description: translate('common.tax'), title: taxDisplayName, - onPress: () => - Navigation.navigate( - getRuleRoute(DYNAMIC_ROUTES.RULES_MERCHANT_TAX_FROM_EXPENSE.path, ROUTES.RULES_MERCHANT_TAX.getRoute(policyID, ruleID, editCategoryTaxRuleFor)), - ), + onPress: () => Navigation.navigate(getRuleRoute(DYNAMIC_ROUTES.RULES_MERCHANT_TAX_FROM_EXPENSE.path, ROUTES.RULES_MERCHANT_TAX.getRoute(policyID, ruleID))), icon: icons.InvoiceGeneric, } : undefined, + { + key: 'amount', + description: translate('iou.amount'), + title: amountDisplayName, + onPress: () => Navigation.navigate(getRuleRoute(DYNAMIC_ROUTES.RULES_MERCHANT_AMOUNT_FROM_EXPENSE.path, ROUTES.RULES_MERCHANT_AMOUNT.getRoute(policyID, ruleID))), + icon: icons.Cash, + }, isVendorFeatureEnabled ? { key: 'vendorID', @@ -640,13 +569,12 @@ function MerchantRulePageBase({ icon: icons.Paycheck, } : undefined, - // Tax is the only default a category rule can set, so the other rows are dropped rather than shown. - ].filter((item) => !isCategoryRule || item?.key === 'tax'), + ], }, ]; const previewMatches = () => { - if (!form?.merchantToMatch?.trim()) { + if (!hasExpenseDefaultConditions(form ?? {})) { setShouldShowError(true); return; } @@ -668,39 +596,32 @@ function MerchantRulePageBase({ // The rules collection is shared across workspaces and rule kinds, so a stale link can resolve a ruleID that // this editor must not write to. Saving would replace it with a merchant rule and drop whatever it holds. - if (ruleID && !!existingRule && !isClosing && !canEditMerchantRule(existingRule, policyID)) { + if (ruleID && !!existingRule && !isClosing && !canEditExpenseDefaultRule(existingRule, policyID)) { return ; } - if (isEditingCategoryTaxRule && !existingCategoryTaxID && !isClosing) { - return ; - } - - // A saved rule stays readable without write access, so only creating one is gated. `isEditing` alone would miss a - // category rule, which is keyed by its category and so carries no ruleID, and send auditors to a not-found page. + // A saved rule stays readable without write access, so only creating one is gated. if (!isEditingSavedRule && !!policy && !canWriteRules) { return ; } const shouldShowDescribeRule = !isEditingSavedRule && !isCategoryLocked; const applyGeneratedRule = (values: GeneratedRuleValues) => { - setDraftMerchantRule(isScopedToCategory ? getCategoryTaxRuleDraftFromGeneratedValues(values, policy) : getMerchantRuleDraftFromGeneratedValues(values)); + setDraftMerchantRule(values); }; const footer = canWriteRules ? ( - {!isCategoryRule && ( - - {translate('workspace.rules.merchantRules.previewMatchesDescription')} - - - )} + + {translate('workspace.rules.merchantRules.previewMatchesDescription')} + + {translate('common.reset')} )} @@ -807,10 +728,8 @@ function MerchantRulePageBase({ {isInLandscapeMode && footer} @@ -821,9 +740,8 @@ function MerchantRulePageBase({ isVisible={isDescribeRuleModalVisible} onClose={() => setIsDescribeRuleModalVisible(false)} policyID={policyID} - ruleType={isScopedToCategory ? CONST.GENERATED_RULE.RULE_TYPE.CATEGORY_TAX_RATE : CONST.GENERATED_RULE.RULE_TYPE.EXPENSE_DEFAULTS} + ruleType={CONST.GENERATED_RULE.RULE_TYPE.EXPENSE_DEFAULTS} onRuleGenerated={applyGeneratedRule} - isCategoryEligible={isScopedToCategory ? (generatedCategory) => !categoryHasTaxRule(policy?.rules?.expenseRules, generatedCategory.name) : undefined} /> )} @@ -831,6 +749,6 @@ function MerchantRulePageBase({ ); } -MerchantRulePageBase.displayName = 'MerchantRulePageBase'; +ExpenseDefaultRulePageBase.displayName = 'ExpenseDefaultRulePageBase'; -export default MerchantRulePageBase; +export default ExpenseDefaultRulePageBase; diff --git a/src/pages/workspace/rules/MerchantRules/ExpenseDefaultTypePage.tsx b/src/pages/workspace/rules/MerchantRules/ExpenseDefaultTypePage.tsx deleted file mode 100644 index 34468f65c7ef..000000000000 --- a/src/pages/workspace/rules/MerchantRules/ExpenseDefaultTypePage.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; -import MenuItem from '@components/MenuItem'; -import ScreenWrapper from '@components/ScreenWrapper'; -import ScrollView from '@components/ScrollView'; -import Text from '@components/Text'; - -import useConfirmModal from '@hooks/useConfirmModal'; -import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; -import useLocalize from '@hooks/useLocalize'; -import usePolicy from '@hooks/usePolicy'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import {setDraftMerchantRule} from '@libs/actions/User'; -import {hasSelectableCategoryTaxRate, hasUsableTaxRates} from '@libs/CategoryTaxRulesUtils'; -import Navigation from '@libs/Navigation/Navigation'; -import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; - -import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; - -import variables from '@styles/variables'; - -import CONST from '@src/CONST'; -import ROUTES from '@src/ROUTES'; -import type SCREENS from '@src/SCREENS'; -import type {ExpenseDefaultRuleType} from '@src/types/form/MerchantRuleForm'; - -import React from 'react'; -import {View} from 'react-native'; - -type ExpenseDefaultTypePageProps = PlatformStackScreenProps; - -/** - * Picks what an expense default matches on before the editor opens, so the editor can drop the condition and the - * defaults the chosen type can't carry instead of showing them locked. - */ -function ExpenseDefaultTypePage({route}: ExpenseDefaultTypePageProps) { - const {policyID} = route.params; - const {translate} = useLocalize(); - const styles = useThemeStyles(); - const policy = usePolicy(policyID); - const illustrations = useMemoizedLazyIllustrations(['FoodTruck', 'FolderOpen']); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['Lock']); - - const {showConfirmModal} = useConfirmModal(); - - // A category rule only sets a default tax rate, so it needs a rate to choose from. The card stays listed either - // way: hiding it would drop the admin on a one-option page with no hint of what is missing. - // - // Taxes being on is not enough. Every rate can be disabled, and the workspace default can be the only one left, - // which a category rule can't apply. Opening the editor then led to a picker with nothing in it, so the lock reads - // the same rule the picker does and the two reasons get their own explainer. - const areTaxesEnabled = hasUsableTaxRates(policy); - const canPickCategoryTaxRate = hasSelectableCategoryTaxRate(policy); - - // Scoping rides in the draft rather than the URL, so the editor keeps it when a picker routes back to the plain - // create URL. Setting it here also starts the rule from a clean draft. - const openEditorScopedTo = (ruleType: ExpenseDefaultRuleType) => { - setDraftMerchantRule({ruleType}); - Navigation.navigate(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); - }; - - const showMissingTaxRateExplainer = () => { - showConfirmModal({ - title: translate(areTaxesEnabled ? 'workspace.rules.merchantRules.addTaxRateFirstTitle' : 'workspace.rules.merchantRules.turnOnTaxesFirstTitle'), - prompt: translate(areTaxesEnabled ? 'workspace.rules.merchantRules.addTaxRateFirstPrompt' : 'workspace.rules.merchantRules.turnOnTaxesFirstPrompt'), - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - }); - }; - - const options = [ - { - key: 'merchant', - icon: illustrations.FoodTruck, - title: translate('workspace.rules.expenseDefaultType.merchant'), - description: translate('workspace.rules.expenseDefaultType.merchantDescription'), - onPress: () => openEditorScopedTo(CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.MERCHANT), - isLocked: false, - }, - { - key: 'category', - icon: illustrations.FolderOpen, - title: translate('workspace.rules.expenseDefaultType.category'), - description: translate('workspace.rules.expenseDefaultType.categoryDescription'), - onPress: canPickCategoryTaxRate ? () => openEditorScopedTo(CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.CATEGORY) : showMissingTaxRateExplainer, - isLocked: !canPickCategoryTaxRate, - }, - ]; - - return ( - - - - - {translate('workspace.rules.expenseDefaultType.subtitle')} - - {options.map((option) => ( - - ))} - - - - - ); -} - -export default ExpenseDefaultTypePage; diff --git a/src/pages/workspace/rules/MerchantRules/ImportedMerchantRulesPage.tsx b/src/pages/workspace/rules/MerchantRules/ImportedMerchantRulesPage.tsx index 767e128a047a..3a480081928a 100644 --- a/src/pages/workspace/rules/MerchantRules/ImportedMerchantRulesPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/ImportedMerchantRulesPage.tsx @@ -17,8 +17,8 @@ import type {ImportedMerchantRule} from '@libs/actions/Policy/Rules'; import {importMerchantRulesSpreadsheet} from '@libs/actions/Policy/Rules'; import Tab from '@libs/actions/Tab'; import {getDecodedCategoryName} from '@libs/CategoryUtils'; -import {getMerchantRuleFormValues, getPolicyExpenseDefaultRules} from '@libs/ExpenseDefaultRuleUtils'; -import type {MerchantRuleFormValues} from '@libs/ExpenseDefaultRuleUtils'; +import {getExpenseDefaultFormValues, getPolicyExpenseDefaultRules} from '@libs/ExpenseDefaultRuleUtils'; +import type {ExpenseDefaultFormValues} from '@libs/ExpenseDefaultRuleUtils'; import {findDuplicate, generateColumnNames} from '@libs/importSpreadsheetUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -62,14 +62,20 @@ const ACTION_COLUMNS: string[] = [ * spreadsheet rows that would recreate a rule the policy already has (e.g. the same spreadsheet * imported twice) as well as duplicate rows within the same spreadsheet. */ -function getRuleContentKey(formValues: Partial): string { +function getRuleContentKey(formValues: Partial): string { return JSON.stringify([ formValues.matchType ?? CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS, formValues.merchantToMatch?.toLowerCase() ?? '', + // A spreadsheet can't set these conditions, but a rule the policy already has can carry them, and a rule + // that matches on more than the row does is a different rule. + formValues.categoriesToMatch ?? [], + formValues.tagsToMatch ?? [], formValues.merchant ?? '', formValues.category ?? '', formValues.tag ?? '', formValues.comment ?? '', + formValues.amount ?? null, + formValues.tax ?? null, formValues.reimbursable ?? null, formValues.billable ?? null, formValues.vendorID ?? null, @@ -200,7 +206,7 @@ function parseSpreadsheetRules( const seenRuleKeys = new Set( getPolicyExpenseDefaultRules(existingRules, policy?.id) .filter(({rule}) => rule.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) - .map(({rule}) => getMerchantRuleFormValues(rule)) + .map(({rule}) => getExpenseDefaultFormValues(rule)) .filter((formValues) => !!formValues) .map(getRuleContentKey), ); @@ -244,7 +250,7 @@ function parseSpreadsheetRules( continue; } - const formValues: Partial = { + const formValues: Partial = { merchantToMatch, matchType: merchantIsValue ? CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO : CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS, ...(updatedMerchant && {merchant: updatedMerchant}), diff --git a/src/pages/workspace/rules/MerchantRules/PreviewMatchesPage.tsx b/src/pages/workspace/rules/MerchantRules/PreviewMatchesPage.tsx index cf944bc3f67a..6199408ca70f 100644 --- a/src/pages/workspace/rules/MerchantRules/PreviewMatchesPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/PreviewMatchesPage.tsx @@ -12,6 +12,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {getTransactionsMatchingCodingRule} from '@libs/actions/Policy/Rules'; +import {buildExpenseDefaultFilters} from '@libs/ExpenseDefaultRuleUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; @@ -25,7 +26,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {Transaction} from '@src/types/onyx'; -import type {CodingRuleFilter} from '@src/types/onyx/Policy'; import type {ListRenderItem} from '@shopify/flash-list'; @@ -55,25 +55,19 @@ function PreviewMatchesPage({route}: PreviewMatchesPageProps) { const [isLoading] = useOnyx(ONYXKEYS.IS_LOADING_POLICY_CODING_RULES_PREVIEW); const [matchingTransactions] = useOnyx(ONYXKEYS.COLLECTION.CODING_RULE_MATCHING_TRANSACTION); - const merchant = form?.merchantToMatch ?? ''; - const operator = form?.matchType ?? CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS; + // The preview matches on everything the rule does, so it asks with the tree the rule would be saved with. + const filters = buildExpenseDefaultFilters(form ?? {}); useEffect(() => { - if (isOffline) { + if (isOffline || !filters) { return; } - const filters: CodingRuleFilter = { - left: 'merchant', - operator, - right: merchant, - }; - getTransactionsMatchingCodingRule(policyID, filters); - }, [merchant, operator, policyID, isOffline]); + }, [filters, policyID, isOffline]); const matchingTransactionsArray = Object.values(matchingTransactions ?? {}).filter((transaction): transaction is Transaction => !!transaction); - const hasMatchingTransactions = !!(merchant && matchingTransactionsArray.length); + const hasMatchingTransactions = !!filters && matchingTransactionsArray.length > 0; const isLoadedAndEmpty = !isLoading && !hasMatchingTransactions; const isLoadedWithTransactions = !isLoading && hasMatchingTransactions; diff --git a/src/pages/workspace/rules/RulesNewPage.tsx b/src/pages/workspace/rules/RulesNewPage.tsx index 0123104667b0..b87294a0eede 100644 --- a/src/pages/workspace/rules/RulesNewPage.tsx +++ b/src/pages/workspace/rules/RulesNewPage.tsx @@ -4,14 +4,11 @@ import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; -import useConfirmModal from '@hooks/useConfirmModal'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import usePermissions from '@hooks/usePermissions'; -import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; -import {categoryHasTaxRule, hasSelectableCategoryTaxRate, hasUsableTaxRates} from '@libs/CategoryTaxRulesUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -53,27 +50,11 @@ function RulesNewPage({route}: RulesNewPageProps) { const illustrations = useMemoizedLazyIllustrations(['CardReaderAlt', 'Flag', 'CheckboxText', 'ReportReceipt', 'AiBot']); const isCategorySettingsFlow = route.name === SCREENS.WORKSPACE.DYNAMIC_CATEGORY_RULES_NEW; const isCategoryScopedCreate = isCategorySettingsFlow || !!categoryName; - const policy = usePolicy(policyID); - const {showConfirmModal} = useConfirmModal(); const {isBetaEnabled} = usePermissions(); const isDescribeRuleBetaEnabled = isBetaEnabled(CONST.BETAS.DESCRIBE_RULE); - // From category settings the only expense default is the category's tax rate, so the type chooser is skipped. It - // can't be set without a rate to pick, so that case gets the chooser's explainer instead of an empty picker. - const openCategoryTaxRule = () => { - const hasCategoryTaxRule = !!categoryName && categoryHasTaxRule(policy?.rules?.expenseRules, categoryName); - if (!hasCategoryTaxRule && !hasSelectableCategoryTaxRate(policy)) { - const areTaxesEnabled = hasUsableTaxRates(policy); - showConfirmModal({ - title: translate(areTaxesEnabled ? 'workspace.rules.merchantRules.addTaxRateFirstTitle' : 'workspace.rules.merchantRules.turnOnTaxesFirstTitle'), - prompt: translate(areTaxesEnabled ? 'workspace.rules.merchantRules.addTaxRateFirstPrompt' : 'workspace.rules.merchantRules.turnOnTaxesFirstPrompt'), - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - }); - return; - } - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_RULES_TAX_NEW.path)); - }; + // From category settings the rule is pre-scoped to the category, so it opens the editor directly. + const openCategoryScopedRule = () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_RULES_TAX_NEW.path)); const newRuleOptions: NewRuleOption[] = [ { @@ -116,7 +97,7 @@ function RulesNewPage({route}: RulesNewPageProps) { icon: illustrations.ReportReceipt, title: translate('workspace.rules.newRule.applyExpenseDefaults'), description: translate('workspace.rules.newRule.applyExpenseDefaultsDescription'), - onPress: () => (isCategorySettingsFlow ? openCategoryTaxRule() : Navigation.navigate(ROUTES.RULES_EXPENSE_DEFAULT_TYPE.getRoute(policyID))), + onPress: () => (isCategorySettingsFlow ? openCategoryScopedRule() : Navigation.navigate(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID))), sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.RULES.NEW_RULE_MENU_ITEM_APPLY_EXPENSE_DEFAULTS, // Only category settings can scope the editor to its category, so a category passed any other way drops it. isWorkspaceOnly: !isCategorySettingsFlow, diff --git a/src/pages/workspace/rules/tabs/RulesExpenseDefaultsTab.tsx b/src/pages/workspace/rules/tabs/RulesExpenseDefaultsTab.tsx index 1cc91fc7cc44..b4868c4aa106 100644 --- a/src/pages/workspace/rules/tabs/RulesExpenseDefaultsTab.tsx +++ b/src/pages/workspace/rules/tabs/RulesExpenseDefaultsTab.tsx @@ -26,14 +26,12 @@ function RulesExpenseDefaultsTab({policyID, canWriteRules, selectedKeys, onSelec const policy = usePolicy(policyID); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); - const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`); const expenseDefaultsTableData = getExpenseDefaultsTableData({ policy, policyID, rules, - policyCategories, policyTags, translate, isOffline, diff --git a/src/pages/workspace/rules/tabs/useRulesTableBulkActions.ts b/src/pages/workspace/rules/tabs/useRulesTableBulkActions.ts index c2c5ae0ba5c7..7748a59c7425 100644 --- a/src/pages/workspace/rules/tabs/useRulesTableBulkActions.ts +++ b/src/pages/workspace/rules/tabs/useRulesTableBulkActions.ts @@ -17,10 +17,9 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import {deleteExpensifyCardRule} from '@libs/actions/Card'; -import {deletePolicyCategoryTaxes, openPolicyCategoriesPage} from '@libs/actions/Policy/Category'; +import {openPolicyCategoriesPage} from '@libs/actions/Policy/Category'; import {openPolicyExpensifyCardsPage} from '@libs/actions/Policy/Policy'; import {deleteRule} from '@libs/actions/Policy/Rules'; -import {getCategoryNameFromTaxRuleKey, isCategoryTaxRuleKey} from '@libs/CategoryTaxRulesUtils'; import {deleteFlagForReviewRule, getFlagForReviewTableData} from '@libs/FlagForReviewRulesUtils'; import {getExpenseDefaultsTableData, isMerchantTypeRuleKey} from '@libs/MerchantTypeRulesUtils'; import Navigation from '@libs/Navigation/Navigation'; @@ -158,8 +157,6 @@ function useRulesTableBulkActions({policyID, activeTab, selectedRuleKeysByTab, c policy, policyID, rules, - // Unlike the tables below, the raw value: a category pending deletion is exactly what marks its rule deleting. - policyCategories: policyCategoriesOnyx, policyTags: policyTagsOnyx, translate, isOffline, @@ -254,26 +251,14 @@ function useRulesTableBulkActions({policyID, activeTab, selectedRuleKeysByTab, c return; } - // Category tax defaults live in `expenseRules`, a plain array, so they delete together in one pass that threads - // the array through rather than one independent write each. - const selectedCategoryNames: string[] = []; - for (const ruleID of filteredSelectedExpenseDefaultKeys) { if (isMerchantTypeRuleKey(ruleID)) { continue; } - if (isCategoryTaxRuleKey(ruleID)) { - selectedCategoryNames.push(getCategoryNameFromTaxRuleKey(ruleID)); - continue; - } - deleteRule(ruleID, rules?.[`${ONYXKEYS.COLLECTION.RULE}${ruleID}`]); } - if (selectedCategoryNames.length > 0) { - deletePolicyCategoryTaxes(policy, selectedCategoryNames); - } clearTableSelection(); }, [ isVendorMatchingBetaEnabled, diff --git a/src/types/form/MerchantRuleForm.ts b/src/types/form/MerchantRuleForm.ts index 5d35a8b8671f..c01ac1295742 100644 --- a/src/types/form/MerchantRuleForm.ts +++ b/src/types/form/MerchantRuleForm.ts @@ -5,6 +5,7 @@ import type {ValueOf} from 'type-fest'; import type Form from './Form'; const INPUT_IDS = { + AMOUNT: 'amount', BILLABLE: 'billable', CATEGORY: 'category', CATEGORIES_TO_MATCH: 'categoriesToMatch', @@ -13,20 +14,18 @@ const INPUT_IDS = { MERCHANT_TO_MATCH: 'merchantToMatch', MERCHANT: 'merchant', REIMBURSABLE: 'reimbursable', - RULE_TYPE: 'ruleType', TAG: 'tag', + TAGS_TO_MATCH: 'tagsToMatch', TAX: 'tax', VENDOR_ID: 'vendorID', } as const; type InputID = ValueOf; -/** What the rule matches on, chosen before the editor opens. */ -type ExpenseDefaultRuleType = ValueOf; - type MerchantRuleForm = Form< InputID, { + [INPUT_IDS.AMOUNT]: string; [INPUT_IDS.BILLABLE]: boolean; [INPUT_IDS.CATEGORY]: string; [INPUT_IDS.CATEGORIES_TO_MATCH]: string[]; @@ -35,14 +34,12 @@ type MerchantRuleForm = Form< [INPUT_IDS.MERCHANT]: string; [INPUT_IDS.MERCHANT_TO_MATCH]: string; [INPUT_IDS.REIMBURSABLE]: boolean; - /** Which kind of expense default this is, chosen before the editor opened. Kept in the draft so it survives a - * trip to any picker, rather than having to ride on every picker's route. */ - [INPUT_IDS.RULE_TYPE]: ExpenseDefaultRuleType; [INPUT_IDS.TAG]: string; + [INPUT_IDS.TAGS_TO_MATCH]: string[]; [INPUT_IDS.TAX]: string; [INPUT_IDS.VENDOR_ID]: string; } >; -export type {ExpenseDefaultRuleType, MerchantRuleForm}; +export type {MerchantRuleForm}; export default INPUT_IDS; diff --git a/src/types/onyx/ExpenseDefaultRules.ts b/src/types/onyx/ExpenseDefaultRules.ts index f0e2e06e43d6..616329d3bbf4 100644 --- a/src/types/onyx/ExpenseDefaultRules.ts +++ b/src/types/onyx/ExpenseDefaultRules.ts @@ -5,7 +5,8 @@ import type {ValueOf} from 'type-fest'; import type {RuleFilterNode} from './RuleFilters'; /** - * A transaction lifecycle event that can fire an expense-default rule. + * A transaction lifecycle event that can fire an expense-default rule. A rule fires on both, so that editing the + * category of an existing expense applies the rule too. */ type ExpenseDefaultTrigger = typeof CONST.RULES.TRIGGERS.CREATE_TRANSACTION | typeof CONST.RULES.TRIGGERS.UPDATE_TRANSACTION; @@ -47,7 +48,7 @@ type ExpenseDefaultTaxValue = { /** * The value a `Set` action writes. Which of these is valid depends on the action's `field`. */ -type ExpenseDefaultActionValue = string | boolean | ExpenseDefaultTaxValue; +type ExpenseDefaultActionValue = string | number | boolean | ExpenseDefaultTaxValue; /** * A single change applied to expenses that match the rule's filters. @@ -83,4 +84,4 @@ type ExpenseDefaultRule = { actions: ExpenseDefaultActions; }; -export type {ExpenseDefaultAction, ExpenseDefaultActionField, ExpenseDefaultActions, ExpenseDefaultRule, ExpenseDefaultTaxValue, ExpenseDefaultTriggers}; +export type {ExpenseDefaultAction, ExpenseDefaultActionField, ExpenseDefaultActions, ExpenseDefaultRule, ExpenseDefaultTaxValue, ExpenseDefaultTrigger, ExpenseDefaultTriggers}; diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index 1e20d55c58a9..06446061a981 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -4237,7 +4237,6 @@ export type { ApprovalRule, ExpenseRule, CodingRule, - CodingRuleFilter, NetSuiteConnectionConfig, MccGroup, Subrate, diff --git a/tests/actions/IOU/MoneyRequestSettersTest.ts b/tests/actions/IOU/MoneyRequestSettersTest.ts index 69f1eec529d2..5f6eeeb3d7f1 100644 --- a/tests/actions/IOU/MoneyRequestSettersTest.ts +++ b/tests/actions/IOU/MoneyRequestSettersTest.ts @@ -29,11 +29,11 @@ import {format} from 'date-fns'; import Onyx from 'react-native-onyx'; import currencyList from '../../unit/currencyList.json'; -import createRandomPolicy, {createCategoryTaxExpenseRules} from '../../utils/collections/policies'; +import createRandomPolicy from '../../utils/collections/policies'; import {createRandomReport} from '../../utils/collections/reports'; import createRandomTransaction from '../../utils/collections/transaction'; import getOnyxValue from '../../utils/getOnyxValue'; -import {getCurrencyDecimalsLocal, getGlobalFetchMock} from '../../utils/TestHelper'; +import {getGlobalFetchMock} from '../../utils/TestHelper'; import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; const topMostReportID = '23423423'; @@ -319,124 +319,6 @@ describe('actions/IOU', () => { }); describe('setMoneyRequestCategory', () => { - it('should set the associated tax for the category based on the tax expense rules', async () => { - // Given a policy with tax expense rules associated with category - const transactionID = '1'; - const category = 'Advertising'; - const policyID = '2'; - const taxCode = 'id_TAX_EXEMPT'; - const ruleTaxCode = 'id_TAX_RATE_1'; - const fakePolicy: Policy = { - ...createRandomPolicy(Number(policyID)), - taxRates: CONST.DEFAULT_TAX, - rules: {expenseRules: createCategoryTaxExpenseRules(category, ruleTaxCode)}, - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, { - taxCode, - taxAmount: 0, - amount: 100, - }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); - - // When setting the money request category - setMoneyRequestCategory(transactionID, category, fakePolicy, getCurrencyDecimalsLocal); - - await waitForBatchedUpdates(); - - // Then the transaction tax rate and amount should be updated based on the expense rules - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, - callback: (transaction) => { - Onyx.disconnect(connection); - expect(transaction?.taxCode).toBe(ruleTaxCode); - expect(transaction?.taxAmount).toBe(5); - resolve(); - }, - }); - }); - }); - - describe('should not change the tax', () => { - it('if the transaction type is distance', async () => { - // Given a policy with tax expense rules associated with category and a distance transaction - const transactionID = '1'; - const category = 'Advertising'; - const policyID = '2'; - const taxCode = 'id_TAX_EXEMPT'; - const ruleTaxCode = 'id_TAX_RATE_1'; - const taxAmount = 0; - const fakePolicy: Policy = { - ...createRandomPolicy(Number(policyID)), - taxRates: CONST.DEFAULT_TAX, - rules: {expenseRules: createCategoryTaxExpenseRules(category, ruleTaxCode)}, - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, { - taxCode, - taxAmount, - amount: 100, - iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, - }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); - - // When setting the money request category - setMoneyRequestCategory(transactionID, category, fakePolicy, getCurrencyDecimalsLocal); - - await waitForBatchedUpdates(); - - // Then the transaction tax rate and amount shouldn't be updated - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, - callback: (transaction) => { - Onyx.disconnect(connection); - expect(transaction?.taxCode).toBe(taxCode); - expect(transaction?.taxAmount).toBe(taxAmount); - resolve(); - }, - }); - }); - }); - - it('if there are no tax expense rules', async () => { - // Given a policy without tax expense rules - const transactionID = '1'; - const category = 'Advertising'; - const policyID = '2'; - const taxCode = 'id_TAX_EXEMPT'; - const taxAmount = 0; - const fakePolicy: Policy = { - ...createRandomPolicy(Number(policyID)), - taxRates: CONST.DEFAULT_TAX, - rules: {}, - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, { - taxCode, - taxAmount, - amount: 100, - }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); - - // When setting the money request category - setMoneyRequestCategory(transactionID, category, fakePolicy, getCurrencyDecimalsLocal); - - await waitForBatchedUpdates(); - - // Then the transaction tax rate and amount shouldn't be updated - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, - callback: (transaction) => { - Onyx.disconnect(connection); - expect(transaction?.taxCode).toBe(taxCode); - expect(transaction?.taxAmount).toBe(taxAmount); - resolve(); - }, - }); - }); - }); - }); - it('should clear the tax when the policyID is empty', async () => { // Given a transaction with a tax const transactionID = '1'; @@ -449,7 +331,7 @@ describe('actions/IOU', () => { }); // When setting the money request category without a policyID - setMoneyRequestCategory(transactionID, '', undefined, getCurrencyDecimalsLocal); + setMoneyRequestCategory(transactionID, '', undefined); await waitForBatchedUpdates(); // Then the transaction tax should be cleared diff --git a/tests/actions/IOUTest/BulkEditTest.ts b/tests/actions/IOUTest/BulkEditTest.ts index c3083d41f924..3ce2af033d45 100644 --- a/tests/actions/IOUTest/BulkEditTest.ts +++ b/tests/actions/IOUTest/BulkEditTest.ts @@ -11,7 +11,7 @@ import type {OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; -import createRandomPolicy, {createCategoryTaxExpenseRules} from '../../utils/collections/policies'; +import createRandomPolicy from '../../utils/collections/policies'; import {createRandomReport} from '../../utils/collections/reports'; import createRandomTransaction from '../../utils/collections/transaction'; import createMock from '../../utils/createMock'; @@ -1876,105 +1876,6 @@ describe('actions/IOU/BulkEdit', () => { canEditFieldSpy.mockRestore(); }); - it('uses per-transaction policy for category tax mapping in cross-policy bulk edit', () => { - // Given: two different policies – transactionPolicy has expense rules mapping "Advertising" → "id_TAX_RATE_1", - // while the shared bulk-edit policy has no expense rules at all. - const transactionID = 'transaction-cross-policy-1'; - const transactionThreadReportID = 'thread-cross-policy-1'; - const iouReportID = 'iou-cross-policy-1'; - - const category = 'Advertising'; - const expectedTaxCode = 'id_TAX_RATE_1'; - - // Transaction's own policy – has tax expense rules - const transactionPolicy: Policy = { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - taxRates: CONST.DEFAULT_TAX, - rules: {expenseRules: createCategoryTaxExpenseRules(category, expectedTaxCode)}, - }; - - // Shared bulk-edit policy – no expense rules, different ID - const sharedBulkEditPolicy: Policy = { - ...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), - taxRates: CONST.DEFAULT_TAX, - // No expense rules — category should NOT resolve to a tax code via this policy - }; - - const iouReport: Report = { - ...createRandomReport(2, undefined), - reportID: iouReportID, - policyID: transactionPolicy.id, - type: CONST.REPORT.TYPE.EXPENSE, - }; - - const reports = { - [`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]: iouReport, - }; - - const transaction: Transaction = { - ...createRandomTransaction(1), - transactionID, - reportID: iouReportID, - transactionThreadReportID, - amount: -1000, - currency: CONST.CURRENCY.USD, - category: '', - }; - const transactions = { - [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, - }; - - // Make the transaction's own policy resolvable via allPolicies - const allPolicies = { - [`${ONYXKEYS.COLLECTION.POLICY}${transactionPolicy.id}`]: transactionPolicy, - [`${ONYXKEYS.COLLECTION.POLICY}${sharedBulkEditPolicy.id}`]: sharedBulkEditPolicy, - }; - - const canEditFieldSpy = jest.spyOn(require('@libs/ReportUtils'), 'canEditFieldOfMoneyRequest').mockReturnValue(true); - // eslint-disable-next-line rulesdir/no-multiple-api-calls - const writeSpy = jest.spyOn(API, 'write').mockImplementation(jest.fn()); - - // When: bulk-editing with the shared policy (different from transaction's policy) - updateMultipleMoneyRequests({ - isVendorMatchingBetaEnabled: false, - personalDetailsList: undefined, - transactionIDs: [transactionID], - changes: {category}, - policy: sharedBulkEditPolicy, - reports, - transactions, - reportActions: {}, - policyCategories: undefined, - policyTags: {}, - violations: undefined, - hash: undefined, - allPolicies, - currentUserAccountID: RORY_ACCOUNT_ID, - delegateAccountID: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, - getCurrencySymbol: getCurrencySymbolLocal, - rules: undefined, - }); - - // Then: the optimistic transaction update should use the transaction's own policy for tax resolution. - // Check the optimistic Onyx data passed to API.write (3rd argument) for the TRANSACTION merge. - const writeCall = writeSpy.mock.calls.at(0); - expect(writeCall).toBeDefined(); - const [, , onyxData] = getRequiredWriteCall(writeSpy.mock.calls, 0); - const transactionOnyxUpdate = getRequiredOnyxUpdates(onyxData, 'optimisticData').find( - (update) => isObject(update) && update.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - ); - expect(transactionOnyxUpdate).toBeDefined(); - - // The tax code should resolve from the transaction's policy (which has the expense rule), - // NOT from the shared bulk-edit policy (which has no expense rules) - const transactionOnyxValue = isObject(transactionOnyxUpdate) && isObject(transactionOnyxUpdate.value) ? transactionOnyxUpdate.value : undefined; - expect(transactionOnyxValue?.taxCode).toBe(expectedTaxCode); - - writeSpy.mockRestore(); - canEditFieldSpy.mockRestore(); - }); - it('passes per-transaction policy to buildOptimisticModifiedExpenseReportAction in cross-policy bulk edit', () => { // Given: two different policies — the transaction belongs to transactionPolicy, // but the shared bulk-edit policy is a different workspace. diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts index 7e50ad882488..b6963141bc0a 100644 --- a/tests/actions/IOUTest/SplitTest.ts +++ b/tests/actions/IOUTest/SplitTest.ts @@ -24,15 +24,7 @@ import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; import updateSplitTransactionsFromSplitExpensesFlow from '@pages/iou/updateSplitTransactionsFromSplitExpensesFlow'; -import { - completeSplitBill, - createDistanceRequest, - resolveOptimisticSplitChatReportID, - setDraftSplitTransaction, - splitBill, - splitBillAndOpenReport, - startSplitBill, -} from '@userActions/IOU/Split'; +import {completeSplitBill, createDistanceRequest, resolveOptimisticSplitChatReportID, splitBill, splitBillAndOpenReport, startSplitBill} from '@userActions/IOU/Split'; import { addSplitExpenseField, evenlyDistributeSplitExpenseAmounts, @@ -69,7 +61,7 @@ import Onyx from 'react-native-onyx'; import type {MockFetch} from '../../utils/TestHelper'; import createPersonalDetails from '../../utils/collections/personalDetails'; -import createRandomPolicy, {createCategoryTaxExpenseRules} from '../../utils/collections/policies'; +import createRandomPolicy from '../../utils/collections/policies'; import createRandomReportAction from '../../utils/collections/reportActions'; import {createRandomReport, createSelfDM} from '../../utils/collections/reports'; import createRandomTransaction from '../../utils/collections/transaction'; @@ -10144,127 +10136,6 @@ describe('updateSplitExpenseAmountField', () => { }); }); -describe('setDraftSplitTransaction', () => { - it('should set the associated tax for the category based on the tax expense rules', async () => { - // Given a policy with tax expense rules associated with category - const transactionID = '1'; - const category = 'Advertising'; - const policyID = '2'; - const taxCode = 'id_TAX_EXEMPT'; - const ruleTaxCode = 'id_TAX_RATE_1'; - const fakePolicy: Policy = { - ...createRandomPolicy(Number(policyID)), - taxRates: CONST.DEFAULT_TAX, - rules: {expenseRules: createCategoryTaxExpenseRules(category, ruleTaxCode)}, - }; - const draftTransaction: Transaction = { - ...createRandomTransaction(1), - taxCode, - taxAmount: 0, - amount: 100, - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, draftTransaction); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); - - // When setting a category of a draft split transaction - setDraftSplitTransaction(transactionID, draftTransaction, {category}, getCurrencyDecimalsLocal, getCurrencySymbolLocal, fakePolicy); - - await waitForBatchedUpdates(); - - // Then the transaction tax rate and amount should be updated based on the expense rules - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, - callback: (transaction) => { - Onyx.disconnect(connection); - expect(transaction?.taxCode).toBe(ruleTaxCode); - expect(transaction?.taxAmount).toBe(5); - resolve(); - }, - }); - }); - }); - - describe('should not change the tax', () => { - it('if there are no tax expense rules', async () => { - // Given a policy without tax expense rules - const transactionID = '1'; - const category = 'Advertising'; - const policyID = '2'; - const taxCode = 'id_TAX_EXEMPT'; - const taxAmount = 0; - const fakePolicy: Policy = { - ...createRandomPolicy(Number(policyID)), - taxRates: CONST.DEFAULT_TAX, - rules: {}, - }; - const draftTransaction: Transaction = { - ...createRandomTransaction(1), - taxCode, - taxAmount, - amount: 100, - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, draftTransaction); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); - - // When setting a category of a draft split transaction - setDraftSplitTransaction(transactionID, draftTransaction, {category}, getCurrencyDecimalsLocal, getCurrencySymbolLocal, fakePolicy); - - await waitForBatchedUpdates(); - - // Then the transaction tax rate and amount shouldn't be updated - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, - callback: (transaction) => { - Onyx.disconnect(connection); - expect(transaction?.taxCode).toBe(taxCode); - expect(transaction?.taxAmount).toBe(taxAmount); - resolve(); - }, - }); - }); - }); - - it('if we are not updating category', async () => { - // Given a policy with tax expense rules associated with category - const transactionID = '1'; - const category = 'Advertising'; - const policyID = '2'; - const ruleTaxCode = 'id_TAX_RATE_1'; - const fakePolicy: Policy = { - ...createRandomPolicy(Number(policyID)), - taxRates: CONST.DEFAULT_TAX, - rules: {expenseRules: createCategoryTaxExpenseRules(category, ruleTaxCode)}, - }; - const draftTransaction: Transaction = { - ...createRandomTransaction(1), - amount: 100, - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, draftTransaction); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); - - // When setting a draft split transaction without category update - setDraftSplitTransaction(transactionID, draftTransaction, {}, getCurrencyDecimalsLocal, getCurrencySymbolLocal, fakePolicy); - - await waitForBatchedUpdates(); - - // Then the transaction tax rate and amount shouldn't be updated - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, - callback: (transaction) => { - Onyx.disconnect(connection); - expect(transaction?.taxCode).toBeUndefined(); - expect(transaction?.taxAmount).toBeUndefined(); - resolve(); - }, - }); - }); - }); - }); -}); - describe('initSplitExpenseItemData', () => { it('should create split expense item data from transaction', () => { const transaction: Transaction = { diff --git a/tests/actions/IOUTest/UpdateMoneyRequestTest.ts b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts index ef04c2a94672..e261985c5065 100644 --- a/tests/actions/IOUTest/UpdateMoneyRequestTest.ts +++ b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts @@ -17,7 +17,7 @@ import {editTransactionMerchantInline} from '@libs/actions/TransactionInlineEdit import * as API from '@libs/API'; import {WRITE_COMMANDS} from '@libs/API/types'; import type * as PolicyUtils from '@libs/PolicyUtils'; -import {getOriginalMessage, isActionOfType} from '@libs/ReportActionsUtils'; +import {isActionOfType} from '@libs/ReportActionsUtils'; import {buildOptimisticIOUReportAction} from '@libs/ReportUtils'; import CONST from '@src/CONST'; @@ -41,7 +41,7 @@ import type {MockFetch} from '../../utils/TestHelper'; import currencyList from '../../unit/currencyList.json'; import createPersonalDetails from '../../utils/collections/personalDetails'; -import createRandomPolicy, {createCategoryTaxExpenseRules} from '../../utils/collections/policies'; +import createRandomPolicy from '../../utils/collections/policies'; import {createRandomReport} from '../../utils/collections/reports'; import createRandomTransaction from '../../utils/collections/transaction'; import createMock from '../../utils/createMock'; @@ -163,215 +163,6 @@ describe('actions/IOU/UpdateMoneyRequest', () => { }); describe('updateMoneyRequestCategory', () => { - it('should update the tax when there are tax expense rules', async () => { - // Given a policy with tax expense rules associated with category - const transactionID = '1'; - const policyID = '2'; - const transactionThreadReportID = '3'; - const transactionThreadReport = {reportID: transactionThreadReportID}; - const category = 'Advertising'; - const taxCode = 'id_TAX_EXEMPT'; - const ruleTaxCode = 'id_TAX_RATE_1'; - const fakePolicy: Policy = { - ...createRandomPolicy(Number(policyID)), - taxRates: CONST.DEFAULT_TAX, - rules: { - expenseRules: createCategoryTaxExpenseRules(category, ruleTaxCode), - }, - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { - taxCode, - taxAmount: 0, - amount: 100, - }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, transactionThreadReport); - - // When updating a money request category - updateMoneyRequestCategory({ - isVendorMatchingBetaEnabled: false, - rules: undefined, - transactionID, - transactionThreadReport, - parentReport: undefined, - iouReportOwnerLogin: undefined, - reportPolicyTags: undefined, - category, - policy: fakePolicy, - policyTagList: undefined, - policyCategories: undefined, - policyRecentlyUsedCategories: [], - currentUserAccountIDParam: 123, - currentUserEmailParam: 'existing@example.com', - isASAPSubmitBetaEnabled: false, - delegateAccountID: undefined, - isTrackIntentUser: false, - violations: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, - getCurrencySymbol: getCurrencySymbolLocal, - }); - - await waitForBatchedUpdates(); - - // Then the transaction tax rate and amount should be updated based on the expense rules - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - callback: (transaction) => { - Onyx.disconnect(connection); - expect(transaction?.taxCode).toBe(ruleTaxCode); - expect(transaction?.taxAmount).toBe(5); - resolve(); - }, - }); - }); - - // But the original message should only contains the old and new category data - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, - callback: (reportActions) => { - Onyx.disconnect(connection); - const reportAction = Object.values(reportActions ?? {}).at(0); - if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE)) { - const originalMessage = getOriginalMessage(reportAction); - expect(originalMessage?.oldCategory).toBe(''); - expect(originalMessage?.category).toBe(category); - expect(originalMessage?.oldTaxRate).toBeUndefined(); - expect(originalMessage?.oldTaxAmount).toBeUndefined(); - resolve(); - } - }, - }); - }); - }); - - describe('should not update the tax', () => { - it('if the transaction type is distance', async () => { - // Given a policy with tax expense rules associated with category and a distance transaction - const transactionID = '1'; - const policyID = '2'; - const category = 'Advertising'; - const taxCode = 'id_TAX_EXEMPT'; - const taxAmount = 0; - const ruleTaxCode = 'id_TAX_RATE_1'; - const fakePolicy: Policy = { - ...createRandomPolicy(Number(policyID)), - taxRates: CONST.DEFAULT_TAX, - rules: { - expenseRules: createCategoryTaxExpenseRules(category, ruleTaxCode), - }, - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { - taxCode, - taxAmount, - amount: 100, - iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, - comment: { - type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, - customUnit: { - name: CONST.CUSTOM_UNITS.NAME_DISTANCE, - }, - }, - }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); - - // When updating a money request category - updateMoneyRequestCategory({ - isVendorMatchingBetaEnabled: false, - rules: undefined, - transactionID, - transactionThreadReport: {reportID: '3'}, - parentReport: undefined, - iouReportOwnerLogin: undefined, - reportPolicyTags: undefined, - category, - policy: fakePolicy, - policyTagList: undefined, - policyCategories: undefined, - policyRecentlyUsedCategories: [], - currentUserAccountIDParam: 123, - currentUserEmailParam: 'existing@example.com', - isASAPSubmitBetaEnabled: false, - delegateAccountID: undefined, - isTrackIntentUser: false, - violations: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, - getCurrencySymbol: getCurrencySymbolLocal, - }); - - await waitForBatchedUpdates(); - - // Then the transaction tax rate and amount shouldn't be updated - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - callback: (transaction) => { - Onyx.disconnect(connection); - expect(transaction?.taxCode).toBe(taxCode); - expect(transaction?.taxAmount).toBe(taxAmount); - resolve(); - }, - }); - }); - }); - - it('if there are no tax expense rules', async () => { - // Given a policy without tax expense rules - const transactionID = '1'; - const policyID = '2'; - const category = 'Advertising'; - const fakePolicy: Policy = { - ...createRandomPolicy(Number(policyID)), - taxRates: CONST.DEFAULT_TAX, - rules: {}, - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { - amount: 100, - }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); - - // When updating the money request category - updateMoneyRequestCategory({ - isVendorMatchingBetaEnabled: false, - rules: undefined, - transactionID, - transactionThreadReport: {reportID: '3'}, - parentReport: undefined, - iouReportOwnerLogin: undefined, - reportPolicyTags: undefined, - category, - policy: fakePolicy, - policyTagList: undefined, - policyCategories: undefined, - policyRecentlyUsedCategories: [], - currentUserAccountIDParam: 123, - currentUserEmailParam: 'existing@example.com', - isASAPSubmitBetaEnabled: false, - delegateAccountID: undefined, - isTrackIntentUser: false, - violations: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, - getCurrencySymbol: getCurrencySymbolLocal, - }); - - await waitForBatchedUpdates(); - - // Then the transaction tax rate and amount shouldn't be updated - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - callback: (transaction) => { - Onyx.disconnect(connection); - expect(transaction?.taxCode).toBeUndefined(); - expect(transaction?.taxAmount).toBeUndefined(); - resolve(); - }, - }); - }); - }); - }); - it('should remove all existing category violations when the transaction Category is unset', async () => { const transactionID = '1'; const policyID = '2'; diff --git a/tests/actions/PolicyCategoryTest.ts b/tests/actions/PolicyCategoryTest.ts index 2587abc3d0f8..34ef7eb37755 100644 --- a/tests/actions/PolicyCategoryTest.ts +++ b/tests/actions/PolicyCategoryTest.ts @@ -11,7 +11,6 @@ import { importPolicyCategories, renamePolicyCategory, setPolicyCategoryReceiptsAndItemizedReceiptRequired, - setPolicyCategoryTax, setWorkspaceCategoryEnabled, setWorkspaceRequiresCategory, } from '@libs/actions/Policy/Category'; @@ -421,107 +420,6 @@ describe('actions/PolicyCategory', () => { }); }); - describe('SetPolicyCategoryTax', () => { - it('should set expense rule when category expense rule is not present', async () => { - // Given a policy - const fakePolicy = createRandomPolicy(0); - fakePolicy.areCategoriesEnabled = true; - const categoryName = 'Fake category'; - const fakePolicyCategories = { - [categoryName]: { - name: categoryName, - enabled: false, - // eslint-disable-next-line @typescript-eslint/naming-convention - 'GL Code': '', - unencodedName: categoryName, - externalID: '', - areCommentsRequired: false, - origin: '', - }, - }; - - mockFetch.pause(); - - await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy); - await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${fakePolicy.id}`, fakePolicyCategories); - - setPolicyCategoryTax(fakePolicy, categoryName, 'VAT'); - await waitForBatchedUpdates(); - - // Then the approval rule should be created with the tag name - const updatedPolicy = await OnyxUtils.get(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`); - - expect(updatedPolicy?.rules?.expenseRules).toHaveLength(1); - expect(updatedPolicy?.rules?.expenseRules?.[0]?.applyWhen?.[0]?.value).toBe(categoryName); - expect(updatedPolicy?.rules?.expenseRules?.[0]?.applyWhen?.[0]?.condition).toBe(CONST.POLICY.RULE_CONDITIONS.MATCHES); - expect(updatedPolicy?.rules?.expenseRules?.[0]?.applyWhen?.[0]?.field).toBe(CONST.POLICY.FIELDS.CATEGORY); - expect(updatedPolicy?.rules?.expenseRules?.[0].tax.field_id_TAX.externalID).toBe('VAT'); - - mockFetch.resume(); - await waitForBatchedUpdates(); - }); - - it('should update expense rule when category expense rule is present', async () => { - // Given a policy with approval rules that reference a tag - const fakePolicy = createRandomPolicy(0); - fakePolicy.areCategoriesEnabled = true; - const categoryName = 'Fake category'; - const fakePolicyCategories = { - [categoryName]: { - name: categoryName, - enabled: false, - // eslint-disable-next-line @typescript-eslint/naming-convention - 'GL Code': '', - unencodedName: categoryName, - externalID: '', - areCommentsRequired: false, - origin: '', - }, - }; - - // Create expense rule that uses the tag - fakePolicy.rules = { - expenseRules: [ - { - tax: { - // eslint-disable-next-line @typescript-eslint/naming-convention - field_id_TAX: { - externalID: 'GST', - }, - }, - applyWhen: [ - { - condition: CONST.POLICY.RULE_CONDITIONS.MATCHES, - field: CONST.POLICY.FIELDS.CATEGORY, - value: categoryName, - }, - ], - }, - ], - }; - - mockFetch.pause(); - - await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy); - await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${fakePolicy.id}`, fakePolicyCategories); - - setPolicyCategoryTax(fakePolicy, categoryName, 'VAT'); - await waitForBatchedUpdates(); - - // Then the approval rule should be created with the tag name - const updatedPolicy = await OnyxUtils.get(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`); - - expect(updatedPolicy?.rules?.expenseRules).toHaveLength(1); - expect(updatedPolicy?.rules?.expenseRules?.[0]?.applyWhen?.[0]?.value).toBe(categoryName); - expect(updatedPolicy?.rules?.expenseRules?.[0]?.applyWhen?.[0]?.condition).toBe(CONST.POLICY.RULE_CONDITIONS.MATCHES); - expect(updatedPolicy?.rules?.expenseRules?.[0]?.applyWhen?.[0]?.field).toBe(CONST.POLICY.FIELDS.CATEGORY); - expect(updatedPolicy?.rules?.expenseRules?.[0].tax.field_id_TAX.externalID).toBe('VAT'); - - mockFetch.resume(); - await waitForBatchedUpdates(); - }); - }); - describe('createPolicyCategory with onboarding task completion', () => { it('should complete SETUP_CATEGORIES_AND_TAGS task when creating category and tags already exist', async () => { const fakePolicy = createRandomPolicy(0); diff --git a/tests/actions/PolicyRulesTest.ts b/tests/actions/PolicyRulesTest.ts index 3d538ba67112..9137c91def90 100644 --- a/tests/actions/PolicyRulesTest.ts +++ b/tests/actions/PolicyRulesTest.ts @@ -1,7 +1,7 @@ import OnyxUpdateManager from '@libs/actions/OnyxUpdateManager'; import {addPolicyAgentRule, clearMerchantRuleErrors, clearPolicyAgentRuleErrors, deleteRule, deletePolicyAgentRule, setRule, updatePolicyAgentRule} from '@libs/actions/Policy/Rules'; import {WRITE_COMMANDS} from '@libs/API/types'; -import {buildMerchantRule} from '@libs/ExpenseDefaultRuleUtils'; +import {buildExpenseDefaultRule} from '@libs/ExpenseDefaultRuleUtils'; import {flush as flushSequentialQueue} from '@libs/Network/SequentialQueue'; import {toIndexMap} from '@libs/RuleUtils'; @@ -51,7 +51,7 @@ async function getRules(): Promise> { } /** A minimal merchant rule as stored in the rules collection. */ -function buildMerchantRuleForPolicy(policyID: string): Rule { +function buildExpenseDefaultRuleForPolicy(policyID: string): Rule { return { scope: CONST.RULES.SCOPE.POLICY, scopeID: policyID, @@ -463,7 +463,7 @@ describe('actions/PolicyRules', () => { setRule( fakePolicy.id, - buildMerchantRule({merchantToMatch: 'Starbucks', matchType: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, category: 'Coffee'}, fakePolicy), + buildExpenseDefaultRule({merchantToMatch: 'Starbucks', matchType: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, category: 'Coffee'}, fakePolicy), CONST.RULES.EXPENSE_DEFAULT.PRIORITY, ); await waitForBatchedUpdates(); @@ -494,11 +494,11 @@ describe('actions/PolicyRules', () => { mockFetch?.pause?.(); await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy); - setRule(fakePolicy.id, buildMerchantRule({merchantToMatch: 'Starbucks', category: 'Coffee', tag: 'Team A'}, fakePolicy), CONST.RULES.EXPENSE_DEFAULT.PRIORITY, ruleID); + setRule(fakePolicy.id, buildExpenseDefaultRule({merchantToMatch: 'Starbucks', category: 'Coffee', tag: 'Team A'}, fakePolicy), CONST.RULES.EXPENSE_DEFAULT.PRIORITY, ruleID); await waitForBatchedUpdates(); const existingRule = (await getRules())?.[`${ONYXKEYS.COLLECTION.RULE}${ruleID}`]; - setRule(fakePolicy.id, buildMerchantRule({merchantToMatch: 'Starbucks', category: 'Coffee'}, fakePolicy), CONST.RULES.EXPENSE_DEFAULT.PRIORITY, ruleID, existingRule); + setRule(fakePolicy.id, buildExpenseDefaultRule({merchantToMatch: 'Starbucks', category: 'Coffee'}, fakePolicy), CONST.RULES.EXPENSE_DEFAULT.PRIORITY, ruleID, existingRule); await waitForBatchedUpdates(); const updatedRule = (await getRules())?.[`${ONYXKEYS.COLLECTION.RULE}${ruleID}`]; @@ -516,7 +516,7 @@ describe('actions/PolicyRules', () => { const fakePolicy = createRandomPolicy(0); await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy); - setRule(fakePolicy.id, buildMerchantRule({merchantToMatch: '', category: 'Coffee'}, fakePolicy), CONST.RULES.EXPENSE_DEFAULT.PRIORITY); + setRule(fakePolicy.id, buildExpenseDefaultRule({merchantToMatch: '', category: 'Coffee'}, fakePolicy), CONST.RULES.EXPENSE_DEFAULT.PRIORITY); await waitForBatchedUpdates(); expect(Object.keys((await getRules()) ?? {})).toHaveLength(0); @@ -531,7 +531,7 @@ describe('actions/PolicyRules', () => { // When the rule is saved setRule( fakePolicy.id, - buildMerchantRule({merchantToMatch: 'Starbucks', matchType: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, category: 'Coffee'}, fakePolicy), + buildExpenseDefaultRule({merchantToMatch: 'Starbucks', matchType: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, category: 'Coffee'}, fakePolicy), CONST.RULES.EXPENSE_DEFAULT.PRIORITY, ruleID, ); @@ -557,7 +557,7 @@ describe('actions/PolicyRules', () => { it('marks the rule as pending delete', async () => { const fakePolicy = createRandomPolicy(0); const ruleID = 'merchantRule1'; - const rule = buildMerchantRuleForPolicy(fakePolicy.id); + const rule = buildExpenseDefaultRuleForPolicy(fakePolicy.id); mockFetch?.pause?.(); await Onyx.set(`${ONYXKEYS.COLLECTION.RULE}${ruleID}`, rule); @@ -574,7 +574,7 @@ describe('actions/PolicyRules', () => { // Given a saved merchant rule const fakePolicy = createRandomPolicy(0); const ruleID = 'merchantRule1'; - const rule = buildMerchantRuleForPolicy(fakePolicy.id); + const rule = buildExpenseDefaultRuleForPolicy(fakePolicy.id); await Onyx.set(`${ONYXKEYS.COLLECTION.RULE}${ruleID}`, rule); // When it is deleted @@ -590,7 +590,7 @@ describe('actions/PolicyRules', () => { it('removes the rule entirely when its pendingAction was ADD', async () => { const ruleID = 'merchantRule1'; const rule: Rule = { - ...buildMerchantRuleForPolicy('policy1'), + ...buildExpenseDefaultRuleForPolicy('policy1'), pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, errors: {[ERROR_KEY]: 'boom'}, }; @@ -605,7 +605,7 @@ describe('actions/PolicyRules', () => { it('clears only the errors when the rule has a non-ADD pending action', async () => { const ruleID = 'merchantRule1'; const rule: Rule = { - ...buildMerchantRuleForPolicy('policy1'), + ...buildExpenseDefaultRuleForPolicy('policy1'), pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, errors: {[ERROR_KEY]: 'boom'}, }; diff --git a/tests/ui/DynamicCategoryDefaultTaxRatePageTest.tsx b/tests/ui/DynamicCategoryDefaultTaxRatePageTest.tsx deleted file mode 100644 index cb7cd8eda8ae..000000000000 --- a/tests/ui/DynamicCategoryDefaultTaxRatePageTest.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import {act, render} from '@testing-library/react-native'; - -import SelectionList from '@components/SelectionList'; - -import DynamicCategoryDefaultTaxRatePage from '@pages/workspace/categories/DynamicCategoryDefaultTaxRatePage'; - -import CONST from '@src/CONST'; -import type {Policy, TaxRate} from '@src/types/onyx'; - -import type * as ReactNavigation from '@react-navigation/native'; -import type {PropsWithChildren} from 'react'; - -import React from 'react'; - -let mockIsFocused = true; -let mockPolicy: Policy | undefined; -let mockPersistedTaxRate: string | undefined; - -jest.mock('@react-navigation/native', () => { - const actualNavigation: typeof ReactNavigation = jest.requireActual('@react-navigation/native'); - const ReactMock = jest.requireActual('react'); - - return { - ...actualNavigation, - useFocusEffect: jest.fn((callback: () => void) => { - ReactMock.useEffect(() => { - if (!mockIsFocused) { - return; - } - - return callback(); - }, [callback, mockIsFocused]); - }), - }; -}); - -jest.mock('@components/SelectionList', () => jest.fn(() => null)); -jest.mock('@components/SelectionList/ListItem/SingleSelectListItem', () => jest.fn(() => null)); -jest.mock('@components/ScreenWrapper', () => jest.fn(({children}: PropsWithChildren) => children)); -jest.mock('@components/HeaderWithBackButton', () => jest.fn(() => null)); -jest.mock('@pages/workspace/AccessOrNotFoundWrapper', () => jest.fn(({children}: PropsWithChildren) => children)); - -jest.mock('@hooks/usePolicy', () => jest.fn(() => mockPolicy)); -jest.mock('@hooks/useDynamicBackPath', () => jest.fn(() => '')); -jest.mock('@hooks/useThemeStyles', () => jest.fn(() => new Proxy({}, {get: () => ({})}))); -jest.mock('@hooks/useLocalize', () => - jest.fn(() => ({ - translate: (key: string) => key, - localeCompare: (a: string, b: string) => a.localeCompare(b), - })), -); - -// Return the persisted rate and use the taxID itself as the display text so the alphabetical sort is predictable. -jest.mock('@libs/CategoryUtils', () => ({ - getCategoryDefaultTaxRate: jest.fn(() => mockPersistedTaxRate), - formatDefaultTaxRateText: jest.fn((_translate: unknown, taxID: string) => taxID), -})); - -jest.mock('@libs/Navigation/Navigation', () => ({ - goBack: jest.fn(), - setNavigationActionToMicrotaskQueue: jest.fn(), -})); - -jest.mock('@userActions/Policy/Category', () => ({ - setPolicyCategoryTax: jest.fn(), -})); - -const POLICY_ID = 'policy1'; -const CATEGORY_NAME = 'Travel'; - -type MockTaxItem = {value: string; keyForList?: string; text?: string; isSelected?: boolean}; - -type MockSelectionListProps = { - data: MockTaxItem[]; - onSelectRow?: (item: MockTaxItem) => void; - initiallyFocusedItemKey?: string; - shouldScrollToFocusedIndexOnMount?: boolean; - shouldUpdateFocusedIndex?: boolean; -}; - -/** Build a policy whose taxRates.taxes has `count` entries keyed id00..id{count-1} (zero-padded so the text sort is numeric). */ -function buildPolicyWithTaxes(count: number): Policy { - const taxes: Record = {}; - for (let index = 0; index < count; index++) { - const key = `id${String(index).padStart(2, '0')}`; - taxes[key] = {name: key, value: '0%'} as TaxRate; - } - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test-only minimal Policy stub - return {taxRates: {name: 'Tax', defaultExternalID: 'id00', foreignTaxDefault: 'id00', taxes}} as Policy; -} - -function renderPage() { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test-only route stub; the page only reads route.params - const props = {route: {params: {policyID: POLICY_ID, categoryName: CATEGORY_NAME}}} as React.ComponentProps; - return render(); -} - -describe('DynamicCategoryDefaultTaxRatePage', () => { - const mockedSelectionList = jest.mocked(SelectionList); - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test-only: narrow the props captured from the mocked SelectionList - const getSelectionListProps = () => mockedSelectionList.mock.lastCall?.[0] as MockSelectionListProps | undefined; - - beforeEach(() => { - mockedSelectionList.mockClear(); - mockIsFocused = true; - mockPolicy = buildPolicyWithTaxes(CONST.STANDARD_LIST_ITEM_LIMIT + 2); - // "id07" sorts to the middle, so seeing it at the top proves pinning (not the natural order) put it there. - mockPersistedTaxRate = 'id07'; - }); - - it('pins the persisted tax rate to the top on open', () => { - renderPage(); - - const props = getSelectionListProps(); - - expect(props?.data.at(0)?.keyForList).toBe('id07'); - expect(props?.data.at(0)?.isSelected).toBe(true); - expect(props?.initiallyFocusedItemKey).toBe('id07'); - // Alphabetically "id00" would be first if nothing were pinned. - expect(props?.data.at(0)?.value).not.toBe('id00'); - // Keyboard focus starts on the selected rate, and shouldUpdateFocusedIndex + no mount scroll keep the list from auto-scrolling on select. - expect(props?.shouldScrollToFocusedIndexOnMount).toBe(false); - expect(props?.shouldUpdateFocusedIndex).toBe(true); - }); - - it('keeps the persisted rate pinned while the live selection changes during the same session', () => { - renderPage(); - - let props = getSelectionListProps(); - const otherItem = props?.data.find((item) => item.keyForList === 'id03'); - expect(otherItem).toBeDefined(); - - act(() => { - if (!otherItem) { - return; - } - props?.onSelectRow?.(otherItem); - }); - - props = getSelectionListProps(); - - // The originally persisted rate stays pinned at the top; the newly selected one does not jump above it. - expect(props?.data.at(0)?.keyForList).toBe('id07'); - expect(props?.data.at(0)?.isSelected).toBe(false); - expect(props?.data.find((item) => item.keyForList === 'id03')?.isSelected).toBe(true); - expect(props?.data.findIndex((item) => item.keyForList === 'id03')).toBeGreaterThan(0); - }); - - it('does not reorder when the list is under the item-limit threshold', () => { - mockPolicy = buildPolicyWithTaxes(CONST.STANDARD_LIST_ITEM_LIMIT - 2); - - renderPage(); - - const props = getSelectionListProps(); - - // Below the threshold moveInitialSelectionToTop is a no-op, so the natural alphabetical order is preserved. - expect(props?.data.at(0)?.keyForList).toBe('id00'); - expect(props?.data.findIndex((item) => item.keyForList === 'id07')).toBe(7); - }); -}); diff --git a/tests/ui/RulesNewPageDescribeRuleTest.tsx b/tests/ui/RulesNewPageDescribeRuleTest.tsx index c719342834a2..fb4491633468 100644 --- a/tests/ui/RulesNewPageDescribeRuleTest.tsx +++ b/tests/ui/RulesNewPageDescribeRuleTest.tsx @@ -10,6 +10,7 @@ import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import {toIndexMap} from '@libs/RuleUtils'; import RulesNewPage from '@pages/workspace/rules/RulesNewPage'; @@ -17,7 +18,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; -import type {GeneratedRule} from '@src/types/onyx'; +import type {GeneratedRule, Rule} from '@src/types/onyx'; import type {GeneratedRuleState, GeneratedRuleType} from '@src/types/onyx/GeneratedRule'; import type * as ReactNavigationNative from '@react-navigation/native'; @@ -311,7 +312,7 @@ describe('New rule describe section', () => { expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.RULES_FLAG_FOR_REVIEW_RULE_NEW.getRoute(POLICY_ID)); }); - it('scopes an expense defaults draft to a merchant rule', async () => { + it('seeds the expense defaults draft from a merchant answer', async () => { // Given the admin described an expense default for a merchant await renderRulesNewPage(); const {generationID} = describeRule(writeSpy, 'Set Uber expenses to not billable'); @@ -324,12 +325,12 @@ describe('New rule describe section', () => { rule: {merchantToMatch: 'Uber'}, }); - // Then the draft carries the merchant rule type, because the merchant editor sends a draft without one to the type picker - expect(await getOnyxValue(ONYXKEYS.FORMS.MERCHANT_RULE_FORM)).toEqual({merchantToMatch: 'Uber', ruleType: CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.MERCHANT}); + // Then the editor opens with the generated values, so the admin only reviews and saves + expect(await getOnyxValue(ONYXKEYS.FORMS.MERCHANT_RULE_FORM)).toEqual({merchantToMatch: 'Uber'}); expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.RULES_MERCHANT_NEW.getRoute(POLICY_ID)); }); - it('opens the category tax rate editor for a categoryTaxRate answer', async () => { + it('opens the expense defaults editor for a categoryTaxRate answer', async () => { // Given a workspace with a Meals category and a GST rate that a category rule can use, and an admin who describes a tax default mockedUsePolicy.mockReturnValue({ ...createRandomPolicy(0, CONST.POLICY.TYPE.CORPORATE), @@ -351,8 +352,8 @@ describe('New rule describe section', () => { rule: {categoriesToMatch: ['Meals'], tax: 'GST'}, }); - // Then the expense defaults editor opens scoped to categories, because it takes its rule type from the draft - expect(await getOnyxValue(ONYXKEYS.FORMS.MERCHANT_RULE_FORM)).toEqual({categoriesToMatch: ['Meals'], tax: 'GST', ruleType: CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.CATEGORY}); + // Then the editor opens matching on the category, because a category tax default is an ordinary expense default + expect(await getOnyxValue(ONYXKEYS.FORMS.MERCHANT_RULE_FORM)).toEqual({categoriesToMatch: ['Meals'], tax: 'GST'}); expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.RULES_MERCHANT_NEW.getRoute(POLICY_ID)); }); @@ -362,18 +363,18 @@ describe('New rule describe section', () => { ...createRandomPolicy(0, CONST.POLICY.TYPE.CORPORATE), id: POLICY_ID, role: CONST.POLICY.ROLE.ADMIN, - rules: { - expenseRules: [ - { - // eslint-disable-next-line @typescript-eslint/naming-convention -- the expense rule stores the tax under the field name that the backend uses - tax: {field_id_TAX: {externalID: 'GST'}}, - applyWhen: [{condition: CONST.POLICY.RULE_CONDITIONS.MATCHES, field: CONST.POLICY.FIELDS.CATEGORY, value: 'Meals'}], - }, - ], - }, }); + const existingRule: Rule = { + scope: CONST.RULES.SCOPE.POLICY, + scopeID: POLICY_ID, + triggers: toIndexMap([CONST.RULES.TRIGGERS.CREATE_TRANSACTION]), + filters: {left: CONST.RULES.EXPENSE_DEFAULT.FIELD.CATEGORY, operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, right: 'Meals'}, + // eslint-disable-next-line @typescript-eslint/naming-convention -- the rule stores the tax under the field name that the backend uses + actions: {'1': {name: CONST.RULES.ACTIONS.SET, field: CONST.RULES.EXPENSE_DEFAULT.FIELD.TAX, value: {field_id_TAX: {externalID: 'GST'}}}}, + }; await act(async () => { await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${POLICY_ID}`, {Meals: {name: 'Meals', enabled: true}}); + await Onyx.set(`${ONYXKEYS.COLLECTION.RULE}existing-category-tax-rule`, existingRule); }); await renderRulesNewPage(); const {generationID} = describeRule(writeSpy, 'Use GST for meals'); diff --git a/tests/unit/AddVendorPageTest.ts b/tests/unit/AddVendorPageTest.ts index 33faef33e276..b61e1ce2da7d 100644 --- a/tests/unit/AddVendorPageTest.ts +++ b/tests/unit/AddVendorPageTest.ts @@ -143,12 +143,12 @@ describe('AddVendorPage', () => { }); /** - * The "Set vendor to" row in MerchantRulePageBase is assembled from these already-exported helpers + * The "Set vendor to" row in ExpenseDefaultRulePageBase is assembled from these already-exported helpers * (see the `isVendorFeatureEnabled` / `vendorFieldLabel` / `vendorDisplayName` derivations). Asserting * them here pins the row's decision logic; the row's render/JSX wiring and navigation are exercised by * the Playwright click-through. */ - describe('vendor rule row derivation (MerchantRulePageBase)', () => { + describe('vendor rule row derivation (ExpenseDefaultRulePageBase)', () => { const qboPolicy = buildQBOPolicy([{id: 'v-1', name: 'Acme Co', currency: 'USD'}]); const intacctPolicy = buildIntacctPolicy([{id: 'iv-1', name: 'V001', value: 'Acme Intacct'}]); const xeroPolicy = buildXeroPolicy({xc1: {id: 'xc1', name: 'Acme Xero', email: 'acme@example.com'}}); diff --git a/tests/unit/CategoryContextualRulesUtilsTest.ts b/tests/unit/CategoryContextualRulesUtilsTest.ts index 5be5c76e16b8..fd73d6431624 100644 --- a/tests/unit/CategoryContextualRulesUtilsTest.ts +++ b/tests/unit/CategoryContextualRulesUtilsTest.ts @@ -1,9 +1,13 @@ import getCategoryContextualRules from '@libs/CategoryContextualRulesUtils'; +import {toIndexMap} from '@libs/RuleUtils'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; +import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; -import type {Policy, PolicyCategory} from '@src/types/onyx'; +import type {Policy, PolicyCategory, Rule} from '@src/types/onyx'; + +import type {OnyxCollection} from 'react-native-onyx'; import createRandomPolicy from '../utils/collections/policies'; import {convertToDisplayString, translateLocal} from '../utils/TestHelper'; @@ -135,14 +139,16 @@ describe('getCategoryContextualRules', () => { TAX_VAT: {name: 'VAT', value: '20%'}, }, }, - rules: { - expenseRules: [ - { - // eslint-disable-next-line @typescript-eslint/naming-convention -- matches the backend's expense rule shape - tax: {field_id_TAX: {externalID: 'TAX_VAT'}}, - applyWhen: [{condition: CONST.POLICY.RULE_CONDITIONS.MATCHES, field: CONST.POLICY.FIELDS.CATEGORY, value: 'Travel'}], - }, - ], + }; + // A category's tax default is an ordinary rule matching that category alone and setting a tax. + const taxDefaultRules: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.RULE}rule1`]: { + scope: CONST.RULES.SCOPE.POLICY, + scopeID: policy.id, + triggers: toIndexMap([CONST.RULES.TRIGGERS.CREATE_TRANSACTION]), + filters: {left: CONST.RULES.EXPENSE_DEFAULT.FIELD.CATEGORY, operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, right: ['Travel']}, + // eslint-disable-next-line @typescript-eslint/naming-convention -- matches the rules engine's tax value shape + actions: toIndexMap([{name: CONST.RULES.ACTIONS.SET, field: CONST.RULES.EXPENSE_DEFAULT.FIELD.TAX, value: {field_id_TAX: {externalID: 'TAX_VAT'}}}]), }, }; const category: PolicyCategory = {name: 'Travel', enabled: true}; @@ -153,6 +159,7 @@ describe('getCategoryContextualRules', () => { const rules = getCategoryContextualRules({ policy: policyWithTaxRule, policyCategories: undefined, + policyRules: taxDefaultRules, category, categoryName: 'Travel', translate: translateLocal, @@ -172,6 +179,7 @@ describe('getCategoryContextualRules', () => { const rules = getCategoryContextualRules({ policy: policyWithTaxRule, policyCategories: undefined, + policyRules: taxDefaultRules, category: {name: 'Meals', enabled: true}, categoryName: 'Meals', translate: translateLocal, @@ -202,6 +210,7 @@ describe('getCategoryContextualRules', () => { const params = { policy: policyWithDeletingRate, policyCategories: undefined, + policyRules: taxDefaultRules, category, categoryName: 'Travel', translate: translateLocal, diff --git a/tests/unit/CategoryTaxRulesUtilsTest.ts b/tests/unit/CategoryTaxRulesUtilsTest.ts index 4e6f31a5de7e..f378e8e8da21 100644 --- a/tests/unit/CategoryTaxRulesUtilsTest.ts +++ b/tests/unit/CategoryTaxRulesUtilsTest.ts @@ -1,143 +1,37 @@ -import {getCategoryTaxRulesTableData, hasSelectableCategoryTaxRate, isSelectableTaxRate} from '@libs/CategoryTaxRulesUtils'; +import {isSelectableTaxRate} from '@libs/CategoryTaxRulesUtils'; import CONST from '@src/CONST'; -import IntlStore from '@src/languages/IntlStore'; -import type {Policy} from '@src/types/onyx'; -import type {ExpenseRule, TaxRate} from '@src/types/onyx/Policy'; +import type {TaxRate} from '@src/types/onyx/Policy'; -import createRandomPolicy from '../utils/collections/policies'; import createMock from '../utils/createMock'; -import {translateLocal} from '../utils/TestHelper'; -import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; - -const CATEGORY_NAME = 'Travel'; -const TAX_ID = 'id_TAX_RATE_1'; -const DEFAULT_TAX_ID = 'id_TAX_EXEMPT'; - -/** A saved category tax rule, which stores the rate as an ID with no label of its own. */ -const buildCategoryTaxRule = (taxID: string): ExpenseRule => ({ - tax: { - // eslint-disable-next-line @typescript-eslint/naming-convention - field_id_TAX: {externalID: taxID}, - }, - applyWhen: [ - { - condition: CONST.POLICY.RULE_CONDITIONS.MATCHES, - field: CONST.POLICY.FIELDS.CATEGORY, - value: CATEGORY_NAME, - }, - ], -}); - -/** A policy holding one category tax rule, with `taxes` describing which rates the workspace still has. */ -const buildPolicy = (taxes: Record, expenseRules: ExpenseRule[] = [buildCategoryTaxRule(TAX_ID)]): Policy => - createMock({ - ...createRandomPolicy(0), - taxRates: { - name: 'Taxes', - defaultExternalID: DEFAULT_TAX_ID, - defaultValue: '0%', - foreignTaxDefault: DEFAULT_TAX_ID, - taxes, - }, - rules: {expenseRules}, - }); - -const buildTableData = (policy: Policy) => - getCategoryTaxRulesTableData({ - policy, - policyCategories: {[CATEGORY_NAME]: createMock({name: CATEGORY_NAME, enabled: true})}, - translate: translateLocal, - isOffline: false, - onNavigate: () => {}, - }); describe('CategoryTaxRulesUtils', () => { - beforeAll(() => { - IntlStore.load(CONST.LOCALES.EN); - return waitForBatchedUpdates(); - }); - - describe('hasSelectableCategoryTaxRate', () => { - /** `hasSelectableCategoryTaxRate` reads tracking as well as the rates, so this states it rather than leaving it to the random policy. */ - const buildTaxTrackingPolicy = (taxes: Record, trackingEnabled = true): Policy => - createMock({ - ...buildPolicy(taxes), - tax: {trackingEnabled}, - }); - - it('is true when a rate other than the workspace default is enabled', () => { - const policy = buildTaxTrackingPolicy({ - [DEFAULT_TAX_ID]: createMock({name: 'Tax exempt', value: '0%'}), - [TAX_ID]: createMock({name: 'VAT', value: '5%'}), - }); - expect(hasSelectableCategoryTaxRate(policy)).toBe(true); - }); - - it('is false when the workspace default is the only rate left', () => { - const policy = buildTaxTrackingPolicy({[DEFAULT_TAX_ID]: createMock({name: 'Tax exempt', value: '0%'})}); - expect(hasSelectableCategoryTaxRate(policy)).toBe(false); - }); - - it('is false when every other rate is disabled', () => { - const policy = buildTaxTrackingPolicy({ - [DEFAULT_TAX_ID]: createMock({name: 'Tax exempt', value: '0%'}), - [TAX_ID]: createMock({name: 'VAT', value: '5%', isDisabled: true}), - }); - expect(hasSelectableCategoryTaxRate(policy)).toBe(false); - }); - - it('is false when every other rate is being deleted', () => { - const policy = buildTaxTrackingPolicy({ - [DEFAULT_TAX_ID]: createMock({name: 'Tax exempt', value: '0%'}), - [TAX_ID]: createMock({name: 'VAT', value: '5%', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}), - }); - expect(hasSelectableCategoryTaxRate(policy)).toBe(false); - }); - - it('is false with tax tracking off, whatever rates the workspace holds', () => { - const policy = buildTaxTrackingPolicy({[TAX_ID]: createMock({name: 'VAT', value: '5%'})}, false); - expect(hasSelectableCategoryTaxRate(policy)).toBe(false); - }); - }); - describe('isSelectableTaxRate', () => { - it('offers the workspace default to a merchant rule but not to a category rule', () => { - const policy = buildPolicy({[DEFAULT_TAX_ID]: createMock({name: 'Tax exempt', value: '0%'})}); - const defaultRate = createMock({name: 'Tax exempt', value: '0%'}); - expect(isSelectableTaxRate(policy, DEFAULT_TAX_ID, defaultRate, false)).toBe(true); - expect(isSelectableTaxRate(policy, DEFAULT_TAX_ID, defaultRate, true)).toBe(false); - }); - }); + it('offers a rate the workspace still has', () => { + // Given an ordinary tax rate + const taxRate = createMock({name: 'VAT', value: '5%'}); - describe('getCategoryTaxRulesTableData', () => { - it('names the rate the rule holds', () => { - const policy = buildPolicy({[TAX_ID]: createMock({name: 'VAT', value: '5%'})}); - expect(buildTableData(policy).at(0)?.ruleDescription).toContain('VAT (5%)'); + // When a rule's tax picker asks whether it can be chosen + // Then it can, including the workspace default, which a rule may set like any other rate + expect(isSelectableTaxRate(taxRate)).toBe(true); }); - it('reads the rename rather than the label saved when the rule was created', () => { - const policy = buildPolicy({[TAX_ID]: createMock({name: 'VAT renamed', value: '7%'})}); - expect(buildTableData(policy).at(0)?.ruleDescription).toContain('VAT renamed (7%)'); - }); + it('does not offer a disabled rate', () => { + // Given a rate turned off for the workspace + const taxRate = createMock({name: 'VAT', value: '5%', isDisabled: true}); - it('never shows the raw tax ID once the rate has left the workspace', () => { - // A category tax rule stores only the ID, so with the rate gone there is nothing to name it from. The row - // has to read as unset, the way the editor it opens does, rather than printing the ID at the admin. - const row = buildTableData(buildPolicy({})).at(0); - expect(row).toBeDefined(); - expect(row?.ruleDescription).not.toContain(TAX_ID); - expect(row?.searchTokens).not.toContain(TAX_ID); + // When the picker asks + // Then it is not offered, since a new rule should not start applying it + expect(isSelectableTaxRate(taxRate)).toBe(false); }); - it('still lists the rule when its rate is disabled, since the rule keeps applying it', () => { - const policy = buildPolicy({[TAX_ID]: createMock({name: 'VAT', value: '5%', isDisabled: true})}); - expect(buildTableData(policy).at(0)?.ruleDescription).toContain('VAT (5%)'); - }); + it('does not offer a rate on its way out', () => { + // Given a rate being deleted + const taxRate = createMock({name: 'VAT', value: '5%', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}); - it('still lists the rule when its rate has become the workspace default', () => { - const policy = buildPolicy({[DEFAULT_TAX_ID]: createMock({name: 'Tax exempt', value: '0%'})}, [buildCategoryTaxRule(DEFAULT_TAX_ID)]); - expect(buildTableData(policy).at(0)?.ruleDescription).toContain('Tax exempt (0%)'); + // When the picker asks + // Then it is not offered, because it is about to stop existing + expect(isSelectableTaxRate(taxRate)).toBe(false); }); }); }); diff --git a/tests/unit/CategoryUtilsTest.ts b/tests/unit/CategoryUtilsTest.ts index 67535d533fa3..4b1fbef6cf77 100644 --- a/tests/unit/CategoryUtilsTest.ts +++ b/tests/unit/CategoryUtilsTest.ts @@ -1,7 +1,6 @@ import { formatRequireItemizedReceiptsOverText, getAvailableNonPersonalPolicyCategories, - getCategoryDefaultTaxRate, getCategoryDescriptionHint, getCategoryGLCode, getCategoryNameError, @@ -17,7 +16,6 @@ import { import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, PolicyCategories} from '@src/types/onyx'; -import type {ExpenseRule} from '@src/types/onyx/Policy'; import type {OnyxCollection} from 'react-native-onyx'; @@ -621,30 +619,3 @@ describe('getCategoryNameError', () => { expect(getCategoryNameError(categories, '<>', 'Food')).toBeUndefined(); }); }); - -describe('getCategoryDefaultTaxRate', () => { - const buildCategoryTaxRule = (categoryName: string, taxID: string): ExpenseRule => ({ - applyWhen: [{condition: CONST.POLICY.RULE_CONDITIONS.MATCHES, field: CONST.POLICY.FIELDS.CATEGORY, value: categoryName}], - // eslint-disable-next-line @typescript-eslint/naming-convention - tax: {field_id_TAX: {externalID: taxID}}, - }); - - it("returns the category's own rate", () => { - expect(getCategoryDefaultTaxRate([buildCategoryTaxRule('Travel', 'id_TAX_A')], 'Travel', 'id_TAX_DEFAULT')).toBe('id_TAX_A'); - }); - - it('falls back to the workspace rate when the category has no rule', () => { - expect(getCategoryDefaultTaxRate([buildCategoryTaxRule('Travel', 'id_TAX_A')], 'Meals', 'id_TAX_DEFAULT')).toBe('id_TAX_DEFAULT'); - }); - - it('ignores a rule that carries the name on some other condition', () => { - // Matching on the value alone would read a rule that a save or delete never targets, handing the expense a - // rate the admin never set for this category. - const tagNamedAfterTheCategory: ExpenseRule = { - applyWhen: [{condition: CONST.POLICY.RULE_CONDITIONS.MATCHES, field: CONST.POLICY.FIELDS.TAG, value: 'Travel'}], - // eslint-disable-next-line @typescript-eslint/naming-convention - tax: {field_id_TAX: {externalID: 'id_TAX_B'}}, - }; - expect(getCategoryDefaultTaxRate([tagNamedAfterTheCategory], 'Travel', 'id_TAX_DEFAULT')).toBe('id_TAX_DEFAULT'); - }); -}); diff --git a/tests/unit/ExpenseDefaultRuleUtilsTest.ts b/tests/unit/ExpenseDefaultRuleUtilsTest.ts index 3791859ff2b0..19713e7f3017 100644 --- a/tests/unit/ExpenseDefaultRuleUtilsTest.ts +++ b/tests/unit/ExpenseDefaultRuleUtilsTest.ts @@ -1,14 +1,16 @@ import { - buildMerchantRule, - canEditMerchantRule, + buildExpenseDefaultRule, + canEditExpenseDefaultRule, + getCategoryTaxDefaultRule, + getExpenseDefaultConditionSummary, getExpenseDefaultRuleCount, getExpenseDefaultRuleSummaryFields, - getMerchantRuleFormValues, + getExpenseDefaultFormValues, getPolicyExpenseDefaultRules, - getRuleMerchantMatchSummary, hasExpenseDefaultRuleErrors, + haveSameConditions, } from '@libs/ExpenseDefaultRuleUtils'; -import type {MerchantRuleFormValues} from '@libs/ExpenseDefaultRuleUtils'; +import type {ExpenseDefaultFormValues} from '@libs/ExpenseDefaultRuleUtils'; import Parser from '@libs/Parser'; import {getRuleFilterLeaves, isExpenseDefaultRule, toIndexMap} from '@libs/RuleUtils'; @@ -62,9 +64,9 @@ function buildRuleWithOverrides(overrides: Record): ExpenseDefa } describe('ExpenseDefaultRuleUtils', () => { - describe('buildMerchantRule', () => { + describe('buildExpenseDefaultRule', () => { it('builds both transaction triggers, the merchant filter and one Set action per field', () => { - const rule = buildMerchantRule( + const rule = buildExpenseDefaultRule( { merchantToMatch: 'Starbucks', matchType: EQUAL_TO, @@ -98,13 +100,13 @@ describe('ExpenseDefaultRuleUtils', () => { }); it('defaults the match type to contains and trims the matched merchant', () => { - const rule = buildMerchantRule({merchantToMatch: ' Uber ', category: 'Travel'}, policy); + const rule = buildExpenseDefaultRule({merchantToMatch: ' Uber ', category: 'Travel'}, policy); expect(rule?.filters).toEqual({left: FIELD.MERCHANT, operator: CONTAINS, right: 'Uber'}); }); it('keeps false booleans, which are meaningful values rather than empty ones', () => { - const rule = buildMerchantRule({merchantToMatch: 'Uber', billable: false, reimbursable: false}, policy); + const rule = buildExpenseDefaultRule({merchantToMatch: 'Uber', billable: false, reimbursable: false}, policy); expect(Object.values(rule?.actions ?? {})).toEqual([ {name: ACTION.SET, field: FIELD.REIMBURSABLE, value: false}, @@ -113,28 +115,28 @@ describe('ExpenseDefaultRuleUtils', () => { }); it('returns undefined when there is nothing to match on', () => { - expect(buildMerchantRule({merchantToMatch: ' ', category: 'Coffee'}, policy)).toBeUndefined(); + expect(buildExpenseDefaultRule({merchantToMatch: ' ', category: 'Coffee'}, policy)).toBeUndefined(); }); it('returns undefined when there is nothing to set', () => { - expect(buildMerchantRule({merchantToMatch: 'Starbucks'}, policy)).toBeUndefined(); + expect(buildExpenseDefaultRule({merchantToMatch: 'Starbucks'}, policy)).toBeUndefined(); }); it('treats a whitespace-only description as nothing to set', () => { - expect(buildMerchantRule({merchantToMatch: 'Starbucks', comment: ' '}, policy)).toBeUndefined(); + expect(buildExpenseDefaultRule({merchantToMatch: 'Starbucks', comment: ' '}, policy)).toBeUndefined(); }); it('still records the tax external ID when the rate is missing from the policy', () => { - const rule = buildMerchantRule({merchantToMatch: 'Starbucks', tax: 'id_UNKNOWN'}, policy); + const rule = buildExpenseDefaultRule({merchantToMatch: 'Starbucks', tax: 'id_UNKNOWN'}, policy); // eslint-disable-next-line @typescript-eslint/naming-convention expect(Object.values(rule?.actions ?? {}).at(0)?.value).toEqual({field_id_TAX: {externalID: 'id_UNKNOWN'}}); }); }); - describe('getMerchantRuleFormValues round trip', () => { + describe('getExpenseDefaultFormValues round trip', () => { it('returns the values it was built from', () => { - const formValues: MerchantRuleFormValues = { + const formValues: ExpenseDefaultFormValues = { merchantToMatch: 'Starbucks', matchType: EQUAL_TO, merchant: 'Starbucks Coffee', @@ -147,41 +149,238 @@ describe('ExpenseDefaultRuleUtils', () => { billable: false, }; - expect(getMerchantRuleFormValues(buildMerchantRule(formValues, policy))).toEqual(formValues); + expect(getExpenseDefaultFormValues(buildExpenseDefaultRule(formValues, policy))).toEqual(formValues); }); it('round trips a rule that only renames the merchant', () => { - const formValues: MerchantRuleFormValues = {merchantToMatch: 'STARBUCKS #123', matchType: CONTAINS, merchant: 'Starbucks'}; + const formValues: ExpenseDefaultFormValues = {merchantToMatch: 'STARBUCKS #123', matchType: CONTAINS, merchant: 'Starbucks'}; - expect(getMerchantRuleFormValues(buildMerchantRule(formValues, policy))).toEqual(formValues); + expect(getExpenseDefaultFormValues(buildExpenseDefaultRule(formValues, policy))).toEqual(formValues); }); it('converts the description back to markdown', () => { - const rule = buildMerchantRule({merchantToMatch: 'Starbucks', comment: 'A description'}, policy); + const rule = buildExpenseDefaultRule({merchantToMatch: 'Starbucks', comment: 'A description'}, policy); - expect(getMerchantRuleFormValues(rule)?.comment).toBe('A description'); + expect(getExpenseDefaultFormValues(rule)?.comment).toBe('A description'); }); it('accepts a single-value list on the right of the merchant filter', () => { const rule = buildRuleWithOverrides({filters: {left: FIELD.MERCHANT, operator: CONTAINS, right: ['Starbucks']}}); - expect(getMerchantRuleFormValues(rule)).toEqual({merchantToMatch: 'Starbucks', matchType: CONTAINS, category: 'Coffee'}); + expect(getExpenseDefaultFormValues(rule)).toEqual({merchantToMatch: 'Starbucks', matchType: CONTAINS, category: 'Coffee'}); }); }); - describe('getMerchantRuleFormValues rejects rules the form cannot represent', () => { + describe('buildExpenseDefaultRule conditions', () => { + it('builds one condition per field, ANDed together and nested to the left', () => { + // Given a form matching on a merchant, two categories and a tag + const formValues: Partial = { + merchantToMatch: 'Starbucks', + matchType: EQUAL_TO, + categoriesToMatch: ['Coffee', 'Travel'], + tagsToMatch: ['Team A'], + category: 'Coffee', + }; + + // When the rule is built + const rule = buildExpenseDefaultRule(formValues, policy); + + // Then the conditions are ANDed in form order, with each list left as an OR of its values + expect(rule?.filters).toEqual({ + operator: AND, + left: { + operator: AND, + left: {left: FIELD.MERCHANT, operator: EQUAL_TO, right: 'Starbucks'}, + right: {left: FIELD.CATEGORY, operator: EQUAL_TO, right: ['Coffee', 'Travel']}, + }, + right: {left: FIELD.TAG, operator: EQUAL_TO, right: ['Team A']}, + }); + }); + + it('builds a rule that matches on a category alone', () => { + // Given a form with no merchant, which is a rule the editor could not express before + const rule = buildExpenseDefaultRule({categoriesToMatch: ['Coffee'], tax: TAX_KEY}, policy); + + // When its filters are read + // Then the single category condition is the whole tree + expect(rule?.filters).toEqual({left: FIELD.CATEGORY, operator: EQUAL_TO, right: ['Coffee']}); + }); + + it('returns undefined when every condition is blank', () => { + // Given a form whose pickers were emptied, leaving blanks behind + // When the rule is built + // Then there is nothing to match on, so no rule is built + expect(buildExpenseDefaultRule({merchantToMatch: ' ', categoriesToMatch: [' '], tagsToMatch: [], category: 'Coffee'}, policy)).toBeUndefined(); + }); + + it('round trips every condition', () => { + // Given a form matching on all three fields + const formValues: ExpenseDefaultFormValues = { + merchantToMatch: 'Starbucks', + matchType: CONTAINS, + categoriesToMatch: ['Coffee'], + tagsToMatch: ['Team A', 'Team B'], + category: 'Coffee', + }; + + // When the built rule is read back + // Then the form gets the same conditions it wrote + expect(getExpenseDefaultFormValues(buildExpenseDefaultRule(formValues, policy))).toEqual(formValues); + }); + }); + + describe('buildExpenseDefaultRule amount default', () => { + it('stores the amount negated, the way an expense carries it', () => { + // Given a rule setting the amount to $30.00 + const rule = buildExpenseDefaultRule({merchantToMatch: 'Starbucks', amount: '3000'}, policy); + + // When the action is read + // Then the stored value is negative cents + expect(Object.values(rule?.actions ?? {})).toEqual([{name: ACTION.SET, field: FIELD.AMOUNT, value: -3000}]); + }); + + it('round trips the amount as the admin typed it', () => { + // Given a rule setting an amount + const formValues: ExpenseDefaultFormValues = {merchantToMatch: 'Starbucks', matchType: CONTAINS, amount: '3000'}; + + // When it is read back + // Then the form gets the positive amount, not the negated one + expect(getExpenseDefaultFormValues(buildExpenseDefaultRule(formValues, policy))).toEqual(formValues); + }); + + it('leaves out an amount of zero, which is not a default', () => { + // Given a rule whose amount was cleared + // When it is built + // Then nothing is set, so the rule has no default and is not built + expect(buildExpenseDefaultRule({merchantToMatch: 'Starbucks', amount: '0'}, policy)).toBeUndefined(); + }); + + it('is read-only for a rule that sets a credit', () => { + // Given a rule storing a positive amount, which sets a credit rather than an expense + const rule = buildRuleWithOverrides({actions: toIndexMap([{name: ACTION.SET, field: FIELD.AMOUNT, value: 3000}])}); + + // When the form tries to read it + // Then it is refused, because saving the form back would flip the sign + expect(getExpenseDefaultFormValues(rule)).toBeUndefined(); + }); + }); + + describe('getExpenseDefaultConditionSummary', () => { + it('groups every condition by the field it matches on', () => { + // Given a rule matching on a merchant, two categories and a tag + const rule = buildExpenseDefaultRule({merchantToMatch: 'Starbucks', categoriesToMatch: ['Coffee', 'Travel'], tagsToMatch: ['Team A'], category: 'Coffee'}, policy); + + // When its conditions are summarized + // Then each field keeps its own values, so the table can name them separately + expect(getExpenseDefaultConditionSummary(rule?.filters)).toEqual({ + merchants: [{value: 'Starbucks', isExactMatch: false}], + categories: ['Coffee', 'Travel'], + tags: ['Team A'], + }); + }); + + it('summarizes a rule the editor cannot open', () => { + // Given two merchants ORed together, which the form has one input for + const filters: RuleFilterNode = { + left: merchantFilter, + operator: OR, + right: {left: FIELD.MERCHANT, operator: CONTAINS, right: 'Costa'}, + }; + + // When it is summarized + // Then both merchants are listed, so a read-only rule still shows what it matches + expect(getExpenseDefaultConditionSummary(filters)).toEqual({ + merchants: [ + {value: 'Starbucks', isExactMatch: false}, + {value: 'Costa', isExactMatch: false}, + ], + categories: [], + tags: [], + }); + }); + + it('keeps the match type of each merchant condition', () => { + // Given one rule matching a merchant exactly and another matching it by substring + const exactRule = buildExpenseDefaultRule({merchantToMatch: 'Uber', matchType: EQUAL_TO, category: 'Travel'}, policy); + const containsRule = buildExpenseDefaultRule({merchantToMatch: 'Uber', matchType: CONTAINS, category: 'Travel'}, policy); + + // When each is summarized + // Then the match type comes back with the merchant, so the table can tell the two rules apart + expect(getExpenseDefaultConditionSummary(exactRule?.filters).merchants).toEqual([{value: 'Uber', isExactMatch: true}]); + expect(getExpenseDefaultConditionSummary(containsRule?.filters).merchants).toEqual([{value: 'Uber', isExactMatch: false}]); + }); + + it('ignores a field the table has no wording for', () => { + // Given a rule matching on a merchant category code + const filters: RuleFilterNode = {left: CONST.SEARCH.SYNTAX_FILTER_KEYS.MCC, operator: EQUAL_TO, right: '5812'}; + + // When it is summarized + // Then nothing is reported, rather than the code being labelled as a merchant + expect(getExpenseDefaultConditionSummary(filters)).toEqual({merchants: [], categories: [], tags: []}); + }); + + it('reports nothing for a rule with no filters', () => { + // Given a rule whose filters never loaded + // When it is summarized + // Then every field is empty + expect(getExpenseDefaultConditionSummary(undefined)).toEqual({merchants: [], categories: [], tags: []}); + }); + }); + + describe('haveSameConditions', () => { + it('ignores the order of a multi-select', () => { + // Given two rules picking the same categories in a different order + // When they are compared + // Then they are duplicates, because the list is matched as an OR + expect(haveSameConditions({categoriesToMatch: ['Coffee', 'Travel']}, {categoriesToMatch: ['Travel', 'Coffee']})).toBe(true); + }); + + it('treats a different match type on the same merchant as a different rule', () => { + // Given the same merchant matched exactly by one rule and partially by the other + // When they are compared + // Then they are not duplicates, because they match different expenses + expect(haveSameConditions({merchantToMatch: 'Starbucks', matchType: EQUAL_TO}, {merchantToMatch: 'Starbucks', matchType: CONTAINS})).toBe(false); + }); + + it('ignores the match type when neither rule matches on a merchant', () => { + // Given two category rules whose match type is left at its default + // When they are compared + // Then they are duplicates, because the match type only applies to the merchant input + expect(haveSameConditions({categoriesToMatch: ['Coffee'], matchType: EQUAL_TO}, {categoriesToMatch: ['Coffee'], matchType: CONTAINS})).toBe(true); + }); + + it('is false when one rule matches on a condition the other does not', () => { + // Given two rules on the same merchant, one of which is also narrowed to a tag + // When they are compared + // Then they are not duplicates + expect(haveSameConditions({merchantToMatch: 'Starbucks'}, {merchantToMatch: 'Starbucks', tagsToMatch: ['Team A']})).toBe(false); + }); + }); + + describe('getExpenseDefaultFormValues rejects rules the form cannot represent', () => { it.each([ [ - 'a nested filter tree', + 'conditions combined with or rather than and', { filters: { left: merchantFilter, - operator: AND, + operator: OR, right: {left: FIELD.CATEGORY, operator: EQUAL_TO, right: 'Coffee'}, }, }, ], - ['a filter on a field the form has no input for', {filters: {left: FIELD.CATEGORY, operator: EQUAL_TO, right: 'Coffee'}}], + [ + 'two conditions on the same field, which the form has one input for', + { + filters: { + left: {left: FIELD.CATEGORY, operator: EQUAL_TO, right: 'Coffee'}, + operator: AND, + right: {left: FIELD.CATEGORY, operator: EQUAL_TO, right: 'Travel'}, + }, + }, + ], + ['a condition on an operator the form has no control for', {filters: {left: FIELD.CATEGORY, operator: CONTAINS, right: 'Coffee'}}], + ['a filter on a field the form has no input for', {filters: {left: CONST.SEARCH.SYNTAX_FILTER_KEYS.MCC, operator: EQUAL_TO, right: '5812'}}], ['an operator the form has no control for', {filters: {left: FIELD.MERCHANT, operator: GREATER_THAN, right: 'Starbucks'}}], ['a list of merchants, which the form has one input for', {filters: {left: FIELD.MERCHANT, operator: OR, right: ['Starbucks', 'Costa']}}], ['a trigger the form does not set', {triggers: toIndexMap([TRIGGER.CREATE_TRANSACTION, CONST.RULES.TRIGGERS.REPORT_SUBMIT])}], @@ -198,11 +397,48 @@ describe('ExpenseDefaultRuleUtils', () => { ['a value of the wrong type', {actions: toIndexMap([{name: ACTION.SET, field: FIELD.BILLABLE, value: 'true'}])}], ['a malformed tax value', {actions: toIndexMap([{name: ACTION.SET, field: FIELD.TAX, value: TAX_KEY}])}], ])('returns undefined for %s', (_description, overrides) => { - expect(getMerchantRuleFormValues(buildRuleWithOverrides(overrides))).toBeUndefined(); + expect(getExpenseDefaultFormValues(buildRuleWithOverrides(overrides))).toBeUndefined(); }); it('returns undefined for an undefined rule', () => { - expect(getMerchantRuleFormValues(undefined)).toBeUndefined(); + expect(getExpenseDefaultFormValues(undefined)).toBeUndefined(); + }); + }); + + describe('triggers the backend sets', () => { + // Given a category tax default as the backend writes it, which also fires when an expense is edited + const backendTriggers = toIndexMap([TRIGGER.CREATE_TRANSACTION, CONST.RULES.TRIGGERS.UPDATE_TRANSACTION]); + + it('opens a rule that also fires on UpdateTransaction', () => { + // When the editor reads it + const formValues = getExpenseDefaultFormValues(buildRuleWithOverrides({triggers: backendTriggers})); + + // Then it is editable rather than read-only, because the form can hand every trigger back untouched + expect(formValues).toEqual({merchantToMatch: 'Starbucks', matchType: CONTAINS, category: 'Coffee'}); + }); + + it('keeps both triggers when the rule is saved again', () => { + // When the edited rule is rebuilt from the form + const rule = buildExpenseDefaultRule({categoriesToMatch: ['Coffee'], tax: TAX_KEY}, policy, backendTriggers); + + // Then the trigger the form has no input for survives the save + expect(rule?.triggers).toEqual(backendTriggers); + }); + + it('sets both triggers on a new rule', () => { + // When a rule is built with no existing triggers to carry over + const rule = buildExpenseDefaultRule({categoriesToMatch: ['Coffee'], tax: TAX_KEY}, policy); + + // Then it fires on creation and on an edit, matching what the backend writes + expect(rule?.triggers).toEqual(backendTriggers); + }); + + it('stays read-only when a trigger is one the form does not know', () => { + // When the rule carries a trigger outside the transaction lifecycle + const rule = buildRuleWithOverrides({triggers: toIndexMap([TRIGGER.CREATE_TRANSACTION, CONST.RULES.TRIGGERS.REPORT_APPROVE])}); + + // Then the editor refuses it, since saving would drop that trigger + expect(getExpenseDefaultFormValues(rule)).toBeUndefined(); }); }); @@ -223,12 +459,21 @@ describe('ExpenseDefaultRuleUtils', () => { expect(isExpenseDefaultRule(rule)).toBe(false); }); - it('is false for a rule that matches on something other than a merchant', () => { - // Given a category rule, which the merchant rule editor has no way to open - const rule = asStoredRule(buildRuleWithOverrides({filters: {left: FIELD.CATEGORY, operator: CONTAINS, right: 'Coffee'}})); + it('is true for a rule that matches on a category rather than a merchant', () => { + // Given a category rule, which the editor can now open + const rule = asStoredRule(buildRuleWithOverrides({filters: {left: FIELD.CATEGORY, operator: EQUAL_TO, right: 'Coffee'}})); // When it is tested - // Then it is not an expense default, so a client that cannot edit it does not list it either + // Then it is an expense default, because merchant is only one of the conditions it can match on + expect(isExpenseDefaultRule(rule)).toBe(true); + }); + + it('is false for a rule with no conditions', () => { + // Given a rule that sets a field but matches on nothing, which would apply to every expense + const rule = asStoredRule(buildRuleWithOverrides({filters: undefined})); + + // When it is tested + // Then it is not an expense default expect(isExpenseDefaultRule(rule)).toBe(false); }); @@ -286,6 +531,85 @@ describe('ExpenseDefaultRuleUtils', () => { }); }); + describe('getCategoryTaxDefaultRule', () => { + const categoryTaxRuleBody: ExpenseDefaultRule = { + triggers: toIndexMap([TRIGGER.CREATE_TRANSACTION]), + filters: {left: FIELD.CATEGORY, operator: EQUAL_TO, right: 'Coffee'}, + // eslint-disable-next-line @typescript-eslint/naming-convention -- the rule stores the tax under the field name that the backend uses + actions: toIndexMap([{name: ACTION.SET, field: FIELD.TAX, value: {field_id_TAX: {externalID: TAX_KEY, name: 'GST', value: '10%'}}} as ExpenseDefaultAction]), + }; + + it('finds the rule that sets a tax for the category', () => { + // Given a rule matching that category alone and setting a tax + const collection = {[`${ONYXKEYS.COLLECTION.RULE}1`]: asStoredRule(categoryTaxRuleBody)}; + + // When the category's tax default is looked up + // Then that rule is returned, so category settings can open it + expect(getCategoryTaxDefaultRule(collection, POLICY_ID, 'Coffee')?.ruleID).toBe('1'); + }); + + it('ignores a rule being deleted', () => { + // Given the same rule, deleted while offline so it is still in the collection + const collection = { + [`${ONYXKEYS.COLLECTION.RULE}1`]: {...asStoredRule(categoryTaxRuleBody), pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}, + }; + + // When the category's tax default is looked up + // Then nothing is returned, because a deleted rule no longer applies and must not be offered for editing + expect(getCategoryTaxDefaultRule(collection, POLICY_ID, 'Coffee')).toBeUndefined(); + }); + + it('finds a rule that also matches on a merchant, which category settings can create', () => { + // Given a rule the category settings flow made that matches the category and a merchant + const ruleWithMerchant: ExpenseDefaultRule = { + ...categoryTaxRuleBody, + filters: { + operator: AND, + left: {left: FIELD.CATEGORY, operator: EQUAL_TO, right: 'Coffee'}, + right: {left: FIELD.MERCHANT, operator: CONTAINS, right: 'Starbucks'}, + }, + }; + const collection = {[`${ONYXKEYS.COLLECTION.RULE}1`]: asStoredRule(ruleWithMerchant)}; + + // When the category's tax default is looked up + // Then it is found, because hiding it would offer to add a second rule for the same category + expect(getCategoryTaxDefaultRule(collection, POLICY_ID, 'Coffee')?.ruleID).toBe('1'); + }); + + it('finds a rule that sets no tax, which is the rule a duplicate used to be created against', () => { + // Given a rule matching the category that sets a merchant rather than a tax + const ruleWithoutTax: ExpenseDefaultRule = { + ...categoryTaxRuleBody, + actions: toIndexMap([{name: ACTION.SET, field: FIELD.MERCHANT, value: 'Starbucks Coffee'} as ExpenseDefaultAction]), + }; + const collection = {[`${ONYXKEYS.COLLECTION.RULE}1`]: asStoredRule(ruleWithoutTax)}; + + // When the category's tax default is looked up + // Then it is found, so category settings edits it instead of adding another rule + expect(getCategoryTaxDefaultRule(collection, POLICY_ID, 'Coffee')?.ruleID).toBe('1'); + }); + + it('prefers the rule that matches on the category alone', () => { + // Given a rule matching the category and a merchant, stored ahead of one matching the category alone + const ruleWithMerchant: ExpenseDefaultRule = { + ...categoryTaxRuleBody, + filters: { + operator: AND, + left: {left: FIELD.CATEGORY, operator: EQUAL_TO, right: 'Coffee'}, + right: {left: FIELD.MERCHANT, operator: CONTAINS, right: 'Starbucks'}, + }, + }; + const collection = { + [`${ONYXKEYS.COLLECTION.RULE}1`]: asStoredRule(ruleWithMerchant), + [`${ONYXKEYS.COLLECTION.RULE}2`]: asStoredRule(categoryTaxRuleBody), + }; + + // When the category's tax default is looked up + // Then the category-only rule wins, rather than whichever the collection happens to yield first + expect(getCategoryTaxDefaultRule(collection, POLICY_ID, 'Coffee')?.ruleID).toBe('2'); + }); + }); + describe('hasExpenseDefaultRuleErrors', () => { const failedRule = {...asStoredRule(merchantRuleBody), errors: {error: 'Whoops'}}; @@ -302,17 +626,17 @@ describe('ExpenseDefaultRuleUtils', () => { }); }); - describe('canEditMerchantRule', () => { + describe('canEditExpenseDefaultRule', () => { it('allows a representable rule scoped to the policy', () => { - expect(canEditMerchantRule(asStoredRule(merchantRuleBody), POLICY_ID)).toBe(true); + expect(canEditExpenseDefaultRule(asStoredRule(merchantRuleBody), POLICY_ID)).toBe(true); }); it('refuses a rule belonging to another policy', () => { - expect(canEditMerchantRule(asStoredRule(merchantRuleBody, OTHER_POLICY_ID), POLICY_ID)).toBe(false); + expect(canEditExpenseDefaultRule(asStoredRule(merchantRuleBody, OTHER_POLICY_ID), POLICY_ID)).toBe(false); }); it('refuses an account scoped rule', () => { - expect(canEditMerchantRule(asStoredRule(merchantRuleBody, POLICY_ID, CONST.RULES.SCOPE.ACCOUNT), POLICY_ID)).toBe(false); + expect(canEditExpenseDefaultRule(asStoredRule(merchantRuleBody, POLICY_ID, CONST.RULES.SCOPE.ACCOUNT), POLICY_ID)).toBe(false); }); it('refuses an approval workflow rule that happens to share the ruleID', () => { @@ -323,7 +647,7 @@ describe('ExpenseDefaultRuleUtils', () => { }), ); - expect(canEditMerchantRule(approvalWorkflowRule, POLICY_ID)).toBe(false); + expect(canEditExpenseDefaultRule(approvalWorkflowRule, POLICY_ID)).toBe(false); }); it('refuses a rule the form cannot represent, so saving cannot overwrite it', () => { @@ -333,68 +657,12 @@ describe('ExpenseDefaultRuleUtils', () => { }), ); - expect(canEditMerchantRule(nested, POLICY_ID)).toBe(false); + expect(canEditExpenseDefaultRule(nested, POLICY_ID)).toBe(false); }); it('refuses without a rule or without a policy', () => { - expect(canEditMerchantRule(undefined, POLICY_ID)).toBe(false); - expect(canEditMerchantRule(asStoredRule(merchantRuleBody), undefined)).toBe(false); - }); - }); - - describe('getRuleMerchantMatchSummary', () => { - it('reads a single merchant leaf', () => { - expect(getRuleMerchantMatchSummary(merchantFilter)).toEqual({merchants: 'Starbucks', isExactMatch: false}); - }); - - it('flags an exact match', () => { - expect(getRuleMerchantMatchSummary({left: FIELD.MERCHANT, operator: EQUAL_TO, right: 'Starbucks'})).toEqual({merchants: 'Starbucks', isExactMatch: true}); - }); - - it('joins the values of a merchant leaf holding a list, which the editor cannot represent', () => { - const filters: RuleFilterComparison = {left: FIELD.MERCHANT, operator: EQUAL_TO, right: ['Starbucks', 'Costa']}; - - expect(getRuleMerchantMatchSummary(filters)).toEqual({merchants: 'Starbucks, Costa', isExactMatch: true}); - }); - - it('joins the merchants of a nested tree, which the editor cannot represent', () => { - const filters: RuleFilterNode = { - left: merchantFilter, - operator: AND, - right: {left: FIELD.MERCHANT, operator: CONTAINS, right: 'Costa'}, - }; - - expect(getRuleMerchantMatchSummary(filters)).toEqual({merchants: 'Starbucks, Costa', isExactMatch: false}); - // The same rule stays read-only, so the summary is the only thing the row can show. - expect(getMerchantRuleFormValues(asStoredRule({...merchantRuleBody, filters}))).toBeUndefined(); - }); - - it('is not an exact match when the merchant leaves disagree on the operator', () => { - const filters: RuleFilterNode = { - left: {left: FIELD.MERCHANT, operator: EQUAL_TO, right: 'Starbucks'}, - operator: OR, - right: {left: FIELD.MERCHANT, operator: CONTAINS, right: 'Costa'}, - }; - - expect(getRuleMerchantMatchSummary(filters).isExactMatch).toBe(false); - }); - - it('ignores leaves on other fields', () => { - const filters: RuleFilterNode = { - left: merchantFilter, - operator: AND, - right: {left: FIELD.CATEGORY, operator: EQUAL_TO, right: 'Coffee'}, - }; - - expect(getRuleMerchantMatchSummary(filters)).toEqual({merchants: 'Starbucks', isExactMatch: false}); - }); - - it('is empty without a merchant leaf', () => { - expect(getRuleMerchantMatchSummary({left: FIELD.CATEGORY, operator: EQUAL_TO, right: 'Coffee'})).toEqual({merchants: '', isExactMatch: false}); - }); - - it('is empty without filters', () => { - expect(getRuleMerchantMatchSummary(undefined)).toEqual({merchants: '', isExactMatch: false}); + expect(canEditExpenseDefaultRule(undefined, POLICY_ID)).toBe(false); + expect(canEditExpenseDefaultRule(asStoredRule(merchantRuleBody), undefined)).toBe(false); }); }); diff --git a/tests/unit/MerchantRuleSuggestionUtilsTest.ts b/tests/unit/MerchantRuleSuggestionUtilsTest.ts index 4c95590467f0..9415c6aa6427 100644 --- a/tests/unit/MerchantRuleSuggestionUtilsTest.ts +++ b/tests/unit/MerchantRuleSuggestionUtilsTest.ts @@ -118,9 +118,8 @@ describe('getMerchantRuleDraftFromTransaction', () => { expect(getMerchantRuleDraftFromTransaction(transaction, [CONST.MERCHANT_RULE_SUGGESTION_FIELDS.CATEGORY], undefined)).toBeUndefined(); }); - it('names the rule type itself, so the editor opens rather than the type chooser', () => { + it('seeds the merchant the rule matches on', () => { const draft = getMerchantRuleDraftFromTransaction(buildTransaction(), [], undefined); - expect(draft?.ruleType).toBe(CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.MERCHANT); expect(draft?.merchantToMatch).toBe('Starbucks'); }); diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index a0a4655bbc11..eff314c6f8b5 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -19,7 +19,7 @@ import type {Routes} from '../../src/types/onyx/Transaction'; import type {TransactionViolation} from '../../src/types/onyx/TransactionViolation'; import * as TransactionUtils from '../../src/libs/TransactionUtils'; -import createRandomPolicy, {createCategoryTaxExpenseRules} from '../utils/collections/policies'; +import createRandomPolicy from '../utils/collections/policies'; import {createRandomReport} from '../utils/collections/reports'; import createRandomTransaction from '../utils/collections/transaction'; import createMock from '../utils/createMock'; @@ -334,122 +334,6 @@ describe('TransactionUtils', () => { }); }); - describe('getCategoryTaxDetails', () => { - it('should return the associated tax when the category matches the tax expense rules', () => { - // Given a policy with tax expense rules associated with a category - const category = 'Advertising'; - const fakePolicy: Policy = { - ...createRandomPolicy(0), - taxRates: CONST.DEFAULT_TAX, - rules: {expenseRules: createCategoryTaxExpenseRules(category, 'id_TAX_RATE_1')}, - }; - const transaction = generateTransaction(); - - // When retrieving the tax from the associated category - const {categoryTaxCode, categoryTaxAmount, categoryTaxValue} = TransactionUtils.getCategoryTaxDetails(category, transaction, fakePolicy, getCurrencyDecimalsLocal); - - // Then it should return the associated tax code, amount, and value - expect(categoryTaxCode).toBe('id_TAX_RATE_1'); - expect(categoryTaxAmount).toBe(5); - expect(categoryTaxValue).toBe('5%'); - }); - - it("should return the default tax when the category doesn't match the tax expense rules", () => { - // Given a policy with tax expense rules associated with a category - const ruleCategory = 'Advertising'; - const selectedCategory = 'Benefits'; - const fakePolicy: Policy = { - ...createRandomPolicy(0), - taxRates: CONST.DEFAULT_TAX, - rules: {expenseRules: createCategoryTaxExpenseRules(ruleCategory, 'id_TAX_RATE_1')}, - }; - const transaction = generateTransaction(); - - // When retrieving the tax from a category that is not associated with the tax expense rules - const {categoryTaxCode, categoryTaxAmount, categoryTaxValue} = TransactionUtils.getCategoryTaxDetails(selectedCategory, transaction, fakePolicy, getCurrencyDecimalsLocal); - - // Then it should return the default tax code, amount, and value - expect(categoryTaxCode).toBe('id_TAX_EXEMPT'); - expect(categoryTaxAmount).toBe(0); - expect(categoryTaxValue).toBe('0%'); - }); - - it("should return the foreign default tax when the category doesn't match the tax expense rules and using a foreign currency", () => { - // Given a policy with tax expense rules associated with a category and a transaction with a foreign currency - const ruleCategory = 'Advertising'; - const selectedCategory = 'Benefits'; - const fakePolicy: Policy = { - ...createRandomPolicy(0), - taxRates: { - ...CONST.DEFAULT_TAX, - foreignTaxDefault: 'id_TAX_RATE_2', - taxes: { - ...CONST.DEFAULT_TAX.taxes, - // eslint-disable-next-line @typescript-eslint/naming-convention - id_TAX_RATE_2: { - name: 'Tax rate 2', - value: '10%', - }, - }, - }, - outputCurrency: 'IDR', - rules: {expenseRules: createCategoryTaxExpenseRules(ruleCategory, 'id_TAX_RATE_1')}, - }; - const transaction = generateTransaction(); - - // When retrieving the tax from a category that is not associated with the tax expense rules - const {categoryTaxCode, categoryTaxAmount, categoryTaxValue} = TransactionUtils.getCategoryTaxDetails(selectedCategory, transaction, fakePolicy, getCurrencyDecimalsLocal); - - // Then it should return the default tax code, amount, and value - expect(categoryTaxCode).toBe('id_TAX_RATE_2'); - expect(categoryTaxAmount).toBe(9); - expect(categoryTaxValue).toBe('10%'); - }); - - describe('should return undefined tax', () => { - it('if the transaction type is distance', () => { - // Given a policy with tax expense rules associated with a category - const category = 'Advertising'; - const fakePolicy: Policy = { - ...createRandomPolicy(0), - taxRates: CONST.DEFAULT_TAX, - rules: {expenseRules: createCategoryTaxExpenseRules(category, 'id_TAX_RATE_1')}, - }; - const transaction: Transaction = { - ...generateTransaction(), - iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, - }; - - // When retrieving the tax from the associated category - const {categoryTaxCode, categoryTaxAmount, categoryTaxValue} = TransactionUtils.getCategoryTaxDetails(category, transaction, fakePolicy, getCurrencyDecimalsLocal); - - // Then it should return undefined for the tax code, amount, and value - expect(categoryTaxCode).toBe(undefined); - expect(categoryTaxAmount).toBe(undefined); - expect(categoryTaxValue).toBe(undefined); - }); - - it('if there are no policy tax expense rules', () => { - // Given a policy without tax expense rules - const category = 'Advertising'; - const fakePolicy: Policy = { - ...createRandomPolicy(0), - taxRates: CONST.DEFAULT_TAX, - rules: {}, - }; - const transaction = generateTransaction(); - - // When retrieving the tax from a category - const {categoryTaxCode, categoryTaxAmount, categoryTaxValue} = TransactionUtils.getCategoryTaxDetails(category, transaction, fakePolicy, getCurrencyDecimalsLocal); - - // Then it should return undefined for the tax code, amount, and value - expect(categoryTaxCode).toBe(undefined); - expect(categoryTaxAmount).toBe(undefined); - expect(categoryTaxValue).toBe(undefined); - }); - }); - }); - describe('getDefaultTaxCode for distance requests', () => { const RATE_ID = 'rate_1'; @@ -547,7 +431,6 @@ describe('TransactionUtils', () => { isFromExpenseReport: true, transactionChanges: {merchant: 'Zero Scan edited'}, personalPolicyOutputCurrency: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, getCurrencySymbol: getCurrencySymbolLocal, }); updatedTransaction.pendingFields = {merchant: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}; @@ -573,7 +456,6 @@ describe('TransactionUtils', () => { isFromExpenseReport: true, transactionChanges: {merchant: 'Failed Scan edited'}, personalPolicyOutputCurrency: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, getCurrencySymbol: getCurrencySymbolLocal, }); updatedTransaction.pendingFields = {merchant: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}; @@ -583,94 +465,6 @@ describe('TransactionUtils', () => { expect(TransactionUtils.isFailedScanAmountPlaceholder(updatedTransaction)).toBe(true); }); - it('should return updated category and tax when updating category with a category tax rules', () => { - // Given a policy with tax expense rules associated with a category - const category = 'Advertising'; - const taxCode = 'id_TAX_RATE_1'; - const fakePolicy: Policy = { - ...createRandomPolicy(0), - taxRates: CONST.DEFAULT_TAX, - rules: {expenseRules: createCategoryTaxExpenseRules(category, taxCode)}, - }; - const transaction = generateTransaction(); - - // When updating the transaction category to the category associated with the rule - const updatedTransaction = TransactionUtils.getUpdatedTransaction({ - transaction, - isFromExpenseReport: true, - policy: fakePolicy, - transactionChanges: {category}, - personalPolicyOutputCurrency: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, - getCurrencySymbol: getCurrencySymbolLocal, - }); - - // Then the updated transaction should contain the tax from the matched rule - expect(updatedTransaction.category).toBe(category); - expect(updatedTransaction.taxCode).toBe(taxCode); - expect(updatedTransaction.taxAmount).toBe(5); - expect(updatedTransaction.taxValue).toBe('5%'); - }); - - it('should keep the existing tax when clearing the category on a server backed edit', () => { - // Given an expense carrying the tax of a category that has its own rule - const category = 'Advertising'; - const taxCode = 'id_TAX_RATE_1'; - const fakePolicy: Policy = { - ...createRandomPolicy(0), - taxRates: CONST.DEFAULT_TAX, - rules: {expenseRules: createCategoryTaxExpenseRules(category, taxCode)}, - }; - const transaction = generateTransaction({category, taxCode, taxAmount: 5, taxValue: '5%'}); - - // When the category is cleared - const updatedTransaction = TransactionUtils.getUpdatedTransaction({ - transaction, - isFromExpenseReport: true, - policy: fakePolicy, - transactionChanges: {category: ''}, - personalPolicyOutputCurrency: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, - getCurrencySymbol: getCurrencySymbolLocal, - }); - - // Then the tax is left untouched, because the API is not told to change it and the server keeps its own - expect(updatedTransaction.category).toBe(''); - expect(updatedTransaction.taxCode).toBe(taxCode); - expect(updatedTransaction.taxAmount).toBe(5); - expect(updatedTransaction.taxValue).toBe('5%'); - }); - - it('should reset to the workspace default tax when clearing the category on a split draft', () => { - // Given a split draft carrying the tax of a category that has its own rule - const category = 'Advertising'; - const taxCode = 'id_TAX_RATE_1'; - const fakePolicy: Policy = { - ...createRandomPolicy(0), - taxRates: CONST.DEFAULT_TAX, - rules: {expenseRules: createCategoryTaxExpenseRules(category, taxCode)}, - }; - const transaction = generateTransaction({category, taxCode, taxAmount: 5, taxValue: '5%'}); - - // When the category is cleared on the draft - const updatedTransaction = TransactionUtils.getUpdatedTransaction({ - transaction, - isFromExpenseReport: false, - isSplitTransaction: true, - policy: fakePolicy, - transactionChanges: {category: ''}, - personalPolicyOutputCurrency: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, - getCurrencySymbol: getCurrencySymbolLocal, - }); - - // Then the draft falls back to the workspace default, since the draft tax is what gets sent - expect(updatedTransaction.category).toBe(''); - expect(updatedTransaction.taxCode).toBe('id_TAX_EXEMPT'); - expect(updatedTransaction.taxAmount).toBe(0); - expect(updatedTransaction.taxValue).toBe('0%'); - }); - it('should update transaction when distance is changed', () => { // Given: a policy with a mileage rate const fakePolicy: Policy = { @@ -712,7 +506,6 @@ describe('TransactionUtils', () => { policy: fakePolicy, transactionChanges: {distance: newDistance}, personalPolicyOutputCurrency: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, getCurrencySymbol: getCurrencySymbolLocal, }); @@ -776,7 +569,6 @@ describe('TransactionUtils', () => { policy: undefined, transactionChanges: {distance: 20}, personalPolicyOutputCurrency: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, getCurrencySymbol: getCurrencySymbolLocal, }); @@ -823,7 +615,6 @@ describe('TransactionUtils', () => { policy: fakePolicy, transactionChanges: {customUnitRateID: 'rate2'}, personalPolicyOutputCurrency: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, getCurrencySymbol: getCurrencySymbolLocal, }); @@ -871,7 +662,6 @@ describe('TransactionUtils', () => { policy: fakePolicy, transactionChanges: {customUnitRateID: 'rate2'}, personalPolicyOutputCurrency: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, getCurrencySymbol: getCurrencySymbolLocal, }); @@ -934,7 +724,6 @@ describe('TransactionUtils', () => { policy: fakePolicy, transactionChanges: {customUnitRateID: 'rate2'}, personalPolicyOutputCurrency: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, getCurrencySymbol: getCurrencySymbolLocal, }); @@ -1000,7 +789,6 @@ describe('TransactionUtils', () => { ...(rateUnitChanged ? {customUnitRateID: 'ID2'} : {}), }, personalPolicyOutputCurrency: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, getCurrencySymbol: getCurrencySymbolLocal, }); @@ -1066,7 +854,6 @@ describe('TransactionUtils', () => { policy: fakePolicy, transactionChanges: {distance: 20}, personalPolicyOutputCurrency: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, getCurrencySymbol: getCurrencySymbolLocal, }); @@ -1144,7 +931,6 @@ describe('TransactionUtils', () => { policy: fakePolicy, transactionChanges: {selectedRouteKey: 'route1'}, personalPolicyOutputCurrency: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, getCurrencySymbol: getCurrencySymbolLocal, }); @@ -1216,7 +1002,6 @@ describe('TransactionUtils', () => { policies, transactionChanges: {customUnitRateID: 'ID2'}, personalPolicyOutputCurrency: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, getCurrencySymbol: getCurrencySymbolLocal, }); @@ -1295,7 +1080,6 @@ describe('TransactionUtils', () => { policy: undefined, transactionChanges: {distance: 20}, personalPolicyOutputCurrency: 'EUR', - getCurrencyDecimals: getCurrencyDecimalsLocal, getCurrencySymbol: getCurrencySymbolLocal, }); @@ -1350,7 +1134,6 @@ describe('TransactionUtils', () => { }, }, personalPolicyOutputCurrency: 'EUR', - getCurrencyDecimals: getCurrencyDecimalsLocal, getCurrencySymbol: getCurrencySymbolLocal, }); @@ -1411,7 +1194,6 @@ describe('TransactionUtils', () => { }, }, personalPolicyOutputCurrency: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, getCurrencySymbol: getCurrencySymbolLocal, }); @@ -1461,7 +1243,6 @@ describe('TransactionUtils', () => { routes: {route0: {distance: 32186.88, geometry: {coordinates: [[0, 0]], type: 'LineString'}}}, }, personalPolicyOutputCurrency: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, getCurrencySymbol: getCurrencySymbolLocal, }); @@ -1516,7 +1297,6 @@ describe('TransactionUtils', () => { routes: {route0: {distance: 32186.88, geometry: {coordinates: [[0, 0]], type: 'LineString'}}}, }, personalPolicyOutputCurrency: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, getCurrencySymbol: getCurrencySymbolLocal, }); @@ -1536,7 +1316,6 @@ describe('TransactionUtils', () => { isFromExpenseReport: true, transactionChanges: {amount: newAmount}, personalPolicyOutputCurrency: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, getCurrencySymbol: getCurrencySymbolLocal, }); @@ -1552,7 +1331,6 @@ describe('TransactionUtils', () => { isFromExpenseReport: false, transactionChanges: {amount: newAmount}, personalPolicyOutputCurrency: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, getCurrencySymbol: getCurrencySymbolLocal, }); @@ -1567,7 +1345,6 @@ describe('TransactionUtils', () => { isFromExpenseReport: true, transactionChanges: {taxCode: 'id_TAX_RATE_1', taxAmount: 50, taxValue: '5%'}, personalPolicyOutputCurrency: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, getCurrencySymbol: getCurrencySymbolLocal, }); @@ -1584,7 +1361,6 @@ describe('TransactionUtils', () => { isFromExpenseReport: false, transactionChanges: {taxValue: '5%'}, personalPolicyOutputCurrency: undefined, - getCurrencyDecimals: getCurrencyDecimalsLocal, getCurrencySymbol: getCurrencySymbolLocal, }); diff --git a/tests/unit/VendorMatchingMerchantRulesTest.ts b/tests/unit/VendorMatchingMerchantRulesTest.ts index b7b910f53a67..c08c4dc02c6f 100644 --- a/tests/unit/VendorMatchingMerchantRulesTest.ts +++ b/tests/unit/VendorMatchingMerchantRulesTest.ts @@ -1,4 +1,4 @@ -import {buildMerchantRule} from '@libs/ExpenseDefaultRuleUtils'; +import {buildExpenseDefaultRule} from '@libs/ExpenseDefaultRuleUtils'; import {getMerchantRulesTableData} from '@libs/MerchantTypeRulesUtils'; import {hasVendorFeature} from '@libs/PolicyUtils'; import {toIndexMap} from '@libs/RuleUtils'; @@ -25,8 +25,9 @@ const buildForm = (overrides: Partial = {}): MerchantRuleForm merchant: '', category: '', categoriesToMatch: [], - ruleType: CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.MERCHANT, + amount: '', tag: '', + tagsToMatch: [], tax: '', vendorID: '', comment: '', @@ -107,7 +108,7 @@ const buildQBOWithStaleXeroPolicy = (qboVendors: Array<{id: string; name: string }); const buildVendorRule = (policy: Policy, vendorID: string): Rule => ({ - ...buildMerchantRule({merchantToMatch: 'Coffee Shop', matchType: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, vendorID}, policy), + ...buildExpenseDefaultRule({merchantToMatch: 'Coffee Shop', matchType: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, vendorID}, policy), scope: CONST.RULES.SCOPE.POLICY, scopeID: policy.id, triggers: toIndexMap([CONST.RULES.TRIGGERS.CREATE_TRANSACTION]), @@ -118,14 +119,14 @@ const buildVendorRule = (policy: Policy, vendorID: string): Rule => ({ const withVendorRule = (policy: Policy, vendorID: string) => ({[`${ONYXKEYS.COLLECTION.RULE}rule1`]: buildVendorRule(policy, vendorID)}); describe('Vendor matching on merchant rules', () => { - describe('buildMerchantRule vendor action', () => { + describe('buildExpenseDefaultRule vendor action', () => { it('writes a Set action for a vendorID', () => { - const actions = Object.values(buildMerchantRule(buildForm({vendorID: 'v-1'}), undefined)?.actions ?? {}); + const actions = Object.values(buildExpenseDefaultRule(buildForm({vendorID: 'v-1'}), undefined)?.actions ?? {}); expect(actions).toContainEqual({name: CONST.RULES.ACTIONS.SET, field: CONST.RULES.EXPENSE_DEFAULT.FIELD.VENDOR_ID, value: 'v-1'}); }); it('writes no vendor action when the vendorID is unset, so the rule stops setting it', () => { - const actions = Object.values(buildMerchantRule(buildForm({vendorID: '', category: 'Coffee'}), undefined)?.actions ?? {}); + const actions = Object.values(buildExpenseDefaultRule(buildForm({vendorID: '', category: 'Coffee'}), undefined)?.actions ?? {}); expect(actions.some((action) => action.field === CONST.RULES.EXPENSE_DEFAULT.FIELD.VENDOR_ID)).toBe(false); }); }); @@ -200,7 +201,7 @@ describe('Vendor matching on merchant rules', () => { }); }); - describe('vendor row gating (hasVendorFeature governs MerchantRulePageBase row visibility)', () => { + describe('vendor row gating (hasVendorFeature governs ExpenseDefaultRulePageBase row visibility)', () => { it('is visible when the beta is on and a vendor integration is connected', () => { expect(hasVendorFeature(buildQBOPolicy([{id: 'v-1', name: 'Acme Co', currency: 'USD'}]), true)).toBe(true); }); diff --git a/tests/unit/hooks/useUpdateTransactionCategory.test.ts b/tests/unit/hooks/useUpdateTransactionCategory.test.ts index b134b020ee8e..102f6da2c138 100644 --- a/tests/unit/hooks/useUpdateTransactionCategory.test.ts +++ b/tests/unit/hooks/useUpdateTransactionCategory.test.ts @@ -99,7 +99,7 @@ describe('useUpdateTransactionCategory', () => { // Then it lands on the money request draft, and says so: the category step reads this to decide whether // it may carry forward into confirmation. expect(setMoneyRequestCategory).toHaveBeenCalledTimes(1); - expect(setMoneyRequestCategory).toHaveBeenCalledWith(TRANSACTION_ID, CATEGORY, policy, expect.any(Function)); + expect(setMoneyRequestCategory).toHaveBeenCalledWith(TRANSACTION_ID, CATEGORY, policy); expect(setDraftSplitTransaction).not.toHaveBeenCalled(); expect(updateMoneyRequestCategory).not.toHaveBeenCalled(); expect(result.current.isDraftUpdate).toBe(true); @@ -140,6 +140,6 @@ describe('useUpdateTransactionCategory', () => { result.current.updateCategory(''); // Then the empty value is written through rather than dropped - expect(setMoneyRequestCategory).toHaveBeenCalledWith(TRANSACTION_ID, '', policy, expect.any(Function)); + expect(setMoneyRequestCategory).toHaveBeenCalledWith(TRANSACTION_ID, '', policy); }); }); diff --git a/tests/utils/collections/policies.ts b/tests/utils/collections/policies.ts index 67867e3a9dc3..e1bb23b46b61 100644 --- a/tests/utils/collections/policies.ts +++ b/tests/utils/collections/policies.ts @@ -39,20 +39,3 @@ export default function createRandomPolicy(index: number, type?: ValueOf