diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 3ff13bc28e49..bd2b6b0c62c4 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -2666,10 +2666,14 @@ function isRilletVendorMatchingActive(policy: OnyxEntry): boolean { return !!policy?.connections?.[CONST.POLICY.CONNECTIONS.NAME.RILLET]?.config?.isConfigured; } +function isDualEntryVendorMatchingActive(policy: OnyxEntry): boolean { + return !!policy?.connections?.[CONST.POLICY.CONNECTIONS.NAME.DUALENTRY]?.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) so the UI labels, copy, and + * in `getActiveVendorMatchingIntegration` (QBO → Intacct → Xero → Rillet → DualEntry) 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. @@ -2685,11 +2689,12 @@ 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, and Rillet branches but not on QBO: + * `isVendorMatchingBetaEnabled` is consulted on the Intacct, Xero, Rillet, and DualEntry branches but not on 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 */ function hasVendorFeature(policy: OnyxEntry, isVendorMatchingBetaEnabled: boolean): boolean { if (!policy) { @@ -2698,12 +2703,15 @@ function hasVendorFeature(policy: OnyxEntry, isVendorMatchingBetaEnabled if (isQBOVendorMatchingActive(policy)) { return true; } - return isVendorMatchingBetaEnabled && (isIntacctVendorMatchingActive(policy) || isXeroVendorMatchingActive(policy) || isRilletVendorMatchingActive(policy)); + return ( + isVendorMatchingBetaEnabled && + (isIntacctVendorMatchingActive(policy) || isXeroVendorMatchingActive(policy) || isRilletVendorMatchingActive(policy) || isDualEntryVendorMatchingActive(policy)) + ); } /** * Single source of truth for which connected integration scopes the vendor field for this workspace - * (QBO, Sage Intacct, Xero, or Rillet) and what its vendor list looks like. Returns `undefined` when no + * (QBO, Sage Intacct, Xero, Rillet, or DualEntry) 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". * @@ -2741,6 +2749,9 @@ function getActiveVendorMatchingIntegration(policy: OnyxEntry): Connecti if (isRilletVendorMatchingActive(policy)) { return CONST.POLICY.CONNECTIONS.NAME.RILLET; } + if (isDualEntryVendorMatchingActive(policy)) { + return CONST.POLICY.CONNECTIONS.NAME.DUALENTRY; + } return undefined; } @@ -2782,12 +2793,15 @@ function getActiveVendorMatchingVendors(policy: OnyxEntry): Vendor[] | u email: vendor.email ?? '', })); } + if (isDualEntryVendorMatchingActive(policy)) { + return policy.connections?.[CONST.POLICY.CONNECTIONS.NAME.DUALENTRY]?.data?.vendors === undefined ? undefined : getDualEntryVendors(policy); + } return undefined; } /** * Returns the vendor list imported into the workspace from whichever connected integration scopes - * the vendor field for this workspace (QBO, Sage Intacct, or Xero). Empty array when no integration + * the vendor field for this workspace (QBO, Sage Intacct, Xero, Rillet, or DualEntry). 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. */ @@ -2862,7 +2876,7 @@ function findVendorByID(policy: OnyxEntry, vendorID: string | undefined) email: rilletVendor.email ?? '', }; } - return undefined; + return getDualEntryVendors(policy).find((vendor) => vendor.id === vendorID); } /** @@ -2907,6 +2921,11 @@ function getVendorEmptyState(policy: OnyxEntry, translate: LocaleContext title: translate('workspace.rillet.noVendorsFound'), subtitle: translate('workspace.rillet.noVendorsFoundDescription'), }; + case CONST.POLICY.CONNECTIONS.NAME.DUALENTRY: + return { + title: translate('workspace.dualEntry.noVendorsFound'), + subtitle: translate('workspace.dualEntry.noVendorsFoundDescription'), + }; case CONST.POLICY.CONNECTIONS.NAME.QBO: default: { const integrationName = getQuickbooksOnlineIntegrationName(policy, translate); @@ -2933,6 +2952,15 @@ function getXeroSuppliers(policy: OnyxEntry): Vendor[] { return Object.values(contacts).map((contact) => ({id: contact.id, name: contact.name, currency: '', email: contact.email})); } +/** DualEntry export settings and expense matching must use vendors available to the selected company */ +function getDualEntryVendors(policy: OnyxEntry): Vendor[] { + const connection = policy?.connections?.[CONST.POLICY.CONNECTIONS.NAME.DUALENTRY]; + const companyID = connection?.config?.subsidiaryID; + return (connection?.data?.vendors ?? []) + .filter((vendor) => !!vendor.id && vendor.isActive === true && (!vendor.companyID || vendor.companyID === companyID)) + .map((vendor) => ({id: vendor.id, name: vendor.name, currency: '', email: vendor.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 @@ -3423,7 +3451,9 @@ export { getVendorRuleDisplayValue, getXeroSupplierByID, getXeroSuppliers, + getDualEntryVendors, isRilletVendorMatchingActive, + isDualEntryVendorMatchingActive, isXeroActiveMatchingSource, isXeroVendorMatchingActive, hasVendorFeature, diff --git a/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx b/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx index ea938e3ab1ab..6debb78ce4b9 100644 --- a/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx +++ b/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx @@ -32,6 +32,7 @@ import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; import { arePolicyRulesEnabled, canPolicyAccessFeature, + getActiveVendorMatchingIntegration, getConnectedIntegration, getDistanceRateCustomUnit, getPerDiemCustomUnit, @@ -170,15 +171,18 @@ 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. // - // Beta gating mirrors `hasVendorFeature`: QBO (R1) is GA, so a connected QBO workspace always - // sees the row regardless of the `vendorMatching` beta. Sage Intacct (R2), Xero (R3), and Rillet (R4) - // haven't reached GA, so they only show the row while the beta is enabled. - const vendorMatchingConnection = getConnectedIntegration(policy, [ - CONST.POLICY.CONNECTIONS.NAME.QBO, - CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT, - CONST.POLICY.CONNECTIONS.NAME.XERO, - CONST.POLICY.CONNECTIONS.NAME.RILLET, - ]); + // 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. + const vendorMatchingConnection = + getActiveVendorMatchingIntegration(policy) ?? + getConnectedIntegration(policy, [ + CONST.POLICY.CONNECTIONS.NAME.QBO, + CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT, + CONST.POLICY.CONNECTIONS.NAME.XERO, + CONST.POLICY.CONNECTIONS.NAME.RILLET, + CONST.POLICY.CONNECTIONS.NAME.DUALENTRY, + ]); const shouldShowVendorsFeature = vendorMatchingConnection === CONST.POLICY.CONNECTIONS.NAME.QBO || (isVendorMatchingEnabled && !!vendorMatchingConnection); const warnAccountingManagesOrganizeFeature = async () => { diff --git a/src/pages/workspace/accounting/dualentry/export/DualEntryDefaultCompanyCardVendorPage.tsx b/src/pages/workspace/accounting/dualentry/export/DualEntryDefaultCompanyCardVendorPage.tsx index 133c376ba621..8f80d11645d4 100644 --- a/src/pages/workspace/accounting/dualentry/export/DualEntryDefaultCompanyCardVendorPage.tsx +++ b/src/pages/workspace/accounting/dualentry/export/DualEntryDefaultCompanyCardVendorPage.tsx @@ -11,7 +11,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {clearDualEntryErrorField, updateDualEntryDefaultVendor} from '@libs/actions/connections/DualEntry'; import {getLatestErrorField} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; -import {settingsPendingAction} from '@libs/PolicyUtils'; +import {getDualEntryVendors, settingsPendingAction} from '@libs/PolicyUtils'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; @@ -35,19 +35,15 @@ function DualEntryDefaultCompanyCardVendorPage({policy}: WithPolicyConnectionsPr const illustrations = useMemoizedLazyIllustrations(['Telescope']); const policyID = policy?.id; const dualentryConfig = policy?.connections?.dualEntry?.config; - const dualentryData = policy?.connections?.dualEntry?.data; const defaultCompanyCardVendorID = dualentryConfig?.export?.defaultVendorID; const backPath = policyID ? ROUTES.POLICY_ACCOUNTING_DUALENTRY_EXPORT.getRoute(policyID) : undefined; - const data: VendorListItem[] = - dualentryData?.vendors - ?.filter((vendorItem) => vendorItem.isActive) - .map((vendorItem) => ({ - value: vendorItem.id, - text: vendorItem.name, - keyForList: vendorItem.id, - isSelected: defaultCompanyCardVendorID === vendorItem.id, - })) ?? []; + const data: VendorListItem[] = getDualEntryVendors(policy).map((vendorItem) => ({ + value: vendorItem.id, + text: vendorItem.name, + keyForList: vendorItem.id, + isSelected: defaultCompanyCardVendorID === vendorItem.id, + })); const {filteredData, textInputOptions} = useSelectionListSearch(data); const headerContent = ( diff --git a/tests/ui/WorkspaceMoreFeaturesPageTest.tsx b/tests/ui/WorkspaceMoreFeaturesPageTest.tsx index fab7084036f2..5802a28d4f4b 100644 --- a/tests/ui/WorkspaceMoreFeaturesPageTest.tsx +++ b/tests/ui/WorkspaceMoreFeaturesPageTest.tsx @@ -467,6 +467,31 @@ describe('WorkspaceMoreFeaturesPage', () => { await expect(findLockedSwitch('workspace.moreFeatures.vendors.subtitle')).resolves.toBeOnTheScreen(); }); + it.each([ + {isBetaEnabled: false, qboDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL, shouldShowVendors: false}, + {isBetaEnabled: true, qboDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL, shouldShowVendors: true}, + {isBetaEnabled: false, qboDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD, shouldShowVendors: true}, + ])( + 'sets Vendors visibility to $shouldShowVendors for DualEntry with QBO exporting $qboDestination and beta=$isBetaEnabled', + async ({isBetaEnabled, qboDestination, shouldShowVendors}) => { + // Given a configured DualEntry connection and a retained QBO connection + const connections = { + [CONST.POLICY.CONNECTIONS.NAME.QBO]: {config: {nonReimbursableExpensesExportDestination: qboDestination}}, + [CONST.POLICY.CONNECTIONS.NAME.DUALENTRY]: {config: {isConfigured: true}}, + }; + + // When the More features page renders with the selected beta state + await renderWithVendorMatching(connections, isBetaEnabled); + + // Then visibility follows the active vendor source's beta requirement + if (shouldShowVendors) { + await expect(findLockedSwitch('workspace.moreFeatures.vendors.subtitle')).resolves.toBeOnTheScreen(); + } else { + expect(vendorsSwitchQuery()).toBeNull(); + } + }, + ); + // Sage Intacct (R2) and Xero (R3) are still beta-gated, so they stay hidden when the beta is off. it('hides the Vendors row for a beta-gated integration (Xero) when the beta is disabled', async () => { await renderWithVendorMatching({[CONST.POLICY.CONNECTIONS.NAME.XERO]: {config: {}}}, false); diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index bc2cebcc9aef..bb6ff8403f67 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -26,6 +26,7 @@ import { getDefaultChatEnabledPolicySelection, getDefaultTimeTrackingRate, getDefaultWorkspacePlanType, + getDualEntryVendors, getEligibleBankAccountShareRecipientEmails, getExcludedUsers, getExpensifyTeamExclusions, @@ -36,6 +37,7 @@ import { getMatchingVendorByID, getMatchingVendors, getVendorEmptyState, + getVendorRuleDisplayValue, getPolicyApproverLogins, getPolicyBrickRoadIndicatorStatus, getPolicyByCustomUnitID, @@ -68,6 +70,8 @@ import { hasPolicyWithXeroConnection, hasVendorFeature, isArchivedPolicy, + isDualEntryVendorMatchingActive, + isMatchingVendorListLoaded, isMaxExpenseAmountSet, isMergeHRCompleteSetupNeededSelector, isPerDiemEligiblePolicy, @@ -91,7 +95,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {PersonalDetailsList, Policy, PolicyEmployeeList, PolicyTags, PolicyTagLists, Report, Transaction} from '@src/types/onyx'; import type {ApprovalWorkflowRule} from '@src/types/onyx/ApprovalWorkflowRules'; -import type {Connections, QBONonReimbursableExportAccountType, SageIntacctExportConfig, TaxRates} from '@src/types/onyx/Policy'; +import type {Connections, DualEntryVendor, QBONonReimbursableExportAccountType, SageIntacctExportConfig, TaxRates} from '@src/types/onyx/Policy'; import type Rule from '@src/types/onyx/Rule'; import type {TransactionCollectionDataSet} from '@src/types/onyx/Transaction'; @@ -4154,6 +4158,86 @@ describe('PolicyUtils', () => { }, }); + describe('DualEntry vendors', () => { + const vendors: DualEntryVendor[] = [ + {id: '1', name: 'Company vendor', companyID: '10', email: 'vendor@example.com', isActive: true}, + {id: '2', name: 'Organization vendor', isActive: true}, + {id: '3', name: 'Empty company', companyID: '', isActive: true}, + {id: '4', name: 'Other company', companyID: '20', isActive: true}, + {id: '5', name: 'Inactive vendor', companyID: '10', isActive: false}, + {id: '', name: 'Missing ID', isActive: true}, + ]; + const buildDualEntryPolicy = (vendorList: DualEntryVendor[] | undefined, isConfigured = true, subsidiaryID = '10'): Policy => + createMock({ + ...createRandomPolicy(0), + connections: { + dualEntry: {config: {isConfigured, subsidiaryID}, data: {vendors: vendorList}}, + }, + }); + + it('requires a configured connection and the matching beta', () => { + const policy = buildDualEntryPolicy(vendors); + expect(isDualEntryVendorMatchingActive(policy)).toBe(true); + expect(hasVendorFeature(policy, true)).toBe(true); + expect(hasVendorFeature(policy, false)).toBe(false); + expect(hasVendorFeature(buildDualEntryPolicy(vendors, false), true)).toBe(false); + expect(isDualEntryVendorMatchingActive(undefined)).toBe(false); + }); + + it('normalizes only eligible vendors for matching and the default picker', () => { + const policy = buildDualEntryPolicy(vendors); + const expected = [ + {id: '1', name: 'Company vendor', currency: '', email: 'vendor@example.com'}, + {id: '2', name: 'Organization vendor', currency: '', email: ''}, + {id: '3', name: 'Empty company', currency: '', email: ''}, + ]; + expect(getMatchingVendors(policy)).toEqual(expected); + expect(getDualEntryVendors(policy)).toEqual(expected); + expect(getActiveVendorMatchingIntegration(policy)).toBe(CONST.POLICY.CONNECTIONS.NAME.DUALENTRY); + }); + + it('distinguishes an unloaded list from a loaded list with no eligible vendors', () => { + expect(isMatchingVendorListLoaded(buildDualEntryPolicy(undefined))).toBe(false); + expect(isMatchingVendorListLoaded(buildDualEntryPolicy([]))).toBe(true); + expect(isMatchingVendorListLoaded(buildDualEntryPolicy([{id: '5', name: 'Inactive', isActive: false}]))).toBe(true); + expect(getMatchingVendors(buildDualEntryPolicy(undefined))).toEqual([]); + }); + + it('filters historical names and rule values after a company switch', () => { + // Given a vendor selected before the workspace changed companies + const policy = buildDualEntryPolicy(vendors, true, '20'); + + // Then the old company vendor is unavailable while shared vendors still resolve + expect(getMatchingVendorByID(policy, '1')).toBeUndefined(); + expect(findVendorByID(policy, '1')).toBeUndefined(); + expect(findVendorByID(policy, '5')).toBeUndefined(); + expect(findVendorByID(policy, '4')?.name).toBe('Other company'); + expect(findVendorByID(policy, '2')?.name).toBe('Organization vendor'); + expect(getVendorRuleDisplayValue(policy, '1', 'Unavailable')).toBe('Unavailable'); + expect(getVendorRuleDisplayValue(policy, '2', 'Unavailable')).toBe('Organization vendor'); + }); + + it('keeps an offline rule ID while the vendor list loads', () => { + expect(getVendorRuleDisplayValue(buildDualEntryPolicy(undefined), '1', 'Unavailable')).toBe('1'); + }); + + it('keeps the DualEntry default picker bound to DualEntry when Rillet takes precedence', () => { + const policy = buildDualEntryPolicy(vendors); + 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']); + expect(getDualEntryVendors(policy).map((vendor) => vendor.id)).toEqual(['1', '2', '3']); + }); + + it('uses the existing DualEntry empty state', () => { + const translate = TestHelper.translateLocal; + expect(getVendorEmptyState(buildDualEntryPolicy([]), translate)).toEqual({ + title: translate('workspace.dualEntry.noVendorsFound'), + subtitle: translate('workspace.dualEntry.noVendorsFoundDescription'), + }); + }); + }); + 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);