Skip to content
8 changes: 4 additions & 4 deletions src/libs/PolicyUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2478,19 +2478,19 @@ 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 and Xero branches but not on QBO:
* `isVendorMatchingBetaEnabled` is consulted on the Xero branch but not on QBO or Sage Intacct:
* - 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
* - Sage Intacct (R2) with non-reimbursable export = Credit Card Charge. GA, so no beta required
* - Xero (R3) has no export destination enum, so a present connection is enough. Beta required
*/
function hasVendorFeature(policy: OnyxEntry<Policy>, isVendorMatchingBetaEnabled: boolean): boolean {
if (!policy) {
return false;
}
if (isQBOVendorMatchingActive(policy)) {
if (isQBOVendorMatchingActive(policy) || isIntacctVendorMatchingActive(policy)) {
return true;
}
return isVendorMatchingBetaEnabled && (isIntacctVendorMatchingActive(policy) || isXeroVendorMatchingActive(policy));
return isVendorMatchingBetaEnabled && isXeroVendorMatchingActive(policy);
}

/**
Expand Down
5 changes: 3 additions & 2 deletions src/libs/Violations/ViolationsUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -573,8 +573,9 @@ const ViolationsUtils = {
: getTagViolationsForMultiLevelTags(updatedTransaction, newTransactionViolations, policyTagList, hasDependentTags);
}

// Inactive vendor violation, gated behind the `vendorMatching` beta. The transaction's
// vendor is never cleared here — admins need to see what was set so they can re-pick.
// Inactive vendor violation, gated on `hasVendorFeature`, which only consults the
// `vendorMatching` beta for integrations that haven't reached GA. The transaction's
// vendor is never cleared here because admins need to see what was set so they can re-pick.
if (allBetas !== undefined) {
const isVendorMatchingBetaEnabled = Permissions.isBetaEnabled(CONST.BETAS.VENDOR_MATCHING, allBetas);
const hasInactiveVendorViolation = newTransactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.INACTIVE_VENDOR);
Expand Down
12 changes: 7 additions & 5 deletions src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,11 +167,13 @@ 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) and Xero (R3) 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.XERO, CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT]);
const shouldShowVendorsFeature = vendorMatchingConnection === CONST.POLICY.CONNECTIONS.NAME.QBO || (isVendorMatchingEnabled && !!vendorMatchingConnection);
// Beta gating mirrors `hasVendorFeature`: QBO (R1) and Sage Intacct (R2) are GA, so a workspace
// connected to either always sees the row regardless of the `vendorMatching` beta. Xero (R3)
// hasn't reached GA, so it only shows the row while the beta is enabled. The two groups are
// checked separately so a GA connection wins even when a beta-gated one is also connected.
const hasGenerallyAvailableVendorConnection = !!getConnectedIntegration(policy, [CONST.POLICY.CONNECTIONS.NAME.QBO, CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT]);
const hasBetaGatedVendorConnection = !!getConnectedIntegration(policy, [CONST.POLICY.CONNECTIONS.NAME.XERO]);
const shouldShowVendorsFeature = hasGenerallyAvailableVendorConnection || (isVendorMatchingEnabled && hasBetaGatedVendorConnection);

const warnAccountingManagesOrganizeFeature = async () => {
if (!hasAccountingConnection || !policyID) {
Expand Down
32 changes: 32 additions & 0 deletions tests/ui/MoneyRequestViewTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,38 @@ describe('MoneyRequestView edit fields', () => {
});
});

it('shows the vendor row on Sage Intacct without the vendorMatching beta because Intacct (R2) is generally available', async () => {
const threadReport = {
...LHNTestUtils.getFakeReport(),
parentReportID: expenseReportID,
parentReportActionID,
};

await setupTestData();
await act(async () => {
await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {
reimbursable: false,
comment: {vendor: {externalID: 'iv-1', wasManuallySet: false}},
});
});
await waitForBatchedUpdatesWithAct();

renderMoneyRequestView(threadReport, {
connections: {
[CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT]: {
config: {export: {nonReimbursable: CONST.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE.CREDIT_CARD_CHARGE}},
data: {vendors: [{id: 'iv-1', name: 'V001', value: 'Acme Intacct'}]},
},
},
});
await waitForBatchedUpdatesWithAct();

// Intacct keeps the "Vendor" label and shows the vendor's display name, which Intacct stores in `value`.
await waitFor(() => {
expect(screen.getByTestId('menu-item-title-common.vendor')).toHaveTextContent('Acme Intacct');
});
});

it('hides the vendor row on Xero without the vendorMatching beta because Xero (R3) is still pre-GA', async () => {
const threadReport = {
...LHNTestUtils.getFakeReport(),
Expand Down
31 changes: 30 additions & 1 deletion tests/ui/WorkspaceMoreFeaturesPageTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -467,7 +467,36 @@ describe('WorkspaceMoreFeaturesPage', () => {
await expect(findLockedSwitch('workspace.moreFeatures.vendors.subtitle')).resolves.toBeOnTheScreen();
});

// Sage Intacct (R2) and Xero (R3) are still beta-gated, so they stay hidden when the beta is off.
// Sage Intacct R2 is GA, so a connected Intacct workspace shows the row regardless of the vendorMatching beta.
it('shows the Vendors row locked ON for Sage Intacct scoping vendors even with the beta disabled (Intacct is GA)', async () => {
await renderWithVendorMatching(
{[CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT]: {config: {export: {nonReimbursable: CONST.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE.CREDIT_CARD_CHARGE}}}},
false,
);
await expect(findLockedSwitch('workspace.moreFeatures.vendors.subtitle')).resolves.toBeOnTheScreen();
});

it('shows the Vendors row locked OFF for Sage Intacct not scoping vendors even with the beta disabled (discovery state, Intacct is GA)', async () => {
await renderWithVendorMatching(
{[CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT]: {config: {export: {nonReimbursable: CONST.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE.VENDOR_BILL}}}},
false,
);
await expect(findLockedSwitch('workspace.moreFeatures.vendors.subtitle')).resolves.toBeOnTheScreen();
});

// A GA connection wins the visibility decision even when a beta-gated one is also connected.
it('shows the Vendors row for a Sage Intacct workspace with a lingering Xero connection when the beta is disabled', async () => {
await renderWithVendorMatching(
{
[CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT]: {config: {export: {nonReimbursable: CONST.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE.CREDIT_CARD_CHARGE}}},
[CONST.POLICY.CONNECTIONS.NAME.XERO]: {config: {isConfigured: true}},
},
false,
);
await expect(findLockedSwitch('workspace.moreFeatures.vendors.subtitle')).resolves.toBeOnTheScreen();
});

// Xero (R3) is still beta-gated, so it stays 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);
expect(vendorsSwitchQuery()).toBeNull();
Expand Down
17 changes: 17 additions & 0 deletions tests/unit/AddVendorPageTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@ const buildQBOPolicy = (vendors: Array<{id: string; name: string; currency: stri
}),
});

/** Sage Intacct policy whose Credit Card Charge export scopes vendor matching to Intacct. */
const buildIntacctPolicy = (vendors: Array<{id: string; name: string; value: string}>): Policy =>
createMock<Policy>({
...createRandomPolicy(0),
connections: createMock<Connections>({
[CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT]: {
config: {export: {nonReimbursable: CONST.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE.CREDIT_CARD_CHARGE}},
data: {vendors},
},
}),
});

/** Xero policy whose supplier list scopes vendor matching to Xero (label flips vendor -> supplier). */
const buildXeroPolicy = (contacts: Record<string, {id: string; name: string; email: string}>): Policy =>
createMock<Policy>({
Expand Down Expand Up @@ -124,12 +136,17 @@ describe('AddVendorPage', () => {
*/
describe('vendor rule row derivation (MerchantRulePageBase)', () => {
const qboPolicy = buildQBOPolicy([{id: 'v-1', name: 'Acme Co', currency: 'USD'}]);
const intacctPolicy = buildIntacctPolicy([{id: 'iv-1', name: 'V001', value: 'Acme Intacct'}]);
const xeroPolicy = buildXeroPolicy({xc1: {id: 'xc1', name: 'Acme Xero', email: 'acme@example.com'}});

it('shows the row on QBO with the beta off because QBO vendor matching is generally available', () => {
expect(hasVendorFeature(qboPolicy, false)).toBe(true);
});

it('shows the row on Sage Intacct with the beta off because Intacct vendor matching is generally available', () => {
expect(hasVendorFeature(intacctPolicy, false)).toBe(true);
});

it('hides the row on Xero when the beta is off because Xero vendor matching is not generally available yet', () => {
expect(hasVendorFeature(xeroPolicy, false)).toBe(false);
});
Expand Down
12 changes: 10 additions & 2 deletions tests/unit/PolicyUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3799,8 +3799,16 @@ describe('PolicyUtils', () => {
expect(hasVendorFeature(buildQBOPolicy(CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL), false)).toBe(false);
});

it('returns false when beta is disabled and Intacct CC Charge export is configured because Intacct (R2) is still pre-GA', () => {
expect(hasVendorFeature(buildIntacctPolicy(CONST.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE.CREDIT_CARD_CHARGE), false)).toBe(false);
it('returns true when beta is disabled and Intacct non-reimbursable export is Credit Card Charge because Intacct (R2) is generally available', () => {
expect(hasVendorFeature(buildIntacctPolicy(CONST.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE.CREDIT_CARD_CHARGE), false)).toBe(true);
});

it('returns false when beta is disabled and Intacct non-reimbursable export is Vendor Bill because GA did not widen the export mode gate', () => {
expect(hasVendorFeature(buildIntacctPolicy(CONST.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE.VENDOR_BILL), false)).toBe(false);
});

it('returns false when beta is disabled and the Intacct non-reimbursable export destination is not set', () => {
expect(hasVendorFeature(buildIntacctPolicy(undefined), false)).toBe(false);
});

it('returns false when beta is disabled and Xero is connected because Xero (R3) is still pre-GA', () => {
Expand Down
16 changes: 16 additions & 0 deletions tests/unit/VendorMatchingMerchantRulesTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,18 @@ const buildQBOWithVendorBillExportPolicy = (vendors: Array<{id: string; name: st
}),
});

/** Sage Intacct policy whose Credit Card Charge export scopes vendor matching to Intacct. */
const buildIntacctPolicy = (vendors: Array<{id: string; name: string; value: string}>): Policy =>
createMock<Policy>({
...createRandomPolicy(0),
connections: createMock<Connections>({
[CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT]: {
config: {export: {nonReimbursable: CONST.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE.CREDIT_CARD_CHARGE}},
data: {vendors},
},
}),
});

/** Xero policy whose supplier list scopes vendor matching to Xero (label flips vendor -> supplier). */
const buildXeroPolicy = (contacts: Record<string, {id: string; name: string; email: string}> | undefined): Policy =>
createMock<Policy>({
Expand Down Expand Up @@ -261,6 +273,10 @@ describe('Vendor matching on merchant rules', () => {
expect(hasVendorFeature(buildQBOPolicy([{id: 'v-1', name: 'Acme Co', currency: 'USD'}]), false)).toBe(true);
});

it('is visible on Sage Intacct when the beta is off because Intacct (R2) is generally available', () => {
expect(hasVendorFeature(buildIntacctPolicy([{id: 'iv-1', name: 'V001', value: 'Acme Intacct'}]), false)).toBe(true);
});

it('is hidden on Xero when the beta is off because Xero (R3) is still pre-GA', () => {
expect(hasVendorFeature(buildXeroPolicy({xc1: {id: 'xc1', name: 'Acme Xero', email: 'acme@example.com'}}), false)).toBe(false);
});
Expand Down
89 changes: 89 additions & 0 deletions tests/unit/ViolationUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import CONST from '@src/CONST';
import IntlStore from '@src/languages/IntlStore';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Policy, PolicyCategories, PolicyTagLists, Report, Transaction, TransactionViolation} from '@src/types/onyx';
import type {SageIntacctExportConfig} from '@src/types/onyx/Policy';
import type {TransactionCollectionDataSet} from '@src/types/onyx/Transaction';

import Onyx from 'react-native-onyx';
Expand Down Expand Up @@ -3005,6 +3006,94 @@ describe('getViolationsOnyxData', () => {
expect(result.value).toContainEqual(inactiveVendorViolation);
});

describe('Sage Intacct (R2)', () => {
// Pass a `vendors` array to control the synced list, or `null` to simulate the list still
// hydrating. Intacct vendors carry the display name in `value`.
const policyWithIntacctVendorFeature = (
nonReimbursable: SageIntacctExportConfig['nonReimbursable'] = CONST.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE.CREDIT_CARD_CHARGE,
vendors: Array<{id: string; name: string; value: string}> | null = [{id: 'iv-active', name: 'V001', value: 'Acme Intacct'}],
) =>
createMock<Policy>({
requiresTag: false,
requiresCategory: false,
connections: {
[CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT]: {
config: {export: {nonReimbursable}},
data: vendors ? {vendors} : {},
},
},
});

it('adds the violation when the vendorMatching beta is disabled but Intacct is configured, because Intacct (R2) is generally available', () => {
isBetaEnabledSpy.mockImplementation(() => false);
policy = policyWithIntacctVendorFeature();
transaction.comment = {...transaction.comment, vendor: {externalID: 'iv-missing', wasManuallySet: true}};
const result = ViolationsUtils.getViolationsOnyxData({
ownerLogin: undefined,
updatedTransaction: transaction,
transactionViolations,
policy,
policyTagList: policyTags,
policyCategories,
hasDependentTags: false,
isInvoiceTransaction: false,
});
// Intacct is not a supplier source, so the plain vendor violation (no isSupplierViolation flag) is expected.
expect(result.value).toEqual(expect.arrayContaining([inactiveVendorViolation]));
});

it('does not add the violation when the Intacct vendor is in the synced list', () => {
isBetaEnabledSpy.mockImplementation(() => false);
policy = policyWithIntacctVendorFeature();
transaction.comment = {...transaction.comment, vendor: {externalID: 'iv-active', wasManuallySet: true}};
const result = ViolationsUtils.getViolationsOnyxData({
ownerLogin: undefined,
updatedTransaction: transaction,
transactionViolations,
policy,
policyTagList: policyTags,
policyCategories,
hasDependentTags: false,
isInvoiceTransaction: false,
});
expect(result.value).not.toContainEqual(inactiveVendorViolation);
});

it('does not add the violation while the Intacct vendor list is still hydrating (vendors undefined)', () => {
isBetaEnabledSpy.mockImplementation(() => false);
policy = policyWithIntacctVendorFeature(CONST.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE.CREDIT_CARD_CHARGE, null);
transaction.comment = {...transaction.comment, vendor: {externalID: 'iv-anything', wasManuallySet: true}};
const result = ViolationsUtils.getViolationsOnyxData({
ownerLogin: undefined,
updatedTransaction: transaction,
transactionViolations,
policy,
policyTagList: policyTags,
policyCategories,
hasDependentTags: false,
isInvoiceTransaction: false,
});
expect(result.value).not.toContainEqual(inactiveVendorViolation);
});

it('removes an existing violation when the Intacct export switches to Vendor Bill with the beta disabled', () => {
isBetaEnabledSpy.mockImplementation(() => false);
policy = policyWithIntacctVendorFeature(CONST.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE.VENDOR_BILL);
transaction.comment = {...transaction.comment, vendor: {externalID: 'iv-missing', wasManuallySet: true}};
const result = ViolationsUtils.getViolationsOnyxData({
ownerLogin: undefined,
updatedTransaction: transaction,
transactionViolations: [inactiveVendorViolation],
policy,
policyTagList: policyTags,
policyCategories,
hasDependentTags: false,
isInvoiceTransaction: false,
});
expect(result.value).not.toContainEqual(inactiveVendorViolation);
});
});

describe('Xero (R4)', () => {
// Placeholder for "Xero connected, contacts not yet synced". Explicit symbol avoids the
// default-parameter trap where `undefined` would fall back to the populated default.
Expand Down
Loading