From d458b5c8c91720c9ee12e5e03d59dde864aeaed4 Mon Sep 17 00:00:00 2001 From: Alex Beaman Date: Fri, 19 Jun 2026 13:49:00 -0600 Subject: [PATCH 01/14] Vendor matching CC - R4: App (Xero extension) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the R1 vendor-matching pipeline to Xero workspaces, labelled "Supplier" in the UI per Xero's terminology. Zero new primitives — reuses R1's IOURequestStepVendor RHP, updateMoneyRequestVendor action, BETAS gate, and inactiveVendor violation client-compute. Foundations: defaultContact on XeroConnectionConfig + contacts on XeroConnectionData (Record); hasVendorFeature gains a Xero branch (no export-type gate — Xero has no destination enum); getXeroSuppliers / getXeroSupplierByID mirror the QBO helpers and normalize the keyed Record to the shared Vendor shape; ViolationsUtils inactiveVendor extended to look up Xero suppliers with a guardrail that short-circuits when connections.xero.data.contacts is undefined so we don't falsely flag transactions before Integration-Server has synced the supplier list for a workspace. Default-supplier row inserted on DynamicXeroExportConfigurationPage after "Xero bank account", wrapped in hasVendorFeature(policy) so non-beta admins don't see a dead control. Persists via the generic updatePolicyConnectionConfiguration (api.php:2941) — no dedicated command. Supplier label flip on the existing vendor picker (IOURequestStepVendor) and Vendor field row (MoneyRequestView + ModifiedExpenseMessage) when the workspace is on Xero. Picker data source branches to the keyed-Record contacts list for Xero. Translations: common.supplier + workspace.xero.{defaultSupplier, defaultSupplierDescription, noSuppliersFound, noSuppliersFoundDescription} across all 10 supported locales. --- src/CONST/index.ts | 1 + src/ROUTES.ts | 4 + src/SCREENS.ts | 1 + .../ReportActionItem/MoneyRequestView.tsx | 8 +- src/languages/de.ts | 5 + src/languages/en.ts | 5 + src/languages/es.ts | 5 + src/languages/fr.ts | 5 + src/languages/it.ts | 5 + src/languages/ja.ts | 5 + src/languages/nl.ts | 5 + src/languages/pl.ts | 5 + src/languages/pt-BR.ts | 5 + src/languages/zh-hans.ts | 5 + src/libs/ModifiedExpenseMessage.ts | 13 +-- .../ModalStackNavigators/index.tsx | 2 + .../RELATIONS/WORKSPACE_TO_RHP.ts | 1 + src/libs/Navigation/linkingConfig/config.ts | 2 + src/libs/Navigation/types.ts | 3 + src/libs/PolicyUtils.ts | 84 +++++++++++++++-- src/libs/Violations/ViolationsUtils.ts | 22 ++++- .../iou/request/step/IOURequestStepVendor.tsx | 14 +-- .../DynamicXeroExportConfigurationPage.tsx | 18 +++- ...onReimbursableDefaultContactSelectPage.tsx | 91 +++++++++++++++++++ src/types/onyx/Policy.ts | 19 ++++ 25 files changed, 301 insertions(+), 32 deletions(-) create mode 100644 src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 3d3410e0d8f9..6006aac9a793 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2983,6 +2983,7 @@ const CONST = { }, ACCOUNTING_METHOD: 'accountingMethod', TRAVEL_INVOICING_PAYABLE_ACCOUNT: 'travelInvoicingPayableAccountID', + DEFAULT_CONTACT: 'defaultContact', }, SAGE_INTACCT_MAPPING_VALUE: { diff --git a/src/ROUTES.ts b/src/ROUTES.ts index cebee2179f7b..17bd944925f5 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -397,6 +397,10 @@ const DYNAMIC_ROUTES = { path: 'bank-account-select', entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_EXPORT], }, + POLICY_ACCOUNTING_XERO_NON_REIMBURSABLE_DEFAULT_CONTACT_SELECT: { + path: 'default-supplier-select', + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_EXPORT], + }, POLICY_ACCOUNTING_XERO_BILL_STATUS_SELECTOR: { path: 'purchase-bill-status-selector', entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_EXPORT], diff --git a/src/SCREENS.ts b/src/SCREENS.ts index a87ec988f59f..ca242f4ed0e4 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -625,6 +625,7 @@ const SCREENS = { DYNAMIC_XERO_EXPORT_PREFERRED_EXPORTER_SELECT: 'Dynamic_Workspace_Accounting_Xero_Export_Preferred_Exporter_Select', XERO_BILL_PAYMENT_ACCOUNT_SELECTOR: 'Policy_Accounting_Xero_Bill_Payment_Account_Selector', DYNAMIC_XERO_EXPORT_BANK_ACCOUNT_SELECT: 'Dynamic_Policy_Accounting_Xero_Export_Bank_Account_Select', + DYNAMIC_XERO_NON_REIMBURSABLE_DEFAULT_CONTACT_SELECT: 'Dynamic_Policy_Accounting_Xero_Non_Reimbursable_Default_Contact_Select', NETSUITE_IMPORT_MAPPING: 'Policy_Accounting_NetSuite_Import_Mapping', NETSUITE_IMPORT_CUSTOM_FIELD: 'Policy_Accounting_NetSuite_Import_Custom_Field', NETSUITE_IMPORT_CUSTOM_FIELD_VIEW: 'Policy_Accounting_NetSuite_Import_Custom_Field_View', diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 286f24a40405..cb8989833c24 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -64,7 +64,8 @@ import { getLengthOfTag, getPerDiemCustomUnit, getPolicyByCustomUnitID, - getQBOVendorByID, + getMatchingVendorName, + isXeroVendorMatchingActive, getTagLists, hasDependentTags as hasDependentTagsPolicyUtils, hasVendorFeature, @@ -471,8 +472,9 @@ function MoneyRequestView({ const shouldShowAttendees = shouldShowAttendeesTransactionUtils(iouType, policy); const transactionVendor = transaction?.comment?.vendor; - const transactionVendorName = getQBOVendorByID(policy, transactionVendor?.externalID)?.name ?? ''; + const transactionVendorName = getMatchingVendorName(policy, transactionVendor?.externalID); const shouldShowVendor = hasVendorFeature(policy, isBetaEnabled(CONST.BETAS.VENDOR_MATCHING)) && !(updatedTransaction?.reimbursable ?? !!transactionReimbursable) && !isInvoice; + const vendorFieldLabel = isXeroVendorMatchingActive(policy) ? translate('common.supplier') : translate('common.vendor'); const tripID = getTripIDFromTransactionParentReportID(parentReport?.parentReportID); const shouldShowViewTripDetails = hasReservationList(transaction) && !!tripID; @@ -1263,7 +1265,7 @@ function MoneyRequestView({ {shouldShowVendor && ( = { change: 'Ändern', category: 'Kategorie', vendor: 'Anbieter', + supplier: 'Lieferant', report: 'Bericht', billable: 'Abrechenbar', nonBillable: 'Nicht abrechenbar', @@ -4904,6 +4905,10 @@ ${amount} für ${merchant} – ${date}`, }, noAccountsFound: 'Keine Konten gefunden', noAccountsFoundDescription: 'Bitte fügen Sie das Konto in Xero hinzu und synchronisieren Sie die Verbindung erneut', + defaultSupplier: 'Standardlieferant', + defaultSupplierDescription: 'Legen Sie einen Standardlieferanten fest, der beim Export auf alle Kreditkartentransaktionen angewendet wird.', + noSuppliersFound: 'Keine Lieferanten gefunden', + noSuppliersFoundDescription: 'Bitte fügen Sie den Lieferanten in Xero hinzu und synchronisieren Sie die Verbindung erneut.', accountingMethods: { label: 'Wann exportieren', description: 'Wähle aus, wann die Ausgaben exportiert werden sollen:', diff --git a/src/languages/en.ts b/src/languages/en.ts index a602e6238653..5450bdff6b69 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -334,6 +334,7 @@ const translations = { change: 'Change', category: 'Category', vendor: 'Vendor', + supplier: 'Supplier', report: 'Report', billable: 'Billable', nonBillable: 'Non-billable', @@ -4997,6 +4998,10 @@ const translations = { }, noAccountsFound: 'No accounts found', noAccountsFoundDescription: 'Please add the account in Xero and sync the connection again', + defaultSupplier: 'Default supplier', + defaultSupplierDescription: 'Set a default supplier that will apply to all credit card transactions upon export.', + noSuppliersFound: 'No suppliers found', + noSuppliersFoundDescription: 'Please add the supplier in Xero and sync the connection again.', accountingMethods: { label: 'When to Export', description: 'Choose when to export the expenses:', diff --git a/src/languages/es.ts b/src/languages/es.ts index 15087b043c81..6d108cdfd803 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -272,6 +272,7 @@ const translations: TranslationDeepObject = { change: 'Cambio', category: 'Categoría', vendor: 'Proveedor', + supplier: 'Proveedor', report: 'Informe', billable: 'Facturable', nonBillable: 'No facturable', @@ -4785,6 +4786,10 @@ ${amount} para ${merchant} - ${date}`, }, noAccountsFound: 'No se ha encontrado ninguna cuenta', noAccountsFoundDescription: 'Añade la cuenta en Xero y sincroniza de nuevo la conexión', + defaultSupplier: 'Proveedor predeterminado', + defaultSupplierDescription: 'Configura un proveedor predeterminado que se aplicará a todas las transacciones de tarjeta de crédito al exportar.', + noSuppliersFound: 'No se han encontrado proveedores', + noSuppliersFoundDescription: 'Añade el proveedor en Xero y sincroniza de nuevo la conexión.', accountingMethods: { label: 'Cuándo Exportar', description: 'Elige cuándo exportar los gastos:', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 8e700e34026c..b552bb41c00f 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -317,6 +317,7 @@ const translations: TranslationDeepObject = { change: 'Modifier', category: 'Catégorie', vendor: 'Fournisseur', + supplier: 'Fournisseur', report: 'Note de frais', billable: 'Facturable', nonBillable: 'Non refacturable', @@ -4916,6 +4917,10 @@ ${amount} pour ${merchant} - ${date}`, }, noAccountsFound: 'Aucun compte trouvé', noAccountsFoundDescription: 'Veuillez ajouter le compte dans Xero et synchroniser à nouveau la connexion', + defaultSupplier: 'Fournisseur par défaut', + defaultSupplierDescription: 'Définissez un fournisseur par défaut qui sera appliqué à toutes les transactions par carte de crédit lors de l’exportation.', + noSuppliersFound: 'Aucun fournisseur trouvé', + noSuppliersFoundDescription: 'Veuillez ajouter le fournisseur dans Xero et synchroniser à nouveau la connexion.', accountingMethods: { label: 'Quand exporter', description: 'Choisissez quand exporter les dépenses :', diff --git a/src/languages/it.ts b/src/languages/it.ts index fc1658236d3f..650d2f347234 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -317,6 +317,7 @@ const translations: TranslationDeepObject = { change: 'Modifica', category: 'Categoria', vendor: 'Fornitore', + supplier: 'Fornitore', report: 'Report', billable: 'Fatturabile', nonBillable: 'Non fatturabile', @@ -4889,6 +4890,10 @@ ${amount} per ${merchant} - ${date}`, }, noAccountsFound: 'Nessun account trovato', noAccountsFoundDescription: 'Aggiungi l’account in Xero e sincronizza nuovamente la connessione', + defaultSupplier: 'Fornitore predefinito', + defaultSupplierDescription: 'Imposta un fornitore predefinito che verrà applicato a tutte le transazioni con carta di credito durante l’esportazione.', + noSuppliersFound: 'Nessun fornitore trovato', + noSuppliersFoundDescription: 'Aggiungi il fornitore in Xero e sincronizza nuovamente la connessione.', accountingMethods: { label: 'Quando esportare', description: 'Scegli quando esportare le spese:', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 1b06b1fc90eb..274dcd12572b 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -317,6 +317,7 @@ const translations: TranslationDeepObject = { change: '変更', category: 'カテゴリ', vendor: 'ベンダー', + supplier: 'サプライヤー', report: 'レポート', billable: '請求可能', nonBillable: '請求不可', @@ -4849,6 +4850,10 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' }, noAccountsFound: 'アカウントが見つかりません', noAccountsFoundDescription: 'Xero にアカウントを追加して、もう一度同期してください', + defaultSupplier: 'デフォルトのサプライヤー', + defaultSupplierDescription: 'エクスポート時にすべてのクレジットカード取引に適用されるデフォルトのサプライヤーを設定します。', + noSuppliersFound: 'サプライヤーが見つかりません', + noSuppliersFoundDescription: 'Xero にサプライヤーを追加して、もう一度同期してください。', accountingMethods: { label: 'エクスポートのタイミング', description: '経費をエクスポートするタイミングを選択:', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 17a2fa7d61d0..4941c5edbd5a 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -317,6 +317,7 @@ const translations: TranslationDeepObject = { change: 'Wijzigen', category: 'Categorie', vendor: 'Leverancier', + supplier: 'Leverancier', report: 'Rapport', billable: 'Factureerbaar', nonBillable: 'Niet-factureerbaar', @@ -4879,6 +4880,10 @@ ${amount} voor ${merchant} - ${date}`, }, noAccountsFound: 'Geen accounts gevonden', noAccountsFoundDescription: 'Voeg het account toe in Xero en synchroniseer de verbinding opnieuw', + defaultSupplier: 'Standaardleverancier', + defaultSupplierDescription: 'Stel een standaardleverancier in die wordt toegepast op alle creditcardtransacties bij het exporteren.', + noSuppliersFound: 'Geen leveranciers gevonden', + noSuppliersFoundDescription: 'Voeg de leverancier toe in Xero en synchroniseer de verbinding opnieuw.', accountingMethods: { label: 'Wanneer exporteren', description: 'Kies wanneer de onkosten moeten worden geëxporteerd:', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 81c400582abb..dc4ef1d646aa 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -317,6 +317,7 @@ const translations: TranslationDeepObject = { change: 'Zmień', category: 'Kategoria', vendor: 'Dostawca', + supplier: 'Dostawca', report: 'Raport', billable: 'Fakturowalne', nonBillable: 'Nierozliczalne', @@ -4872,6 +4873,10 @@ ${amount} dla ${merchant} - ${date}`, }, noAccountsFound: 'Nie znaleziono kont', noAccountsFoundDescription: 'Dodaj proszę konto w Xero i zsynchronizuj połączenie ponownie', + defaultSupplier: 'Domyślny dostawca', + defaultSupplierDescription: 'Ustaw domyślnego dostawcę, który zostanie zastosowany do wszystkich transakcji kartą kredytową podczas eksportu.', + noSuppliersFound: 'Nie znaleziono dostawców', + noSuppliersFoundDescription: 'Dodaj dostawcę w Xero i zsynchronizuj połączenie ponownie.', accountingMethods: { label: 'Kiedy eksportować', description: 'Wybierz, kiedy eksportować wydatki:', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 13ae04229d98..956e9d716e8b 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -317,6 +317,7 @@ const translations: TranslationDeepObject = { change: 'Alterar', category: 'Categoria', vendor: 'Fornecedor', + supplier: 'Fornecedor', report: 'Relatório', billable: 'Faturável', nonBillable: 'Não faturável', @@ -4871,6 +4872,10 @@ ${amount} para ${merchant} - ${date}`, }, noAccountsFound: 'Nenhuma conta encontrada', noAccountsFoundDescription: 'Adicione a conta no Xero e sincronize a conexão novamente', + defaultSupplier: 'Fornecedor padrão', + defaultSupplierDescription: 'Defina um fornecedor padrão que será aplicado a todas as transações de cartão de crédito ao exportar.', + noSuppliersFound: 'Nenhum fornecedor encontrado', + noSuppliersFoundDescription: 'Adicione o fornecedor no Xero e sincronize a conexão novamente.', accountingMethods: { label: 'Quando Exportar', description: 'Escolha quando exportar as despesas:', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 84b6e80ebaa6..0add2750351b 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -317,6 +317,7 @@ const translations: TranslationDeepObject = { change: '更改', category: '类别', vendor: '供应商', + supplier: '供应商', report: '报表', billable: '可计费', nonBillable: '不可计费', @@ -4753,6 +4754,10 @@ ${amount},商户:${merchant} - 日期:${date}`, }, noAccountsFound: '未找到账户', noAccountsFoundDescription: '请在 Xero 中添加该账户,然后再次同步连接', + defaultSupplier: '默认供应商', + defaultSupplierDescription: '设置一个默认供应商,在导出时将其应用于所有信用卡交易。', + noSuppliersFound: '未找到供应商', + noSuppliersFoundDescription: '请在 Xero 中添加供应商,然后再次同步连接。', accountingMethods: { label: '何时导出', description: '选择何时导出报销:', diff --git a/src/libs/ModifiedExpenseMessage.ts b/src/libs/ModifiedExpenseMessage.ts index ee7ac77b3720..45b40e4ed769 100644 --- a/src/libs/ModifiedExpenseMessage.ts +++ b/src/libs/ModifiedExpenseMessage.ts @@ -15,7 +15,7 @@ import {formatList} from './Localize'; import Log from './Log'; import Parser from './Parser'; import {getPersonalDetailByEmail} from './PersonalDetailsUtils'; -import {getCleanedTagName, getCommaSeparatedTagNameWithSanitizedColons, getQBOVendorByID, getSortedTagKeys, isPolicyAdmin} from './PolicyUtils'; +import {getCleanedTagName, getCommaSeparatedTagNameWithSanitizedColons, getMatchingVendorName, getSortedTagKeys, isPolicyAdmin, isXeroVendorMatchingActive} from './PolicyUtils'; import {getOriginalMessage, isModifiedExpenseAction} from './ReportActionsUtils'; // This cycle import is safe because ReportNameUtils was extracted from ReportUtils to separate report name computation logic. // The functions imported here are pure utility functions that don't create initialization-time dependencies. @@ -457,20 +457,21 @@ function getForReportAction({ const hasModifiedVendor = isReportActionOriginalMessageAnObject && ('oldVendor' in reportActionOriginalMessage || 'vendor' in reportActionOriginalMessage); if (hasModifiedVendor) { // Vendor is stored on the action as `{externalID, isManuallySet}` (or absent/null). Resolve - // the display name from the policy's QBO vendor list; if the vendor has since been removed - // from QBO the name is unrecoverable, so fall back to the externalID so the fragment still - // identifies which vendor was set rather than rendering `set vendor ""`. + // the display name from the active integration's vendor list (QBO vendors or Xero + // suppliers); if the vendor has since been removed from the source integration the name is + // unrecoverable, so fall back to the externalID so the fragment still identifies which + // vendor was set rather than rendering `set vendor ""`. const resolveVendorName = (entry: typeof reportActionOriginalMessage.vendor): string => { if (!entry?.externalID) { return ''; } - return getQBOVendorByID(policy, entry.externalID)?.name ?? entry.externalID; + return getMatchingVendorName(policy, entry.externalID) || entry.externalID; }; buildMessageFragmentForValue( translate, resolveVendorName(reportActionOriginalMessage?.vendor), resolveVendorName(reportActionOriginalMessage?.oldVendor), - translate('common.vendor'), + isXeroVendorMatchingActive(policy) ? translate('common.supplier') : translate('common.vendor'), true, setFragments, removalFragments, diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 25ea6b9eec5a..7f5c1b3d5b77 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -783,6 +783,8 @@ const SettingsModalStackNavigator = createModalStackNavigator('../../../../pages/workspace/accounting/xero/export/XeroTravelInvoicingPayableAccountSelectPage').default, [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_EXPORT_BANK_ACCOUNT_SELECT]: () => require('../../../../pages/workspace/accounting/xero/export/DynamicXeroBankAccountSelectPage').default, + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_NON_REIMBURSABLE_DEFAULT_CONTACT_SELECT]: () => + require('../../../../pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage').default, [SCREENS.WORKSPACE.ACCOUNTING.XERO_ADVANCED]: () => require('../../../../pages/workspace/accounting/xero/advanced/XeroAdvancedPage').default, [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_AUTO_SYNC]: () => require('../../../../pages/workspace/accounting/xero/advanced/DynamicXeroAutoSyncPage').default, [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_ACCOUNTING_METHOD]: () => diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts index 3411b25a6db9..3839b5fc87e3 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts @@ -112,6 +112,7 @@ const WORKSPACE_TO_RHP: Partial['config'] = { [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_EXPORT]: DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_EXPORT.path, [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_EXPORT_PURCHASE_BILL_DATE_SELECT]: DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_EXPORT_PURCHASE_BILL_DATE_SELECT.path, [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_EXPORT_BANK_ACCOUNT_SELECT]: DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_EXPORT_BANK_ACCOUNT_SELECT.path, + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_NON_REIMBURSABLE_DEFAULT_CONTACT_SELECT]: + DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_NON_REIMBURSABLE_DEFAULT_CONTACT_SELECT.path, [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_TRAVEL_INVOICING_CONFIGURATION]: DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_TRAVEL_INVOICING_CONFIGURATION.path, [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_TRAVEL_INVOICING_PAYABLE_ACCOUNT_SELECT]: DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_TRAVEL_INVOICING_PAYABLE_ACCOUNT_SELECT.path, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 2c49e16df4bf..850de96108e6 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -924,6 +924,9 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_EXPORT_BANK_ACCOUNT_SELECT]: { policyID: string; }; + [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_NON_REIMBURSABLE_DEFAULT_CONTACT_SELECT]: { + policyID: string; + }; [SCREENS.WORKSPACE.ACCOUNTING.XERO_INVOICE_ACCOUNT_SELECTOR]: { policyID: string; }; diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 133d1ddcc101..980b8e1d72a2 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -2120,21 +2120,34 @@ function getConnectedIntegration(policy: Policy | undefined, connectionNames: re } /** - * QBO vendor feature gate. Returns true when the workspace has the `vendorMatching` beta enabled - * AND QBO is connected with an individual card transaction non-reimbursable export type — the only - * scope the Vendor field is shown for. Mirrors `QuickbooksOnline::hasVendorFeature` on the PHP side - * so the App and backend agree on which workspaces see the field. + * True when QBO is the connected integration scoping the vendor field — i.e. non-reimbursable + * export is set to Credit Card or Debit Card. + */ +function isQBOVendorMatchingActive(policy: OnyxEntry): boolean { + const destination = policy?.connections?.[CONST.POLICY.CONNECTIONS.NAME.QBO]?.config?.nonReimbursableExpensesExportDestination; + return destination === CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD || destination === CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD; +} + +/** + * True when Xero is the connected integration scoping the vendor field. Xero has no + * export-destination enum (bank-transactions is the only non-reimbursable mode), so the connection + * being present is sufficient — mirrors `Xero::hasVendorFeature` on the PHP side. + */ +function isXeroVendorMatchingActive(policy: OnyxEntry): boolean { + return !!policy?.connections?.[CONST.POLICY.CONNECTIONS.NAME.XERO]; +} + +/** + * Vendor feature gate. Returns true when the workspace has the `vendorMatching` beta enabled AND a + * supported accounting integration is connected with a configuration that scopes the vendor field. + * Mirrors the per-integration `hasVendorFeature` checks on the PHP side so the App and backend + * agree on which workspaces see the field. */ function hasVendorFeature(policy: OnyxEntry, isVendorMatchingBetaEnabled: boolean): boolean { if (!isVendorMatchingBetaEnabled || !policy) { return false; } - const qboConnection = policy.connections?.[CONST.POLICY.CONNECTIONS.NAME.QBO]; - if (!qboConnection) { - return false; - } - const exportDestination = qboConnection.config?.nonReimbursableExpensesExportDestination; - return exportDestination === CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD || exportDestination === CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD; + return isQBOVendorMatchingActive(policy) || isXeroVendorMatchingActive(policy); } /** @@ -2158,6 +2171,52 @@ function getQBOVendorByID(policy: OnyxEntry, vendorID: string | undefine return getQBOVendors(policy).find((vendor) => vendor.id === vendorID); } +/** + * Returns the Xero supplier list imported into the workspace, normalized to the shared `Vendor` + * shape (id + name). Xero persists suppliers as a keyed object at + * `connections.xero.data.contacts`; empty array when Xero isn't connected or Integration-Server + * hasn't synced suppliers for the workspace yet. + */ +function getXeroSuppliers(policy: OnyxEntry): Vendor[] { + const contacts = policy?.connections?.[CONST.POLICY.CONNECTIONS.NAME.XERO]?.data?.contacts; + if (!contacts) { + return []; + } + return Object.values(contacts).map((contact) => ({id: contact.id, name: contact.name, currency: '', email: contact.email})); +} + +/** + * Look up a single Xero supplier by `externalID`. Returns undefined when the ID isn't found + * (which happens after a supplier is removed from Xero — see the inactive-vendor violation) or + * when the contacts sync hasn't populated yet. + */ +function getXeroSupplierByID(policy: OnyxEntry, supplierID: string | undefined): Vendor | undefined { + if (!supplierID) { + return undefined; + } + const contact = policy?.connections?.[CONST.POLICY.CONNECTIONS.NAME.XERO]?.data?.contacts?.[supplierID]; + return contact ? {id: contact.id, name: contact.name, currency: '', email: contact.email} : undefined; +} + +/** + * Resolve the vendor name shown to admins/submitters across supported integrations. Prefers QBO's + * vendor list when QBO is connected (R1 default), falls back to the Xero supplier list when Xero + * is connected (R4). Returns an empty string when the vendor list hasn't populated or the ID isn't + * found. + */ +function getMatchingVendorName(policy: OnyxEntry, vendorID: string | undefined): string { + if (!vendorID) { + return ''; + } + if (policy?.connections?.[CONST.POLICY.CONNECTIONS.NAME.QBO]) { + return getQBOVendorByID(policy, vendorID)?.name ?? ''; + } + if (policy?.connections?.[CONST.POLICY.CONNECTIONS.NAME.XERO]) { + return getXeroSupplierByID(policy, vendorID)?.name ?? ''; + } + return ''; +} + function getValidConnectedIntegration(policy: Policy | undefined, connectionNames: readonly ConnectionName[] = getAccountingConnectionNames()) { return connectionNames.find((integration) => !!policy?.connections?.[integration] && !isConnectionUnverified(policy, integration)); } @@ -2573,6 +2632,11 @@ export { getConnectionExporters, getQBOVendorByID, getQBOVendors, + getXeroSupplierByID, + getXeroSuppliers, + getMatchingVendorName, + isXeroVendorMatchingActive, + isQBOVendorMatchingActive, hasVendorFeature, getValidConnectedIntegration, getCountOfEnabledTagsOfList, diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index e08c1324b99c..fa545ca90ec9 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -19,10 +19,12 @@ import { getPerDiemRateCustomUnitRate, getQBOVendorByID, getSortedTagKeys, + getXeroSupplierByID, hasVendorFeature, isAttendeeTrackingEnabled as isAttendeeTrackingEnabledForPolicy, isDefaultTagName, isTaxTrackingEnabled, + isXeroVendorMatchingActive, } from '@libs/PolicyUtils'; import {isCurrentUserSubmitter} from '@libs/ReportUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; @@ -477,11 +479,21 @@ const ViolationsUtils = { newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.INACTIVE_VENDOR}); } } else if (transactionVendorID) { - const matchedVendor = getQBOVendorByID(policy, transactionVendorID); - if (!matchedVendor && !hasInactiveVendorViolation) { - newTransactionViolations.push({name: CONST.VIOLATIONS.INACTIVE_VENDOR, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true}); - } else if (matchedVendor && hasInactiveVendorViolation) { - newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.INACTIVE_VENDOR}); + // For Xero workspaces, skip the violation check entirely until Integration-Server + // has synced the supplier list (data.contacts is undefined). Otherwise every + // matched transaction would falsely flag inactive between the beta flip and the + // first supplier sync for the workspace. + const isOnXero = isXeroVendorMatchingActive(policy); + const xeroContactsSynced = policy.connections?.[CONST.POLICY.CONNECTIONS.NAME.XERO]?.data?.contacts !== undefined; + if (isOnXero && !xeroContactsSynced) { + // No-op — supplier list not yet known for this workspace. + } else { + const matchedVendor = isOnXero ? getXeroSupplierByID(policy, transactionVendorID) : getQBOVendorByID(policy, transactionVendorID); + if (!matchedVendor && !hasInactiveVendorViolation) { + newTransactionViolations.push({name: CONST.VIOLATIONS.INACTIVE_VENDOR, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true}); + } else if (matchedVendor && hasInactiveVendorViolation) { + newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.INACTIVE_VENDOR}); + } } } else if (hasInactiveVendorViolation) { // Vendor was cleared while the feature is still active — drop the now-stale violation. diff --git a/src/pages/iou/request/step/IOURequestStepVendor.tsx b/src/pages/iou/request/step/IOURequestStepVendor.tsx index 8a8db9f95426..913e83b46841 100644 --- a/src/pages/iou/request/step/IOURequestStepVendor.tsx +++ b/src/pages/iou/request/step/IOURequestStepVendor.tsx @@ -14,7 +14,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {updateMoneyRequestVendor} from '@libs/actions/IOU/UpdateMoneyRequest'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Navigation from '@libs/Navigation/Navigation'; -import {getQBOVendors, hasVendorFeature} from '@libs/PolicyUtils'; +import {getQBOVendors, getXeroSuppliers, hasVendorFeature, isXeroVendorMatchingActive} from '@libs/PolicyUtils'; import {isPerDiemRequest} from '@libs/TransactionUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -56,12 +56,14 @@ function IOURequestStepVendor({ const delegateAccountID = useDelegateAccountID(); const isFeatureAvailable = hasVendorFeature(policy, isBetaEnabled(CONST.BETAS.VENDOR_MATCHING)); + const isOnXero = isXeroVendorMatchingActive(policy); - // Vendor is scoped to non-reimbursable expenses on a policy expense chat; block deep-link / stale-open access if the transaction is reimbursable or is an invoice (invoices are non-reimbursable but don't route through the QBO CC vendor-matching flow). + // Vendor is scoped to non-reimbursable expenses on a policy expense chat; block deep-link / stale-open access if the transaction is reimbursable or is an invoice (invoices are non-reimbursable but don't route through the vendor-matching flow). const isReimbursable = !!transaction?.reimbursable; const isInvoice = iouType === CONST.IOU.TYPE.INVOICE; - const vendors = getQBOVendors(policy); + const vendors = isOnXero ? getXeroSuppliers(policy) : getQBOVendors(policy); const currentVendorID = transaction?.comment?.vendor?.externalID; + const vendorLabel = isOnXero ? translate('common.supplier') : translate('common.vendor'); const trimmedSearch = searchValue.trim().toLowerCase(); const vendorRows: VendorListItem[] = vendors @@ -118,15 +120,15 @@ function IOURequestStepVendor({ icon={illustrations.Telescope} iconWidth={variables.emptyListIconWidth} iconHeight={variables.emptyListIconHeight} - title={translate('workspace.qbo.noAccountsFound')} - subtitle={translate('workspace.qbo.noAccountsFoundDescription')} + title={isOnXero ? translate('workspace.xero.noSuppliersFound') : translate('workspace.qbo.noAccountsFound')} + subtitle={isOnXero ? translate('workspace.xero.noSuppliersFoundDescription') : translate('workspace.qbo.noAccountsFoundDescription')} containerStyle={styles.pb10} /> ) : null; return ( (!policyID ? undefined : Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_NON_REIMBURSABLE_DEFAULT_CONTACT_SELECT.path))), + title: defaultSupplierName, + subscribedSettings: [CONST.XERO_CONFIG.DEFAULT_CONTACT], + }, + ] + : []), ]; return ( diff --git a/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx b/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx new file mode 100644 index 000000000000..e081fed925c9 --- /dev/null +++ b/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx @@ -0,0 +1,91 @@ +import React, {useCallback, useMemo} from 'react'; +import BlockingView from '@components/BlockingViews/BlockingView'; +import type {SelectorType} from '@components/SelectionScreen'; +import SelectionScreen from '@components/SelectionScreen'; +import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {updateManyPolicyConnectionConfigs} from '@libs/actions/connections'; +import {clearXeroErrorField} from '@libs/actions/Policy/Policy'; +import {getLatestErrorField} from '@libs/ErrorUtils'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import Navigation from '@libs/Navigation/Navigation'; +import {getXeroSuppliers, settingsPendingAction} from '@libs/PolicyUtils'; +import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; +import withPolicyConnections from '@pages/workspace/withPolicyConnections'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; + +function DynamicXeroNonReimbursableDefaultContactSelectPage({policy}: WithPolicyConnectionsProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const illustrations = useMemoizedLazyIllustrations(['Telescope']); + + const policyID = policy?.id; + const xeroConfig = policy?.connections?.xero?.config; + const currentContactID = xeroConfig?.defaultContact; + + const suppliers = useMemo(() => getXeroSuppliers(policy), [policy]); + const data: SelectorType[] = useMemo( + () => + suppliers.map((supplier) => ({ + value: supplier.id, + text: supplier.name, + keyForList: supplier.id, + isSelected: supplier.id === currentContactID, + })), + [suppliers, currentContactID], + ); + + const goBack = useCallback(() => { + Navigation.goBack(policyID ? createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_EXPORT.path, ROUTES.POLICY_ACCOUNTING.getRoute(policyID)) : undefined); + }, [policyID]); + + const selectSupplier = useCallback( + ({value}: SelectorType) => { + if (value !== currentContactID && policyID) { + updateManyPolicyConnectionConfigs(policyID, CONST.POLICY.CONNECTIONS.NAME.XERO, {[CONST.XERO_CONFIG.DEFAULT_CONTACT]: value}, {[CONST.XERO_CONFIG.DEFAULT_CONTACT]: currentContactID}); + } + goBack(); + }, + [currentContactID, policyID, goBack], + ); + + const listEmptyContent = useMemo( + () => ( + + ), + [translate, styles.pb10, illustrations.Telescope], + ); + + return ( + item.isSelected)?.keyForList} + listEmptyContent={listEmptyContent} + connectionName={CONST.POLICY.CONNECTIONS.NAME.XERO} + onBackButtonPress={goBack} + pendingAction={settingsPendingAction([CONST.XERO_CONFIG.DEFAULT_CONTACT], xeroConfig?.pendingFields)} + errors={getLatestErrorField(xeroConfig ?? {}, CONST.XERO_CONFIG.DEFAULT_CONTACT)} + errorRowStyles={[styles.ph5, styles.pv3]} + onClose={() => clearXeroErrorField(policyID, CONST.XERO_CONFIG.DEFAULT_CONTACT)} + /> + ); +} + +export default withPolicyConnections(DynamicXeroNonReimbursableDefaultContactSelectPage); diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index 7473b11e5d4c..76be6fdf23e5 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -644,6 +644,18 @@ type XeroTrackingCategory = { name: string; }; +/** Xero supplier contact imported into the workspace. */ +type XeroContact = { + /** Contact ID assigned by Xero */ + id: string; + + /** Display name of the contact */ + name: string; + + /** Contact's email address */ + email: string; +}; + /** * Data imported from Xero * @@ -653,6 +665,9 @@ type XeroConnectionData = { /** Collection of bank accounts */ bankAccounts: Account[]; + /** Supplier contacts keyed by their Xero contact ID. Undefined until Integration-Server has synced suppliers for the workspace. */ + contacts?: Record; + /** TODO: Will be handled in another issue */ countryCode: string; @@ -781,6 +796,9 @@ type XeroConnectionConfig = OnyxCommon.OnyxValueWithOfflineFeedback< /** ID of Xero organization */ tenantID: string; + /** Default supplier contact used as a fallback when a non-reimbursable card transaction has no contact set. */ + defaultContact?: string; + /** TODO: Will be handled in another issue */ errors?: OnyxCommon.Errors; @@ -2533,5 +2551,6 @@ export type { GustoConnectionConfig, ZenefitsConnectionConfig, Vendor, + XeroContact, AgentRule, }; From 4e89087fb6940b92463c87f251f52373a413800a Mon Sep 17 00:00:00 2001 From: Alex Beaman Date: Fri, 19 Jun 2026 14:58:35 -0600 Subject: [PATCH 02/14] Drop unused exports flagged by knip (isQBOVendorMatchingActive, XeroContact) --- src/libs/PolicyUtils.ts | 1 - src/types/onyx/Policy.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 980b8e1d72a2..b1b872e9c89b 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -2636,7 +2636,6 @@ export { getXeroSuppliers, getMatchingVendorName, isXeroVendorMatchingActive, - isQBOVendorMatchingActive, hasVendorFeature, getValidConnectedIntegration, getCountOfEnabledTagsOfList, diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index 76be6fdf23e5..7c8a378c0a75 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -2551,6 +2551,5 @@ export type { GustoConnectionConfig, ZenefitsConnectionConfig, Vendor, - XeroContact, AgentRule, }; From 2e8d98c0380626b2c0614a1084927804cde8b51d Mon Sep 17 00:00:00 2001 From: Alex Beaman Date: Fri, 19 Jun 2026 14:59:03 -0600 Subject: [PATCH 03/14] Apply prettier formatting to MoneyRequestView and DynamicXeroNonReimbursableDefaultContactSelectPage --- src/components/ReportActionItem/MoneyRequestView.tsx | 4 ++-- .../DynamicXeroNonReimbursableDefaultContactSelectPage.tsx | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index cb8989833c24..b143b43b88e3 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -62,10 +62,9 @@ import Parser from '@libs/Parser'; import { canSubmitPerDiemExpenseFromWorkspace, getLengthOfTag, + getMatchingVendorName, getPerDiemCustomUnit, getPolicyByCustomUnitID, - getMatchingVendorName, - isXeroVendorMatchingActive, getTagLists, hasDependentTags as hasDependentTagsPolicyUtils, hasVendorFeature, @@ -74,6 +73,7 @@ import { isMultiLevelTags, isPolicyAccessible, isTaxTrackingEnabled, + isXeroVendorMatchingActive, } from '@libs/PolicyUtils'; import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import {getReportName} from '@libs/ReportNameUtils'; diff --git a/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx b/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx index e081fed925c9..9f88bcd64096 100644 --- a/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx +++ b/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx @@ -45,7 +45,12 @@ function DynamicXeroNonReimbursableDefaultContactSelectPage({policy}: WithPolicy const selectSupplier = useCallback( ({value}: SelectorType) => { if (value !== currentContactID && policyID) { - updateManyPolicyConnectionConfigs(policyID, CONST.POLICY.CONNECTIONS.NAME.XERO, {[CONST.XERO_CONFIG.DEFAULT_CONTACT]: value}, {[CONST.XERO_CONFIG.DEFAULT_CONTACT]: currentContactID}); + updateManyPolicyConnectionConfigs( + policyID, + CONST.POLICY.CONNECTIONS.NAME.XERO, + {[CONST.XERO_CONFIG.DEFAULT_CONTACT]: value}, + {[CONST.XERO_CONFIG.DEFAULT_CONTACT]: currentContactID}, + ); } goBack(); }, From e5ee59b99cd7452504a457df0a47b70acad495b3 Mon Sep 17 00:00:00 2001 From: Alex Beaman Date: Fri, 19 Jun 2026 15:00:10 -0600 Subject: [PATCH 04/14] Apply Polyglot Parrot translation suggestions --- src/languages/de.ts | 3 +-- src/languages/es.ts | 6 +++--- src/languages/fr.ts | 3 +-- src/languages/it.ts | 5 ++--- src/languages/ja.ts | 9 ++++----- src/languages/nl.ts | 5 ++--- src/languages/pl.ts | 1 - src/languages/pt-BR.ts | 3 +-- src/languages/zh-hans.ts | 5 ++--- 9 files changed, 16 insertions(+), 24 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 4fb2e0de017b..6fba770983c8 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -4906,7 +4906,7 @@ ${amount} für ${merchant} – ${date}`, noAccountsFound: 'Keine Konten gefunden', noAccountsFoundDescription: 'Bitte fügen Sie das Konto in Xero hinzu und synchronisieren Sie die Verbindung erneut', defaultSupplier: 'Standardlieferant', - defaultSupplierDescription: 'Legen Sie einen Standardlieferanten fest, der beim Export auf alle Kreditkartentransaktionen angewendet wird.', + defaultSupplierDescription: 'Legen Sie einen Standardsupplier fest, der beim Export auf alle Kreditkartentransaktionen angewendet wird.', noSuppliersFound: 'Keine Lieferanten gefunden', noSuppliersFoundDescription: 'Bitte fügen Sie den Lieferanten in Xero hinzu und synchronisieren Sie die Verbindung erneut.', accountingMethods: { @@ -9723,7 +9723,6 @@ Hier ist ein *Testbeleg*, um dir zu zeigen, wie es funktioniert:`, pdfFailedBody: 'Your file could not be generated. Try again, or reach out to Concierge for help.', readyPartialBody: ({count, total}: {count: number; total: number}) => `${count} of ${total} reports exported. If it didn't automatically download, use the button below. See which reports failed in Concierge.`, - close: 'Close', }, domain: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 6d108cdfd803..0bb4c10de111 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -4787,9 +4787,9 @@ ${amount} para ${merchant} - ${date}`, noAccountsFound: 'No se ha encontrado ninguna cuenta', noAccountsFoundDescription: 'Añade la cuenta en Xero y sincroniza de nuevo la conexión', defaultSupplier: 'Proveedor predeterminado', - defaultSupplierDescription: 'Configura un proveedor predeterminado que se aplicará a todas las transacciones de tarjeta de crédito al exportar.', - noSuppliersFound: 'No se han encontrado proveedores', - noSuppliersFoundDescription: 'Añade el proveedor en Xero y sincroniza de nuevo la conexión.', + defaultSupplierDescription: 'Establece un proveedor predeterminado que se aplicará a todas las transacciones con tarjeta de crédito al exportar.', + noSuppliersFound: 'No se encontraron proveedores', + noSuppliersFoundDescription: 'Por favor, añade el proveedor en Xero y sincroniza de nuevo la conexión.', accountingMethods: { label: 'Cuándo Exportar', description: 'Elige cuándo exportar los gastos:', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index b552bb41c00f..685db3d8e902 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -4918,7 +4918,7 @@ ${amount} pour ${merchant} - ${date}`, noAccountsFound: 'Aucun compte trouvé', noAccountsFoundDescription: 'Veuillez ajouter le compte dans Xero et synchroniser à nouveau la connexion', defaultSupplier: 'Fournisseur par défaut', - defaultSupplierDescription: 'Définissez un fournisseur par défaut qui sera appliqué à toutes les transactions par carte de crédit lors de l’exportation.', + defaultSupplierDescription: 'Définissez un fournisseur par défaut qui s’appliquera à toutes les transactions par carte de crédit lors de l’exportation.', noSuppliersFound: 'Aucun fournisseur trouvé', noSuppliersFoundDescription: 'Veuillez ajouter le fournisseur dans Xero et synchroniser à nouveau la connexion.', accountingMethods: { @@ -9755,7 +9755,6 @@ Voici un *reçu test* pour vous montrer comment ça fonctionne :`, pdfFailedBody: 'Your file could not be generated. Try again, or reach out to Concierge for help.', readyPartialBody: ({count, total}: {count: number; total: number}) => `${count} of ${total} reports exported. If it didn't automatically download, use the button below. See which reports failed in Concierge.`, - close: 'Close', }, domain: { diff --git a/src/languages/it.ts b/src/languages/it.ts index 650d2f347234..5c969a3458cb 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -4891,9 +4891,9 @@ ${amount} per ${merchant} - ${date}`, noAccountsFound: 'Nessun account trovato', noAccountsFoundDescription: 'Aggiungi l’account in Xero e sincronizza nuovamente la connessione', defaultSupplier: 'Fornitore predefinito', - defaultSupplierDescription: 'Imposta un fornitore predefinito che verrà applicato a tutte le transazioni con carta di credito durante l’esportazione.', + defaultSupplierDescription: 'Imposta un fornitore predefinito che verrà applicato a tutte le transazioni con carta di credito al momento dell’esportazione.', noSuppliersFound: 'Nessun fornitore trovato', - noSuppliersFoundDescription: 'Aggiungi il fornitore in Xero e sincronizza nuovamente la connessione.', + noSuppliersFoundDescription: 'Aggiungi il fornitore in Xero e sincronizza di nuovo la connessione.', accountingMethods: { label: 'Quando esportare', description: 'Scegli quando esportare le spese:', @@ -9711,7 +9711,6 @@ Ecco una *ricevuta di prova* per mostrarti come funziona:`, pdfFailedBody: 'Your file could not be generated. Try again, or reach out to Concierge for help.', readyPartialBody: ({count, total}: {count: number; total: number}) => `${count} of ${total} reports exported. If it didn't automatically download, use the button below. See which reports failed in Concierge.`, - close: 'Close', }, domain: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 274dcd12572b..3f4af0754d35 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -4850,10 +4850,10 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' }, noAccountsFound: 'アカウントが見つかりません', noAccountsFoundDescription: 'Xero にアカウントを追加して、もう一度同期してください', - defaultSupplier: 'デフォルトのサプライヤー', - defaultSupplierDescription: 'エクスポート時にすべてのクレジットカード取引に適用されるデフォルトのサプライヤーを設定します。', - noSuppliersFound: 'サプライヤーが見つかりません', - noSuppliersFoundDescription: 'Xero にサプライヤーを追加して、もう一度同期してください。', + defaultSupplier: 'デフォルト仕入先', + defaultSupplierDescription: 'エクスポート時にすべてのクレジットカード取引に適用されるデフォルトの仕入先を設定します。', + noSuppliersFound: '仕入先が見つかりません', + noSuppliersFoundDescription: 'Xero に仕入先を追加して、もう一度同期してください。', accountingMethods: { label: 'エクスポートのタイミング', description: '経費をエクスポートするタイミングを選択:', @@ -9588,7 +9588,6 @@ ${reportName}`, pdfFailedBody: 'Your file could not be generated. Try again, or reach out to Concierge for help.', readyPartialBody: ({count, total}: {count: number; total: number}) => `${count} of ${total} reports exported. If it didn't automatically download, use the button below. See which reports failed in Concierge.`, - close: 'Close', }, domain: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 4941c5edbd5a..3e83cbb99487 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -4881,9 +4881,9 @@ ${amount} voor ${merchant} - ${date}`, noAccountsFound: 'Geen accounts gevonden', noAccountsFoundDescription: 'Voeg het account toe in Xero en synchroniseer de verbinding opnieuw', defaultSupplier: 'Standaardleverancier', - defaultSupplierDescription: 'Stel een standaardleverancier in die wordt toegepast op alle creditcardtransacties bij het exporteren.', + defaultSupplierDescription: 'Stel een standaardsupplier in die wordt toegepast op alle creditcardtransacties bij het exporteren.', noSuppliersFound: 'Geen leveranciers gevonden', - noSuppliersFoundDescription: 'Voeg de leverancier toe in Xero en synchroniseer de verbinding opnieuw.', + noSuppliersFoundDescription: 'Voeg de leverancier toe in Xero en synchroniseer de koppeling opnieuw.', accountingMethods: { label: 'Wanneer exporteren', description: 'Kies wanneer de onkosten moeten worden geëxporteerd:', @@ -9677,7 +9677,6 @@ Hier is een *proefbon* om je te laten zien hoe het werkt:`, pdfFailedBody: 'Your file could not be generated. Try again, or reach out to Concierge for help.', readyPartialBody: ({count, total}: {count: number; total: number}) => `${count} of ${total} reports exported. If it didn't automatically download, use the button below. See which reports failed in Concierge.`, - close: 'Close', }, domain: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index dc4ef1d646aa..54c367c3060c 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -9662,7 +9662,6 @@ Oto *paragon testowy*, żeby pokazać Ci, jak to działa:`, pdfFailedBody: 'Your file could not be generated. Try again, or reach out to Concierge for help.', readyPartialBody: ({count, total}: {count: number; total: number}) => `${count} of ${total} reports exported. If it didn't automatically download, use the button below. See which reports failed in Concierge.`, - close: 'Close', }, domain: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 956e9d716e8b..f88af25aed67 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -4873,7 +4873,7 @@ ${amount} para ${merchant} - ${date}`, noAccountsFound: 'Nenhuma conta encontrada', noAccountsFoundDescription: 'Adicione a conta no Xero e sincronize a conexão novamente', defaultSupplier: 'Fornecedor padrão', - defaultSupplierDescription: 'Defina um fornecedor padrão que será aplicado a todas as transações de cartão de crédito ao exportar.', + defaultSupplierDescription: 'Defina um fornecedor padrão que será aplicado a todas as transações de cartão de crédito na exportação.', noSuppliersFound: 'Nenhum fornecedor encontrado', noSuppliersFoundDescription: 'Adicione o fornecedor no Xero e sincronize a conexão novamente.', accountingMethods: { @@ -9666,7 +9666,6 @@ Aqui está um *comprovante de teste* para mostrar como funciona:`, pdfFailedBody: 'Your file could not be generated. Try again, or reach out to Concierge for help.', readyPartialBody: ({count, total}: {count: number; total: number}) => `${count} of ${total} reports exported. If it didn't automatically download, use the button below. See which reports failed in Concierge.`, - close: 'Close', }, domain: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 0add2750351b..eefdb98e3875 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -4755,9 +4755,9 @@ ${amount},商户:${merchant} - 日期:${date}`, noAccountsFound: '未找到账户', noAccountsFoundDescription: '请在 Xero 中添加该账户,然后再次同步连接', defaultSupplier: '默认供应商', - defaultSupplierDescription: '设置一个默认供应商,在导出时将其应用于所有信用卡交易。', + defaultSupplierDescription: '设置一个默认供应商,在导出时应用于所有信用卡交易。', noSuppliersFound: '未找到供应商', - noSuppliersFoundDescription: '请在 Xero 中添加供应商,然后再次同步连接。', + noSuppliersFoundDescription: '请在 Xero 中添加该供应商,然后再次同步连接。', accountingMethods: { label: '何时导出', description: '选择何时导出报销:', @@ -9406,7 +9406,6 @@ ${reportName}`, pdfFailedBody: 'Your file could not be generated. Try again, or reach out to Concierge for help.', readyPartialBody: ({count, total}: {count: number; total: number}) => `${count} of ${total} reports exported. If it didn't automatically download, use the button below. See which reports failed in Concierge.`, - close: 'Close', }, domain: { From 2ecf812fdfea0f48382afe80bf831000ee527db3 Mon Sep 17 00:00:00 2001 From: Alex Beaman Date: Fri, 19 Jun 2026 15:16:29 -0600 Subject: [PATCH 05/14] Add Xero unit tests for vendor matching helpers + inactive-supplier violation guardrail --- tests/unit/ModifiedExpenseMessageTest.ts | 80 +++++++++++++++++++ tests/unit/PolicyUtilsTest.ts | 63 +++++++++++++++ tests/unit/ViolationUtilsTest.ts | 99 ++++++++++++++++++++++++ 3 files changed, 242 insertions(+) diff --git a/tests/unit/ModifiedExpenseMessageTest.ts b/tests/unit/ModifiedExpenseMessageTest.ts index cdcb87237bac..62c87026af88 100644 --- a/tests/unit/ModifiedExpenseMessageTest.ts +++ b/tests/unit/ModifiedExpenseMessageTest.ts @@ -2271,6 +2271,86 @@ describe('ModifiedExpenseMessage', () => { expect(result).toEqual('set the vendor to "v-deleted"'); }); }); + + describe('Xero supplier changes (R4)', () => { + // Xero policy with two named supplier contacts. The resolver reads + // `connections.xero.data.contacts` (keyed Record), and the label switches to + // "supplier" instead of "vendor" because the policy is on Xero. + const policyWithXeroSuppliers: Policy = { + id: 'p-1', + name: 'My Workspace', + role: CONST.POLICY.ROLE.ADMIN, + type: CONST.POLICY.TYPE.TEAM, + owner: 'test@example.com', + outputCurrency: CONST.CURRENCY.USD, + isPolicyExpenseChatEnabled: true, + connections: { + xero: { + data: { + contacts: { + 'xc-acme': {id: 'xc-acme', name: 'Acme Xero', email: 'acme@example.com'}, + 'xc-office': {id: 'xc-office', name: 'Office Supplies Xero', email: 'office@example.com'}, + }, + }, + }, + }, + } as unknown as Policy; + + it('renders "set the supplier to X" for a Xero workspace when the supplier is set for the first time', () => { + const reportAction = { + ...createRandomReportAction(1), + actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE, + originalMessage: { + vendor: {externalID: 'xc-acme', isManuallySet: true}, + }, + }; + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: policyWithXeroSuppliers, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); + expect(result).toEqual('set the supplier to "Acme Xero"'); + }); + + it('renders "changed the supplier to Y (previously X)" when the supplier is changed on a Xero workspace', () => { + const reportAction = { + ...createRandomReportAction(1), + actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE, + originalMessage: { + oldVendor: {externalID: 'xc-acme', isManuallySet: false}, + vendor: {externalID: 'xc-office', isManuallySet: true}, + }, + }; + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: policyWithXeroSuppliers, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); + expect(result).toEqual('changed the supplier to "Office Supplies Xero" (previously "Acme Xero")'); + }); + + it('falls back to rendering the externalID for a Xero supplier no longer in the contacts list', () => { + const reportAction = { + ...createRandomReportAction(1), + actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE, + originalMessage: { + vendor: {externalID: 'xc-deleted', isManuallySet: false}, + }, + }; + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: policyWithXeroSuppliers, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); + expect(result).toEqual('set the supplier to "xc-deleted"'); + }); + }); }); }); }); diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index 3799fc24670c..145f997f58e8 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -3106,6 +3106,19 @@ describe('PolicyUtils', () => { } as unknown as Connections, }) as Policy; + const buildXeroPolicy = ( + contacts: Record | undefined = {'xc-1': {id: 'xc-1', name: 'Acme Xero', email: 'acme@example.com'}}, + ): Policy => + ({ + ...createRandomPolicy(0), + connections: { + [CONST.POLICY.CONNECTIONS.NAME.XERO]: { + config: {}, + data: contacts === undefined ? {} : {contacts}, + }, + } as unknown as Connections, + }) as Policy; + describe('hasVendorFeature', () => { it('returns true when beta is enabled and QBO non-reimbursable export is Credit Card', () => { expect(hasVendorFeature(buildQBOPolicy(CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD), true)).toBe(true); @@ -3119,6 +3132,17 @@ describe('PolicyUtils', () => { expect(hasVendorFeature(buildIntacctPolicy(CONST.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE.CREDIT_CARD_CHARGE), true)).toBe(true); }); + it('returns true when beta is enabled and Xero is connected (R4) — no export-destination enum on Xero', () => { + expect(hasVendorFeature(buildXeroPolicy(), true)).toBe(true); + }); + + it('returns true when beta is enabled and Xero is connected but contacts have not synced yet — feature gate is connection-based, not data-based', () => { + // The matcher itself short-circuits when contacts is undefined (see + // ViolationsUtils' inactiveVendor guardrail); hasVendorFeature stays true so the + // App still surfaces the UI surfaces (picker, default-supplier row). + expect(hasVendorFeature(buildXeroPolicy(undefined), true)).toBe(true); + }); + it('returns false when beta is disabled, even with Credit Card export configured', () => { expect(hasVendorFeature(buildQBOPolicy(CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD), false)).toBe(false); }); @@ -3127,6 +3151,10 @@ describe('PolicyUtils', () => { expect(hasVendorFeature(buildIntacctPolicy(CONST.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE.CREDIT_CARD_CHARGE), false)).toBe(false); }); + it('returns false when beta is disabled, even with Xero connected', () => { + expect(hasVendorFeature(buildXeroPolicy(), false)).toBe(false); + }); + it('returns false when QBO non-reimbursable export is Vendor Bill', () => { expect(hasVendorFeature(buildQBOPolicy(CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL), true)).toBe(false); }); @@ -3210,6 +3238,24 @@ describe('PolicyUtils', () => { expect(getMatchingVendors(policy)).toEqual(qboVendors); }); + it('returns the Xero supplier list normalized from the keyed Record to the shared Vendor shape (R4)', () => { + const policy = buildXeroPolicy({ + 'xc-1': {id: 'xc-1', name: 'Acme Xero', email: 'acme@example.com'}, + 'xc-2': {id: 'xc-2', name: 'Other Xero', email: 'other@example.com'}, + }); + expect(getMatchingVendors(policy)).toEqual([ + {id: 'xc-1', name: 'Acme Xero', currency: '', email: 'acme@example.com'}, + {id: 'xc-2', name: 'Other Xero', currency: '', email: 'other@example.com'}, + ]); + }); + + it('returns an empty array when Xero is connected but contacts have not synced yet (data.contacts undefined)', () => { + // Integration-Server has not yet completed a supplier sync for this workspace. + // The matcher must treat the contact list as unknown rather than failing — this + // is the same guardrail that prevents inactiveVendor from firing falsely. + expect(getMatchingVendors(buildXeroPolicy(undefined))).toEqual([]); + }); + it('returns an empty array when no supported connection exists', () => { const policy = {...createRandomPolicy(0), connections: {}} as Policy; expect(getMatchingVendors(policy)).toEqual([]); @@ -3237,6 +3283,18 @@ describe('PolicyUtils', () => { expect(getMatchingVendorByID(policy, 'iv-2')).toEqual({id: 'iv-2', name: 'Other Intacct', currency: '', email: ''}); }); + it('returns the matching Xero supplier (normalized) when the ID exists in the contacts list (R4)', () => { + const policy = buildXeroPolicy({ + 'xc-1': {id: 'xc-1', name: 'Acme Xero', email: 'acme@example.com'}, + 'xc-2': {id: 'xc-2', name: 'Other Xero', email: 'other@example.com'}, + }); + expect(getMatchingVendorByID(policy, 'xc-2')).toEqual({id: 'xc-2', name: 'Other Xero', currency: '', email: 'other@example.com'}); + }); + + it('returns undefined for a Xero supplier ID that is not in the contacts list (inactive-supplier case)', () => { + expect(getMatchingVendorByID(buildXeroPolicy(), 'xc-missing')).toBeUndefined(); + }); + it('returns undefined when the ID is not in the list (the inactive-vendor case)', () => { const policy = buildQBOPolicy(CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD); expect(getMatchingVendorByID(policy, 'v-missing')).toBeUndefined(); @@ -3259,6 +3317,11 @@ describe('PolicyUtils', () => { expect(findVendorByID(policy, 'iv-1')).toEqual({id: 'iv-1', name: 'Acme Intacct', currency: '', email: ''}); }); + it('resolves a Xero supplier (normalized) from connections.xero.data.contacts (R4)', () => { + const policy = buildXeroPolicy({'xc-1': {id: 'xc-1', name: 'Acme Xero', email: 'acme@example.com'}}); + expect(findVendorByID(policy, 'xc-1')).toEqual({id: 'xc-1', name: 'Acme Xero', currency: '', email: 'acme@example.com'}); + }); + it('prefers the active Intacct integration over stale QBO data when both hold the same vendor ID', () => { // Workspace state: QBO connected but in Vendor Bill mode (not vendor-matching), // Intacct connected in Credit Card Charge mode (the active matching integration). diff --git a/tests/unit/ViolationUtilsTest.ts b/tests/unit/ViolationUtilsTest.ts index eb1897a381cc..ce53468d5467 100644 --- a/tests/unit/ViolationUtilsTest.ts +++ b/tests/unit/ViolationUtilsTest.ts @@ -2244,6 +2244,105 @@ describe('getViolationsOnyxData', () => { }); expect(result.value).not.toContainEqual(inactiveVendorViolation); }); + + describe('Xero (R4)', () => { + const policyWithXeroVendorFeature = ( + contacts: Record | undefined = {'xc-active': {id: 'xc-active', name: 'Acme Xero', email: 'acme@example.com'}}, + ) => + ({ + requiresTag: false, + requiresCategory: false, + connections: { + [CONST.POLICY.CONNECTIONS.NAME.XERO]: { + config: {}, + data: contacts === undefined ? {} : {contacts}, + }, + }, + }) as unknown as Policy; + + it('adds the violation when the Xero supplier ID is not in the synced contacts list', () => { + policy = policyWithXeroVendorFeature(); + transaction.comment = {...transaction.comment, vendor: {externalID: 'xc-missing', isManuallySet: true}}; + const result = ViolationsUtils.getViolationsOnyxData({ + updatedTransaction: transaction, + transactionViolations, + policy, + policyTagList: policyTags, + policyCategories, + hasDependentTags: false, + isInvoiceTransaction: false, + }); + expect(result.value).toEqual(expect.arrayContaining([inactiveVendorViolation])); + }); + + it('removes an existing violation when the Xero supplier is restored in the contacts list', () => { + policy = policyWithXeroVendorFeature(); + transaction.comment = {...transaction.comment, vendor: {externalID: 'xc-active', isManuallySet: true}}; + const result = ViolationsUtils.getViolationsOnyxData({ + updatedTransaction: transaction, + transactionViolations: [inactiveVendorViolation], + policy, + policyTagList: policyTags, + policyCategories, + hasDependentTags: false, + isInvoiceTransaction: false, + }); + expect(result.value).not.toContainEqual(inactiveVendorViolation); + }); + + it('does NOT add the violation when Xero contacts have not synced yet (data.contacts undefined) — the guardrail', () => { + // Integration-Server hasn't populated suppliers for the workspace yet. We don't + // know the supplier list, so we must not flag the existing transaction vendor as + // missing. Otherwise every matched transaction would falsely flag inactive between + // the beta flip and the first supplier sync. + policy = policyWithXeroVendorFeature(undefined); + transaction.comment = {...transaction.comment, vendor: {externalID: 'xc-anything', isManuallySet: true}}; + const result = ViolationsUtils.getViolationsOnyxData({ + updatedTransaction: transaction, + transactionViolations, + policy, + policyTagList: policyTags, + policyCategories, + hasDependentTags: false, + isInvoiceTransaction: false, + }); + expect(result.value).not.toContainEqual(inactiveVendorViolation); + }); + + it('does NOT remove a server-fired violation while Xero contacts are unsynced (avoids stripping the server signal during the sync gap)', () => { + // Mirror of the prior test from the inverse angle: when contacts are unknown and a + // server-fired inactiveVendor violation is already on the transaction, the App + // must preserve it rather than wiping it during the sync gap. + policy = policyWithXeroVendorFeature(undefined); + transaction.comment = {...transaction.comment, vendor: {externalID: 'xc-active', isManuallySet: true}}; + const result = ViolationsUtils.getViolationsOnyxData({ + updatedTransaction: transaction, + transactionViolations: [inactiveVendorViolation], + policy, + policyTagList: policyTags, + policyCategories, + hasDependentTags: false, + isInvoiceTransaction: false, + }); + expect(result.value).toEqual(expect.arrayContaining([inactiveVendorViolation])); + }); + + it('does not add the violation when the vendorMatching beta is disabled, even with Xero connected', () => { + isBetaEnabledSpy.mockImplementation(() => false); + policy = policyWithXeroVendorFeature(); + transaction.comment = {...transaction.comment, vendor: {externalID: 'xc-missing', isManuallySet: true}}; + const result = ViolationsUtils.getViolationsOnyxData({ + updatedTransaction: transaction, + transactionViolations, + policy, + policyTagList: policyTags, + policyCategories, + hasDependentTags: false, + isInvoiceTransaction: false, + }); + expect(result.value).not.toContainEqual(inactiveVendorViolation); + }); + }); }); describe('shouldRemoveRejectedExpenseViolation (move transaction / explicit removal)', () => { const autoRejectedViolation: TransactionViolation = { From d02cb2818c19c78adf54490c3f121d1d3bcc91af Mon Sep 17 00:00:00 2001 From: Alex Beaman Date: Fri, 19 Jun 2026 15:22:34 -0600 Subject: [PATCH 06/14] =?UTF-8?q?Fix=20Xero=20unsynced-contacts=20test=20h?= =?UTF-8?q?elper=20=E2=80=94=20default-param=20trap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Calling buildXeroPolicy(undefined) / policyWithXeroVendorFeature(undefined) triggered the default-parameter mechanic, replacing the explicit undefined with the populated contacts list. Switch to an explicit XERO_CONTACTS_UNSYNCED sentinel so the test can actually exercise the data.contacts === undefined path that the inactive-vendor guardrail relies on. --- tests/unit/PolicyUtilsTest.ts | 14 ++++++++++---- tests/unit/ViolationUtilsTest.ts | 13 +++++++++---- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index 145f997f58e8..b474214a28fe 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -3106,15 +3106,21 @@ describe('PolicyUtils', () => { } as unknown as Connections, }) as Policy; + // Sentinel for "Xero connected but Integration-Server has not yet synced suppliers" + // (i.e. `data.contacts` is undefined on the connection). Distinct from the populated + // default so callers can opt into the unsynced state without colliding with the + // default-parameter mechanic, which would otherwise replace an explicit `undefined` + // with the default contacts list. + const XERO_CONTACTS_UNSYNCED = Symbol('XERO_CONTACTS_UNSYNCED'); const buildXeroPolicy = ( - contacts: Record | undefined = {'xc-1': {id: 'xc-1', name: 'Acme Xero', email: 'acme@example.com'}}, + contacts: Record | typeof XERO_CONTACTS_UNSYNCED = {'xc-1': {id: 'xc-1', name: 'Acme Xero', email: 'acme@example.com'}}, ): Policy => ({ ...createRandomPolicy(0), connections: { [CONST.POLICY.CONNECTIONS.NAME.XERO]: { config: {}, - data: contacts === undefined ? {} : {contacts}, + data: contacts === XERO_CONTACTS_UNSYNCED ? {} : {contacts}, }, } as unknown as Connections, }) as Policy; @@ -3140,7 +3146,7 @@ describe('PolicyUtils', () => { // The matcher itself short-circuits when contacts is undefined (see // ViolationsUtils' inactiveVendor guardrail); hasVendorFeature stays true so the // App still surfaces the UI surfaces (picker, default-supplier row). - expect(hasVendorFeature(buildXeroPolicy(undefined), true)).toBe(true); + expect(hasVendorFeature(buildXeroPolicy(XERO_CONTACTS_UNSYNCED), true)).toBe(true); }); it('returns false when beta is disabled, even with Credit Card export configured', () => { @@ -3253,7 +3259,7 @@ describe('PolicyUtils', () => { // Integration-Server has not yet completed a supplier sync for this workspace. // The matcher must treat the contact list as unknown rather than failing — this // is the same guardrail that prevents inactiveVendor from firing falsely. - expect(getMatchingVendors(buildXeroPolicy(undefined))).toEqual([]); + expect(getMatchingVendors(buildXeroPolicy(XERO_CONTACTS_UNSYNCED))).toEqual([]); }); it('returns an empty array when no supported connection exists', () => { diff --git a/tests/unit/ViolationUtilsTest.ts b/tests/unit/ViolationUtilsTest.ts index ce53468d5467..1c3834c5c942 100644 --- a/tests/unit/ViolationUtilsTest.ts +++ b/tests/unit/ViolationUtilsTest.ts @@ -2246,8 +2246,13 @@ describe('getViolationsOnyxData', () => { }); describe('Xero (R4)', () => { + // Sentinel for "Xero connected, contacts not yet synced". Explicit symbol avoids the + // default-parameter trap where `undefined` would fall back to the populated default. + const XERO_CONTACTS_UNSYNCED = Symbol('XERO_CONTACTS_UNSYNCED'); const policyWithXeroVendorFeature = ( - contacts: Record | undefined = {'xc-active': {id: 'xc-active', name: 'Acme Xero', email: 'acme@example.com'}}, + contacts: Record | typeof XERO_CONTACTS_UNSYNCED = { + 'xc-active': {id: 'xc-active', name: 'Acme Xero', email: 'acme@example.com'}, + }, ) => ({ requiresTag: false, @@ -2255,7 +2260,7 @@ describe('getViolationsOnyxData', () => { connections: { [CONST.POLICY.CONNECTIONS.NAME.XERO]: { config: {}, - data: contacts === undefined ? {} : {contacts}, + data: contacts === XERO_CONTACTS_UNSYNCED ? {} : {contacts}, }, }, }) as unknown as Policy; @@ -2295,7 +2300,7 @@ describe('getViolationsOnyxData', () => { // know the supplier list, so we must not flag the existing transaction vendor as // missing. Otherwise every matched transaction would falsely flag inactive between // the beta flip and the first supplier sync. - policy = policyWithXeroVendorFeature(undefined); + policy = policyWithXeroVendorFeature(XERO_CONTACTS_UNSYNCED); transaction.comment = {...transaction.comment, vendor: {externalID: 'xc-anything', isManuallySet: true}}; const result = ViolationsUtils.getViolationsOnyxData({ updatedTransaction: transaction, @@ -2313,7 +2318,7 @@ describe('getViolationsOnyxData', () => { // Mirror of the prior test from the inverse angle: when contacts are unknown and a // server-fired inactiveVendor violation is already on the transaction, the App // must preserve it rather than wiping it during the sync gap. - policy = policyWithXeroVendorFeature(undefined); + policy = policyWithXeroVendorFeature(XERO_CONTACTS_UNSYNCED); transaction.comment = {...transaction.comment, vendor: {externalID: 'xc-active', isManuallySet: true}}; const result = ViolationsUtils.getViolationsOnyxData({ updatedTransaction: transaction, From 1ed1e619b7b94641d45c7dbeabad099a40860e48 Mon Sep 17 00:00:00 2001 From: Alex Beaman Date: Fri, 19 Jun 2026 15:44:59 -0600 Subject: [PATCH 07/14] Scope Xero unsynced inactive-vendor guardrail to active matching source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2 (https://github.com/Expensify/App/pull/94093#discussion_r4535347196): when a workspace has a Xero connection alongside an active QBO or Intacct vendor-matching connection, the prior guardrail short-circuited the inactiveVendor check the moment the Xero connection existed, regardless of whether Xero was actually the active matching source. In dual/stale states that meant missing QBO/Intacct vendors silently lost their violation and existing violations stopped clearing. Tighten the predicate to fire only when Xero is the *active* source — i.e. neither QBO nor Intacct is in a vendor-matching export mode — so the unsynced-contacts grace window applies only to truly Xero-driven workspaces. Export isQBOVendorMatchingActive and isIntacctVendorMatchingActive from PolicyUtils to support the predicate. Add a regression test covering the QBO-active + Xero-lingering scenario. --- src/libs/PolicyUtils.ts | 2 ++ src/libs/Violations/ViolationsUtils.ts | 17 ++++++++----- tests/unit/ViolationUtilsTest.ts | 33 ++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 8b047a6597c9..bee04314cffc 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -2663,6 +2663,8 @@ export { findVendorByID, getMatchingVendorByID, getMatchingVendors, + isIntacctVendorMatchingActive, + isQBOVendorMatchingActive, isXeroVendorMatchingActive, hasVendorFeature, getValidConnectedIntegration, diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index 348ba0beb2cd..c89070f11324 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -22,6 +22,8 @@ import { hasVendorFeature, isAttendeeTrackingEnabled as isAttendeeTrackingEnabledForPolicy, isDefaultTagName, + isIntacctVendorMatchingActive, + isQBOVendorMatchingActive, isTaxTrackingEnabled, isXeroVendorMatchingActive, } from '@libs/PolicyUtils'; @@ -478,13 +480,16 @@ const ViolationsUtils = { newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.INACTIVE_VENDOR}); } } else if (transactionVendorID) { - // For Xero workspaces, skip the violation check entirely until Integration-Server - // has synced the supplier list (data.contacts is undefined). Otherwise every - // matched transaction would falsely flag inactive between the beta flip and the - // first supplier sync for the workspace. - const isOnXero = isXeroVendorMatchingActive(policy); + // When Xero is the *active* matching source (i.e. neither QBO nor Intacct is in + // a vendor-matching export mode), skip the inactive-vendor check while + // Integration-Server has not yet synced suppliers — `data.contacts === undefined` + // means the vendor list is unknown, so flagging would be premature. Scoped to the + // active-source case so dual/stale-connection states (e.g. active QBO + lingering + // Xero with no contacts synced) still run the QBO check normally instead of being + // silenced by the Xero connection's existence. + const isXeroActiveSource = isXeroVendorMatchingActive(policy) && !isQBOVendorMatchingActive(policy) && !isIntacctVendorMatchingActive(policy); const xeroContactsSynced = policy.connections?.[CONST.POLICY.CONNECTIONS.NAME.XERO]?.data?.contacts !== undefined; - if (isOnXero && !xeroContactsSynced) { + if (isXeroActiveSource && !xeroContactsSynced) { // No-op — supplier list not yet known for this workspace. } else { const matchedVendor = getMatchingVendorByID(policy, transactionVendorID); diff --git a/tests/unit/ViolationUtilsTest.ts b/tests/unit/ViolationUtilsTest.ts index 1c3834c5c942..2739e970942f 100644 --- a/tests/unit/ViolationUtilsTest.ts +++ b/tests/unit/ViolationUtilsTest.ts @@ -2347,6 +2347,39 @@ describe('getViolationsOnyxData', () => { }); expect(result.value).not.toContainEqual(inactiveVendorViolation); }); + + it('still fires for a missing QBO vendor when both QBO and Xero are connected but Xero contacts are unsynced (regression — dual-connection state)', () => { + // QBO is the active matching source (Credit Card export). Xero is also connected — + // e.g. an admin started setting up Xero but Integration-Server hasn't synced + // contacts yet. The guardrail must only fire when Xero is the active source; here + // QBO owns the vendor list, so a QBO vendor ID that isn't in the QBO vendor list + // must still flag inactive. + policy = { + requiresTag: false, + requiresCategory: false, + connections: { + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}, + data: {vendors: [{id: 'v-active', name: 'Acme QBO', currency: 'USD'}]}, + }, + [CONST.POLICY.CONNECTIONS.NAME.XERO]: { + config: {}, + data: {}, + }, + }, + } as unknown as Policy; + transaction.comment = {...transaction.comment, vendor: {externalID: 'v-missing', isManuallySet: true}}; + const result = ViolationsUtils.getViolationsOnyxData({ + updatedTransaction: transaction, + transactionViolations, + policy, + policyTagList: policyTags, + policyCategories, + hasDependentTags: false, + isInvoiceTransaction: false, + }); + expect(result.value).toEqual(expect.arrayContaining([inactiveVendorViolation])); + }); }); }); describe('shouldRemoveRejectedExpenseViolation (move transaction / explicit removal)', () => { From 47ee830ffd456921511a79b821f41e978ffcc36e Mon Sep 17 00:00:00 2001 From: Alex Beaman Date: Fri, 19 Jun 2026 16:41:22 -0600 Subject: [PATCH 08/14] Fix ESLint: rename Xero contact keys to camelCase + bump seatbelt for ViolationUtilsTest * Object-literal keys 'xc-active', 'xc-1', 'xc-acme' etc. violated @typescript-eslint/naming-convention. Renamed to camelCase (xcActive, xc1, xcAcme) across the new Xero tests in PolicyUtilsTest, ViolationUtilsTest, and ModifiedExpenseMessageTest. The string values inside externalID/id stay in sync with their now-renamed keys. * The Xero (R4) describe block in ViolationUtilsTest adds two unsafe Policy literal casts mirroring R2's policyWithQBOVendorFeature pattern. Bump the eslint-seatbelt entry from 20 to 22 to match; the auto-tighten cron will re-baseline after merge. --- config/eslint/eslint.seatbelt.tsv | 2 +- tests/unit/ModifiedExpenseMessageTest.ts | 12 ++++++------ tests/unit/PolicyUtilsTest.ts | 22 +++++++++++----------- tests/unit/ViolationUtilsTest.ts | 12 ++++++------ 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 73c92d3c0139..cb02ca2afe80 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -1866,7 +1866,7 @@ "../../tests/unit/ValidateAttachmentFileTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 "../../tests/unit/ValidationUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../tests/unit/VideoRendererTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../tests/unit/ViolationUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 20 +"../../tests/unit/ViolationUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 22 "../../tests/unit/WhisperContentMentionContextTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../tests/unit/WorkflowUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 8 "../../tests/unit/WorkspaceReportFieldUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 diff --git a/tests/unit/ModifiedExpenseMessageTest.ts b/tests/unit/ModifiedExpenseMessageTest.ts index 62c87026af88..8b16c24b9ef9 100644 --- a/tests/unit/ModifiedExpenseMessageTest.ts +++ b/tests/unit/ModifiedExpenseMessageTest.ts @@ -2288,8 +2288,8 @@ describe('ModifiedExpenseMessage', () => { xero: { data: { contacts: { - 'xc-acme': {id: 'xc-acme', name: 'Acme Xero', email: 'acme@example.com'}, - 'xc-office': {id: 'xc-office', name: 'Office Supplies Xero', email: 'office@example.com'}, + xcAcme: {id: 'xcAcme', name: 'Acme Xero', email: 'acme@example.com'}, + xcOffice: {id: 'xcOffice', name: 'Office Supplies Xero', email: 'office@example.com'}, }, }, }, @@ -2301,7 +2301,7 @@ describe('ModifiedExpenseMessage', () => { ...createRandomReportAction(1), actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE, originalMessage: { - vendor: {externalID: 'xc-acme', isManuallySet: true}, + vendor: {externalID: 'xcAcme', isManuallySet: true}, }, }; const result = getForReportAction({ @@ -2319,8 +2319,8 @@ describe('ModifiedExpenseMessage', () => { ...createRandomReportAction(1), actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE, originalMessage: { - oldVendor: {externalID: 'xc-acme', isManuallySet: false}, - vendor: {externalID: 'xc-office', isManuallySet: true}, + oldVendor: {externalID: 'xcAcme', isManuallySet: false}, + vendor: {externalID: 'xcOffice', isManuallySet: true}, }, }; const result = getForReportAction({ @@ -2338,7 +2338,7 @@ describe('ModifiedExpenseMessage', () => { ...createRandomReportAction(1), actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE, originalMessage: { - vendor: {externalID: 'xc-deleted', isManuallySet: false}, + vendor: {externalID: 'xcDeleted', isManuallySet: false}, }, }; const result = getForReportAction({ diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index b474214a28fe..79343f77b5af 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -3113,7 +3113,7 @@ describe('PolicyUtils', () => { // with the default contacts list. const XERO_CONTACTS_UNSYNCED = Symbol('XERO_CONTACTS_UNSYNCED'); const buildXeroPolicy = ( - contacts: Record | typeof XERO_CONTACTS_UNSYNCED = {'xc-1': {id: 'xc-1', name: 'Acme Xero', email: 'acme@example.com'}}, + contacts: Record | typeof XERO_CONTACTS_UNSYNCED = {xc1: {id: 'xc1', name: 'Acme Xero', email: 'acme@example.com'}}, ): Policy => ({ ...createRandomPolicy(0), @@ -3246,12 +3246,12 @@ describe('PolicyUtils', () => { it('returns the Xero supplier list normalized from the keyed Record to the shared Vendor shape (R4)', () => { const policy = buildXeroPolicy({ - 'xc-1': {id: 'xc-1', name: 'Acme Xero', email: 'acme@example.com'}, - 'xc-2': {id: 'xc-2', name: 'Other Xero', email: 'other@example.com'}, + xc1: {id: 'xc1', name: 'Acme Xero', email: 'acme@example.com'}, + xc2: {id: 'xc2', name: 'Other Xero', email: 'other@example.com'}, }); expect(getMatchingVendors(policy)).toEqual([ - {id: 'xc-1', name: 'Acme Xero', currency: '', email: 'acme@example.com'}, - {id: 'xc-2', name: 'Other Xero', currency: '', email: 'other@example.com'}, + {id: 'xc1', name: 'Acme Xero', currency: '', email: 'acme@example.com'}, + {id: 'xc2', name: 'Other Xero', currency: '', email: 'other@example.com'}, ]); }); @@ -3291,14 +3291,14 @@ describe('PolicyUtils', () => { it('returns the matching Xero supplier (normalized) when the ID exists in the contacts list (R4)', () => { const policy = buildXeroPolicy({ - 'xc-1': {id: 'xc-1', name: 'Acme Xero', email: 'acme@example.com'}, - 'xc-2': {id: 'xc-2', name: 'Other Xero', email: 'other@example.com'}, + xc1: {id: 'xc1', name: 'Acme Xero', email: 'acme@example.com'}, + xc2: {id: 'xc2', name: 'Other Xero', email: 'other@example.com'}, }); - expect(getMatchingVendorByID(policy, 'xc-2')).toEqual({id: 'xc-2', name: 'Other Xero', currency: '', email: 'other@example.com'}); + expect(getMatchingVendorByID(policy, 'xc2')).toEqual({id: 'xc2', name: 'Other Xero', currency: '', email: 'other@example.com'}); }); it('returns undefined for a Xero supplier ID that is not in the contacts list (inactive-supplier case)', () => { - expect(getMatchingVendorByID(buildXeroPolicy(), 'xc-missing')).toBeUndefined(); + expect(getMatchingVendorByID(buildXeroPolicy(), 'xcMissing')).toBeUndefined(); }); it('returns undefined when the ID is not in the list (the inactive-vendor case)', () => { @@ -3324,8 +3324,8 @@ describe('PolicyUtils', () => { }); it('resolves a Xero supplier (normalized) from connections.xero.data.contacts (R4)', () => { - const policy = buildXeroPolicy({'xc-1': {id: 'xc-1', name: 'Acme Xero', email: 'acme@example.com'}}); - expect(findVendorByID(policy, 'xc-1')).toEqual({id: 'xc-1', name: 'Acme Xero', currency: '', email: 'acme@example.com'}); + const policy = buildXeroPolicy({xc1: {id: 'xc1', name: 'Acme Xero', email: 'acme@example.com'}}); + expect(findVendorByID(policy, 'xc1')).toEqual({id: 'xc1', name: 'Acme Xero', currency: '', email: 'acme@example.com'}); }); it('prefers the active Intacct integration over stale QBO data when both hold the same vendor ID', () => { diff --git a/tests/unit/ViolationUtilsTest.ts b/tests/unit/ViolationUtilsTest.ts index 2739e970942f..7f15c75aafb6 100644 --- a/tests/unit/ViolationUtilsTest.ts +++ b/tests/unit/ViolationUtilsTest.ts @@ -2251,7 +2251,7 @@ describe('getViolationsOnyxData', () => { const XERO_CONTACTS_UNSYNCED = Symbol('XERO_CONTACTS_UNSYNCED'); const policyWithXeroVendorFeature = ( contacts: Record | typeof XERO_CONTACTS_UNSYNCED = { - 'xc-active': {id: 'xc-active', name: 'Acme Xero', email: 'acme@example.com'}, + xcActive: {id: 'xcActive', name: 'Acme Xero', email: 'acme@example.com'}, }, ) => ({ @@ -2267,7 +2267,7 @@ describe('getViolationsOnyxData', () => { it('adds the violation when the Xero supplier ID is not in the synced contacts list', () => { policy = policyWithXeroVendorFeature(); - transaction.comment = {...transaction.comment, vendor: {externalID: 'xc-missing', isManuallySet: true}}; + transaction.comment = {...transaction.comment, vendor: {externalID: 'xcMissing', isManuallySet: true}}; const result = ViolationsUtils.getViolationsOnyxData({ updatedTransaction: transaction, transactionViolations, @@ -2282,7 +2282,7 @@ describe('getViolationsOnyxData', () => { it('removes an existing violation when the Xero supplier is restored in the contacts list', () => { policy = policyWithXeroVendorFeature(); - transaction.comment = {...transaction.comment, vendor: {externalID: 'xc-active', isManuallySet: true}}; + transaction.comment = {...transaction.comment, vendor: {externalID: 'xcActive', isManuallySet: true}}; const result = ViolationsUtils.getViolationsOnyxData({ updatedTransaction: transaction, transactionViolations: [inactiveVendorViolation], @@ -2301,7 +2301,7 @@ describe('getViolationsOnyxData', () => { // missing. Otherwise every matched transaction would falsely flag inactive between // the beta flip and the first supplier sync. policy = policyWithXeroVendorFeature(XERO_CONTACTS_UNSYNCED); - transaction.comment = {...transaction.comment, vendor: {externalID: 'xc-anything', isManuallySet: true}}; + transaction.comment = {...transaction.comment, vendor: {externalID: 'xcAnything', isManuallySet: true}}; const result = ViolationsUtils.getViolationsOnyxData({ updatedTransaction: transaction, transactionViolations, @@ -2319,7 +2319,7 @@ describe('getViolationsOnyxData', () => { // server-fired inactiveVendor violation is already on the transaction, the App // must preserve it rather than wiping it during the sync gap. policy = policyWithXeroVendorFeature(XERO_CONTACTS_UNSYNCED); - transaction.comment = {...transaction.comment, vendor: {externalID: 'xc-active', isManuallySet: true}}; + transaction.comment = {...transaction.comment, vendor: {externalID: 'xcActive', isManuallySet: true}}; const result = ViolationsUtils.getViolationsOnyxData({ updatedTransaction: transaction, transactionViolations: [inactiveVendorViolation], @@ -2335,7 +2335,7 @@ describe('getViolationsOnyxData', () => { it('does not add the violation when the vendorMatching beta is disabled, even with Xero connected', () => { isBetaEnabledSpy.mockImplementation(() => false); policy = policyWithXeroVendorFeature(); - transaction.comment = {...transaction.comment, vendor: {externalID: 'xc-missing', isManuallySet: true}}; + transaction.comment = {...transaction.comment, vendor: {externalID: 'xcMissing', isManuallySet: true}}; const result = ViolationsUtils.getViolationsOnyxData({ updatedTransaction: transaction, transactionViolations, From 6293966ae63de40c9e4c400a2f897f479d538f66 Mon Sep 17 00:00:00 2001 From: Alex Beaman Date: Fri, 19 Jun 2026 16:44:58 -0600 Subject: [PATCH 09/14] Scope Xero default-supplier UI to Xero contacts only (Xero-scoped helpers) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2 (https://github.com/Expensify/App/pull/94093#discussion_r4536228064): on a dual-connected workspace where QBO or Intacct is the active matching source, getMatchingVendors prefers their vendor list — so the Xero default-supplier picker would have listed non-Xero vendors and saved their IDs into xero.config.defaultContact, and the Xero export config row would have rendered a non-Xero vendor's name when findVendorByID cross-integration fallback resolved an ID also present in QBO/Intacct. Restore Xero-scoped helpers getXeroSuppliers and getXeroSupplierByID (reading connections.xero.data.contacts directly) and switch both DynamicXeroNonReimbursableDefaultContactSelectPage and DynamicXeroExportConfigurationPage to use them. Add tests covering the dual-connection case to lock in the behavior. --- src/libs/PolicyUtils.ts | 30 +++++++ .../DynamicXeroExportConfigurationPage.tsx | 4 +- ...onReimbursableDefaultContactSelectPage.tsx | 4 +- tests/unit/PolicyUtilsTest.ts | 83 +++++++++++++++++++ 4 files changed, 117 insertions(+), 4 deletions(-) diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index bee04314cffc..6a44383847d8 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -2247,6 +2247,34 @@ function findVendorByID(policy: OnyxEntry, vendorID: string | undefined) return undefined; } +/** + * Xero-scoped supplier list, normalized to the shared `Vendor` shape. Use this from Xero-specific + * UI (the default-supplier picker, the Xero export config row) so the data source stays bound to + * `connections.xero.data.contacts` regardless of whether QBO or Intacct is the *active* matching + * source on a dual-connected workspace — `getMatchingVendors` is integration-priority-aware and + * would return non-Xero vendors in that state, which is wrong for Xero-only controls. + */ +function getXeroSuppliers(policy: OnyxEntry): Vendor[] { + const contacts = policy?.connections?.[CONST.POLICY.CONNECTIONS.NAME.XERO]?.data?.contacts; + if (!contacts) { + return []; + } + return Object.values(contacts).map((contact) => ({id: contact.id, name: contact.name, currency: '', email: contact.email})); +} + +/** + * Xero-scoped supplier lookup. Same rationale as `getXeroSuppliers`: bound strictly to Xero data + * so the Xero export config display can never accidentally render a non-Xero vendor's name when + * another integration is the active matching source. + */ +function getXeroSupplierByID(policy: OnyxEntry, supplierID: string | undefined): Vendor | undefined { + if (!supplierID) { + return undefined; + } + const contact = policy?.connections?.[CONST.POLICY.CONNECTIONS.NAME.XERO]?.data?.contacts?.[supplierID]; + return contact ? {id: contact.id, name: contact.name, currency: '', email: contact.email} : undefined; +} + function getValidConnectedIntegration(policy: Policy | undefined, connectionNames: readonly ConnectionName[] = getAccountingConnectionNames()) { return connectionNames.find((integration) => !!policy?.connections?.[integration] && !isConnectionUnverified(policy, integration)); } @@ -2663,6 +2691,8 @@ export { findVendorByID, getMatchingVendorByID, getMatchingVendors, + getXeroSupplierByID, + getXeroSuppliers, isIntacctVendorMatchingActive, isQBOVendorMatchingActive, isXeroVendorMatchingActive, diff --git a/src/pages/workspace/accounting/xero/export/DynamicXeroExportConfigurationPage.tsx b/src/pages/workspace/accounting/xero/export/DynamicXeroExportConfigurationPage.tsx index ba24e5b8dbb9..c2457c863ed6 100644 --- a/src/pages/workspace/accounting/xero/export/DynamicXeroExportConfigurationPage.tsx +++ b/src/pages/workspace/accounting/xero/export/DynamicXeroExportConfigurationPage.tsx @@ -11,7 +11,7 @@ import useWorkspaceAccountID from '@hooks/useWorkspaceAccountID'; import {getCardSettings} from '@libs/CardUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; -import {areSettingsInErrorFields, findVendorByID, getCurrentXeroOrganizationName, hasVendorFeature, settingsPendingAction} from '@libs/PolicyUtils'; +import {areSettingsInErrorFields, getCurrentXeroOrganizationName, getXeroSupplierByID, hasVendorFeature, settingsPendingAction} from '@libs/PolicyUtils'; import {getIsTravelInvoicingEnabled, getTravelInvoicingCardSettingsKey} from '@libs/TravelInvoicingUtils'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; @@ -30,7 +30,7 @@ function DynamicXeroExportConfigurationPage({policy}: WithPolicyConnectionsProps const {bankAccounts} = policy?.connections?.xero?.data ?? {}; const isVendorFeatureAvailable = hasVendorFeature(policy, isBetaEnabled(CONST.BETAS.VENDOR_MATCHING)); - const defaultSupplierName = findVendorByID(policy, defaultContact)?.name ?? ''; + const defaultSupplierName = getXeroSupplierByID(policy, defaultContact)?.name ?? ''; const exportPath = policyID ? `${ROUTES.POLICY_ACCOUNTING.getRoute(policyID)}/${DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_EXPORT.path}` : undefined; const workspaceAccountID = useWorkspaceAccountID(policyID); const [cardSettings] = useOnyx(getTravelInvoicingCardSettingsKey(workspaceAccountID)); diff --git a/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx b/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx index 89fe49973f9f..9f88bcd64096 100644 --- a/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx +++ b/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx @@ -10,7 +10,7 @@ import {clearXeroErrorField} from '@libs/actions/Policy/Policy'; import {getLatestErrorField} from '@libs/ErrorUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; -import {getMatchingVendors, settingsPendingAction} from '@libs/PolicyUtils'; +import {getXeroSuppliers, settingsPendingAction} from '@libs/PolicyUtils'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import variables from '@styles/variables'; @@ -26,7 +26,7 @@ function DynamicXeroNonReimbursableDefaultContactSelectPage({policy}: WithPolicy const xeroConfig = policy?.connections?.xero?.config; const currentContactID = xeroConfig?.defaultContact; - const suppliers = useMemo(() => getMatchingVendors(policy), [policy]); + const suppliers = useMemo(() => getXeroSuppliers(policy), [policy]); const data: SelectorType[] = useMemo( () => suppliers.map((supplier) => ({ diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index 79343f77b5af..6d16a38c0063 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -34,6 +34,8 @@ import { getTagListByOrderWeight, getUberConnectionErrorDirectlyFromPolicy, getUnitRateValue, + getXeroSupplierByID, + getXeroSuppliers, hasConfiguredRules, hasDependentTags, hasDynamicExternalWorkflow, @@ -3398,6 +3400,87 @@ describe('PolicyUtils', () => { expect(findVendorByID(policy, undefined)).toBeUndefined(); }); }); + + describe('getXeroSuppliers (Xero-scoped, R4)', () => { + it('returns the Xero supplier list normalized to the shared Vendor shape', () => { + const policy = buildXeroPolicy({ + xc1: {id: 'xc1', name: 'Acme Xero', email: 'acme@example.com'}, + xc2: {id: 'xc2', name: 'Other Xero', email: 'other@example.com'}, + }); + expect(getXeroSuppliers(policy)).toEqual([ + {id: 'xc1', name: 'Acme Xero', currency: '', email: 'acme@example.com'}, + {id: 'xc2', name: 'Other Xero', currency: '', email: 'other@example.com'}, + ]); + }); + + it('returns Xero contacts even when QBO is the active matching source (dual-connection state)', () => { + // This is the scenario the Xero-scoped helper exists for: a workspace has QBO + // actively matching (Credit Card export) AND a Xero connection with synced + // contacts. The Xero default-supplier picker must list Xero suppliers, not QBO + // vendors, even though `getMatchingVendors` would return QBO here. + const xeroPolicy = buildXeroPolicy({xc1: {id: 'xc1', name: 'Acme Xero', email: 'acme@example.com'}}); + const policy = { + ...xeroPolicy, + connections: { + ...xeroPolicy.connections, + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}, + data: {vendors: [{id: 'qbo-active', name: 'Acme QBO', currency: 'USD'}]}, + }, + }, + } as Policy; + expect(getXeroSuppliers(policy)).toEqual([{id: 'xc1', name: 'Acme Xero', currency: '', email: 'acme@example.com'}]); + }); + + it('returns an empty array when Xero contacts have not synced yet (data.contacts undefined)', () => { + expect(getXeroSuppliers(buildXeroPolicy(XERO_CONTACTS_UNSYNCED))).toEqual([]); + }); + + it('returns an empty array when Xero is not connected', () => { + const policy = {...createRandomPolicy(0), connections: {}} as Policy; + expect(getXeroSuppliers(policy)).toEqual([]); + }); + }); + + describe('getXeroSupplierByID (Xero-scoped, R4)', () => { + it('returns the matching Xero supplier when the ID exists in the contacts list', () => { + const policy = buildXeroPolicy({ + xc1: {id: 'xc1', name: 'Acme Xero', email: 'acme@example.com'}, + xc2: {id: 'xc2', name: 'Other Xero', email: 'other@example.com'}, + }); + expect(getXeroSupplierByID(policy, 'xc2')).toEqual({id: 'xc2', name: 'Other Xero', currency: '', email: 'other@example.com'}); + }); + + it('returns the Xero supplier even when QBO is connected with a same-ID vendor (dual-connection state)', () => { + // Workspace state: Xero is connected with a supplier id `shared`, and QBO is also + // connected with a vendor coincidentally named `shared`. The Xero export config + // display row must show the Xero supplier name, not the QBO vendor's. + const xeroPolicy = buildXeroPolicy({shared: {id: 'shared', name: 'Xero Supplier', email: 'xero@example.com'}}); + const policy = { + ...xeroPolicy, + connections: { + ...xeroPolicy.connections, + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}, + data: {vendors: [{id: 'shared', name: 'QBO Vendor', currency: 'USD'}]}, + }, + }, + } as Policy; + expect(getXeroSupplierByID(policy, 'shared')).toEqual({id: 'shared', name: 'Xero Supplier', currency: '', email: 'xero@example.com'}); + }); + + it('returns undefined when the ID is not in the Xero contacts list', () => { + expect(getXeroSupplierByID(buildXeroPolicy(), 'xcMissing')).toBeUndefined(); + }); + + it('returns undefined when supplierID is undefined', () => { + expect(getXeroSupplierByID(buildXeroPolicy(), undefined)).toBeUndefined(); + }); + + it('returns undefined when Xero contacts have not synced yet', () => { + expect(getXeroSupplierByID(buildXeroPolicy(XERO_CONTACTS_UNSYNCED), 'xc1')).toBeUndefined(); + }); + }); }); describe('hasPolicyRulesError', () => { From 6b96e2b388c056bf0de67f5cb8c04db62080fb64 Mon Sep 17 00:00:00 2001 From: Alex Beaman Date: Fri, 19 Jun 2026 16:50:17 -0600 Subject: [PATCH 10/14] Update Xero-deleted-supplier test assertion to match camelCase rename --- tests/unit/ModifiedExpenseMessageTest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/ModifiedExpenseMessageTest.ts b/tests/unit/ModifiedExpenseMessageTest.ts index 8b16c24b9ef9..c2981ef4561c 100644 --- a/tests/unit/ModifiedExpenseMessageTest.ts +++ b/tests/unit/ModifiedExpenseMessageTest.ts @@ -2348,7 +2348,7 @@ describe('ModifiedExpenseMessage', () => { policyTags: undefined, currentUserLogin: CURRENT_USER_LOGIN, }); - expect(result).toEqual('set the supplier to "xc-deleted"'); + expect(result).toEqual('set the supplier to "xcDeleted"'); }); }); }); From 3095ddfbe34038d5fc62c5568d75969c2a8b5a1f Mon Sep 17 00:00:00 2001 From: Alex Beaman Date: Fri, 19 Jun 2026 16:58:42 -0600 Subject: [PATCH 11/14] Route Supplier/Vendor label flips through isXeroActiveMatchingSource MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2 (https://github.com/Expensify/App/pull/94093#discussion_r3444841743): isXeroVendorMatchingActive returns true whenever Xero is connected — correct for hasVendorFeature (the eligibility gate) but wrong for the Supplier-vs-Vendor label flip. In a dual-connection state with QBO/Intacct as the active matching source + a lingering Xero connection, getMatchingVendors still surfaces QBO/Intacct vendors. Using the connection-presence predicate for labels would relabel those non-Xero vendors as 'Supplier'. Introduce isXeroActiveMatchingSource (Xero connected AND neither QBO nor Intacct in a vendor-matching export mode) and route the three label consumers + the violations guardrail through it: * MoneyRequestView.tsx (vendor row label) * ModifiedExpenseMessage.ts (system message label) * IOURequestStepVendor.tsx (picker header + empty-state copy) * ViolationsUtils.ts (replaces the inline composition introduced earlier) Keep isXeroVendorMatchingActive as the eligibility predicate for hasVendorFeature. Add unit tests covering single-Xero, dual-active QBO, dual-active Intacct, QBO-Vendor-Bill-with-Xero, no-Xero, and undefined policy cases. --- .../ReportActionItem/MoneyRequestView.tsx | 4 +- src/libs/ModifiedExpenseMessage.ts | 4 +- src/libs/PolicyUtils.ts | 25 +++++-- src/libs/Violations/ViolationsUtils.ts | 7 +- .../iou/request/step/IOURequestStepVendor.tsx | 4 +- tests/unit/PolicyUtilsTest.ts | 68 +++++++++++++++++++ 6 files changed, 96 insertions(+), 16 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 0cac2e4eaf17..22f0970495b4 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -73,7 +73,7 @@ import { isMultiLevelTags, isPolicyAccessible, isTaxTrackingEnabled, - isXeroVendorMatchingActive, + isXeroActiveMatchingSource, } from '@libs/PolicyUtils'; import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import {getReportName} from '@libs/ReportNameUtils'; @@ -474,7 +474,7 @@ function MoneyRequestView({ const transactionVendor = transaction?.comment?.vendor; const transactionVendorName = findVendorByID(policy, transactionVendor?.externalID)?.name ?? ''; const shouldShowVendor = hasVendorFeature(policy, isBetaEnabled(CONST.BETAS.VENDOR_MATCHING)) && !(updatedTransaction?.reimbursable ?? !!transactionReimbursable) && !isInvoice; - const vendorFieldLabel = isXeroVendorMatchingActive(policy) ? translate('common.supplier') : translate('common.vendor'); + const vendorFieldLabel = isXeroActiveMatchingSource(policy) ? translate('common.supplier') : translate('common.vendor'); const tripID = getTripIDFromTransactionParentReportID(parentReport?.parentReportID); const shouldShowViewTripDetails = hasReservationList(transaction) && !!tripID; diff --git a/src/libs/ModifiedExpenseMessage.ts b/src/libs/ModifiedExpenseMessage.ts index 2d08b000250c..6be038066d30 100644 --- a/src/libs/ModifiedExpenseMessage.ts +++ b/src/libs/ModifiedExpenseMessage.ts @@ -15,7 +15,7 @@ import {formatList} from './Localize'; import Log from './Log'; import Parser from './Parser'; import {getPersonalDetailByEmail} from './PersonalDetailsUtils'; -import {findVendorByID, getCleanedTagName, getCommaSeparatedTagNameWithSanitizedColons, getSortedTagKeys, isPolicyAdmin, isXeroVendorMatchingActive} from './PolicyUtils'; +import {findVendorByID, getCleanedTagName, getCommaSeparatedTagNameWithSanitizedColons, getSortedTagKeys, isPolicyAdmin, isXeroActiveMatchingSource} from './PolicyUtils'; import {getOriginalMessage, isModifiedExpenseAction} from './ReportActionsUtils'; // This cycle import is safe because ReportNameUtils was extracted from ReportUtils to separate report name computation logic. // The functions imported here are pure utility functions that don't create initialization-time dependencies. @@ -473,7 +473,7 @@ function getForReportAction({ translate, resolveVendorName(reportActionOriginalMessage?.vendor), resolveVendorName(reportActionOriginalMessage?.oldVendor), - isXeroVendorMatchingActive(policy) ? translate('common.supplier') : translate('common.vendor'), + isXeroActiveMatchingSource(policy) ? translate('common.supplier') : translate('common.vendor'), true, setFragments, removalFragments, diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 6a44383847d8..bc608db28b0d 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -2137,14 +2137,30 @@ function isIntacctVendorMatchingActive(policy: OnyxEntry): boolean { } /** - * True when Xero is the connected integration scoping the vendor field. Xero has no - * export-destination enum (bank-transactions is the only non-reimbursable mode), so the connection - * being present is sufficient — mirrors `Xero::hasVendorFeature` on the PHP side. + * True when Xero is connected. Xero has no export-destination enum (bank-transactions is the only + * non-reimbursable mode), so connection presence is sufficient — mirrors `Xero::hasVendorFeature` + * on the PHP side. This is the *eligibility* predicate used by `hasVendorFeature`, NOT the source + * predicate — on dual-connected workspaces QBO/Intacct precedence still applies in + * `getMatchingVendors`. Use `isXeroActiveMatchingSource` when the question is "is Xero the + * integration whose vendors are actually being shown to the user?" (e.g. for the Supplier/Vendor + * label flip in the expense row, picker, and modified-expense fragments). */ function isXeroVendorMatchingActive(policy: OnyxEntry): boolean { return !!policy?.connections?.[CONST.POLICY.CONNECTIONS.NAME.XERO]; } +/** + * True when Xero is the *active* vendor-matching source for the workspace — i.e. Xero is + * connected AND neither QBO nor Intacct is in a vendor-matching export mode. Mirrors the precedence + * in `getMatchingVendors` (QBO → Intacct → Xero) so the UI labels, copy, and inactive-vendor + * guardrail stay bound to whichever integration's vendor list is actually being consulted. Without + * this scoping, a workspace with active QBO matching + a lingering Xero connection would render + * QBO vendors under the "Supplier" label. + */ +function isXeroActiveMatchingSource(policy: OnyxEntry): boolean { + return isXeroVendorMatchingActive(policy) && !isQBOVendorMatchingActive(policy) && !isIntacctVendorMatchingActive(policy); +} + /** * Vendor matching feature gate. Returns true when the workspace has the `vendorMatching` beta * enabled AND a supported accounting integration is connected with a non-reimbursable export type @@ -2693,8 +2709,7 @@ export { getMatchingVendors, getXeroSupplierByID, getXeroSuppliers, - isIntacctVendorMatchingActive, - isQBOVendorMatchingActive, + isXeroActiveMatchingSource, isXeroVendorMatchingActive, hasVendorFeature, getValidConnectedIntegration, diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index c89070f11324..e72e720ae2fb 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -22,10 +22,8 @@ import { hasVendorFeature, isAttendeeTrackingEnabled as isAttendeeTrackingEnabledForPolicy, isDefaultTagName, - isIntacctVendorMatchingActive, - isQBOVendorMatchingActive, isTaxTrackingEnabled, - isXeroVendorMatchingActive, + isXeroActiveMatchingSource, } from '@libs/PolicyUtils'; import {isCurrentUserSubmitter} from '@libs/ReportUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; @@ -487,9 +485,8 @@ const ViolationsUtils = { // active-source case so dual/stale-connection states (e.g. active QBO + lingering // Xero with no contacts synced) still run the QBO check normally instead of being // silenced by the Xero connection's existence. - const isXeroActiveSource = isXeroVendorMatchingActive(policy) && !isQBOVendorMatchingActive(policy) && !isIntacctVendorMatchingActive(policy); const xeroContactsSynced = policy.connections?.[CONST.POLICY.CONNECTIONS.NAME.XERO]?.data?.contacts !== undefined; - if (isXeroActiveSource && !xeroContactsSynced) { + if (isXeroActiveMatchingSource(policy) && !xeroContactsSynced) { // No-op — supplier list not yet known for this workspace. } else { const matchedVendor = getMatchingVendorByID(policy, transactionVendorID); diff --git a/src/pages/iou/request/step/IOURequestStepVendor.tsx b/src/pages/iou/request/step/IOURequestStepVendor.tsx index f9a3018bb375..f7ab7dd89284 100644 --- a/src/pages/iou/request/step/IOURequestStepVendor.tsx +++ b/src/pages/iou/request/step/IOURequestStepVendor.tsx @@ -14,7 +14,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {updateMoneyRequestVendor} from '@libs/actions/IOU/UpdateMoneyRequest'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Navigation from '@libs/Navigation/Navigation'; -import {getMatchingVendors, hasVendorFeature, isXeroVendorMatchingActive} from '@libs/PolicyUtils'; +import {getMatchingVendors, hasVendorFeature, isXeroActiveMatchingSource} from '@libs/PolicyUtils'; import {isPerDiemRequest} from '@libs/TransactionUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -56,7 +56,7 @@ function IOURequestStepVendor({ const delegateAccountID = useDelegateAccountID(); const isFeatureAvailable = hasVendorFeature(policy, isBetaEnabled(CONST.BETAS.VENDOR_MATCHING)); - const isOnXero = isXeroVendorMatchingActive(policy); + const isOnXero = isXeroActiveMatchingSource(policy); // Vendor is scoped to non-reimbursable expenses on a policy expense chat; block deep-link / stale-open access if the transaction is reimbursable or is an invoice (invoices are non-reimbursable but don't route through the vendor-matching flow). const isReimbursable = !!transaction?.reimbursable; diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index 6d16a38c0063..40d4b5a96573 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -48,6 +48,7 @@ import { hasVendorFeature, isPolicyMemberWithoutPendingDelete, isSubmitterApproveBlockedOnSubmitWorkspace, + isXeroActiveMatchingSource, shouldShowPolicy, sortPoliciesByName, sortWorkspacesBySelected, @@ -3481,6 +3482,73 @@ describe('PolicyUtils', () => { expect(getXeroSupplierByID(buildXeroPolicy(XERO_CONTACTS_UNSYNCED), 'xc1')).toBeUndefined(); }); }); + + describe('isXeroActiveMatchingSource (R4)', () => { + it('returns true when only Xero is connected', () => { + expect(isXeroActiveMatchingSource(buildXeroPolicy())).toBe(true); + }); + + it('returns false when QBO is the active matching source even if Xero is also connected', () => { + // The exact dual-connection state the helper was introduced for: QBO is in + // vendor-matching export mode (CC), and Xero is connected with contacts. The + // label flip / picker copy must stay on "Vendor" because the user is actually + // seeing QBO vendors via getMatchingVendors' precedence rule. + const xeroPolicy = buildXeroPolicy({xc1: {id: 'xc1', name: 'Acme Xero', email: 'acme@example.com'}}); + const policy = { + ...xeroPolicy, + connections: { + ...xeroPolicy.connections, + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}, + data: {vendors: [{id: 'v-1', name: 'Acme QBO', currency: 'USD'}]}, + }, + }, + } as Policy; + expect(isXeroActiveMatchingSource(policy)).toBe(false); + }); + + it('returns false when Intacct is the active matching source even if Xero is also connected', () => { + const xeroPolicy = buildXeroPolicy({xc1: {id: 'xc1', name: 'Acme Xero', email: 'acme@example.com'}}); + const policy = { + ...xeroPolicy, + connections: { + ...xeroPolicy.connections, + [CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT]: { + config: {export: {nonReimbursable: CONST.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE.CREDIT_CARD_CHARGE}}, + data: {vendors: [{id: 'iv-1', name: 'V001', value: 'Acme Intacct'}]}, + }, + }, + } as Policy; + expect(isXeroActiveMatchingSource(policy)).toBe(false); + }); + + it('returns true when QBO is connected but in a non-matching export mode (Vendor Bill) + Xero is connected', () => { + // QBO is connected but not in CC/DC mode, so isQBOVendorMatchingActive is false. + // Xero is connected, so it takes over as the active source via getMatchingVendors' + // fall-through. + const xeroPolicy = buildXeroPolicy({xc1: {id: 'xc1', name: 'Acme Xero', email: 'acme@example.com'}}); + const policy = { + ...xeroPolicy, + connections: { + ...xeroPolicy.connections, + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL}, + data: {vendors: [{id: 'v-1', name: 'Acme QBO', currency: 'USD'}]}, + }, + }, + } as Policy; + expect(isXeroActiveMatchingSource(policy)).toBe(true); + }); + + it('returns false when Xero is not connected', () => { + const policy = {...createRandomPolicy(0), connections: {}} as Policy; + expect(isXeroActiveMatchingSource(policy)).toBe(false); + }); + + it('returns false when policy is undefined', () => { + expect(isXeroActiveMatchingSource(undefined)).toBe(false); + }); + }); }); describe('hasPolicyRulesError', () => { From 7c1dc635ebacffe62d676b79f78711d484802b86 Mon Sep 17 00:00:00 2001 From: Alex Beaman Date: Fri, 19 Jun 2026 17:27:08 -0600 Subject: [PATCH 12/14] Gate Xero supplier select page on hasVendorFeature; drop unused export; bump seatbelt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Codex P2 (https://github.com/Expensify/App/pull/94093#discussion_r3444858620): the default-supplier picker can be reached via direct deep-link even when the parent page hides the row (beta disabled or no Xero connection). Gate the page with hasVendorFeature(policy, isBetaEnabled(BETAS.VENDOR_MATCHING)) via shouldBeBlocked, and short-circuit selectSupplier defensively so a race-completed tap can never persist the update. * Drop the isXeroVendorMatchingActive export (knip) — only the function is needed internally now that label consumers route through isXeroActiveMatchingSource. Function stays defined for hasVendorFeature. * Bump the PolicyUtilsTest no-unsafe-type-assertion seatbelt 111 -> 119 to cover the new dual-connection regression tests added for the Xero-scoped helpers and isXeroActiveMatchingSource. --- config/eslint/eslint.seatbelt.tsv | 2 +- src/libs/PolicyUtils.ts | 1 - ...oNonReimbursableDefaultContactSelectPage.tsx | 17 +++++++++++++++-- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index cb02ca2afe80..8fc854dff3ab 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -1798,7 +1798,7 @@ "../../tests/unit/PersonalDetailsSelectorTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 "../../tests/unit/PolicyChangeLogContentTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../tests/unit/PolicySelectorTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 22 -"../../tests/unit/PolicyUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 111 +"../../tests/unit/PolicyUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 119 "../../tests/unit/PopoverMenuV2Test.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../tests/unit/PressResponderTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../tests/unit/QuickActionUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 7 diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index bc608db28b0d..e2466338d560 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -2710,7 +2710,6 @@ export { getXeroSupplierByID, getXeroSuppliers, isXeroActiveMatchingSource, - isXeroVendorMatchingActive, hasVendorFeature, getValidConnectedIntegration, getCountOfEnabledTagsOfList, diff --git a/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx b/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx index 9f88bcd64096..904a596d9b5e 100644 --- a/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx +++ b/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx @@ -4,13 +4,14 @@ import type {SelectorType} from '@components/SelectionScreen'; import SelectionScreen from '@components/SelectionScreen'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; import {updateManyPolicyConnectionConfigs} from '@libs/actions/connections'; import {clearXeroErrorField} from '@libs/actions/Policy/Policy'; import {getLatestErrorField} from '@libs/ErrorUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; -import {getXeroSuppliers, settingsPendingAction} from '@libs/PolicyUtils'; +import {getXeroSuppliers, hasVendorFeature, settingsPendingAction} from '@libs/PolicyUtils'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import variables from '@styles/variables'; @@ -20,11 +21,16 @@ import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; function DynamicXeroNonReimbursableDefaultContactSelectPage({policy}: WithPolicyConnectionsProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); + const {isBetaEnabled} = usePermissions(); const illustrations = useMemoizedLazyIllustrations(['Telescope']); const policyID = policy?.id; const xeroConfig = policy?.connections?.xero?.config; const currentContactID = xeroConfig?.defaultContact; + // Match the parent page's gate so direct deep-links (or stale-open tabs after the beta is + // revoked) cannot reach the supplier updater. The parent page hides the row when the feature + // is off, but the route remains addressable on its own. + const isFeatureAvailable = hasVendorFeature(policy, isBetaEnabled(CONST.BETAS.VENDOR_MATCHING)); const suppliers = useMemo(() => getXeroSuppliers(policy), [policy]); const data: SelectorType[] = useMemo( @@ -44,6 +50,12 @@ function DynamicXeroNonReimbursableDefaultContactSelectPage({policy}: WithPolicy const selectSupplier = useCallback( ({value}: SelectorType) => { + // Defence-in-depth: if the screen render race-ended with isFeatureAvailable still + // truthy and the user managed to tap, refuse to persist the update. + if (!isFeatureAvailable) { + goBack(); + return; + } if (value !== currentContactID && policyID) { updateManyPolicyConnectionConfigs( policyID, @@ -54,7 +66,7 @@ function DynamicXeroNonReimbursableDefaultContactSelectPage({policy}: WithPolicy } goBack(); }, - [currentContactID, policyID, goBack], + [currentContactID, policyID, isFeatureAvailable, goBack], ); const listEmptyContent = useMemo( @@ -76,6 +88,7 @@ function DynamicXeroNonReimbursableDefaultContactSelectPage({policy}: WithPolicy policyID={policyID} accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN, CONST.POLICY.ACCESS_VARIANTS.PAID]} featureName={CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED} + shouldBeBlocked={!isFeatureAvailable} displayName="DynamicXeroNonReimbursableDefaultContactSelectPage" title="workspace.xero.defaultSupplier" data={data} From 17ffd991e96d5a6545a9716af501341a0b2cb51a Mon Sep 17 00:00:00 2001 From: Alex Beaman Date: Fri, 19 Jun 2026 17:45:46 -0600 Subject: [PATCH 13/14] Fix spellcheck: British 'Defence' -> American 'Defense' --- .../DynamicXeroNonReimbursableDefaultContactSelectPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx b/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx index 904a596d9b5e..4f29382923d1 100644 --- a/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx +++ b/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx @@ -50,7 +50,7 @@ function DynamicXeroNonReimbursableDefaultContactSelectPage({policy}: WithPolicy const selectSupplier = useCallback( ({value}: SelectorType) => { - // Defence-in-depth: if the screen render race-ended with isFeatureAvailable still + // Defense-in-depth: if the screen render race-ended with isFeatureAvailable still // truthy and the user managed to tap, refuse to persist the update. if (!isFeatureAvailable) { goBack(); From f44abb75f64dd8bfc322569ea5a1de19d002fdda Mon Sep 17 00:00:00 2001 From: Alex Beaman Date: Fri, 19 Jun 2026 18:29:55 -0600 Subject: [PATCH 14/14] Bump ModifiedExpenseMessageTest seatbelt 6 -> 7 for the Xero supplier policy literal cast --- config/eslint/eslint.seatbelt.tsv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 8fc854dff3ab..dcf52ed5b50f 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -1770,7 +1770,7 @@ "../../tests/unit/MentionUserRendererTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 8 "../../tests/unit/MentionUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../tests/unit/MiddlewareTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 20 -"../../tests/unit/ModifiedExpenseMessageTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 6 +"../../tests/unit/ModifiedExpenseMessageTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 7 "../../tests/unit/ModifiedExpenseMessageTest.ts" "no-restricted-imports" 1 "../../tests/unit/MoneyRequestReportTransactionListActiveTransactionIDsTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../tests/unit/MoneyRequestReportUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 8