Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
834eed7
Add CC/DC default vendor row in QBO export settings (Phase 2)
Beamanator May 27, 2026
82a69b0
R2: Generalize vendor matching helpers to support Sage Intacct
Beamanator May 28, 2026
a0cc988
Merge remote-tracking branch 'origin/beaman-vendor-matching-r1-app-ph…
Beamanator Jun 10, 2026
4f80a5e
Merge remote-tracking branch 'origin/main' into beaman-vendor-matchin…
Beamanator Jun 10, 2026
dafd8ae
Migrate vendor step to DYNAMIC_ROUTES pattern
Beamanator Jun 10, 2026
9a2e5ca
Wire DYNAMIC_STEP_VENDOR through navigators and consumers
Beamanator Jun 10, 2026
207e986
Use Intacct vendor `value` for the matching-vendor display label
Beamanator Jun 10, 2026
ba3afcf
Scope getMatchingVendors to the active vendor-matching integration
Beamanator Jun 10, 2026
5a0fca6
Keep QBO default-vendor row scoped to QBO
Beamanator Jun 10, 2026
8b424f8
Bump eslint-seatbelt baselines for vendor test files
Beamanator Jun 10, 2026
c456bb3
Revert "Wire DYNAMIC_STEP_VENDOR through navigators and consumers"
Beamanator Jun 12, 2026
214bdd6
Revert "Migrate vendor step to DYNAMIC_ROUTES pattern"
Beamanator Jun 12, 2026
123ef84
Merge remote-tracking branch 'origin/main' into beaman-vendor-matchin…
Beamanator Jun 12, 2026
f0dc1c9
Add useMemo import to IOURequestStepVendor after main merge
Beamanator Jun 12, 2026
417c97e
Add QBO export config to ModifiedExpenseMessage vendor test fixture
Beamanator Jun 12, 2026
23ffae1
Add permissive findVendorByID for historical display lookups
Beamanator Jun 12, 2026
e0249b0
Prefer active matching integration in findVendorByID
Beamanator Jun 12, 2026
f3aaa79
Remove redundant useMemo around getMatchingVendors
Beamanator Jun 18, 2026
8bbad05
Merge remote-tracking branch 'origin/main' into beaman-vendor-matchin…
Beamanator Jun 19, 2026
4ae57a9
Extract isQBOVendorMatchingActive / isIntacctVendorMatchingActive hel…
Beamanator Jun 19, 2026
8e08394
Keep the "None" row visible in vendor picker when a vendor is set
Beamanator Jun 19, 2026
51c25a4
Bump PolicyUtilsTest seatbelt to match actual violation count post-merge
Beamanator Jun 19, 2026
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
2 changes: 1 addition & 1 deletion config/eslint/eslint.seatbelt.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -1805,7 +1805,7 @@
"../../tests/unit/PersonalDetailsSelectorTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 5
"../../tests/unit/PolicyChangeLogContentTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4
"../../tests/unit/PolicySelectorTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 22
"../../tests/unit/PolicyUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 105
"../../tests/unit/PolicyUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 111

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please don't increase instances of this. Use partial object types instead.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what dat meeeeeeeean :O i want to do that but i don't know how to do that!!!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I realized that we don't have a ton of examples of this in the codebase. Creating the pattern here: #92979

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

niftay!!

"../../tests/unit/PopoverMenuV2Test.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4
"../../tests/unit/PressResponderTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../tests/unit/QuickActionUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 7
Expand Down
4 changes: 2 additions & 2 deletions src/components/ReportActionItem/MoneyRequestView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,10 @@ import {hasEnabledOptions} from '@libs/OptionsListUtils';
import Parser from '@libs/Parser';
import {
canSubmitPerDiemExpenseFromWorkspace,
findVendorByID,
getLengthOfTag,
getPerDiemCustomUnit,
getPolicyByCustomUnitID,
getQBOVendorByID,
getTagLists,
hasDependentTags as hasDependentTagsPolicyUtils,
hasVendorFeature,
Expand Down Expand Up @@ -471,7 +471,7 @@ function MoneyRequestView({
const shouldShowAttendees = shouldShowAttendeesTransactionUtils(iouType, policy);

const transactionVendor = transaction?.comment?.vendor;
const transactionVendorName = getQBOVendorByID(policy, transactionVendor?.externalID)?.name ?? '';
const transactionVendorName = findVendorByID(policy, transactionVendor?.externalID)?.name ?? '';
const shouldShowVendor = hasVendorFeature(policy, isBetaEnabled(CONST.BETAS.VENDOR_MATCHING)) && !(updatedTransaction?.reimbursable ?? !!transactionReimbursable) && !isInvoice;

const tripID = getTripIDFromTransactionParentReportID(parentReport?.parentReportID);
Expand Down
13 changes: 8 additions & 5 deletions src/libs/ModifiedExpenseMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {formatList} from './Localize';
import Log from './Log';
import Parser from './Parser';
import {getPersonalDetailByEmail} from './PersonalDetailsUtils';
import {getCleanedTagName, getCommaSeparatedTagNameWithSanitizedColons, getQBOVendorByID, getSortedTagKeys, isPolicyAdmin} from './PolicyUtils';
import {findVendorByID, getCleanedTagName, getCommaSeparatedTagNameWithSanitizedColons, getSortedTagKeys, isPolicyAdmin} from './PolicyUtils';
import {getOriginalMessage, isModifiedExpenseAction} from './ReportActionsUtils';
// This cycle import is safe because ReportNameUtils was extracted from ReportUtils to separate report name computation logic.
// The functions imported here are pure utility functions that don't create initialization-time dependencies.
Expand Down Expand Up @@ -457,14 +457,17 @@ function getForReportAction({
const hasModifiedVendor = isReportActionOriginalMessageAnObject && ('oldVendor' in reportActionOriginalMessage || 'vendor' in reportActionOriginalMessage);
if (hasModifiedVendor) {
// Vendor is stored on the action as `{externalID, isManuallySet}` (or absent/null). Resolve
// the display name from the policy's QBO vendor list; if the vendor has since been removed
// from QBO the name is unrecoverable, so fall back to the externalID so the fragment still
// identifies which vendor was set rather than rendering `set vendor ""`.
// the display name from any connection that has the vendor data (QBO or Intacct), without
// gating on the workspace's current export mode — a past "set vendor" action should still
// render the vendor name after an admin switches the non-reimbursable export type. If the
// vendor has been removed from the integration entirely the name is unrecoverable, so fall
// back to the externalID so the fragment still identifies which vendor was set rather than
// rendering `set vendor ""`.
const resolveVendorName = (entry: typeof reportActionOriginalMessage.vendor): string => {
if (!entry?.externalID) {
return '';
}
return getQBOVendorByID(policy, entry.externalID)?.name ?? entry.externalID;
return findVendorByID(policy, entry.externalID)?.name ?? entry.externalID;
};
buildMessageFragmentForValue(
translate,
Expand Down
111 changes: 89 additions & 22 deletions src/libs/PolicyUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2051,42 +2051,108 @@ function getConnectedIntegration(policy: Policy | undefined, connectionNames: re
}

/**
* QBO vendor feature gate. Returns true when the workspace has the `vendorMatching` beta enabled
* AND QBO is connected with an individual card transaction non-reimbursable export type — the only
* scope the Vendor field is shown for. Mirrors `QuickbooksOnline::hasVendorFeature` on the PHP side
* so the App and backend agree on which workspaces see the field.
* True when the QBO connection is exporting non-reimbursables to a card account, which is the
* mode that scopes the vendor field on QBO.
*/
function isQBOVendorMatchingActive(policy: OnyxEntry<Policy>): boolean {
const destination = policy?.connections?.[CONST.POLICY.CONNECTIONS.NAME.QBO]?.config?.nonReimbursableExpensesExportDestination;
return destination === CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD || destination === CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD;
}

/**
* True when the Sage Intacct connection is exporting non-reimbursables as Credit Card Charge, which
* is the mode that scopes the vendor field on Intacct.
*/
function isIntacctVendorMatchingActive(policy: OnyxEntry<Policy>): boolean {
return policy?.connections?.[CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT]?.config?.export?.nonReimbursable === CONST.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE.CREDIT_CARD_CHARGE;
}

/**
* Vendor matching feature gate. Returns true when the workspace has the `vendorMatching` beta
* enabled AND a supported accounting integration is connected with a non-reimbursable export type
* that scopes the vendor field. Mirrors the per-integration `hasVendorFeature` checks on the PHP
* side so the App and backend agree on which workspaces see the field.
*
* Supported integrations:
* - QBO with non-reimbursable export = Credit Card or Debit Card (R1)
* - Sage Intacct with non-reimbursable export = Credit Card Charge (R2)
*/
function hasVendorFeature(policy: OnyxEntry<Policy>, isVendorMatchingBetaEnabled: boolean): boolean {
if (!isVendorMatchingBetaEnabled || !policy) {
return false;
}
const qboConnection = policy.connections?.[CONST.POLICY.CONNECTIONS.NAME.QBO];
if (!qboConnection) {
return false;
}
const exportDestination = qboConnection.config?.nonReimbursableExpensesExportDestination;
return exportDestination === CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD || exportDestination === CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD;
return isQBOVendorMatchingActive(policy) || isIntacctVendorMatchingActive(policy);
}

/**
* Returns the QBO vendor list imported into the workspace (empty array when QBO isn't connected or
* the sync hasn't populated vendors yet). Source of truth for the workspace Vendors tab and the
* vendor selector RHP.
* Returns the vendor list imported into the workspace from whichever connected integration scopes
* the vendor field for this workspace (QBO or Sage Intacct). 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.
*
* Selection mirrors `hasVendorFeature`: each branch is gated on the integration's own
* non-reimbursable export destination, so a dual-connected workspace (e.g. mid-migration with stale
* QBO data + active Intacct) returns vendors from the integration whose export mode actually drives
* vendor matching, not whichever connection happens to be populated first.
*
* The shape is normalized to `Vendor` (id + name). For Intacct's `SageIntacctDataElementWithValue`,
* the human-readable label lives in `value` (Intacct's `name` is an internal code), matching how
* `getSageIntacctVendors` and `getDefaultVendorName` populate the existing Intacct export UI.
*/
function getQBOVendors(policy: OnyxEntry<Policy>): Vendor[] {
return policy?.connections?.[CONST.POLICY.CONNECTIONS.NAME.QBO]?.data?.vendors ?? [];
function getMatchingVendors(policy: OnyxEntry<Policy>): Vendor[] {
if (!policy) {
return [];
}
if (isQBOVendorMatchingActive(policy)) {
return policy.connections?.[CONST.POLICY.CONNECTIONS.NAME.QBO]?.data?.vendors ?? [];
}
if (isIntacctVendorMatchingActive(policy)) {
const intacctVendors = policy.connections?.[CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT]?.data?.vendors ?? [];
return intacctVendors.map((vendor) => ({id: vendor.id, name: vendor.value, currency: '', email: ''}));
}
return [];
}

/**
* Look up a single QBO vendor by `externalID`. Used to resolve the vendor name for display when
* only the ID is stored on the transaction NVP. Returns undefined when the ID isn't found
* (which happens after a vendor is deleted from QBO — see the inactive-vendor violation).
* Look up a single matching vendor by `externalID`, scoped to the active vendor-matching
* integration. Returns undefined when the ID isn't found in the active list (the inactive-vendor
* violation case — see `getViolationsOnyxData`).
*/
function getQBOVendorByID(policy: OnyxEntry<Policy>, vendorID: string | undefined): Vendor | undefined {
function getMatchingVendorByID(policy: OnyxEntry<Policy>, vendorID: string | undefined): Vendor | undefined {
if (!vendorID) {
return undefined;
}
return getQBOVendors(policy).find((vendor) => vendor.id === vendorID);
return getMatchingVendors(policy).find((vendor) => vendor.id === vendorID);
}

/**
* Resolve a stored vendor ID to a display vendor. Prefers the active vendor-matching integration
* (delegating to `getMatchingVendors`) so a freshly-selected vendor in the dual-connected state
* never gets overshadowed by a stale entry with the same ID on the inactive integration. Falls
* back to a permissive search across every connection's vendor data (QBO then Intacct) so
* historical lookups keep working after an admin switches the workspace's non-reimbursable export
* mode away from the vendor-matching mode — rendering a vendor name stored on a past transaction
* or modified-expense action must not regress to the raw external ID. Use `getMatchingVendorByID`
* instead when the caller is enforcing the active-integration scope (e.g. the inactive-vendor
* violation check).
*/
function findVendorByID(policy: OnyxEntry<Policy>, vendorID: string | undefined): Vendor | undefined {
if (!policy || !vendorID) {
return undefined;
}
const activeMatch = getMatchingVendors(policy).find((vendor) => vendor.id === vendorID);
if (activeMatch) {
return activeMatch;
}
const qboVendor = policy.connections?.[CONST.POLICY.CONNECTIONS.NAME.QBO]?.data?.vendors?.find((vendor) => vendor.id === vendorID);
if (qboVendor) {
return qboVendor;
Comment thread
Beamanator marked this conversation as resolved.
}
const intacctVendor = policy.connections?.[CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT]?.data?.vendors?.find((vendor) => vendor.id === vendorID);
if (intacctVendor) {
return {id: intacctVendor.id, name: intacctVendor.value, currency: '', email: ''};
}
return undefined;
}

function getValidConnectedIntegration(policy: Policy | undefined, connectionNames: readonly ConnectionName[] = getAccountingConnectionNames()) {
Expand Down Expand Up @@ -2502,8 +2568,9 @@ export {
getConnectedIntegration,
getConnectedIntegrationNamesForPolicies,
getConnectionExporters,
getQBOVendorByID,
getQBOVendors,
findVendorByID,
getMatchingVendorByID,
getMatchingVendors,
hasVendorFeature,
getValidConnectedIntegration,
getCountOfEnabledTagsOfList,
Expand Down
4 changes: 2 additions & 2 deletions src/libs/Violations/ViolationsUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ import Parser from '@libs/Parser';
import Permissions from '@libs/Permissions';
import {
getDistanceRateCustomUnitRate,
getMatchingVendorByID,
getPerDiemRateCustomUnitRate,
getQBOVendorByID,
getSortedTagKeys,
hasVendorFeature,
isAttendeeTrackingEnabled as isAttendeeTrackingEnabledForPolicy,
Expand Down Expand Up @@ -477,7 +477,7 @@ const ViolationsUtils = {
newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.INACTIVE_VENDOR});
}
} else if (transactionVendorID) {
const matchedVendor = getQBOVendorByID(policy, transactionVendorID);
const matchedVendor = getMatchingVendorByID(policy, transactionVendorID);
if (!matchedVendor && !hasInactiveVendorViolation) {
newTransactionViolations.push({name: CONST.VIOLATIONS.INACTIVE_VENDOR, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true});
} else if (matchedVendor && hasInactiveVendorViolation) {
Expand Down
2 changes: 1 addition & 1 deletion src/libs/actions/IOU/UpdateMoneyRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -565,7 +565,7 @@ function updateMoneyRequestVendor({transactionID, vendorID, transaction, transac
}

// Optimistically clear any existing inactive-vendor violation. This is the user-driven write
// path: the vendor selector RHP only offers vendors from `getQBOVendors(policy)`, so a user
// path: the vendor selector RHP only offers vendors from `getMatchingVendors(policy)`, so a user
// pick is always a valid vendor (resolving the violation); clearing the vendor likewise
// resolves it (no vendor → no inactive-vendor). Without this, the stale violation persists
// in Onyx until some unrelated recalculation fires, keeping the expense incorrectly flagged.
Expand Down
32 changes: 16 additions & 16 deletions src/pages/iou/request/step/IOURequestStepVendor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import useThemeStyles from '@hooks/useThemeStyles';
import {updateMoneyRequestVendor} from '@libs/actions/IOU/UpdateMoneyRequest';
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
import Navigation from '@libs/Navigation/Navigation';
import {getQBOVendors, hasVendorFeature} from '@libs/PolicyUtils';
import {getMatchingVendors, hasVendorFeature} from '@libs/PolicyUtils';
import {isPerDiemRequest} from '@libs/TransactionUtils';
import variables from '@styles/variables';
import CONST from '@src/CONST';
Expand Down Expand Up @@ -60,7 +60,7 @@ function IOURequestStepVendor({
// Vendor is scoped to non-reimbursable expenses on a policy expense chat; block deep-link / stale-open access if the transaction is reimbursable or is an invoice (invoices are non-reimbursable but don't route through the QBO CC vendor-matching flow).
const isReimbursable = !!transaction?.reimbursable;
const isInvoice = iouType === CONST.IOU.TYPE.INVOICE;
const vendors = getQBOVendors(policy);
const vendors = getMatchingVendors(policy);
const currentVendorID = transaction?.comment?.vendor?.externalID;

const trimmedSearch = searchValue.trim().toLowerCase();
Expand All @@ -75,19 +75,19 @@ function IOURequestStepVendor({
}));

// When a vendor is currently set, offer a "None" row so the user can clear a stale (e.g. removed-from-QBO) vendor without picking a replacement, which resolves an inactiveVendor violation. Hidden during search to keep results clean.
const data: VendorListItem[] =
!currentVendorID || trimmedSearch
? vendorRows
: [
{
value: '',
text: translate('common.none'),
keyForList: 'clear-vendor',
isSelected: false,
searchText: '',
},
...vendorRows,
];
const shouldShowNoneRow = !!currentVendorID && !trimmedSearch;
const data: VendorListItem[] = shouldShowNoneRow
? [
{
value: '',
text: translate('common.none'),
keyForList: 'clear-vendor',
isSelected: false,
searchText: '',
},
...vendorRows,
]
: vendorRows;

const shouldShowNotFoundPage = useShowNotFoundPageInIOUStep(action, iouType, reportActionID, report, transaction) || !isFeatureAvailable || isReimbursable || isInvoice;

Expand Down Expand Up @@ -142,7 +142,7 @@ function IOURequestStepVendor({
onChangeText: setSearchValue,
headerMessage,
}}
initiallyFocusedItemKey={data.find((item) => item.isSelected)?.keyForList}
initiallyFocusedItemKey={shouldShowNoneRow ? undefined : data.find((item) => item.isSelected)?.keyForList}
ListItem={SingleSelectListItem}
shouldShowLoadingPlaceholder={!policy}
listEmptyContent={listEmptyContent}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {updateManyPolicyConnectionConfigs} from '@libs/actions/connections';
import {getQBONonReimbursableExportAccountType} from '@libs/ConnectionUtils';
import {getLatestErrorField} from '@libs/ErrorUtils';
import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute';
import {areSettingsInErrorFields, getQBOVendorByID, hasVendorFeature, settingsPendingAction} from '@libs/PolicyUtils';
import {areSettingsInErrorFields, settingsPendingAction} from '@libs/PolicyUtils';
import Navigation from '@navigation/Navigation';
import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections';
import withPolicyConnections from '@pages/workspace/withPolicyConnections';
Expand All @@ -29,8 +29,13 @@ function DynamicQuickbooksCompanyCardExpenseAccountPage({policy}: WithPolicyConn
const qboConfig = policy?.connections?.quickbooksOnline?.config;
const {vendors} = policy?.connections?.quickbooksOnline?.data ?? {};
const nonReimbursableBillDefaultVendorObject = vendors?.find((vendor) => vendor.id === qboConfig?.nonReimbursableBillDefaultVendor);
const nonReimbursableCreditCardDefaultVendorObject = getQBOVendorByID(policy, qboConfig?.nonReimbursableCreditCardDefaultVendor);
const isVendorFeatureAvailable = hasVendorFeature(policy, isBetaEnabled(CONST.BETAS.VENDOR_MATCHING));
const nonReimbursableCreditCardDefaultVendorObject = vendors?.find((vendor) => vendor.id === qboConfig?.nonReimbursableCreditCardDefaultVendor);
// This page is the QBO-only default-vendor editor: gate the row on QBO's own non-reimbursable export mode rather than the cross-integration `hasVendorFeature`, so an Intacct workspace whose QBO connection is in Vendor Bill mode does not get a QBO default-vendor row whose target setting isn't active.
const qboNonReimbursableDestination = qboConfig?.nonReimbursableExpensesExportDestination;
const isQBOVendorMatchingActive =
qboNonReimbursableDestination === CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD ||
qboNonReimbursableDestination === CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD;
const isVendorFeatureAvailable = isBetaEnabled(CONST.BETAS.VENDOR_MATCHING) && isQBOVendorMatchingActive;
const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT.path);
const {isAccordionExpanded, shouldAnimateAccordionSection} = useAccordionAnimation(!!qboConfig?.autoCreateVendor);

Expand Down
7 changes: 5 additions & 2 deletions tests/unit/ModifiedExpenseMessageTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2158,8 +2158,11 @@ describe('ModifiedExpenseMessage', () => {
});

describe('vendor changes', () => {
// QBO policy with two named vendors used by the resolver. The third case below
// omits a vendor from the list to exercise the externalID fallback path.
// QBO policy with two named vendors used by the resolver. No `config` block — the resolver
// must look up vendors regardless of the workspace's current export mode so historical
// chat entries keep rendering the vendor name after an admin switches export modes away
// from CC/DC. The fourth case below omits a vendor from the list to exercise the
// externalID fallback path.
const policyWithVendors: Policy = {
id: 'p-1',
name: 'My Workspace',
Expand Down
Loading
Loading