diff --git a/cspell.json b/cspell.json index 280aaee25aaa..fa5375f46970 100644 --- a/cspell.json +++ b/cspell.json @@ -150,6 +150,7 @@ "Gclid", "Geral", "gitlink", + "Globex", "Grantmaking", "Gsuite", "Générale", diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 972e947deed2..ceec201896c3 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -3743,6 +3743,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 c43f60cd2071..d4512c3fa2aa 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -932,6 +932,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/de.ts b/src/languages/de.ts index 3281aaa00ed7..b81c0845aff8 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7665,6 +7665,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 105d957d52d6..c64e046b0181 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -7987,6 +7987,8 @@ ${reportName}`, importColumnUpdatedCategory: 'Ενημερωμένη κατηγορία', importColumnUpdatedTag: 'Ενημερωμένη ετικέτα', importColumnUpdatedDescription: 'Ενημερωμένη περιγραφή', + vendorUnavailable: 'Ο προμηθευτής δεν είναι διαθέσιμος', + supplierUnavailable: 'Ο προμηθευτής δεν είναι διαθέσιμος', }, newRule: { title: 'Νέος κανόνας', diff --git a/src/languages/en.ts b/src/languages/en.ts index bacd59966dfe..a90ddbd5d214 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7893,6 +7893,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 fe6e1dcc4204..b283d7ef7679 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7623,6 +7623,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/languages/fr.ts b/src/languages/fr.ts index 9d30a5d6a587..2ecb4a92479f 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7690,6 +7690,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 0e3e2e920188..16a6cf6416c3 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7633,6 +7633,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 a291c45650c0..64f0cd8aa5b3 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7545,6 +7545,8 @@ ${reportName}`, expenseDefaultsSubtitle: '申請者が何も操作しなくてもフィールドを更新する', ifAnyExpenseMatches: 'いずれかの経費が次の条件に一致する場合:', thenApplyFollowingDefaults: '次に、以下のデフォルトを適用します。', + vendorUnavailable: 'ベンダーを利用できません', + supplierUnavailable: 'サプライヤーを利用できません', }, categoryRules: { title: 'カテゴリルール', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 1eb78e138044..4de5c929f615 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7617,6 +7617,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 f96423b398b3..4ab1e1bd8fb4 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7596,6 +7596,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 c2fb6319c1b9..06b811b0deb2 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7614,6 +7614,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 74fbf042a4d6..7a9edc11d986 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7360,6 +7360,8 @@ ${reportName}`, expenseDefaultsSubtitle: '在提交人无须执行任何操作的情况下更新字段', ifAnyExpenseMatches: '如果任一报销符合以下条件:', thenApplyFollowingDefaults: '然后应用以下默认设置:', + vendorUnavailable: '供应商不可用', + supplierUnavailable: '供应商不可用', }, categoryRules: { title: '类别规则', diff --git a/src/libs/MerchantTypeRulesUtils.ts b/src/libs/MerchantTypeRulesUtils.ts index d376b628b7c7..d999d4b129c7 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'; @@ -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, getMatchingVendorByID, 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,26 @@ 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) { + // 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 (activeVendorName) { + vendorValue = activeVendorName; + } else if (isMatchingVendorListLoaded(policy)) { + vendorValue = translate(isOnXero ? 'workspace.rules.merchantRules.supplierUnavailable' : 'workspace.rules.merchantRules.vendorUnavailable'); + } else { + vendorValue = findVendorByID(policy, rule.vendorID)?.name ?? 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 +221,12 @@ 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 77b6f137556b..5b11aa34bfe5 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -1091,6 +1091,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 6445f304b37a..acc843373263 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts @@ -429,6 +429,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 02d9429caba4..dca2a4cb6603 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1748,6 +1748,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..cc715721eda2 --- /dev/null +++ b/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx @@ -0,0 +1,99 @@ +import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +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'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import {getMatchingVendorByID, 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'; +import type {Policy} from '@src/types/onyx'; + +import React 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, 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: getMatchingVendorByID(policy, vendorID)?.name ?? vendorID, value: vendorID} : undefined; +} + +function AddVendorPage({route}: AddVendorPageProps) { + const {policyID, ruleID} = route.params; + const isEditing = ruleID !== ROUTES.NEW; + + const policy = usePolicy(policyID); + 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. + const {isFetchNeeded, isLoadingFetchedFlag} = usePolicyConnectionsPrefetch(policy, isBetaEnabled(CONST.BETAS.VENDOR_MATCHING)); + + const selectedVendorItem = getSelectedVendorItem(policy, form?.vendorID); + + const vendorItems = getVendorSelectionItems(policy); + + const backToRoute = isEditing ? ROUTES.RULES_MERCHANT_EDIT.getRoute(policyID, ruleID) : ROUTES.RULES_MERCHANT_NEW.getRoute(policyID); + + const saveVendor = (value?: string) => { + 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) { + 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))) { + return ; + } + + return ( + Navigation.goBack(backToRoute)} + > + + + ); +} + +export default AddVendorPage; +export {getSelectedVendorItem, getVendorSelectionItems}; diff --git a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx index 5cd165aa521c..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'; @@ -30,7 +31,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 {getCleanedTagName, getMatchingVendorByID, getTagLists, hasVendorFeature, isMatchingVendorListLoaded, isXeroActiveMatchingSource} from '@libs/PolicyUtils'; import {getEnabledTags} from '@libs/TagsOptionsListUtils'; import {getTagArrayFromName} from '@libs/TransactionUtils'; @@ -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; @@ -154,6 +162,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 +223,28 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, const isBillableEnabled = policy?.disabledFields?.defaultBillable !== true; + const isVendorFeatureEnabled = hasVendorFeature(policy, isBetaEnabled(CONST.BETAS.VENDOR_MATCHING)); + const isOnXero = isXeroActiveMatchingSource(policy); + 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 = getMatchingVendorByID(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 = () => { if (!form?.tax || !policy?.taxRates?.taxes) { @@ -394,6 +425,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/pages/workspace/rules/MerchantRulesSection.tsx b/src/pages/workspace/rules/MerchantRulesSection.tsx index 9f4f60aab95d..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} from '@libs/PolicyUtils'; +import {findVendorByID, getCommaSeparatedTagNameWithSanitizedColons, getMatchingVendorByID, 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,26 @@ function getRuleDescription(rule: CodingRule, translate: ReturnType @@ -215,3 +238,4 @@ function MerchantRulesSection({policyID, canWriteRules, showReadOnlyModal}: Merc MerchantRulesSection.displayName = 'MerchantRulesSection'; export default MerchantRulesSection; +export {getRuleDescription}; 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 9396176a1eee..2a1c1a9f0f24 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -2756,6 +2756,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/AddVendorPageTest.ts b/tests/unit/AddVendorPageTest.ts new file mode 100644 index 000000000000..eec2120de6c5 --- /dev/null +++ b/tests/unit/AddVendorPageTest.ts @@ -0,0 +1,140 @@ +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}, + }, + }), + }); + +/** Dual-connected policy: QBO is the active vendor-matching source, with a stale Xero connection lingering. */ +const buildQBOWithStaleXeroPolicy = (qboVendors: Array<{id: string; name: string; currency: string}>, 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)', () => { + 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(); + }); + + 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'}); + }); + }); + + /** + * 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(); + }); + }); +}); diff --git a/tests/unit/VendorMatchingMerchantRulesTest.ts b/tests/unit/VendorMatchingMerchantRulesTest.ts new file mode 100644 index 000000000000..0dcae227eaab --- /dev/null +++ b/tests/unit/VendorMatchingMerchantRulesTest.ts @@ -0,0 +1,248 @@ +import {mapFormFieldsToRuleForAPI, mapFormFieldsToRuleForOnyx} from '@libs/actions/Policy/Rules'; +import {getMerchantCodingRulesTableData} from '@libs/MerchantTypeRulesUtils'; +import {hasVendorFeature, isXeroActiveMatchingSource} from '@libs/PolicyUtils'; + +import {getRuleDescription} from '@pages/workspace/rules/MerchantRulesSection'; + +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}, + }, + }), + }); + +/** + * 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({ + ...createRandomPolicy(0), + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.XERO]: { + config: {isConfigured: true}, + data: {contacts}, + }, + }), + }); + +/** + * Dual-connected policy: QBO is the active vendor-matching source (credit-card export), but a stale Xero + * connection lingers with its own contacts. Used to prove rule surfaces resolve against the active list only. + */ +const buildQBOWithStaleXeroPolicy = (qboVendors: Array<{id: string; name: string; currency: string}>, 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 => ({ + 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('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('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"'); + + const missing = withCodingRules(buildXeroPolicy({}), {rule1: buildVendorRule('xc1')}); + expect(buildTableData(missing).at(0)?.ruleDescription).toContain('Update supplier to "Supplier unavailable"'); + }); + }); + + 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); + }); + + 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); + }); + }); +});