diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 53df7ab78939..a0bce463f4db 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -2478,19 +2478,19 @@ 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 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, 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); } /** diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index 08a392df8f45..cd131b128032 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -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); diff --git a/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx b/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx index f6cdb604ab86..eadaba31bb8b 100644 --- a/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx +++ b/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx @@ -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) { diff --git a/tests/ui/MoneyRequestViewTest.tsx b/tests/ui/MoneyRequestViewTest.tsx index 68c75c9ec0b0..7544093be29c 100644 --- a/tests/ui/MoneyRequestViewTest.tsx +++ b/tests/ui/MoneyRequestViewTest.tsx @@ -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(), diff --git a/tests/ui/WorkspaceMoreFeaturesPageTest.tsx b/tests/ui/WorkspaceMoreFeaturesPageTest.tsx index fab7084036f2..cda32367c796 100644 --- a/tests/ui/WorkspaceMoreFeaturesPageTest.tsx +++ b/tests/ui/WorkspaceMoreFeaturesPageTest.tsx @@ -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(); diff --git a/tests/unit/AddVendorPageTest.ts b/tests/unit/AddVendorPageTest.ts index 8628a8ae1940..9c30112ed0a4 100644 --- a/tests/unit/AddVendorPageTest.ts +++ b/tests/unit/AddVendorPageTest.ts @@ -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({ + ...createRandomPolicy(0), + connections: createMock({ + [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): Policy => createMock({ @@ -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); }); diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index 57baa12d9ed7..8121af85d0bd 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -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', () => { diff --git a/tests/unit/VendorMatchingMerchantRulesTest.ts b/tests/unit/VendorMatchingMerchantRulesTest.ts index 1feaff205adf..1a90e021078b 100644 --- a/tests/unit/VendorMatchingMerchantRulesTest.ts +++ b/tests/unit/VendorMatchingMerchantRulesTest.ts @@ -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({ + ...createRandomPolicy(0), + connections: createMock({ + [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 | undefined): Policy => createMock({ @@ -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); }); diff --git a/tests/unit/ViolationUtilsTest.ts b/tests/unit/ViolationUtilsTest.ts index 59e3f7111ea3..5feb1a6f156d 100644 --- a/tests/unit/ViolationUtilsTest.ts +++ b/tests/unit/ViolationUtilsTest.ts @@ -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'; @@ -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({ + 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.