From 7de667ab3f261f7b622050a05efdd510fa059677 Mon Sep 17 00:00:00 2001 From: "Alex Beaman (via MelvinBot)" Date: Tue, 4 Aug 2026 16:30:19 +0000 Subject: [PATCH 01/14] Add vendorID action field to merchant coding rules (R5 Phase B) Co-authored-by: Alex Beaman --- src/ROUTES.ts | 4 + src/SCREENS.ts | 1 + src/languages/en.ts | 2 + src/languages/es.ts | 2 + src/libs/MerchantTypeRulesUtils.ts | 27 +++- .../ModalStackNavigators/index.tsx | 1 + .../RELATIONS/WORKSPACE_TO_RHP.ts | 1 + src/libs/Navigation/linkingConfig/config.ts | 3 + src/libs/Navigation/types.ts | 4 + src/libs/actions/Policy/Rules.ts | 6 + .../rules/MerchantRules/AddVendorPage.tsx | 53 +++++++ .../MerchantRules/MerchantRulePageBase.tsx | 16 +- src/types/form/MerchantRuleForm.ts | 2 + src/types/onyx/Policy.ts | 3 + tests/unit/VendorMatchingMerchantRulesTest.ts | 140 ++++++++++++++++++ 15 files changed, 261 insertions(+), 4 deletions(-) create mode 100644 src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx create mode 100644 tests/unit/VendorMatchingMerchantRulesTest.ts diff --git a/src/ROUTES.ts b/src/ROUTES.ts index f5589e74ef36..6f187bd11d12 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -3723,6 +3723,10 @@ const ROUTES = { route: 'workspaces/:policyID/rules/merchant-rules/:ruleID/tax', getRoute: (policyID: string, ruleID?: string) => `workspaces/${policyID}/rules/merchant-rules/${ruleID ?? 'new'}/tax` as const, }, + RULES_MERCHANT_VENDOR: { + route: 'workspaces/:policyID/rules/merchant-rules/:ruleID/vendor', + getRoute: (policyID: string, ruleID?: string) => `workspaces/${policyID}/rules/merchant-rules/${ruleID ?? 'new'}/vendor` as const, + }, RULES_MERCHANT_DESCRIPTION: { route: 'workspaces/:policyID/rules/merchant-rules/:ruleID/description', getRoute: (policyID: string, ruleID?: string) => `workspaces/${policyID}/rules/merchant-rules/${ruleID ?? 'new'}/description` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 3ad40de62c35..c75730c9fcaf 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -928,6 +928,7 @@ const SCREENS = { RULES_MERCHANT_CATEGORY: 'Rules_Merchant_Category', RULES_MERCHANT_TAG: 'Rules_Merchant_Tag', RULES_MERCHANT_TAX: 'Rules_Merchant_Tax', + RULES_MERCHANT_VENDOR: 'Rules_Merchant_Vendor', RULES_MERCHANT_DESCRIPTION: 'Rules_Merchant_Description', RULES_MERCHANT_REIMBURSABLE: 'Rules_Merchant_Reimbursable', RULES_MERCHANT_BILLABLE: 'Rules_Merchant_Billable', diff --git a/src/languages/en.ts b/src/languages/en.ts index 91a6c4fc24e8..86b3a1185d3e 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7926,6 +7926,8 @@ const translations = { ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `Update ${fieldName} to "${fieldValue}"`, ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `Mark as "${reimbursable ? 'reimbursable' : 'non-reimbursable'}"`, ruleSummarySubtitleBillable: (billable: boolean) => `Mark as "${billable ? 'billable' : 'non-billable'}"`, + vendorUnavailable: 'Vendor unavailable', + supplierUnavailable: 'Supplier unavailable', matchType: 'Match type', matchTypeContains: 'Contains', matchTypeExact: 'Exactly matches', diff --git a/src/languages/es.ts b/src/languages/es.ts index 10a973e7a9bc..73694f4c729d 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7620,6 +7620,8 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `Actualizar ${fieldName} a "${fieldValue}"`, ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `Marcar como "${reimbursable ? 'reembolsable' : 'no reembolsable'}"`, ruleSummarySubtitleBillable: (billable: boolean) => `Marcar como "${billable ? 'facturable' : 'no facturable'}"`, + vendorUnavailable: 'Proveedor no disponible', + supplierUnavailable: 'Proveedor no disponible', matchType: 'Tipo de coincidencia', matchTypeContains: 'Contiene', matchTypeExact: 'Coincide exactamente', diff --git a/src/libs/MerchantTypeRulesUtils.ts b/src/libs/MerchantTypeRulesUtils.ts index d376b628b7c7..688fa4cdf98f 100644 --- a/src/libs/MerchantTypeRulesUtils.ts +++ b/src/libs/MerchantTypeRulesUtils.ts @@ -15,7 +15,7 @@ import {clearPolicyCodingRuleErrors} from './actions/Policy/Rules'; import {getDecodedCategoryName} from './CategoryUtils'; import Parser from './Parser'; import {getMccGroupDisplayName} from './PolicyRulesUtils'; -import {getCommaSeparatedTagNameWithSanitizedColons} from './PolicyUtils'; +import {findVendorByID, getCommaSeparatedTagNameWithSanitizedColons, isMatchingVendorListLoaded, isXeroActiveMatchingSource} from './PolicyUtils'; const MERCHANT_TYPE_RULE_KEY_PREFIX = 'mcc-group:'; @@ -115,11 +115,13 @@ function getMerchantCodingRulesTableData({ return []; } + const isOnXero = isXeroActiveMatchingSource(policy); const fieldLabels = { category: translate('common.category').toLowerCase(), tag: translate('common.tag').toLowerCase(), description: translate('common.description').toLowerCase(), tax: translate('common.tax').toLowerCase(), + vendor: translate(isOnXero ? 'common.supplier' : 'common.vendor').toLowerCase(), }; return Object.entries(codingRules) @@ -127,7 +129,14 @@ function getMerchantCodingRulesTableData({ .map(([ruleID, rule]: [string, CodingRule]) => { const merchantName = rule.filters?.right ?? ''; const hasOnlyMerchantRename = - !!rule.merchant && !rule.category && !rule.tag && !rule.comment && !rule.tax?.field_id_TAX?.value && rule.reimbursable === undefined && rule.billable === undefined; + !!rule.merchant && + !rule.category && + !rule.tag && + !rule.comment && + !rule.tax?.field_id_TAX?.value && + !rule.vendorID && + rule.reimbursable === undefined && + rule.billable === undefined; const typeLabel = hasOnlyMerchantRename ? translate('workspace.rules.expenseDefaultsTable.rename') : translate('workspace.rules.expenseDefaultsTable.update'); const actions: string[] = []; @@ -147,6 +156,18 @@ function getMerchantCodingRulesTableData({ if (rule.tax?.field_id_TAX?.value) { actions.push(translate('workspace.rules.merchantRules.ruleSummarySubtitleUpdateField', fieldLabels.tax, `${rule.tax.field_id_TAX.name} (${rule.tax.field_id_TAX.value})`)); } + if (rule.vendorID) { + const resolvedVendorName = findVendorByID(policy, rule.vendorID)?.name; + let vendorValue: string; + if (resolvedVendorName) { + vendorValue = resolvedVendorName; + } else if (isMatchingVendorListLoaded(policy)) { + vendorValue = translate(isOnXero ? 'workspace.rules.merchantRules.supplierUnavailable' : 'workspace.rules.merchantRules.vendorUnavailable'); + } else { + vendorValue = rule.vendorID; + } + actions.push(translate('workspace.rules.merchantRules.ruleSummarySubtitleUpdateField', fieldLabels.vendor, vendorValue)); + } if (rule.reimbursable !== undefined) { actions.push(translate('workspace.rules.merchantRules.ruleSummarySubtitleReimbursable', rule.reimbursable)); } @@ -192,4 +213,4 @@ function getExpenseDefaultsTableData({ return [...merchantRules, ...merchantTypeRules]; } -export {getDefaultMccGroupCategory, getExpenseDefaultsTableData, getMerchantTypeRuleFormFromMccGroup, isDefaultMccGroupID, isMerchantTypeRuleKey, saveMerchantTypeRule}; +export {getDefaultMccGroupCategory, getExpenseDefaultsTableData, getMerchantCodingRulesTableData, getMerchantTypeRuleFormFromMccGroup, isDefaultMccGroupID, isMerchantTypeRuleKey, saveMerchantTypeRule}; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 255e00ebf511..387d55c5e7ce 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -1086,6 +1086,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/rules/MerchantRules/AddCategoryPage').default, [SCREENS.WORKSPACE.RULES_MERCHANT_TAG]: () => require('../../../../pages/workspace/rules/MerchantRules/AddTagPage').default, [SCREENS.WORKSPACE.RULES_MERCHANT_TAX]: () => require('../../../../pages/workspace/rules/MerchantRules/AddTaxPage').default, + [SCREENS.WORKSPACE.RULES_MERCHANT_VENDOR]: () => require('../../../../pages/workspace/rules/MerchantRules/AddVendorPage').default, [SCREENS.WORKSPACE.RULES_MERCHANT_DESCRIPTION]: () => 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, diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts index a91238fac19a..051aa2bffbbc 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts @@ -426,6 +426,7 @@ const WORKSPACE_TO_RHP: Partial['config'] = { [SCREENS.WORKSPACE.RULES_MERCHANT_TAX]: { path: ROUTES.RULES_MERCHANT_TAX.route, }, + [SCREENS.WORKSPACE.RULES_MERCHANT_VENDOR]: { + path: ROUTES.RULES_MERCHANT_VENDOR.route, + }, [SCREENS.WORKSPACE.RULES_MERCHANT_DESCRIPTION]: { path: ROUTES.RULES_MERCHANT_DESCRIPTION.route, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index edc481f477e4..ff77fc13429e 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1733,6 +1733,10 @@ type SettingsNavigatorParamList = { policyID: string; ruleID: string; }; + [SCREENS.WORKSPACE.RULES_MERCHANT_VENDOR]: { + policyID: string; + ruleID: string; + }; [SCREENS.WORKSPACE.RULES_MERCHANT_DESCRIPTION]: { policyID: string; ruleID: string; diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index 2b31c382ae08..4b0d2880e4f9 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -74,6 +74,7 @@ function mapFormFieldsToRuleForOnyx(form: MerchantRuleForm, policy: Policy | und category: form.category || null, tag: form.tag || null, tax: buildTaxObject(form.tax, policy) ?? null, + vendorID: form.vendorID || null, comment: convertCommentToHTML(form.comment), reimbursable: form.reimbursable ?? null, billable: form.billable ?? null, @@ -100,6 +101,9 @@ function mapFormFieldsToRuleForAPI(form: MerchantRuleForm, policy: Policy | unde if (tax) { rule.tax = tax; } + if (form.vendorID) { + rule.vendorID = form.vendorID; + } const commentHTML = convertCommentToHTML(form.comment); if (commentHTML) { rule.comment = commentHTML; @@ -678,6 +682,8 @@ function clearPolicyAgentRuleErrors(policyID: string, agentRuleID: string, agent export { openPolicyRulesPage, getAgentRuleSuggestions, + mapFormFieldsToRuleForOnyx, + mapFormFieldsToRuleForAPI, setPolicyCodingRule, importMerchantRulesSpreadsheet, deletePolicyCodingRule, diff --git a/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx b/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx new file mode 100644 index 000000000000..24985ae3001b --- /dev/null +++ b/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx @@ -0,0 +1,53 @@ +import RuleSelectionBase from '@components/Rule/RuleSelectionBase'; + +import useOnyx from '@hooks/useOnyx'; +import usePolicy from '@hooks/usePolicy'; + +import {updateDraftMerchantRule} from '@libs/actions/User'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import {findVendorByID, getMatchingVendors, isXeroActiveMatchingSource} from '@libs/PolicyUtils'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +import React, {useMemo} from 'react'; + +type AddVendorPageProps = PlatformStackScreenProps; + +function AddVendorPage({route}: AddVendorPageProps) { + const {policyID, ruleID} = route.params; + const isEditing = ruleID !== ROUTES.NEW; + + const policy = usePolicy(policyID); + const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM); + + const selectedVendorItem = form?.vendorID ? {name: findVendorByID(policy, form.vendorID)?.name ?? form.vendorID, value: form.vendorID} : undefined; + + const vendorItems = useMemo(() => getMatchingVendors(policy).map((vendor) => ({name: vendor.name, value: vendor.id})), [policy]); + + const backToRoute = isEditing ? ROUTES.RULES_MERCHANT_EDIT.getRoute(policyID, ruleID) : ROUTES.RULES_MERCHANT_NEW.getRoute(policyID); + + const onSave = (value?: string) => { + updateDraftMerchantRule({vendorID: value}); + }; + + return ( + Navigation.goBack(backToRoute)} + > + + + ); +} + +export default AddVendorPage; diff --git a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx index 5cd165aa521c..238386a888b2 100644 --- a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx +++ b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx @@ -30,7 +30,7 @@ import {getDecodedCategoryName} from '@libs/CategoryUtils'; import Navigation from '@libs/Navigation/Navigation'; import {hasEnabledOptions} from '@libs/OptionsListUtils'; import Parser from '@libs/Parser'; -import {getCleanedTagName, getTagLists} from '@libs/PolicyUtils'; +import {findVendorByID, getCleanedTagName, getTagLists, hasVendorFeature, isXeroActiveMatchingSource} from '@libs/PolicyUtils'; import {getEnabledTags} from '@libs/TagsOptionsListUtils'; import {getTagArrayFromName} from '@libs/TransactionUtils'; @@ -154,6 +154,7 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, category: existingRule.category, tag: existingRule.tag, tax: existingRule.tax?.field_id_TAX?.externalID, + vendorID: existingRule.vendorID, comment: commentMarkdown, reimbursable: existingRule.reimbursable, billable: existingRule.billable, @@ -214,6 +215,10 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, const isBillableEnabled = policy?.disabledFields?.defaultBillable !== true; + const isVendorFeatureEnabled = hasVendorFeature(policy, isBetaEnabled(CONST.BETAS.VENDOR_MATCHING)); + const vendorFieldLabel = translate(isXeroActiveMatchingSource(policy) ? 'common.supplier' : 'common.vendor'); + const vendorDisplayName = form?.vendorID ? findVendorByID(policy, form.vendorID)?.name : undefined; + const categoryDisplayName = form?.category ? getDecodedCategoryName(form.category) : undefined; const taxDisplayName = () => { if (!form?.tax || !policy?.taxRates?.taxes) { @@ -394,6 +399,15 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, icon: getItemIcon(icons.InvoiceGeneric), } : undefined, + isVendorFeatureEnabled + ? { + key: 'vendorID', + description: vendorFieldLabel, + title: vendorDisplayName, + onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_VENDOR.getRoute(policyID, ruleID)), + icon: getItemIcon(icons.Basket), + } + : undefined, { key: 'description', description: translate('common.description'), diff --git a/src/types/form/MerchantRuleForm.ts b/src/types/form/MerchantRuleForm.ts index d7558fa20460..6a69e405866c 100644 --- a/src/types/form/MerchantRuleForm.ts +++ b/src/types/form/MerchantRuleForm.ts @@ -14,6 +14,7 @@ const INPUT_IDS = { REIMBURSABLE: 'reimbursable', TAG: 'tag', TAX: 'tax', + VENDOR_ID: 'vendorID', } as const; type InputID = ValueOf; @@ -30,6 +31,7 @@ type MerchantRuleForm = Form< [INPUT_IDS.REIMBURSABLE]: boolean; [INPUT_IDS.TAG]: string; [INPUT_IDS.TAX]: string; + [INPUT_IDS.VENDOR_ID]: string; } >; diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index e74ce510118c..8ca243a3d9f5 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -2434,6 +2434,9 @@ type CodingRule = { /** Tax configuration for the expense */ tax?: CodingRuleTax; + /** The external ID of the vendor to set on matching expenses */ + vendorID?: string; + /** When this rule was created */ created?: string; diff --git a/tests/unit/VendorMatchingMerchantRulesTest.ts b/tests/unit/VendorMatchingMerchantRulesTest.ts new file mode 100644 index 000000000000..09aeae51d048 --- /dev/null +++ b/tests/unit/VendorMatchingMerchantRulesTest.ts @@ -0,0 +1,140 @@ +import {mapFormFieldsToRuleForAPI, mapFormFieldsToRuleForOnyx} from '@libs/actions/Policy/Rules'; +import {getMerchantCodingRulesTableData} from '@libs/MerchantTypeRulesUtils'; +import {hasVendorFeature} from '@libs/PolicyUtils'; + +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; +import type {MerchantRuleForm} from '@src/types/form/MerchantRuleForm'; +import type {Policy} from '@src/types/onyx'; +import type {CodingRule, Connections} 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'; + +/** + * A minimal merchant rule form. Individual tests override only the fields they exercise, so the + * mappers are validated against a realistic full form rather than a hand-picked subset. + */ +const buildForm = (overrides: Partial = {}): MerchantRuleForm => + ({ + merchantToMatch: 'Coffee Shop', + matchType: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + merchant: '', + category: '', + tag: '', + tax: '', + vendorID: '', + comment: '', + reimbursable: false, + billable: false, + ...overrides, + }) as MerchantRuleForm; + +/** QBO policy whose non-reimbursable export destination scopes vendor matching to QBO. */ +const buildQBOPolicy = (vendors: Array<{id: string; name: string; currency: string}> | undefined): Policy => + createMock({ + ...createRandomPolicy(0), + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}, + data: vendors === undefined ? {} : {vendors}, + }, + }), + }); + +/** Xero policy whose supplier list scopes vendor matching to Xero (label flips vendor -> supplier). */ +const buildXeroPolicy = (contacts: Record): Policy => + createMock({ + ...createRandomPolicy(0), + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.XERO]: { + config: {isConfigured: true}, + data: {contacts}, + }, + }), + }); + +const withCodingRules = (policy: Policy, codingRules: Record): Policy => ({...policy, rules: {...policy.rules, codingRules}}); + +const buildVendorRule = (vendorID: string): CodingRule => ({ + filters: {left: 'merchant', operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, right: 'Coffee Shop'}, + vendorID, +}); + +describe('Vendor matching on merchant rules', () => { + describe('mapFormFieldsToRuleForOnyx', () => { + it('serializes a set vendorID', () => { + expect(mapFormFieldsToRuleForOnyx(buildForm({vendorID: 'v-1'}), undefined).vendorID).toBe('v-1'); + }); + + it('serializes an unset vendorID to null so Onyx merge clears it', () => { + expect(mapFormFieldsToRuleForOnyx(buildForm({vendorID: ''}), undefined).vendorID).toBeNull(); + }); + }); + + describe('mapFormFieldsToRuleForAPI', () => { + it('includes vendorID when set', () => { + expect(mapFormFieldsToRuleForAPI(buildForm({vendorID: 'v-1'}), undefined).vendorID).toBe('v-1'); + }); + + it('omits vendorID entirely when unset (never sends null)', () => { + const rule = mapFormFieldsToRuleForAPI(buildForm({vendorID: ''}), undefined); + expect('vendorID' in rule).toBe(false); + }); + }); + + describe('getMerchantCodingRulesTableData vendor summary', () => { + beforeEach(() => { + IntlStore.load(CONST.LOCALES.EN); + return waitForBatchedUpdates(); + }); + + const buildTableData = (policy: Policy) => + getMerchantCodingRulesTableData({ + policy, + policyID: policy.id, + translate: translateLocal, + isOffline: false, + onNavigate: () => {}, + }); + + it('resolves the vendor name when the vendor is in the loaded list', () => { + const policy = withCodingRules(buildQBOPolicy([{id: 'v-1', name: 'Acme Co', currency: 'USD'}]), {rule1: buildVendorRule('v-1')}); + expect(buildTableData(policy).at(0)?.ruleDescription).toContain('Update vendor to "Acme Co"'); + }); + + it('shows "Vendor unavailable" when the list is loaded but the vendor is missing', () => { + const policy = withCodingRules(buildQBOPolicy([]), {rule1: buildVendorRule('v-1')}); + expect(buildTableData(policy).at(0)?.ruleDescription).toContain('Update vendor to "Vendor unavailable"'); + }); + + it('falls back to the raw external ID while the list is not yet loaded', () => { + const policy = withCodingRules(buildQBOPolicy(undefined), {rule1: buildVendorRule('v-1')}); + expect(buildTableData(policy).at(0)?.ruleDescription).toContain('Update vendor to "v-1"'); + }); + + it('uses "supplier" wording and "Supplier unavailable" on Xero workspaces', () => { + const resolved = withCodingRules(buildXeroPolicy({xc1: {id: 'xc1', name: 'Acme Xero', email: 'acme@example.com'}}), {rule1: buildVendorRule('xc1')}); + expect(buildTableData(resolved).at(0)?.ruleDescription).toContain('Update supplier to "Acme Xero"'); + + const missing = withCodingRules(buildXeroPolicy({}), {rule1: buildVendorRule('xc1')}); + expect(buildTableData(missing).at(0)?.ruleDescription).toContain('Update supplier to "Supplier unavailable"'); + }); + }); + + describe('vendor row gating (hasVendorFeature governs MerchantRulePageBase 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); + }); + + it('is hidden when the beta is off', () => { + expect(hasVendorFeature(buildQBOPolicy([{id: 'v-1', name: 'Acme Co', currency: 'USD'}]), false)).toBe(false); + }); + + it('is hidden when no vendor integration is connected', () => { + expect(hasVendorFeature(createRandomPolicy(0), true)).toBe(false); + }); + }); +}); From 1d315dfab066ccdf1244162fa2b19d88302fe942 Mon Sep 17 00:00:00 2001 From: "Alex Beaman (via MelvinBot)" Date: Tue, 4 Aug 2026 17:27:16 +0000 Subject: [PATCH 02/14] Add generated translations for vendorUnavailable/supplierUnavailable Co-authored-by: Alex Beaman --- src/languages/de.ts | 2 ++ src/languages/el.ts | 2 ++ src/languages/fr.ts | 2 ++ src/languages/it.ts | 2 ++ src/languages/ja.ts | 2 ++ src/languages/nl.ts | 2 ++ src/languages/pl.ts | 2 ++ src/languages/pt-BR.ts | 2 ++ src/languages/zh-hans.ts | 2 ++ 9 files changed, 18 insertions(+) diff --git a/src/languages/de.ts b/src/languages/de.ts index d0991c549c73..75af3475e5dc 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7696,6 +7696,8 @@ Fordern Sie Spesendetails wie Belege und Beschreibungen an, legen Sie Limits und expenseDefaultsSubtitle: 'Felder aktualisieren, ohne dass die einreichende Person etwas tun muss', ifAnyExpenseMatches: 'Wenn eine Ausgabe übereinstimmt:', thenApplyFollowingDefaults: 'Wenden Sie dann die folgenden Standardwerte an:', + vendorUnavailable: 'Anbieter nicht verfügbar', + supplierUnavailable: 'Lieferant nicht verfügbar', }, categoryRules: { title: 'Kategorienregeln', diff --git a/src/languages/el.ts b/src/languages/el.ts index 4391e63ad2cd..5176e22b0906 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -8027,6 +8027,8 @@ ${reportName}`, importColumnUpdatedCategory: 'Ενημερωμένη κατηγορία', importColumnUpdatedTag: 'Ενημερωμένη ετικέτα', importColumnUpdatedDescription: 'Ενημερωμένη περιγραφή', + vendorUnavailable: 'Ο προμηθευτής δεν είναι διαθέσιμος', + supplierUnavailable: 'Ο προμηθευτής δεν είναι διαθέσιμος', }, newRule: { title: 'Νέος κανόνας', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index e27e999b89df..9c4069c444e4 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7723,6 +7723,8 @@ Rendez obligatoires des informations de dépense comme les reçus et les descrip expenseDefaultsSubtitle: 'Mettre à jour les champs sans que le déclarant ne fasse quoi que ce soit', ifAnyExpenseMatches: 'Si une dépense correspond :', thenApplyFollowingDefaults: 'Appliquez ensuite les valeurs par défaut suivantes :', + vendorUnavailable: 'Fournisseur indisponible', + supplierUnavailable: 'Fournisseur indisponible', }, categoryRules: { title: 'Règles de catégorie', diff --git a/src/languages/it.ts b/src/languages/it.ts index cf6c4b48a409..0b7ac73f1bf7 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7668,6 +7668,8 @@ Richiedi dettagli sulle spese come ricevute e descrizioni, imposta limiti e valo expenseDefaultsSubtitle: 'Aggiorna i campi senza che chi invia debba fare nulla', ifAnyExpenseMatches: 'Se una qualsiasi spesa corrisponde a:', thenApplyFollowingDefaults: 'Quindi applica le seguenti impostazioni predefinite:', + vendorUnavailable: 'Fornitore non disponibile', + supplierUnavailable: 'Fornitore non disponibile', }, categoryRules: { title: 'Regole di categoria', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index b5cb7e254633..87a6d7d2bab9 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7575,6 +7575,8 @@ ${reportName}`, expenseDefaultsSubtitle: '申請者が何も操作しなくてもフィールドを更新する', ifAnyExpenseMatches: 'いずれかの経費が次の条件に一致する場合:', thenApplyFollowingDefaults: '次に、以下のデフォルトを適用します。', + vendorUnavailable: 'ベンダーを利用できません', + supplierUnavailable: 'サプライヤーを利用できません', }, categoryRules: { title: 'カテゴリルール', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 0ef89e0a32c9..137d17ce3346 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7651,6 +7651,8 @@ Vereis onkostendetails zoals bonnen en beschrijvingen, stel limieten en standaar expenseDefaultsSubtitle: 'Velden bijwerken zonder dat de indiener iets hoeft te doen', ifAnyExpenseMatches: 'Als een uitgave overeenkomt:', thenApplyFollowingDefaults: 'Pas vervolgens de volgende standaardinstellingen toe:', + vendorUnavailable: 'Leverancier niet beschikbaar', + supplierUnavailable: 'Leverancier niet beschikbaar', }, categoryRules: { title: 'Categorisatieregels', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index cbe6f9a63725..fbe2e6732517 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7630,6 +7630,8 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i expenseDefaultsSubtitle: 'Aktualizuj pola bez wymagania działania od osoby zgłaszającej', ifAnyExpenseMatches: 'Jeśli którykolwiek wydatek pasuje:', thenApplyFollowingDefaults: 'Następnie zastosuj następujące domyślne ustawienia:', + vendorUnavailable: 'Dostawca niedostępny', + supplierUnavailable: 'Dostawca niedostępny', }, categoryRules: { title: 'Reguły kategorii', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index d755a20e994d..9d92516656a2 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7646,6 +7646,8 @@ Exija dados de despesas como recibos e descrições, defina limites e padrões e expenseDefaultsSubtitle: 'Atualizar campos sem o responsável pelo envio fazer nada', ifAnyExpenseMatches: 'Se alguma despesa corresponder:', thenApplyFollowingDefaults: 'Em seguida, aplica os seguintes padrões:', + vendorUnavailable: 'Fornecedor indisponível', + supplierUnavailable: 'Fornecedor indisponível', }, categoryRules: { title: 'Regras de categoria', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index ee4798c12823..5452f7f8265e 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7399,6 +7399,8 @@ ${reportName}`, expenseDefaultsSubtitle: '在提交人无须执行任何操作的情况下更新字段', ifAnyExpenseMatches: '如果任一报销符合以下条件:', thenApplyFollowingDefaults: '然后应用以下默认设置:', + vendorUnavailable: '供应商不可用', + supplierUnavailable: '供应商不可用', }, categoryRules: { title: '类别规则', From ffa046bea2f2f71f99e57522bcfdad2091fe8db0 Mon Sep 17 00:00:00 2001 From: "Alex Beaman (via MelvinBot)" Date: Tue, 4 Aug 2026 17:31:32 +0000 Subject: [PATCH 03/14] Fix Oxfmt import ordering/line wrap and typecheck Co-authored-by: Alex Beaman --- src/libs/MerchantTypeRulesUtils.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/libs/MerchantTypeRulesUtils.ts b/src/libs/MerchantTypeRulesUtils.ts index 688fa4cdf98f..19ded2da0497 100644 --- a/src/libs/MerchantTypeRulesUtils.ts +++ b/src/libs/MerchantTypeRulesUtils.ts @@ -4,8 +4,8 @@ import type {ExpenseDefaultTableItem} from '@components/Tables/WorkspaceExpenseD import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; -import type {MerchantTypeRuleForm} from '@src/types/form/MerchantTypeRuleForm'; import INPUT_IDS from '@src/types/form/MerchantTypeRuleForm'; +import type {MerchantTypeRuleForm} from '@src/types/form/MerchantTypeRuleForm'; import type {Policy} from '@src/types/onyx'; import type {CodingRule} from '@src/types/onyx/Policy'; @@ -213,4 +213,12 @@ function getExpenseDefaultsTableData({ return [...merchantRules, ...merchantTypeRules]; } -export {getDefaultMccGroupCategory, getExpenseDefaultsTableData, getMerchantCodingRulesTableData, getMerchantTypeRuleFormFromMccGroup, isDefaultMccGroupID, isMerchantTypeRuleKey, saveMerchantTypeRule}; +export { + getDefaultMccGroupCategory, + getExpenseDefaultsTableData, + getMerchantCodingRulesTableData, + getMerchantTypeRuleFormFromMccGroup, + isDefaultMccGroupID, + isMerchantTypeRuleKey, + saveMerchantTypeRule, +}; From 8b91c1e25c3ad21f45d1b1d0b08e70a2c8ee0121 Mon Sep 17 00:00:00 2001 From: "Alex Beaman (via MelvinBot)" Date: Tue, 4 Aug 2026 20:16:06 +0000 Subject: [PATCH 04/14] Add unit tests for AddVendorPage vendor selection helpers Co-authored-by: Alex Beaman --- .../rules/MerchantRules/AddVendorPage.tsx | 21 +++- tests/unit/AddVendorPageTest.ts | 116 ++++++++++++++++++ 2 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 tests/unit/AddVendorPageTest.ts diff --git a/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx b/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx index 24985ae3001b..8ec5c5d2d6de 100644 --- a/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx @@ -12,11 +12,27 @@ import {findVendorByID, getMatchingVendors, isXeroActiveMatchingSource} from '@l import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; +import type {Policy} from '@src/types/onyx'; import React, {useMemo} from 'react'; type AddVendorPageProps = PlatformStackScreenProps; +type VendorSelectionItem = {name: string; value: string}; + +/** Maps the policy's matching vendors to picker items (label = vendor name, value = the integration's external vendor ID). */ +function getVendorSelectionItems(policy: Policy | undefined): VendorSelectionItem[] { + return getMatchingVendors(policy).map((vendor) => ({name: vendor.name, value: vendor.id})); +} + +/** + * Resolves the picker's currently-selected item for a stored vendorID. Falls back to the raw external ID as the + * label when the vendor can't be resolved (list not synced yet, or the vendor was removed from the integration). + */ +function getSelectedVendorItem(policy: Policy | undefined, vendorID: string | undefined): VendorSelectionItem | undefined { + return vendorID ? {name: findVendorByID(policy, vendorID)?.name ?? vendorID, value: vendorID} : undefined; +} + function AddVendorPage({route}: AddVendorPageProps) { const {policyID, ruleID} = route.params; const isEditing = ruleID !== ROUTES.NEW; @@ -24,9 +40,9 @@ function AddVendorPage({route}: AddVendorPageProps) { const policy = usePolicy(policyID); const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM); - const selectedVendorItem = form?.vendorID ? {name: findVendorByID(policy, form.vendorID)?.name ?? form.vendorID, value: form.vendorID} : undefined; + const selectedVendorItem = getSelectedVendorItem(policy, form?.vendorID); - const vendorItems = useMemo(() => getMatchingVendors(policy).map((vendor) => ({name: vendor.name, value: vendor.id})), [policy]); + const vendorItems = useMemo(() => getVendorSelectionItems(policy), [policy]); const backToRoute = isEditing ? ROUTES.RULES_MERCHANT_EDIT.getRoute(policyID, ruleID) : ROUTES.RULES_MERCHANT_NEW.getRoute(policyID); @@ -51,3 +67,4 @@ function AddVendorPage({route}: AddVendorPageProps) { } export default AddVendorPage; +export {getSelectedVendorItem, getVendorSelectionItems}; diff --git a/tests/unit/AddVendorPageTest.ts b/tests/unit/AddVendorPageTest.ts new file mode 100644 index 000000000000..2b6683593c6f --- /dev/null +++ b/tests/unit/AddVendorPageTest.ts @@ -0,0 +1,116 @@ +import {findVendorByID, hasVendorFeature, isXeroActiveMatchingSource} from '@libs/PolicyUtils'; + +import {getSelectedVendorItem, getVendorSelectionItems} from '@pages/workspace/rules/MerchantRules/AddVendorPage'; + +import CONST from '@src/CONST'; +import type {Policy} from '@src/types/onyx'; +import type {Connections} from '@src/types/onyx/Policy'; + +import createRandomPolicy from '../utils/collections/policies'; +import createMock from '../utils/createMock'; + +/** + * QBO policy whose non-reimbursable export destination scopes vendor matching to QBO. + * Passing `undefined` models the list not yet synced; `[]` models a loaded-but-empty list. + */ +const buildQBOPolicy = (vendors: Array<{id: string; name: string; currency: string}> | undefined): Policy => + createMock({ + ...createRandomPolicy(0), + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}, + data: vendors === undefined ? {} : {vendors}, + }, + }), + }); + +/** Xero policy whose supplier list scopes vendor matching to Xero (label flips vendor -> supplier). */ +const buildXeroPolicy = (contacts: Record): Policy => + createMock({ + ...createRandomPolicy(0), + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.XERO]: { + config: {isConfigured: true}, + data: {contacts}, + }, + }), + }); + +describe('AddVendorPage', () => { + describe('getVendorSelectionItems', () => { + it('maps each matching vendor to a {name, value} picker item (value is the external vendor ID)', () => { + const policy = buildQBOPolicy([ + {id: 'v-1', name: 'Acme Co', currency: 'USD'}, + {id: 'v-2', name: 'Globex', currency: 'USD'}, + ]); + expect(getVendorSelectionItems(policy)).toEqual([ + {name: 'Acme Co', value: 'v-1'}, + {name: 'Globex', value: 'v-2'}, + ]); + }); + + it('returns an empty list when the vendor list is loaded but empty', () => { + expect(getVendorSelectionItems(buildQBOPolicy([]))).toEqual([]); + }); + + it('returns an empty list when the vendor list has not synced yet', () => { + expect(getVendorSelectionItems(buildQBOPolicy(undefined))).toEqual([]); + }); + + it('sources supplier contacts on a Xero workspace', () => { + const policy = buildXeroPolicy({xc1: {id: 'xc1', name: 'Acme Xero', email: 'acme@example.com'}}); + expect(getVendorSelectionItems(policy)).toEqual([{name: 'Acme Xero', value: 'xc1'}]); + }); + }); + + describe('getSelectedVendorItem', () => { + it('resolves the stored vendorID to its current name', () => { + const policy = buildQBOPolicy([{id: 'v-1', name: 'Acme Co', currency: 'USD'}]); + expect(getSelectedVendorItem(policy, 'v-1')).toEqual({name: 'Acme Co', value: 'v-1'}); + }); + + it('falls back to the raw external ID as the label when the vendor cannot be resolved', () => { + const policy = buildQBOPolicy([]); + expect(getSelectedVendorItem(policy, 'v-missing')).toEqual({name: 'v-missing', value: 'v-missing'}); + }); + + it('returns undefined when no vendorID is set', () => { + const policy = buildQBOPolicy([{id: 'v-1', name: 'Acme Co', currency: 'USD'}]); + expect(getSelectedVendorItem(policy, undefined)).toBeUndefined(); + expect(getSelectedVendorItem(policy, '')).toBeUndefined(); + }); + }); + + /** + * The "Set vendor to" row in MerchantRulePageBase 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)', () => { + const qboPolicy = buildQBOPolicy([{id: 'v-1', name: 'Acme Co', currency: 'USD'}]); + const xeroPolicy = buildXeroPolicy({xc1: {id: 'xc1', name: 'Acme Xero', email: 'acme@example.com'}}); + + it('hides the row when the beta is off even with a vendor integration connected', () => { + expect(hasVendorFeature(qboPolicy, false)).toBe(false); + }); + + it('hides the row when no vendor integration is connected', () => { + expect(hasVendorFeature(createRandomPolicy(0), true)).toBe(false); + }); + + it('shows the row when the beta is on and a vendor integration is connected', () => { + expect(hasVendorFeature(qboPolicy, true)).toBe(true); + }); + + it('labels the row "vendor" on QBO and flips to "supplier" on Xero', () => { + expect(isXeroActiveMatchingSource(qboPolicy)).toBe(false); + expect(isXeroActiveMatchingSource(xeroPolicy)).toBe(true); + }); + + it('titles the row with the resolved vendor name, or nothing when unset', () => { + expect(findVendorByID(qboPolicy, 'v-1')?.name).toBe('Acme Co'); + expect(findVendorByID(qboPolicy, undefined)).toBeUndefined(); + }); + }); +}); From cded6ec63d0c8ed16fa98daa4c42f23137497d68 Mon Sep 17 00:00:00 2001 From: "{\"message\":\"Not Found\",\"documentation_url\":\"https://docs.github.com/rest/issues/comments#get-an-issue-comment\",\"status\":\"404\"} (via MelvinBot)" Date: Tue, 4 Aug 2026 20:34:22 +0000 Subject: [PATCH 05/14] Render vendor action in legacy merchant rule summary Co-authored-by: {"message":"Not Found","documentation_url":"https://docs.github.com/rest/issues/comments#get-an-issue-comment","status":"404"} <{"message":"Not Found","documentation_url":"https://docs.github.com/rest/issues/comments#get-an-issue-comment","status":"404"}@users.noreply.github.com> --- .../workspace/rules/MerchantRulesSection.tsx | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/pages/workspace/rules/MerchantRulesSection.tsx b/src/pages/workspace/rules/MerchantRulesSection.tsx index 9f4f60aab95d..40453fc3a198 100644 --- a/src/pages/workspace/rules/MerchantRulesSection.tsx +++ b/src/pages/workspace/rules/MerchantRulesSection.tsx @@ -17,7 +17,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {getDecodedCategoryName} from '@libs/CategoryUtils'; import Navigation from '@libs/Navigation/Navigation'; import Parser from '@libs/Parser'; -import {getCommaSeparatedTagNameWithSanitizedColons} from '@libs/PolicyUtils'; +import {findVendorByID, getCommaSeparatedTagNameWithSanitizedColons, isMatchingVendorListLoaded, isXeroActiveMatchingSource} from '@libs/PolicyUtils'; import tokenizedSearch from '@libs/tokenizedSearch'; import variables from '@styles/variables'; @@ -26,6 +26,7 @@ import {clearPolicyCodingRuleErrors} from '@userActions/Policy/Rules'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; +import type {Policy} from '@src/types/onyx'; import type {CodingRule} from '@src/types/onyx/Policy'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -43,12 +44,13 @@ type FieldLabels = { tag: string; description: string; tax: string; + vendor: string; }; /** * Generates a human-readable description of what a coding rule does */ -function getRuleDescription(rule: CodingRule, translate: ReturnType['translate'], labels: FieldLabels): string { +function getRuleDescription(rule: CodingRule, translate: ReturnType['translate'], labels: FieldLabels, policy: Policy | undefined): string { const actions: string[] = []; if (rule.merchant) { @@ -67,6 +69,18 @@ function getRuleDescription(rule: CodingRule, translate: ReturnType From 64be6e704f2fdc4c15de43b976e52cdf5cc5de17 Mon Sep 17 00:00:00 2001 From: "Alex Beaman (via MelvinBot)" Date: Wed, 5 Aug 2026 19:44:09 +0000 Subject: [PATCH 06/14] Address codex comments (gate vendor picker, vendor display fallback) and fix spellcheck Co-authored-by: Alex Beaman --- cspell.json | 1 + .../rules/MerchantRules/AddVendorPage.tsx | 13 ++++++++++- .../MerchantRules/MerchantRulePageBase.tsx | 22 ++++++++++++++++--- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/cspell.json b/cspell.json index a47b2b92091f..a647f9ba4c68 100644 --- a/cspell.json +++ b/cspell.json @@ -148,6 +148,7 @@ "Gclid", "Geral", "gitlink", + "Globex", "Grantmaking", "Gsuite", "Générale", diff --git a/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx b/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx index 8ec5c5d2d6de..df2b7106644c 100644 --- a/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx @@ -1,14 +1,18 @@ import RuleSelectionBase from '@components/Rule/RuleSelectionBase'; import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; import {updateDraftMerchantRule} from '@libs/actions/User'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import {findVendorByID, getMatchingVendors, isXeroActiveMatchingSource} from '@libs/PolicyUtils'; +import {findVendorByID, getMatchingVendors, hasVendorFeature, isXeroActiveMatchingSource} from '@libs/PolicyUtils'; +import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; + +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; @@ -38,6 +42,7 @@ function AddVendorPage({route}: AddVendorPageProps) { const isEditing = ruleID !== ROUTES.NEW; const policy = usePolicy(policyID); + const {isBetaEnabled} = usePermissions(); const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM); const selectedVendorItem = getSelectedVendorItem(policy, form?.vendorID); @@ -50,6 +55,12 @@ function AddVendorPage({route}: AddVendorPageProps) { updateDraftMerchantRule({vendorID: value}); }; + // Gate direct/deeplink access behind the same predicate that hides the "Set vendor to" row, so the beta can't be + // bypassed by opening this picker's URL directly (which would otherwise write vendorID into the draft and save it). + if (!hasVendorFeature(policy, isBetaEnabled(CONST.BETAS.VENDOR_MATCHING))) { + return ; + } + return ( { + if (!form?.vendorID) { + return undefined; + } + const resolvedVendorName = findVendorByID(policy, form.vendorID)?.name; + if (resolvedVendorName) { + return resolvedVendorName; + } + if (isMatchingVendorListLoaded(policy)) { + return translate(isOnXero ? 'workspace.rules.merchantRules.supplierUnavailable' : 'workspace.rules.merchantRules.vendorUnavailable'); + } + return form.vendorID; + }; + const vendorDisplayName = getVendorDisplayName(); const categoryDisplayName = form?.category ? getDecodedCategoryName(form.category) : undefined; const taxDisplayName = () => { From dbc4828d146b1b024f726db3bc835e2285273266 Mon Sep 17 00:00:00 2001 From: "{\"message\":\"Not Found\",\"documentation_url\":\"https://docs.github.com/rest/issues/comments#get-an-issue-comment\",\"status\":\"404\"} (via MelvinBot)" Date: Thu, 6 Aug 2026 15:43:30 +0000 Subject: [PATCH 07/14] Scope merchant-rule vendor lookups to the active integration Co-authored-by: {"message":"Not Found","documentation_url":"https://docs.github.com/rest/issues/comments#get-an-issue-comment","status":"404"} <{"message":"Not Found","documentation_url":"https://docs.github.com/rest/issues/comments#get-an-issue-comment","status":"404"}@users.noreply.github.com> --- src/libs/MerchantTypeRulesUtils.ts | 5 +-- .../rules/MerchantRules/AddVendorPage.tsx | 10 +++--- .../MerchantRules/MerchantRulePageBase.tsx | 6 ++-- .../workspace/rules/MerchantRulesSection.tsx | 5 +-- tests/unit/AddVendorPageTest.ts | 24 ++++++++++++++ tests/unit/VendorMatchingMerchantRulesTest.ts | 31 +++++++++++++++++++ 6 files changed, 71 insertions(+), 10 deletions(-) diff --git a/src/libs/MerchantTypeRulesUtils.ts b/src/libs/MerchantTypeRulesUtils.ts index 19ded2da0497..ae18102ce8d8 100644 --- a/src/libs/MerchantTypeRulesUtils.ts +++ b/src/libs/MerchantTypeRulesUtils.ts @@ -15,7 +15,7 @@ import {clearPolicyCodingRuleErrors} from './actions/Policy/Rules'; import {getDecodedCategoryName} from './CategoryUtils'; import Parser from './Parser'; import {getMccGroupDisplayName} from './PolicyRulesUtils'; -import {findVendorByID, getCommaSeparatedTagNameWithSanitizedColons, isMatchingVendorListLoaded, isXeroActiveMatchingSource} from './PolicyUtils'; +import {getCommaSeparatedTagNameWithSanitizedColons, getMatchingVendorByID, isMatchingVendorListLoaded, isXeroActiveMatchingSource} from './PolicyUtils'; const MERCHANT_TYPE_RULE_KEY_PREFIX = 'mcc-group:'; @@ -157,7 +157,8 @@ function getMerchantCodingRulesTableData({ actions.push(translate('workspace.rules.merchantRules.ruleSummarySubtitleUpdateField', fieldLabels.tax, `${rule.tax.field_id_TAX.name} (${rule.tax.field_id_TAX.value})`)); } if (rule.vendorID) { - const resolvedVendorName = findVendorByID(policy, rule.vendorID)?.name; + // Active-scoped lookup: a vendorID that only matches a stale/inactive connection resolves to "unavailable" here. + const resolvedVendorName = getMatchingVendorByID(policy, rule.vendorID)?.name; let vendorValue: string; if (resolvedVendorName) { vendorValue = resolvedVendorName; diff --git a/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx b/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx index df2b7106644c..2685fee20ffe 100644 --- a/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx @@ -8,7 +8,7 @@ import {updateDraftMerchantRule} from '@libs/actions/User'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import {findVendorByID, getMatchingVendors, hasVendorFeature, isXeroActiveMatchingSource} from '@libs/PolicyUtils'; +import {getMatchingVendorByID, getMatchingVendors, hasVendorFeature, isXeroActiveMatchingSource} from '@libs/PolicyUtils'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; @@ -30,11 +30,13 @@ function getVendorSelectionItems(policy: Policy | undefined): VendorSelectionIte } /** - * Resolves the picker's currently-selected item for a stored vendorID. Falls back to the raw external ID as the - * label when the vendor can't be resolved (list not synced yet, or the vendor was removed from the integration). + * Resolves the picker's currently-selected item for a stored vendorID, scoped to the active vendor-matching + * integration (the same list the picker offers). Falls back to the raw external ID as the label when the vendor + * can't be resolved against that active list (list not synced yet, the vendor was removed, or the ID only matches + * a stale/inactive connection) so the selection never displays a name the active picker can't actually select. */ function getSelectedVendorItem(policy: Policy | undefined, vendorID: string | undefined): VendorSelectionItem | undefined { - return vendorID ? {name: findVendorByID(policy, vendorID)?.name ?? vendorID, value: vendorID} : undefined; + return vendorID ? {name: getMatchingVendorByID(policy, vendorID)?.name ?? vendorID, value: vendorID} : undefined; } function AddVendorPage({route}: AddVendorPageProps) { diff --git a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx index 3812b679bbbe..9710db54cc70 100644 --- a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx +++ b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx @@ -30,7 +30,7 @@ import {getDecodedCategoryName} from '@libs/CategoryUtils'; import Navigation from '@libs/Navigation/Navigation'; import {hasEnabledOptions} from '@libs/OptionsListUtils'; import Parser from '@libs/Parser'; -import {findVendorByID, getCleanedTagName, getTagLists, hasVendorFeature, isMatchingVendorListLoaded, isXeroActiveMatchingSource} from '@libs/PolicyUtils'; +import {getCleanedTagName, getMatchingVendorByID, getTagLists, hasVendorFeature, isMatchingVendorListLoaded, isXeroActiveMatchingSource} from '@libs/PolicyUtils'; import {getEnabledTags} from '@libs/TagsOptionsListUtils'; import {getTagArrayFromName} from '@libs/TransactionUtils'; @@ -220,11 +220,13 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, const vendorFieldLabel = translate(isOnXero ? 'common.supplier' : 'common.vendor'); // Mirror the rule-summary fallback so an already-stored vendor never renders as unset while the row still saves it: // resolved name when available, the "unavailable" copy once the vendor list has synced without a match, otherwise the raw stored ID. + // Scope the lookup to the active vendor-matching integration (not the permissive `findVendorByID`) so a vendorID that only + // resolves against a stale/inactive connection surfaces as "unavailable" here, matching how the picker and violation logic treat it. const getVendorDisplayName = () => { if (!form?.vendorID) { return undefined; } - const resolvedVendorName = findVendorByID(policy, form.vendorID)?.name; + const resolvedVendorName = getMatchingVendorByID(policy, form.vendorID)?.name; if (resolvedVendorName) { return resolvedVendorName; } diff --git a/src/pages/workspace/rules/MerchantRulesSection.tsx b/src/pages/workspace/rules/MerchantRulesSection.tsx index 40453fc3a198..e4db5e3acd8b 100644 --- a/src/pages/workspace/rules/MerchantRulesSection.tsx +++ b/src/pages/workspace/rules/MerchantRulesSection.tsx @@ -17,7 +17,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {getDecodedCategoryName} from '@libs/CategoryUtils'; import Navigation from '@libs/Navigation/Navigation'; import Parser from '@libs/Parser'; -import {findVendorByID, getCommaSeparatedTagNameWithSanitizedColons, isMatchingVendorListLoaded, isXeroActiveMatchingSource} from '@libs/PolicyUtils'; +import {getCommaSeparatedTagNameWithSanitizedColons, getMatchingVendorByID, isMatchingVendorListLoaded, isXeroActiveMatchingSource} from '@libs/PolicyUtils'; import tokenizedSearch from '@libs/tokenizedSearch'; import variables from '@styles/variables'; @@ -70,7 +70,8 @@ function getRuleDescription(rule: CodingRule, translate: ReturnType, xeroContacts: Record): Policy => + createMock({ + ...createRandomPolicy(0), + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}, + data: {vendors: qboVendors}, + }, + [CONST.POLICY.CONNECTIONS.NAME.XERO]: { + config: {isConfigured: true}, + data: {contacts: xeroContacts}, + }, + }), + }); + describe('AddVendorPage', () => { describe('getVendorSelectionItems', () => { it('maps each matching vendor to a {name, value} picker item (value is the external vendor ID)', () => { @@ -79,6 +95,14 @@ describe('AddVendorPage', () => { expect(getSelectedVendorItem(policy, undefined)).toBeUndefined(); expect(getSelectedVendorItem(policy, '')).toBeUndefined(); }); + + it('falls back to the raw ID (not the stale name) when the vendorID only resolves against an inactive connection', () => { + // QBO is active; the stored ID matches only the lingering Xero connection, which the active picker can't offer. + const policy = buildQBOWithStaleXeroPolicy([{id: 'v-1', name: 'Acme Co', currency: 'USD'}], { + xeroVendor: {id: 'xeroVendor', name: 'Stale Xero Vendor', email: 'stale@example.com'}, + }); + expect(getSelectedVendorItem(policy, 'xeroVendor')).toEqual({name: 'xeroVendor', value: 'xeroVendor'}); + }); }); /** diff --git a/tests/unit/VendorMatchingMerchantRulesTest.ts b/tests/unit/VendorMatchingMerchantRulesTest.ts index 09aeae51d048..b56dba50cbf0 100644 --- a/tests/unit/VendorMatchingMerchantRulesTest.ts +++ b/tests/unit/VendorMatchingMerchantRulesTest.ts @@ -56,6 +56,25 @@ const buildXeroPolicy = (contacts: Record, xeroContacts: Record): Policy => + createMock({ + ...createRandomPolicy(0), + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}, + data: {vendors: qboVendors}, + }, + [CONST.POLICY.CONNECTIONS.NAME.XERO]: { + config: {isConfigured: true}, + data: {contacts: xeroContacts}, + }, + }), + }); + const withCodingRules = (policy: Policy, codingRules: Record): Policy => ({...policy, rules: {...policy.rules, codingRules}}); const buildVendorRule = (vendorID: string): CodingRule => ({ @@ -115,6 +134,18 @@ describe('Vendor matching on merchant rules', () => { expect(buildTableData(policy).at(0)?.ruleDescription).toContain('Update vendor to "v-1"'); }); + it('shows "Vendor unavailable" when the vendorID only resolves against a stale/inactive connection', () => { + // Active source is QBO (empty vendor list, so loaded). The rule's vendorID matches only the stale Xero + // connection, which the active picker and violation logic ignore. The summary must not render the Xero + // name as if the vendor were valid — it should surface the active-scoped "unavailable" copy instead. + const policy = withCodingRules(buildQBOWithStaleXeroPolicy([], {xeroVendor: {id: 'xeroVendor', name: 'Stale Xero Vendor', email: 'stale@example.com'}}), { + rule1: buildVendorRule('xeroVendor'), + }); + const description = buildTableData(policy).at(0)?.ruleDescription; + expect(description).toContain('Update vendor to "Vendor unavailable"'); + expect(description).not.toContain('Stale Xero Vendor'); + }); + it('uses "supplier" wording and "Supplier unavailable" on Xero workspaces', () => { const resolved = withCodingRules(buildXeroPolicy({xc1: {id: 'xc1', name: 'Acme Xero', email: 'acme@example.com'}}), {rule1: buildVendorRule('xc1')}); expect(buildTableData(resolved).at(0)?.ruleDescription).toContain('Update supplier to "Acme Xero"'); From 99fe748c06f72cd44dd4a87251468d7529f8a9e6 Mon Sep 17 00:00:00 2001 From: "Alex Beaman (via MelvinBot)" Date: Fri, 7 Aug 2026 18:18:30 +0000 Subject: [PATCH 08/14] Address github-actions review: drop redundant useMemo and name save callback for its action Co-authored-by: Alex Beaman --- src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx b/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx index 2685fee20ffe..38deaefd3726 100644 --- a/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx @@ -18,7 +18,7 @@ import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {Policy} from '@src/types/onyx'; -import React, {useMemo} from 'react'; +import React from 'react'; type AddVendorPageProps = PlatformStackScreenProps; @@ -49,11 +49,11 @@ function AddVendorPage({route}: AddVendorPageProps) { const selectedVendorItem = getSelectedVendorItem(policy, form?.vendorID); - const vendorItems = useMemo(() => getVendorSelectionItems(policy), [policy]); + const vendorItems = getVendorSelectionItems(policy); const backToRoute = isEditing ? ROUTES.RULES_MERCHANT_EDIT.getRoute(policyID, ruleID) : ROUTES.RULES_MERCHANT_NEW.getRoute(policyID); - const onSave = (value?: string) => { + const saveVendor = (value?: string) => { updateDraftMerchantRule({vendorID: value}); }; @@ -72,7 +72,7 @@ function AddVendorPage({route}: AddVendorPageProps) { From db662bf2f69c95625f270ed61045abd84c7590ca Mon Sep 17 00:00:00 2001 From: "{\"message\":\"Not Found\",\"documentation_url\":\"https://docs.github.com/rest/issues/comments#get-an-issue-comment\",\"status\":\"404\"} (via MelvinBot)" Date: Fri, 7 Aug 2026 18:36:49 +0000 Subject: [PATCH 09/14] Prefetch policy connections before gating vendor rules editor and picker Co-authored-by: {"message":"Not Found","documentation_url":"https://docs.github.com/rest/issues/comments#get-an-issue-comment","status":"404"} <{"message":"Not Found","documentation_url":"https://docs.github.com/rest/issues/comments#get-an-issue-comment","status":"404"}@users.noreply.github.com> --- src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx | 8 ++++++++ .../rules/MerchantRules/MerchantRulePageBase.tsx | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx b/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx index 38deaefd3726..e0d614c4d805 100644 --- a/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx @@ -3,6 +3,7 @@ import RuleSelectionBase from '@components/Rule/RuleSelectionBase'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; +import usePolicyConnectionsPrefetch from '@hooks/usePolicyConnectionsPrefetch'; import {updateDraftMerchantRule} from '@libs/actions/User'; import Navigation from '@libs/Navigation/Navigation'; @@ -47,6 +48,13 @@ function AddVendorPage({route}: AddVendorPageProps) { const {isBetaEnabled} = usePermissions(); const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM); + // This picker can be deep-linked directly, and its gate below reads policy.connections (via + // hasVendorFeature and getMatchingVendorByID), which is empty on a non-active workspace until a page + // requiring connections is opened. Prefetch it here, gated on the beta alone (not hasVendorFeature, + // which itself depends on the connection data — a chicken-and-egg) so the picker becomes available and + // resolves the selected vendor once connections hydrate. + usePolicyConnectionsPrefetch(policy, isBetaEnabled(CONST.BETAS.VENDOR_MATCHING)); + const selectedVendorItem = getSelectedVendorItem(policy, form?.vendorID); const vendorItems = getVendorSelectionItems(policy); diff --git a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx index 9710db54cc70..accdae73401c 100644 --- a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx +++ b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx @@ -17,6 +17,7 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; +import usePolicyConnectionsPrefetch from '@hooks/usePolicyConnectionsPrefetch'; import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import usePressLoading from '@hooks/usePressLoading'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -133,6 +134,13 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, const [shouldUpdateMatchingTransactions, setShouldUpdateMatchingTransactions] = useState(false); const didInitializeCreateDraftRef = useRef(false); + // The "Set vendor to" row gate below reads policy.connections (via hasVendorFeature and + // isMatchingVendorListLoaded), which is empty on a non-active workspace until a page requiring + // connections is opened. This editor only fetches categories/tags, so prefetch connections here, + // gated on the beta alone (not hasVendorFeature, which itself depends on the connection data — a + // chicken-and-egg) so the row appears and resolves the stored vendor once connections hydrate. + usePolicyConnectionsPrefetch(policy, isBetaEnabled(CONST.BETAS.VENDOR_MATCHING)); + // Get the existing rule from the policy (for edit mode) const existingRule = ruleID ? policy?.rules?.codingRules?.[ruleID] : undefined; From 98c0aaf40edc930b8365728f47b1c31edf913577 Mon Sep 17 00:00:00 2001 From: Alex Beaman Date: Mon, 10 Aug 2026 10:58:19 -0700 Subject: [PATCH 10/14] Address deep-link and export-mode reviewer feedback on vendor rules - AddVendorPage: show FullScreenLoadingIndicator while the connections prefetch is in flight instead of falling through to NotFoundPage on a deep-link cold-load. Matches how withPolicyConnections gates. - Rule summary: when the workspace has no active vendor-matching source (e.g. admin switched non-reimbursable export mode to Vendor Bill after authoring the rule), fall back to findVendorByID so the historical vendor name still renders instead of the raw external ID. Keeps the active-source strict scoping when a source is active, so a rule that only resolves against a stale/inactive connection still surfaces as "unavailable". - Add regression test for the export-mode-change case. --- src/libs/MerchantTypeRulesUtils.ts | 19 +++++++++----- .../rules/MerchantRules/AddVendorPage.tsx | 12 ++++++++- tests/unit/VendorMatchingMerchantRulesTest.ts | 25 +++++++++++++++++++ 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/libs/MerchantTypeRulesUtils.ts b/src/libs/MerchantTypeRulesUtils.ts index ae18102ce8d8..d999d4b129c7 100644 --- a/src/libs/MerchantTypeRulesUtils.ts +++ b/src/libs/MerchantTypeRulesUtils.ts @@ -15,7 +15,7 @@ import {clearPolicyCodingRuleErrors} from './actions/Policy/Rules'; import {getDecodedCategoryName} from './CategoryUtils'; import Parser from './Parser'; import {getMccGroupDisplayName} from './PolicyRulesUtils'; -import {getCommaSeparatedTagNameWithSanitizedColons, getMatchingVendorByID, isMatchingVendorListLoaded, isXeroActiveMatchingSource} from './PolicyUtils'; +import {findVendorByID, getCommaSeparatedTagNameWithSanitizedColons, getMatchingVendorByID, isMatchingVendorListLoaded, isXeroActiveMatchingSource} from './PolicyUtils'; const MERCHANT_TYPE_RULE_KEY_PREFIX = 'mcc-group:'; @@ -157,15 +157,22 @@ function getMerchantCodingRulesTableData({ actions.push(translate('workspace.rules.merchantRules.ruleSummarySubtitleUpdateField', fieldLabels.tax, `${rule.tax.field_id_TAX.name} (${rule.tax.field_id_TAX.value})`)); } if (rule.vendorID) { - // Active-scoped lookup: a vendorID that only matches a stale/inactive connection resolves to "unavailable" here. - const resolvedVendorName = getMatchingVendorByID(policy, rule.vendorID)?.name; + // Resolve the display name in three tiers so each case renders correctly: + // 1. Active-source hit — the vendor is in the active vendor-matching integration's list; render its name. + // 2. Active-source miss with a loaded list — the ID doesn't exist in that active list; render "unavailable" + // so a rule targeting a stale/inactive-connection vendor never surfaces a misleading name. + // 3. No active vendor-matching source (e.g. admin switched the non-reimbursable export mode away from + // vendor-matching) — fall back to `findVendorByID`'s permissive search across every connection's data + // so the historical vendor name still renders instead of a raw external ID; otherwise the raw ID + // as a last resort while the connection data hasn't loaded yet. + const activeVendorName = getMatchingVendorByID(policy, rule.vendorID)?.name; let vendorValue: string; - if (resolvedVendorName) { - vendorValue = resolvedVendorName; + if (activeVendorName) { + vendorValue = activeVendorName; } else if (isMatchingVendorListLoaded(policy)) { vendorValue = translate(isOnXero ? 'workspace.rules.merchantRules.supplierUnavailable' : 'workspace.rules.merchantRules.vendorUnavailable'); } else { - vendorValue = rule.vendorID; + vendorValue = findVendorByID(policy, rule.vendorID)?.name ?? rule.vendorID; } actions.push(translate('workspace.rules.merchantRules.ruleSummarySubtitleUpdateField', fieldLabels.vendor, vendorValue)); } diff --git a/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx b/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx index e0d614c4d805..836afb7897b9 100644 --- a/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx @@ -1,3 +1,4 @@ +import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import RuleSelectionBase from '@components/Rule/RuleSelectionBase'; import useOnyx from '@hooks/useOnyx'; @@ -10,6 +11,7 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import {getMatchingVendorByID, getMatchingVendors, hasVendorFeature, isXeroActiveMatchingSource} from '@libs/PolicyUtils'; +import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; @@ -53,7 +55,7 @@ function AddVendorPage({route}: AddVendorPageProps) { // requiring connections is opened. Prefetch it here, gated on the beta alone (not hasVendorFeature, // which itself depends on the connection data — a chicken-and-egg) so the picker becomes available and // resolves the selected vendor once connections hydrate. - usePolicyConnectionsPrefetch(policy, isBetaEnabled(CONST.BETAS.VENDOR_MATCHING)); + const {isFetchNeeded, isLoadingFetchedFlag} = usePolicyConnectionsPrefetch(policy, isBetaEnabled(CONST.BETAS.VENDOR_MATCHING)); const selectedVendorItem = getSelectedVendorItem(policy, form?.vendorID); @@ -65,6 +67,14 @@ function AddVendorPage({route}: AddVendorPageProps) { updateDraftMerchantRule({vendorID: value}); }; + // While the prefetch is in flight, show a loading indicator instead of falling through to the NotFoundPage + // gate below. On a deep-link cold-load policy.connections is empty until the fetch lands, so hasVendorFeature + // would briefly return false and flash NotFoundPage before the picker appears. + if (isFetchNeeded || isLoadingFetchedFlag) { + const reasonAttributes: SkeletonSpanReasonAttributes = {context: 'AddVendorPage', isFetchNeeded, isLoadingFetchedFlag}; + return ; + } + // Gate direct/deeplink access behind the same predicate that hides the "Set vendor to" row, so the beta can't be // bypassed by opening this picker's URL directly (which would otherwise write vendorID into the draft and save it). if (!hasVendorFeature(policy, isBetaEnabled(CONST.BETAS.VENDOR_MATCHING))) { diff --git a/tests/unit/VendorMatchingMerchantRulesTest.ts b/tests/unit/VendorMatchingMerchantRulesTest.ts index b56dba50cbf0..5645fe9bd41f 100644 --- a/tests/unit/VendorMatchingMerchantRulesTest.ts +++ b/tests/unit/VendorMatchingMerchantRulesTest.ts @@ -44,6 +44,22 @@ const buildQBOPolicy = (vendors: Array<{id: string; name: string; currency: stri }), }); +/** + * QBO policy whose non-reimbursable export destination is Vendor Bill (not Credit Card), so QBO is no longer + * the active vendor-matching source even though its vendor list is still populated. Reproduces the state a + * workspace lands in after an admin switches export mode away from vendor-matching mode. + */ +const buildQBOWithVendorBillExportPolicy = (vendors: Array<{id: string; name: string; currency: string}>): Policy => + createMock({ + ...createRandomPolicy(0), + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL}, + data: {vendors}, + }, + }), + }); + /** Xero policy whose supplier list scopes vendor matching to Xero (label flips vendor -> supplier). */ const buildXeroPolicy = (contacts: Record): Policy => createMock({ @@ -146,6 +162,15 @@ describe('Vendor matching on merchant rules', () => { expect(description).not.toContain('Stale Xero Vendor'); }); + it('resolves the historical vendor name when the workspace has switched its export mode away from vendor-matching mode', () => { + // Reproduces the reviewer-flagged case: rule was authored while QBO's non-reimbursable export was Credit Card + // (vendor-matching active). Admin later switches to Vendor Bill, so QBO is no longer the active vendor-matching + // source. The rule summary must still render the vendor's name — not the raw external ID — because the vendor + // list is still known via the connection data. + const policy = withCodingRules(buildQBOWithVendorBillExportPolicy([{id: 'v-1', name: 'Acme Co', currency: 'USD'}]), {rule1: buildVendorRule('v-1')}); + expect(buildTableData(policy).at(0)?.ruleDescription).toContain('Update vendor to "Acme Co"'); + }); + it('uses "supplier" wording and "Supplier unavailable" on Xero workspaces', () => { const resolved = withCodingRules(buildXeroPolicy({xc1: {id: 'xc1', name: 'Acme Xero', email: 'acme@example.com'}}), {rule1: buildVendorRule('xc1')}); expect(buildTableData(resolved).at(0)?.ruleDescription).toContain('Update supplier to "Acme Xero"'); From 98bf7bf577e745d5b83fe8799a2c31a51c5362c1 Mon Sep 17 00:00:00 2001 From: "Alex Beaman (via MelvinBot)" Date: Mon, 10 Aug 2026 18:07:06 +0000 Subject: [PATCH 11/14] Fix knip unlisted finding: inline reasonAttributes literal in AddVendorPage Co-authored-by: Alex Beaman --- src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx b/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx index 836afb7897b9..fc9ee2c81ae2 100644 --- a/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx @@ -11,7 +11,6 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import {getMatchingVendorByID, getMatchingVendors, hasVendorFeature, isXeroActiveMatchingSource} from '@libs/PolicyUtils'; -import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; @@ -71,8 +70,7 @@ function AddVendorPage({route}: AddVendorPageProps) { // gate below. On a deep-link cold-load policy.connections is empty until the fetch lands, so hasVendorFeature // would briefly return false and flash NotFoundPage before the picker appears. if (isFetchNeeded || isLoadingFetchedFlag) { - const reasonAttributes: SkeletonSpanReasonAttributes = {context: 'AddVendorPage', isFetchNeeded, isLoadingFetchedFlag}; - return ; + return ; } // Gate direct/deeplink access behind the same predicate that hides the "Set vendor to" row, so the beta can't be From 7ddce3b63d926a62b9686ff183c781c797fe5095 Mon Sep 17 00:00:00 2001 From: "Alex Beaman (via MelvinBot)" Date: Mon, 10 Aug 2026 18:21:14 +0000 Subject: [PATCH 12/14] Fix typecheck: type reasonAttributes via ComponentProps instead of inline literal Co-authored-by: Alex Beaman --- src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx b/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx index fc9ee2c81ae2..3afbc41b0b43 100644 --- a/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx @@ -70,7 +70,8 @@ function AddVendorPage({route}: AddVendorPageProps) { // gate below. On a deep-link cold-load policy.connections is empty until the fetch lands, so hasVendorFeature // would briefly return false and flash NotFoundPage before the picker appears. if (isFetchNeeded || isLoadingFetchedFlag) { - return ; + const reasonAttributes: React.ComponentProps['reasonAttributes'] = {context: 'AddVendorPage', isFetchNeeded, isLoadingFetchedFlag}; + return ; } // Gate direct/deeplink access behind the same predicate that hides the "Set vendor to" row, so the beta can't be From d5281f28f22e5b5bd1275239ee7f719b125fe39c Mon Sep 17 00:00:00 2001 From: "Alex Beaman (via MelvinBot)" Date: Mon, 10 Aug 2026 18:33:57 +0000 Subject: [PATCH 13/14] Fix typecheck: drop removed reasonAttributes prop from FullScreenLoadingIndicator Co-authored-by: Alex Beaman --- src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx b/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx index 3afbc41b0b43..cc715721eda2 100644 --- a/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx @@ -70,8 +70,7 @@ function AddVendorPage({route}: AddVendorPageProps) { // gate below. On a deep-link cold-load policy.connections is empty until the fetch lands, so hasVendorFeature // would briefly return false and flash NotFoundPage before the picker appears. if (isFetchNeeded || isLoadingFetchedFlag) { - const reasonAttributes: React.ComponentProps['reasonAttributes'] = {context: 'AddVendorPage', isFetchNeeded, isLoadingFetchedFlag}; - return ; + return ; } // Gate direct/deeplink access behind the same predicate that hides the "Set vendor to" row, so the beta can't be From dea79027fcef02e39e1cc9257d7a74f790eaef53 Mon Sep 17 00:00:00 2001 From: Alex Beaman Date: Mon, 10 Aug 2026 23:12:59 -0700 Subject: [PATCH 14/14] Mirror three-tier vendor lookup in legacy MerchantRulesSection The revamp table (getMerchantCodingRulesTableData) was already updated so the rule summary keeps rendering the stored vendor's name after an admin switches the workspace's non-reimbursable export mode away from vendor-matching mode. The legacy pre-revamp path in MerchantRulesSection (rendered whenever the RULES_REVAMP beta is off) still had the two-tier lookup and fell through to the raw external ID in the same state. Add findVendorByID as a tier-3 permissive fallback so both summaries stay consistent, keeping the raw external ID only as a last resort while connection data hasn't hydrated. Export getRuleDescription so the same regression tests we added for the revamp table can run against the legacy path. --- .../workspace/rules/MerchantRulesSection.tsx | 20 ++++--- tests/unit/VendorMatchingMerchantRulesTest.ts | 54 ++++++++++++++++++- 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/src/pages/workspace/rules/MerchantRulesSection.tsx b/src/pages/workspace/rules/MerchantRulesSection.tsx index e4db5e3acd8b..730a3fde7b87 100644 --- a/src/pages/workspace/rules/MerchantRulesSection.tsx +++ b/src/pages/workspace/rules/MerchantRulesSection.tsx @@ -17,7 +17,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {getDecodedCategoryName} from '@libs/CategoryUtils'; import Navigation from '@libs/Navigation/Navigation'; import Parser from '@libs/Parser'; -import {getCommaSeparatedTagNameWithSanitizedColons, getMatchingVendorByID, isMatchingVendorListLoaded, isXeroActiveMatchingSource} from '@libs/PolicyUtils'; +import {findVendorByID, getCommaSeparatedTagNameWithSanitizedColons, getMatchingVendorByID, isMatchingVendorListLoaded, isXeroActiveMatchingSource} from '@libs/PolicyUtils'; import tokenizedSearch from '@libs/tokenizedSearch'; import variables from '@styles/variables'; @@ -70,15 +70,22 @@ function getRuleDescription(rule: CodingRule, translate: ReturnType { }); }); + describe('legacy MerchantRulesSection.getRuleDescription vendor summary', () => { + beforeEach(() => { + IntlStore.load(CONST.LOCALES.EN); + return waitForBatchedUpdates(); + }); + + const buildLabels = (policy: Policy) => ({ + category: translateLocal('common.category').toLowerCase(), + tag: translateLocal('common.tag').toLowerCase(), + description: translateLocal('common.description').toLowerCase(), + tax: translateLocal('common.tax').toLowerCase(), + vendor: translateLocal(isXeroActiveMatchingSource(policy) ? 'common.supplier' : 'common.vendor').toLowerCase(), + }); + + const describeRule = (policy: Policy, vendorID: string) => getRuleDescription(buildVendorRule(vendorID), translateLocal, buildLabels(policy), policy); + + it('resolves the vendor name when the vendor is in the loaded list', () => { + const policy = buildQBOPolicy([{id: 'v-1', name: 'Acme Co', currency: 'USD'}]); + expect(describeRule(policy, 'v-1')).toContain('Update vendor to "Acme Co"'); + }); + + it('shows "Vendor unavailable" when the list is loaded but the vendor is missing', () => { + expect(describeRule(buildQBOPolicy([]), 'v-1')).toContain('Update vendor to "Vendor unavailable"'); + }); + + it('falls back to the raw external ID while the list is not yet loaded and no other connection knows the vendor', () => { + expect(describeRule(buildQBOPolicy(undefined), 'v-1')).toContain('Update vendor to "v-1"'); + }); + + it('resolves the historical vendor name when the workspace has switched its export mode away from vendor-matching mode', () => { + const policy = buildQBOWithVendorBillExportPolicy([{id: 'v-1', name: 'Acme Co', currency: 'USD'}]); + expect(describeRule(policy, 'v-1')).toContain('Update vendor to "Acme Co"'); + }); + + it('shows "Vendor unavailable" when the vendorID only resolves against a stale/inactive connection', () => { + const policy = buildQBOWithStaleXeroPolicy([], {xeroVendor: {id: 'xeroVendor', name: 'Stale Xero Vendor', email: 'stale@example.com'}}); + const description = describeRule(policy, 'xeroVendor'); + expect(description).toContain('Update vendor to "Vendor unavailable"'); + expect(description).not.toContain('Stale Xero Vendor'); + }); + + it('uses "supplier" wording and "Supplier unavailable" on Xero workspaces', () => { + const resolved = buildXeroPolicy({xc1: {id: 'xc1', name: 'Acme Xero', email: 'acme@example.com'}}); + expect(describeRule(resolved, 'xc1')).toContain('Update supplier to "Acme Xero"'); + + const missing = buildXeroPolicy({}); + expect(describeRule(missing, 'xc1')).toContain('Update supplier to "Supplier unavailable"'); + }); + }); + describe('vendor row gating (hasVendorFeature governs MerchantRulePageBase 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);