Skip to content
Open
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
42 changes: 36 additions & 6 deletions src/libs/PolicyUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2666,10 +2666,14 @@ function isRilletVendorMatchingActive(policy: OnyxEntry<Policy>): boolean {
return !!policy?.connections?.[CONST.POLICY.CONNECTIONS.NAME.RILLET]?.config?.isConfigured;
}

function isDualEntryVendorMatchingActive(policy: OnyxEntry<Policy>): 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.
Expand All @@ -2685,11 +2689,12 @@ 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, 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<Policy>, isVendorMatchingBetaEnabled: boolean): boolean {
if (!policy) {
Expand All @@ -2698,12 +2703,15 @@ function hasVendorFeature(policy: OnyxEntry<Policy>, 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".
*
Expand Down Expand Up @@ -2741,6 +2749,9 @@ function getActiveVendorMatchingIntegration(policy: OnyxEntry<Policy>): Connecti
if (isRilletVendorMatchingActive(policy)) {
return CONST.POLICY.CONNECTIONS.NAME.RILLET;
}
if (isDualEntryVendorMatchingActive(policy)) {
return CONST.POLICY.CONNECTIONS.NAME.DUALENTRY;
}
return undefined;
}

Expand Down Expand Up @@ -2782,12 +2793,15 @@ function getActiveVendorMatchingVendors(policy: OnyxEntry<Policy>): 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.
*/
Expand Down Expand Up @@ -2862,7 +2876,7 @@ function findVendorByID(policy: OnyxEntry<Policy>, vendorID: string | undefined)
email: rilletVendor.email ?? '',
};
}
return undefined;
return getDualEntryVendors(policy).find((vendor) => vendor.id === vendorID);
}

/**
Expand Down Expand Up @@ -2907,6 +2921,11 @@ function getVendorEmptyState(policy: OnyxEntry<Policy>, 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);
Expand All @@ -2933,6 +2952,15 @@ function getXeroSuppliers(policy: OnyxEntry<Policy>): 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<Policy>): 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
Expand Down Expand Up @@ -3423,7 +3451,9 @@ export {
getVendorRuleDisplayValue,
getXeroSupplierByID,
getXeroSuppliers,
getDualEntryVendors,
isRilletVendorMatchingActive,
isDualEntryVendorMatchingActive,
isXeroActiveMatchingSource,
isXeroVendorMatchingActive,
hasVendorFeature,
Expand Down
22 changes: 13 additions & 9 deletions src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types';
import {
arePolicyRulesEnabled,
canPolicyAccessFeature,
getActiveVendorMatchingIntegration,
getConnectedIntegration,
getDistanceRateCustomUnit,
getPerDiemCustomUnit,
Expand Down Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 = (
Expand Down
25 changes: 25 additions & 0 deletions tests/ui/WorkspaceMoreFeaturesPageTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
86 changes: 85 additions & 1 deletion tests/unit/PolicyUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
getDefaultChatEnabledPolicySelection,
getDefaultTimeTrackingRate,
getDefaultWorkspacePlanType,
getDualEntryVendors,
getEligibleBankAccountShareRecipientEmails,
getExcludedUsers,
getExpensifyTeamExclusions,
Expand All @@ -36,6 +37,7 @@ import {
getMatchingVendorByID,
getMatchingVendors,
getVendorEmptyState,
getVendorRuleDisplayValue,
getPolicyApproverLogins,
getPolicyBrickRoadIndicatorStatus,
getPolicyByCustomUnitID,
Expand Down Expand Up @@ -68,6 +70,8 @@ import {
hasPolicyWithXeroConnection,
hasVendorFeature,
isArchivedPolicy,
isDualEntryVendorMatchingActive,
isMatchingVendorListLoaded,
isMaxExpenseAmountSet,
isMergeHRCompleteSetupNeededSelector,
isPerDiemEligiblePolicy,
Expand All @@ -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';

Expand Down Expand Up @@ -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<Policy>({
...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);
Expand Down
Loading