diff --git a/cspell.json b/cspell.json index c4cc037494c0..949204955d86 100644 --- a/cspell.json +++ b/cspell.json @@ -90,6 +90,7 @@ "Codat", "Codice", "Combustors", + "Contoso", "conv", "Corpay", "Countertop", diff --git a/src/languages/de.ts b/src/languages/de.ts index 2e048d889719..f7ae9742229b 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -8777,6 +8777,8 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc subsidiarySelectDescription: 'Wählen Sie die Dynamics 365 Business Central-Tochtergesellschaft aus, die mit diesem Workspace synchronisiert werden soll.', noCompaniesFound: 'Keine Unternehmen gefunden', noCompaniesFoundDescription: 'Bitte fügen Sie ein Unternehmen in Dynamics 365 Business Central hinzu und synchronisieren Sie die Verbindung erneut', + noVendorsFound: 'Keine Anbieter gefunden', + noVendorsFoundDescription: 'Bitte fügen Sie Lieferanten in Business Central hinzu und synchronisieren Sie die Verbindung erneut', }, }, getAssistancePage: { diff --git a/src/languages/el.ts b/src/languages/el.ts index 978da7162459..8b3ea48e15b4 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -8943,6 +8943,8 @@ ${reportName}`, subsidiarySelectDescription: 'Επιλέξτε τη θυγατρική του Dynamics 365 Business Central για συγχρονισμό με αυτόν τον χώρο εργασίας.', noCompaniesFound: 'Δεν βρέθηκαν εταιρείες', noCompaniesFoundDescription: 'Παρακαλούμε προσθέστε μια εταιρεία στο Dynamics 365 Business Central και συγχρονίστε ξανά τη σύνδεση', + noVendorsFound: 'Δεν βρέθηκαν προμηθευτές', + noVendorsFoundDescription: 'Παρακαλούμε προσθέστε προμηθευτές στο Business Central και συγχρονίστε ξανά τη σύνδεση', }, }, getAssistancePage: { diff --git a/src/languages/en.ts b/src/languages/en.ts index b5825e8ffeee..9487728eb16f 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -6221,6 +6221,8 @@ const translations = { subsidiarySelectDescription: 'Select the Dynamics 365 Business Central subsidiary to sync with this workspace.', noCompaniesFound: 'No companies found', noCompaniesFoundDescription: 'Please add a company in Dynamics 365 Business Central and sync the connection again', + noVendorsFound: 'No vendors found', + noVendorsFoundDescription: 'Please add vendors in Business Central and sync the connection again', }, type: { free: 'Free', diff --git a/src/languages/es.ts b/src/languages/es.ts index 44ed1ed628eb..45d197fb3a11 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -8612,6 +8612,8 @@ ${reportName}`, subsidiarySelectDescription: 'Selecciona la filial de Dynamics 365 Business Central con la que quieres sincronizar este espacio de trabajo.', noCompaniesFound: 'No se encontraron empresas', noCompaniesFoundDescription: 'Por favor, añade una empresa en Dynamics 365 Business Central y sincroniza la conexión de nuevo', + noVendorsFound: 'No se han encontrado proveedores', + noVendorsFoundDescription: 'Por favor, añade proveedores en Business Central y vuelve a sincronizar la conexión', }, }, getAssistancePage: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 0d793dae8174..bb4dec833a8b 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -8793,6 +8793,8 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e subsidiarySelectDescription: 'Sélectionnez la filiale Dynamics 365 Business Central à synchroniser avec cet espace de travail.', noCompaniesFound: 'Aucune entreprise trouvée', noCompaniesFoundDescription: 'Veuillez ajouter une société dans Dynamics 365 Business Central et synchroniser à nouveau la connexion', + noVendorsFound: 'Aucun fournisseur trouvé', + noVendorsFoundDescription: 'Veuillez ajouter des fournisseurs dans Business Central et synchroniser à nouveau la connexion', }, }, getAssistancePage: { diff --git a/src/languages/it.ts b/src/languages/it.ts index 4f26513f54b4..fd19f29a893d 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -8724,6 +8724,8 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, subsidiarySelectDescription: 'Seleziona la consociata Dynamics 365 Business Central da sincronizzare con questo spazio di lavoro.', noCompaniesFound: 'Nessuna azienda trovata', noCompaniesFoundDescription: "Aggiungi un'azienda in Dynamics 365 Business Central e sincronizza di nuovo la connessione", + noVendorsFound: 'Nessun fornitore trovato', + noVendorsFoundDescription: 'Aggiungi i fornitori in Business Central e sincronizza di nuovo la connessione', }, }, getAssistancePage: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 42828ac56568..df9eea012d89 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -8614,6 +8614,8 @@ ${reportName}`, subsidiarySelectDescription: 'このワークスペースと同期する Dynamics 365 Business Central の子会社を選択してください。', noCompaniesFound: '会社が見つかりません', noCompaniesFoundDescription: 'Dynamics 365 Business Central に会社を追加して、接続をもう一度同期してください', + noVendorsFound: '取引先が見つかりませんでした', + noVendorsFoundDescription: 'Business Central に仕入先を追加してから、もう一度接続を同期してください', }, }, getAssistancePage: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 05e69b44940b..698d03c0d04e 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -8700,6 +8700,8 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, subsidiarySelectDescription: 'Selecteer de Dynamics 365 Business Central-dochteronderneming om met deze workspace te synchroniseren.', noCompaniesFound: 'Geen bedrijven gevonden', noCompaniesFoundDescription: 'Voeg een bedrijf toe in Dynamics 365 Business Central en synchroniseer de verbinding opnieuw', + noVendorsFound: 'Geen leveranciers gevonden', + noVendorsFoundDescription: 'Voeg leveranciers toe in Business Central en synchroniseer de koppeling opnieuw', }, }, getAssistancePage: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 5c9102171d4b..cff93774db6e 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -8721,6 +8721,8 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, subsidiarySelectDescription: 'Wybierz jednostkę zależną Dynamics 365 Business Central do synchronizacji z tym obszarem roboczym.', noCompaniesFound: 'Nie znaleziono firm', noCompaniesFoundDescription: 'Dodaj proszę firmę w Dynamics 365 Business Central i zsynchronizuj połączenie ponownie', + noVendorsFound: 'Nie znaleziono dostawców', + noVendorsFoundDescription: 'Dodaj proszę dostawców w Business Central i zsynchronizuj połączenie ponownie', }, }, getAssistancePage: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 3287f909cc82..c473ced89523 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -8706,6 +8706,8 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, subsidiarySelectDescription: 'Selecione a subsidiária do Dynamics 365 Business Central para sincronizar com este workspace.', noCompaniesFound: 'Nenhuma empresa encontrada', noCompaniesFoundDescription: 'Adicione uma empresa no Dynamics 365 Business Central e sincronize a conexão novamente', + noVendorsFound: 'Nenhum fornecedor encontrado', + noVendorsFoundDescription: 'Adicione fornecedores no Business Central e sincronize a conexão novamente', }, }, getAssistancePage: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 8386da74d887..826bdddb6a96 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -8389,6 +8389,8 @@ ${reportName}`, subsidiarySelectDescription: '选择要与此工作区同步的 Dynamics 365 Business Central 子公司。', noCompaniesFound: '未找到公司', noCompaniesFoundDescription: '请在 Dynamics 365 Business Central 中添加一家公司并重新同步连接', + noVendorsFound: '未找到供应商', + noVendorsFoundDescription: '请在 Business Central 中添加供应商,然后再次同步连接', }, }, getAssistancePage: { diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 39814246c0b1..2751397b4e72 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -2711,10 +2711,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. @@ -2730,12 +2734,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 Xero and Rillet branches but not on QBO, Sage Intacct, or DualEntry: + * `isVendorMatchingBetaEnabled` is consulted on every branch but QBO and Sage Intacct or Dual Entry: * - 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. GA, so no 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. GA, so no beta required + * - Business Central configured connection. Beta required */ function hasVendorFeature(policy: OnyxEntry, isVendorMatchingBetaEnabled: boolean): boolean { if (!policy) { @@ -2744,12 +2749,12 @@ function hasVendorFeature(policy: OnyxEntry, isVendorMatchingBetaEnabled if (isQBOVendorMatchingActive(policy) || isIntacctVendorMatchingActive(policy) || isDualEntryVendorMatchingActive(policy)) { return true; } - return isVendorMatchingBetaEnabled && (isXeroVendorMatchingActive(policy) || isRilletVendorMatchingActive(policy)); + return isVendorMatchingBetaEnabled && (isXeroVendorMatchingActive(policy) || isRilletVendorMatchingActive(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". * @@ -2790,6 +2795,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; } @@ -2834,12 +2842,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. */ @@ -2929,6 +2949,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); } @@ -2979,6 +3008,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); @@ -3507,6 +3541,7 @@ export { getXeroSuppliers, getDualEntryVendors, isRilletVendorMatchingActive, + isBusinessCentralVendorMatchingActive, isDualEntryVendorMatchingActive, isXeroActiveMatchingSource, isXeroVendorMatchingActive, diff --git a/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx b/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx index 3af7c8a3fd3c..d2261f9e7704 100644 --- a/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx +++ b/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx @@ -172,8 +172,9 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro // `hasVendorFeature` stays as the narrower `isActive` predicate (is the export config scoping // vendors right now), so it can't double as the visibility gate. // - // When no source is active, keep the connected integration's discovery row. - // QBO (R1), Sage Intacct (R2), and DualEntry are GA. Xero and Rillet require the vendorMatching beta. + // Use the active vendor source so a stale GA connection cannot bypass the beta for another + // integration. When no source is active, keep the connected integration's discovery row. + // QBO (R1) and Sage Intacct (R2), and DualEntry are GA. Xero, Rillet, and Business Central require the vendorMatching beta. const vendorMatchingConnection = getActiveVendorMatchingIntegration(policy) ?? getConnectedIntegration(policy, [ @@ -182,6 +183,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 isGenerallyAvailableVendorConnection = vendorMatchingConnection === CONST.POLICY.CONNECTIONS.NAME.QBO || diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index d76367dc5e33..7f85cc0d69a1 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -72,6 +72,7 @@ import { hasPolicyRulesError, hasPolicyWithXeroConnection, hasVendorFeature, + isBusinessCentralVendorMatchingActive, isArchivedPolicy, isDualEntryVendorMatchingActive, isMatchingVendorListLoaded, @@ -4329,6 +4330,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> | 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('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}, @@ -4706,6 +4763,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({