Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 4 additions & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
49 changes: 44 additions & 5 deletions src/libs/PolicyUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2685,10 +2685,14 @@ function isDualEntryVendorMatchingActive(policy: OnyxEntry<Policy>): boolean {
return !!policy?.connections?.[CONST.POLICY.CONNECTIONS.NAME.DUALENTRY]?.config?.isConfigured;
}

function isBusinessCentralVendorMatchingActive(policy: OnyxEntry<Policy>): 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.
Expand All @@ -2704,12 +2708,13 @@ function isXeroActiveMatchingSource(policy: OnyxEntry<Policy>): 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<Policy>, isVendorMatchingBetaEnabled: boolean): boolean {
if (!policy) {
Expand All @@ -2720,13 +2725,17 @@ function hasVendorFeature(policy: OnyxEntry<Policy>, 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".
*
Expand Down Expand Up @@ -2767,6 +2776,9 @@ function getActiveVendorMatchingIntegration(policy: OnyxEntry<Policy>): Connecti
if (isDualEntryVendorMatchingActive(policy)) {
return CONST.POLICY.CONNECTIONS.NAME.DUALENTRY;
}
if (isBusinessCentralVendorMatchingActive(policy)) {
return CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL;
}
return undefined;
}

Expand Down Expand Up @@ -2811,12 +2823,24 @@ function getActiveVendorMatchingVendors(policy: OnyxEntry<Policy>): 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.
*/
Expand Down Expand Up @@ -2891,6 +2915,15 @@ function findVendorByID(policy: OnyxEntry<Policy>, 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);
}

Expand Down Expand Up @@ -2941,6 +2974,11 @@ function getVendorEmptyState(policy: OnyxEntry<Policy>, 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);
Expand Down Expand Up @@ -3468,6 +3506,7 @@ export {
getXeroSuppliers,
getDualEntryVendors,
isRilletVendorMatchingActive,
isBusinessCentralVendorMatchingActive,
isDualEntryVendorMatchingActive,
isXeroActiveMatchingSource,
isXeroVendorMatchingActive,
Expand Down
3 changes: 2 additions & 1 deletion src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, [
Expand All @@ -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);

Expand Down
62 changes: 62 additions & 0 deletions tests/unit/PolicyUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import {
hasPolicyRulesError,
hasPolicyWithXeroConnection,
hasVendorFeature,
isBusinessCentralVendorMatchingActive,
isArchivedPolicy,
isDualEntryVendorMatchingActive,
isMatchingVendorListLoaded,
Expand Down Expand Up @@ -4159,6 +4160,62 @@ 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<ReturnType<typeof businessCentralVendor>> | typeof BUSINESS_CENTRAL_VENDORS_UNSYNCED = [businessCentralVendor('bc-1', 'Contoso Supplies', 'ap@contoso.com')],
{isConfigured = true}: {isConfigured?: boolean} = {},
): Policy =>
createMock<Policy>({
...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('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};
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},
Expand Down Expand Up @@ -4527,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<Policy>({
Expand Down
Loading