From eed9be3258708afea52bda59edadd23e6538e11c Mon Sep 17 00:00:00 2001 From: Nikki Wines Date: Thu, 17 Sep 2026 14:57:02 +0100 Subject: [PATCH 1/4] add BC to vendor matching --- src/libs/PolicyUtils.ts | 35 ++++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 39c4a449a18a..5e4db9e32765 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -2685,10 +2685,14 @@ function isDualEntryVendorMatchingActive(policy: OnyxEntry): boolean { return !!policy?.connections?.[CONST.POLICY.CONNECTIONS.NAME.DUALENTRY]?.config?.isConfigured; } +function isBusinessCentralVendorMatchingActive(policy: OnyxEntry): boolean { + return !!policy?.connections?.[CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL]?.config?.isConfigured; +} + /** * 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 `getActiveVendorMatchingIntegration` (QBO → Intacct → Xero → Rillet → DualEntry) so the UI labels, copy, and + * in `getActiveVendorMatchingIntegration` (QBO → Intacct → Xero → Rillet → DualEntry → Business Central) 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. @@ -2704,12 +2708,13 @@ function isXeroActiveMatchingSource(policy: OnyxEntry): boolean { * the field. * * The `vendorMatching` beta only gates the integrations that haven't reached GA yet, so - * `isVendorMatchingBetaEnabled` is consulted on the Intacct, Xero, Rillet, and DualEntry branches but not on QBO: + * `isVendorMatchingBetaEnabled` is consulted on every branch but QBO: * - QBO (R1) with non-reimbursable export = Credit Card or Debit Card. GA, so no beta required * - Sage Intacct (R2) with non-reimbursable export = Credit Card Charge. Beta required * - Xero (R3) has no export destination enum, so a configured connection is enough. Beta required * - Rillet (R4) configured connection. Beta required * - DualEntry configured connection. Beta required + * - Business Central configured connection. Beta required */ function hasVendorFeature(policy: OnyxEntry, isVendorMatchingBetaEnabled: boolean): boolean { if (!policy) { @@ -2720,13 +2725,17 @@ function hasVendorFeature(policy: OnyxEntry, isVendorMatchingBetaEnabled } return ( isVendorMatchingBetaEnabled && - (isIntacctVendorMatchingActive(policy) || isXeroVendorMatchingActive(policy) || isRilletVendorMatchingActive(policy) || isDualEntryVendorMatchingActive(policy)) + (isIntacctVendorMatchingActive(policy) || + isXeroVendorMatchingActive(policy) || + isRilletVendorMatchingActive(policy) || + isDualEntryVendorMatchingActive(policy) || + isBusinessCentralVendorMatchingActive(policy)) ); } /** * Single source of truth for which connected integration scopes the vendor field for this workspace - * (QBO, Sage Intacct, Xero, Rillet, or DualEntry) and what its vendor list looks like. Returns `undefined` when no + * (QBO, Sage Intacct, Xero, Rillet, DualEntry, or Business Central) and what its vendor list looks like. Returns `undefined` when no * vendor-matching integration is active OR when the active integration's list hasn't synced yet — * distinct from `[]` (loaded-empty). Lets callers tell "no vendors" from "not loaded". * @@ -2767,6 +2776,9 @@ function getActiveVendorMatchingIntegration(policy: OnyxEntry): Connecti if (isDualEntryVendorMatchingActive(policy)) { return CONST.POLICY.CONNECTIONS.NAME.DUALENTRY; } + if (isBusinessCentralVendorMatchingActive(policy)) { + return CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL; + } return undefined; } @@ -2811,12 +2823,24 @@ function getActiveVendorMatchingVendors(policy: OnyxEntry): Vendor[] | u if (isDualEntryVendorMatchingActive(policy)) { return policy.connections?.[CONST.POLICY.CONNECTIONS.NAME.DUALENTRY]?.data?.vendors === undefined ? undefined : getDualEntryVendors(policy); } + if (isBusinessCentralVendorMatchingActive(policy)) { + const businessCentralVendors = policy.connections?.[CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL]?.data?.vendors; + if (businessCentralVendors === undefined) { + return undefined; + } + return businessCentralVendors.map((vendor) => ({ + id: vendor.id, + name: vendor.name, + currency: '', + email: vendor.email, + })); + } return undefined; } /** * Returns the vendor list imported into the workspace from whichever connected integration scopes - * the vendor field for this workspace (QBO, Sage Intacct, Xero, Rillet, or DualEntry). Empty array when no integration + * the vendor field for this workspace (QBO, Sage Intacct, Xero, Rillet, DualEntry, or Business Central). Empty array when no integration * is connected or the sync hasn't populated vendors yet. Source of truth for the vendor selector * RHP and inactive-vendor lookups. */ @@ -3468,6 +3492,7 @@ export { getXeroSuppliers, getDualEntryVendors, isRilletVendorMatchingActive, + isBusinessCentralVendorMatchingActive, isDualEntryVendorMatchingActive, isXeroActiveMatchingSource, isXeroVendorMatchingActive, From cc136f3b1f603f6d99b93105d33cf94a1206f8d5 Mon Sep 17 00:00:00 2001 From: Nikki Wines Date: Thu, 17 Sep 2026 14:57:19 +0100 Subject: [PATCH 2/4] add BC vendor matching tests --- tests/unit/PolicyUtilsTest.ts | 49 +++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index f96bd9b46c3a..3b93a1b13418 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -70,6 +70,7 @@ import { hasPolicyRulesError, hasPolicyWithXeroConnection, hasVendorFeature, + isBusinessCentralVendorMatchingActive, isArchivedPolicy, isDualEntryVendorMatchingActive, isMatchingVendorListLoaded, @@ -4159,6 +4160,54 @@ describe('PolicyUtils', () => { }, }); + const BUSINESS_CENTRAL_VENDORS_UNSYNCED = Symbol('BUSINESS_CENTRAL_VENDORS_UNSYNCED'); + const businessCentralVendor = (id: string, name: string, email = '') => ({id, number: '', name, email, blocked: '', expensifyVendorId: '', lastModifiedDateTime: ''}); + const buildBusinessCentralPolicy = ( + vendors: Array> | typeof BUSINESS_CENTRAL_VENDORS_UNSYNCED = [businessCentralVendor('bc-1', 'Contoso Supplies', 'ap@contoso.com')], + {isConfigured = true}: {isConfigured?: boolean} = {}, + ): Policy => + createMock({ + ...createRandomPolicy(0), + connections: { + [CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL]: { + config: {isConfigured}, + data: vendors === BUSINESS_CENTRAL_VENDORS_UNSYNCED ? {} : {vendors}, + }, + }, + }); + + describe('Business Central vendors', () => { + it('requires a configured connection and the matching beta', () => { + const policy = buildBusinessCentralPolicy(); + expect(isBusinessCentralVendorMatchingActive(policy)).toBe(true); + expect(hasVendorFeature(policy, true)).toBe(true); + expect(hasVendorFeature(policy, false)).toBe(false); + expect(hasVendorFeature(buildBusinessCentralPolicy(undefined, {isConfigured: false}), true)).toBe(false); + expect(isBusinessCentralVendorMatchingActive(undefined)).toBe(false); + }); + + it('normalizes the synced vendors for matching', () => { + const policy = buildBusinessCentralPolicy(); + expect(getMatchingVendors(policy)).toEqual([{id: 'bc-1', name: 'Contoso Supplies', currency: '', email: 'ap@contoso.com'}]); + expect(getActiveVendorMatchingIntegration(policy)).toBe(CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL); + }); + + // A company switch removes data.vendors while the connection stays configured, so the + // unloaded state has to stay distinguishable from a company that has no vendors. + it('distinguishes an unloaded list from a loaded empty list', () => { + expect(isMatchingVendorListLoaded(buildBusinessCentralPolicy(BUSINESS_CENTRAL_VENDORS_UNSYNCED))).toBe(false); + expect(isMatchingVendorListLoaded(buildBusinessCentralPolicy([]))).toBe(true); + expect(getMatchingVendors(buildBusinessCentralPolicy(BUSINESS_CENTRAL_VENDORS_UNSYNCED))).toEqual([]); + }); + + it('yields to Rillet, which precedes it in the matching order', () => { + const policy = buildBusinessCentralPolicy(); + policy.connections = {...policy.connections, ...buildRilletPolicy().connections}; + expect(getActiveVendorMatchingIntegration(policy)).toBe(CONST.POLICY.CONNECTIONS.NAME.RILLET); + expect(getMatchingVendors(policy).map((vendor) => vendor.id)).toEqual(['rv-1']); + }); + }); + describe('DualEntry vendors', () => { const vendors: DualEntryVendor[] = [ {id: '1', name: 'Company vendor', companyID: '10', email: 'vendor@example.com', isActive: true}, From dbb8a9838ea981ab872e607bb2b5042d91b6aef0 Mon Sep 17 00:00:00 2001 From: Nikki Wines Date: Thu, 17 Sep 2026 15:21:38 +0100 Subject: [PATCH 3/4] add copy --- src/languages/en.ts | 4 ++++ src/languages/es.ts | 4 ++++ src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx | 3 ++- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/languages/en.ts b/src/languages/en.ts index 4e031d4aadd4..dded58a32fbd 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -6037,6 +6037,10 @@ const translations = { description: "Choose your settlement account and we'll create the payment in Rillet.", }, }, + businessCentral: { + noVendorsFound: 'No vendors found', + noVendorsFoundDescription: 'Please add vendors in Business Central and sync the connection again', + }, dualEntry: { dualEntrySetup: 'DualEntry setup', enterCredentials: 'Enter your DualEntry API key', diff --git a/src/languages/es.ts b/src/languages/es.ts index e5d253156b99..f9dd211cffff 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -5828,6 +5828,10 @@ ${amount} para ${merchant} - ${date}`, }, }, }, + businessCentral: { + noVendorsFound: 'No se han encontrado proveedores', + noVendorsFoundDescription: 'Por favor, añade proveedores en Business Central y sincroniza la conexión de nuevo', + }, dualEntry: { dualEntrySetup: 'Configuración de DualEntry', enterCredentials: 'Introduce tu clave de API de DualEntry', diff --git a/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx b/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx index ff1c8965f7ad..8e979d3c376e 100644 --- a/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx +++ b/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx @@ -173,7 +173,7 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro // // Use the active vendor source so a stale QBO connection cannot bypass the beta for another // integration. When no source is active, keep the connected integration's discovery row. - // QBO (R1) is GA. Sage Intacct, Xero, Rillet, and DualEntry require the vendorMatching beta. + // QBO (R1) is GA. Sage Intacct, Xero, Rillet, DualEntry, and Business Central require the vendorMatching beta. const vendorMatchingConnection = getActiveVendorMatchingIntegration(policy) ?? getConnectedIntegration(policy, [ @@ -182,6 +182,7 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro CONST.POLICY.CONNECTIONS.NAME.XERO, CONST.POLICY.CONNECTIONS.NAME.RILLET, CONST.POLICY.CONNECTIONS.NAME.DUALENTRY, + CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL, ]); const shouldShowVendorsFeature = vendorMatchingConnection === CONST.POLICY.CONNECTIONS.NAME.QBO || (isVendorMatchingEnabled && !!vendorMatchingConnection); From 2762c63494d369da76ba527c8a8196178c57002d Mon Sep 17 00:00:00 2001 From: Nikki Wines Date: Thu, 17 Sep 2026 15:21:58 +0100 Subject: [PATCH 4/4] add to find vendorbyID update test --- src/libs/PolicyUtils.ts | 14 ++++++++++++++ tests/unit/PolicyUtilsTest.ts | 13 +++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 5e4db9e32765..57916830c7c9 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -2915,6 +2915,15 @@ function findVendorByID(policy: OnyxEntry, vendorID: string | undefined) email: rilletVendor.email ?? '', }; } + const businessCentralVendor = policy.connections?.[CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL]?.data?.vendors?.find((vendor) => vendor.id === vendorID); + if (businessCentralVendor) { + return { + id: businessCentralVendor.id, + name: businessCentralVendor.name, + currency: '', + email: businessCentralVendor.email ?? '', + }; + } return getDualEntryVendors(policy).find((vendor) => vendor.id === vendorID); } @@ -2965,6 +2974,11 @@ function getVendorEmptyState(policy: OnyxEntry, translate: LocaleContext title: translate('workspace.dualEntry.noVendorsFound'), subtitle: translate('workspace.dualEntry.noVendorsFoundDescription'), }; + case CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL: + return { + title: translate('workspace.businessCentral.noVendorsFound'), + subtitle: translate('workspace.businessCentral.noVendorsFoundDescription'), + }; case CONST.POLICY.CONNECTIONS.NAME.QBO: default: { const integrationName = getQuickbooksOnlineIntegrationName(policy, translate); diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index 3b93a1b13418..680df2160658 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -4200,6 +4200,14 @@ describe('PolicyUtils', () => { expect(getMatchingVendors(buildBusinessCentralPolicy(BUSINESS_CENTRAL_VENDORS_UNSYNCED))).toEqual([]); }); + it('uses the Business Central empty state when the synced list has no vendors', () => { + const translate = TestHelper.translateLocal; + expect(getVendorEmptyState(buildBusinessCentralPolicy([]), translate)).toEqual({ + title: translate('workspace.businessCentral.noVendorsFound'), + subtitle: translate('workspace.businessCentral.noVendorsFoundDescription'), + }); + }); + it('yields to Rillet, which precedes it in the matching order', () => { const policy = buildBusinessCentralPolicy(); policy.connections = {...policy.connections, ...buildRilletPolicy().connections}; @@ -4576,6 +4584,11 @@ describe('PolicyUtils', () => { expect(findVendorByID(policy, 'rv-1')).toEqual({id: 'rv-1', name: 'Acme Rillet', currency: '', email: 'acme@rillet.com'}); }); + it('resolves a Business Central vendor (normalized) from connections.businessCentral.data.vendors', () => { + const policy = buildBusinessCentralPolicy([businessCentralVendor('bc-1', 'Contoso Supplies', 'ap@contoso.com')]); + expect(findVendorByID(policy, 'bc-1')).toEqual({id: 'bc-1', name: 'Contoso Supplies', currency: '', email: 'ap@contoso.com'}); + }); + it('prefers the active Xero integration over stale Rillet data when both hold the same vendor ID', () => { const xeroPolicy = buildXeroPolicy({shared: {id: 'shared', name: 'Xero Name', email: 'xero@xero.com'}}); const policy = createMock({