From f5da2e489efb1d17214987515464286b8ee162d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Bonet=20=28via=20MelvinBot=29?= Date: Fri, 17 Jul 2026 15:03:31 +0000 Subject: [PATCH 1/2] Auto-create imported merchant rule categories that don't exist on the policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Nicolás Bonet --- src/libs/actions/Policy/Category.ts | 19 +++++++++++++++++++ src/libs/actions/Policy/Rules.ts | 18 ++++++++++++++++-- .../ImportedMerchantRulesPage.tsx | 3 ++- 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts index 31d9dbe58337..4455d2ab6087 100644 --- a/src/libs/actions/Policy/Category.ts +++ b/src/libs/actions/Policy/Category.ts @@ -870,6 +870,24 @@ function createPolicyCategory({ API.write(WRITE_COMMANDS.CREATE_WORKSPACE_CATEGORIES, parameters, onyxData); } +/** + * Creates multiple enabled workspace categories at once. Used by flows (such as the merchant rule + * spreadsheet import) that reference categories which may not yet exist on the policy, so a + * referenced category is added to the workspace instead of being silently dropped. + */ +function createPolicyCategories(policyID: string, categoryNames: string[]) { + if (categoryNames.length === 0) { + return; + } + const onyxData = buildOptimisticPolicyCategories(policyID, categoryNames); + const parameters = { + policyID, + categories: JSON.stringify(categoryNames.map((name) => ({name}))), + }; + + API.write(WRITE_COMMANDS.CREATE_WORKSPACE_CATEGORIES, parameters, onyxData); +} + async function importPolicyCategories(policyID: string, categories: PolicyCategory[], existingCategories?: OnyxEntry): Promise { const policyCategories = existingCategories ?? {}; const seenNames = new Set(); @@ -1904,6 +1922,7 @@ export { isDefaultMccGroupID, clearCategoryErrors, createPolicyCategory, + createPolicyCategories, deleteWorkspaceCategories, downloadCategoriesCSV, enablePolicyCategories, diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index 7dde67ddfb7f..7775baf3e597 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -1,4 +1,5 @@ import {getImportFailedFinalModal} from '@libs/actions/ImportSpreadsheet'; +import {createPolicyCategories} from '@libs/actions/Policy/Category'; import * as API from '@libs/API'; import type {AddPolicyAgentRuleParams, DeletePolicyAgentRuleParams, ImportMerchantRulesSpreadsheetParams, UpdatePolicyAgentRuleParams} from '@libs/API/parameters'; import type OpenPolicyRulesPageParams from '@libs/API/parameters/OpenPolicyRulesPageParams'; @@ -15,9 +16,10 @@ import type {MerchantRuleForm} from '@src/types/form'; import type {ImportFinalModal} from '@src/types/onyx/ImportedSpreadsheet'; import type Policy from '@src/types/onyx/Policy'; import type {AgentRule, CodingRule, CodingRuleFilter, CodingRuleTax} from '@src/types/onyx/Policy'; +import type {PolicyCategories} from '@src/types/onyx/PolicyCategory'; import type {OnyxData} from '@src/types/onyx/Request'; -import type {OnyxUpdate} from 'react-native-onyx'; +import type {OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; @@ -240,13 +242,25 @@ function setPolicyCodingRule(policyID: string, form: MerchantRuleForm, policy: P * Imports coding rules parsed from a spreadsheet into the given policy in bulk * @param policyID - The ID of the policy to import the rules into * @param rules - Coding rule values keyed by client-generated ruleID + * @param policyCategories - The policy's existing categories, used to auto-create any imported category that doesn't exist yet */ -async function importMerchantRulesSpreadsheet(policyID: string, rules: Record): Promise { +async function importMerchantRulesSpreadsheet(policyID: string, rules: Record, policyCategories: OnyxEntry): Promise { // The API rejects an empty rules object, so fail fast when the spreadsheet produced no importable rules if (Object.keys(rules).length === 0) { return getImportFailedFinalModal(); } + // A rule only applies its category to matching expenses if that category exists on the policy, so auto-create any + // imported category that isn't already in the workspace instead of silently dropping it. + const missingCategories = [ + ...new Set( + Object.values(rules) + .map((rule) => rule.category?.trim()) + .filter((category): category is string => !!category && !policyCategories?.[category]), + ), + ]; + createPolicyCategories(policyID, missingCategories); + const importFinalModal: ImportFinalModal = { titleKey: 'spreadsheet.importSuccessfulTitle', promptKey: 'spreadsheet.importMerchantRulesSuccessfulDescription', diff --git a/src/pages/workspace/rules/MerchantRules/ImportedMerchantRulesPage.tsx b/src/pages/workspace/rules/MerchantRules/ImportedMerchantRulesPage.tsx index ef39b9e92432..b23dcb25a926 100644 --- a/src/pages/workspace/rules/MerchantRules/ImportedMerchantRulesPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/ImportedMerchantRulesPage.tsx @@ -77,6 +77,7 @@ type ImportedMerchantRulesPageProps = PlatformStackScreenProps 0 ? {titleKey: 'spreadsheet.importSuccessfulTitle', promptKey: 'spreadsheet.importMerchantRulesSuccessfulDescription', promptKeyParams: {rules: 0}} - : await importMerchantRulesSpreadsheet(policyID, rules); + : await importMerchantRulesSpreadsheet(policyID, rules, policyCategories); const didShowImportFinalModal = await showImportSpreadsheetConfirmModal(importFinalModal, {shouldHandleNavigationBack: false}); if (!didShowImportFinalModal) { setIsImportingRules(false); From e9a9bda7c9f64d3ed24f9814362686d96c4ed2c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Bonet=20=28via=20MelvinBot=29?= Date: Fri, 17 Jul 2026 15:04:35 +0000 Subject: [PATCH 2/2] Use relative import for Category action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Nicolás Bonet --- src/libs/actions/Policy/Rules.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index 7775baf3e597..3e9d5056e3fc 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -1,5 +1,4 @@ import {getImportFailedFinalModal} from '@libs/actions/ImportSpreadsheet'; -import {createPolicyCategories} from '@libs/actions/Policy/Category'; import * as API from '@libs/API'; import type {AddPolicyAgentRuleParams, DeletePolicyAgentRuleParams, ImportMerchantRulesSpreadsheetParams, UpdatePolicyAgentRuleParams} from '@libs/API/parameters'; import type OpenPolicyRulesPageParams from '@libs/API/parameters/OpenPolicyRulesPageParams'; @@ -23,6 +22,8 @@ import type {OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; +import {createPolicyCategories} from './Category'; + /** A coding rule parsed from an imported spreadsheet row, keyed by a client-generated ruleID */ type ImportedMerchantRule = Omit;