From aca20ca2e10fc6071f20311445a4233c9611b339 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:57:14 +0530 Subject: [PATCH 01/14] Add imported vendor enable and disable controls --- .../Tables/WorkspaceVendorsTable/index.tsx | 92 ++++++-- src/languages/en.ts | 5 + src/languages/es.ts | 5 + .../SetPolicyVendorsEnabledParams.ts | 11 + src/libs/API/parameters/index.ts | 1 + src/libs/API/types.ts | 2 + src/libs/PolicyUtils.ts | 35 ++- src/libs/actions/Policy/Vendor.ts | 111 ++++++++++ .../iou/request/step/IOURequestStepVendor.tsx | 62 ++++-- .../vendors/WorkspaceVendorsPage.tsx | 204 ++++++++++++++++-- src/types/onyx/PolicyVendor.ts | 29 +-- src/types/onyx/index.ts | 3 +- tests/unit/PolicyUtilsTest.ts | 81 +++++++ tests/unit/PolicyVendorActionTest.ts | 162 ++++++++++++++ 14 files changed, 724 insertions(+), 79 deletions(-) create mode 100644 src/libs/API/parameters/SetPolicyVendorsEnabledParams.ts create mode 100644 src/libs/actions/Policy/Vendor.ts create mode 100644 tests/unit/PolicyVendorActionTest.ts diff --git a/src/components/Tables/WorkspaceVendorsTable/index.tsx b/src/components/Tables/WorkspaceVendorsTable/index.tsx index 7f3cd7444099..6cf3a2002350 100644 --- a/src/components/Tables/WorkspaceVendorsTable/index.tsx +++ b/src/components/Tables/WorkspaceVendorsTable/index.tsx @@ -1,3 +1,4 @@ +import Switch from '@components/Switch'; import type {CompareItemsCallback, IsItemInSearchCallback, TableColumn, TableData} from '@components/Table'; import Table, {composeTableListHeader} from '@components/Table'; import TextWithTooltip from '@components/TextWithTooltip'; @@ -7,28 +8,47 @@ import useThemeStyles from '@hooks/useThemeStyles'; import tokenizedSearch from '@libs/tokenizedSearch'; +import variables from '@styles/variables'; + import CONST from '@src/CONST'; +import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import type {ListRenderItemInfo} from '@shopify/flash-list'; import React from 'react'; import {View} from 'react-native'; -type WorkspaceVendorTableColumnKey = 'name'; +type WorkspaceVendorTableColumnKey = 'name' | 'enabled'; type WorkspaceVendorTableRowData = TableData & { name: string; + enabled: boolean; + disabled: boolean; + isLocked: boolean; + errors?: OnyxCommon.Errors; + pendingAction?: OnyxCommon.PendingAction; + onToggleEnabled: (enabled: boolean) => void; + dismissError: () => void; }; type WorkspaceVendorsTableProps = { /** Vendor rows to render */ vendors: WorkspaceVendorTableRowData[]; + /** Whether row selection is enabled */ + selectionEnabled?: boolean; + + /** Currently selected vendor keys */ + selectedKeys?: string[]; + + /** Callback fired when row selection changes */ + onRowSelectionChange?: (selectedKeys: string[]) => void; + /** Page-level content rendered above the table header inside the scrollable list */ headerComponent?: React.ReactElement; }; -function WorkspaceVendorsTable({vendors, headerComponent}: WorkspaceVendorsTableProps) { +function WorkspaceVendorsTable({vendors, selectionEnabled = false, selectedKeys = [], onRowSelectionChange, headerComponent}: WorkspaceVendorsTableProps) { const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); @@ -38,10 +58,24 @@ function WorkspaceVendorsTable({vendors, headerComponent}: WorkspaceVendorsTable label: translate('common.name'), sortable: true, }, + { + key: 'enabled', + label: translate('common.enabled'), + sortable: true, + width: variables.tableSwitchColumnWidth, + styling: { + containerStyles: [styles.justifyContentEnd], + }, + }, ]; const compareItems: CompareItemsCallback = (item1, item2, activeSorting) => { const orderMultiplier = activeSorting.order === 'asc' ? 1 : -1; + if (activeSorting.columnKey === 'enabled') { + const enabled1 = item1.enabled ? 1 : 0; + const enabled2 = item2.enabled ? 1 : 0; + return (enabled1 - enabled2) * orderMultiplier; + } return localeCompare(item1.name, item2.name) * orderMultiplier; }; @@ -50,22 +84,41 @@ function WorkspaceVendorsTable({vendors, headerComponent}: WorkspaceVendorsTable return results.length > 0; }; - const renderVendorItem = ({item, index}: ListRenderItemInfo) => ( - - - - - - ); + const renderVendorItem = ({item, index}: ListRenderItemInfo) => { + const accessibilityLabel = [item.name, item.enabled ? translate('common.enabled') : translate('common.disabled')].filter(Boolean).join(', '); + + return ( + + + + + + + + + ); + }; const tableHeaderComponent = composeTableListHeader(headerComponent, ); @@ -79,6 +132,9 @@ function WorkspaceVendorsTable({vendors, headerComponent}: WorkspaceVendorsTable isItemInSearch={isItemInSearch} renderItem={renderVendorItem} keyExtractor={(item) => item.keyForList} + selectionEnabled={selectionEnabled} + selectedKeys={selectedKeys} + onRowSelectionChange={onRowSelectionChange} > {tableHeaderComponent} ): Vendor[] { /** * Sorts vendors alphabetically by name using the provided localeCompare. - * Uses vendor id as a stable tie-breaker when names match. + * Uses vendor id or externalID as a stable tie-breaker when names match. * Non-mutating: returns a new sorted array. */ -function sortVendors(vendors: TVendor[], localeCompare: LocaleContextProps['localeCompare']): TVendor[] { +function sortVendors(vendors: TVendor[], localeCompare: LocaleContextProps['localeCompare']): TVendor[] { return [...vendors].sort((a, b) => { const nameComparison = localeCompare(a.name ?? '', b.name ?? ''); if (nameComparison !== 0) { return nameComparison; } - return localeCompare(a.id, b.id); + const keyA = a.id ?? a.externalID ?? ''; + const keyB = b.id ?? b.externalID ?? ''; + return localeCompare(keyA, keyB); }); } +/** + * Resolves the configured default vendor ID for the active integration that supports vendor enablement (QBO, Intacct, or Xero). + * Returns undefined if no default vendor is configured or if the integration does not have an active default fallback. + */ +function getDefaultVendorID(policy: OnyxEntry, origin?: ConnectionName): string | undefined { + const integrationOrigin = origin ?? getActiveVendorMatchingIntegration(policy); + if (!policy?.connections || !integrationOrigin) { + return undefined; + } + + if (integrationOrigin === CONST.POLICY.CONNECTIONS.NAME.QBO) { + const qboConfig = policy.connections[CONST.POLICY.CONNECTIONS.NAME.QBO]?.config; + if (qboConfig?.nonReimbursableExpensesExportDestination === CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD) { + return qboConfig.nonReimbursableCreditCardDefaultVendor || undefined; + } + } else if (integrationOrigin === CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT) { + const intacctConfig = policy.connections[CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT]?.config; + return intacctConfig?.export?.nonReimbursableCreditCardChargeDefaultVendor || undefined; + } else if (integrationOrigin === CONST.POLICY.CONNECTIONS.NAME.XERO) { + const xeroConfig = policy.connections[CONST.POLICY.CONNECTIONS.NAME.XERO]?.config; + return xeroConfig?.defaultVendor || undefined; + } + + return undefined; +} + /** * True only when the active vendor-matching integration's vendor list has been written to Onyx — * including the loaded-but-empty case. Lets callers distinguish "vendor not in list" (the @@ -3608,6 +3636,7 @@ export { getActiveVendorMatchingIntegration, getMatchingVendorByID, getMatchingVendors, + getDefaultVendorID, sortVendors, getVendorEmptyState, getVendorRuleDisplayValue, diff --git a/src/libs/actions/Policy/Vendor.ts b/src/libs/actions/Policy/Vendor.ts new file mode 100644 index 000000000000..c87782a0b91e --- /dev/null +++ b/src/libs/actions/Policy/Vendor.ts @@ -0,0 +1,111 @@ +import * as API from '@libs/API'; +import type {SetPolicyVendorsEnabledParams} from '@libs/API/parameters'; +import {WRITE_COMMANDS} from '@libs/API/types'; +import * as ErrorUtils from '@libs/ErrorUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PolicyVendor, PolicyVendors} from '@src/types/onyx'; +import type {OnyxData} from '@src/types/onyx/Request'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import Onyx from 'react-native-onyx'; + +type SetPolicyVendorsEnabledArgs = { + policyID: string; + vendorIDs: string[]; + enabled: boolean; + policyVendors?: OnyxEntry; +}; + +function setPolicyVendorsEnabled({policyID, vendorIDs, enabled, policyVendors}: SetPolicyVendorsEnabledArgs) { + if (!vendorIDs.length) { + return; + } + + const optimisticVendors: Record> = {}; + const successVendors: Record> = {}; + const failureVendors: Record> = {}; + + for (const vendorID of vendorIDs) { + const existing = policyVendors?.[vendorID]; + optimisticVendors[vendorID] = { + ...existing, + enabled, + pendingFields: { + ...existing?.pendingFields, + enabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + pendingAction: existing?.pendingAction ?? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + errors: null, + }; + + successVendors[vendorID] = { + ...existing, + enabled, + pendingFields: { + ...existing?.pendingFields, + enabled: null, + }, + pendingAction: null, + errors: null, + }; + + failureVendors[vendorID] = { + ...existing, + pendingFields: { + ...existing?.pendingFields, + enabled: null, + }, + pendingAction: null, + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.vendors.updateFailureMessage'), + }; + } + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_VENDORS}${policyID}`, + value: optimisticVendors, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_VENDORS}${policyID}`, + value: successVendors, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_VENDORS}${policyID}`, + value: failureVendors, + }, + ], + }; + + const parameters: SetPolicyVendorsEnabledParams = { + policyID, + vendorIDs: JSON.stringify(vendorIDs), + enabled, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_VENDORS_ENABLED, parameters, onyxData); +} + +function clearVendorErrors(policyID: string, vendorID: string) { + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_VENDORS}${policyID}`, { + [vendorID]: { + errors: null, + pendingAction: null, + pendingFields: null, + errorFields: null, + }, + }); +} + +export {setPolicyVendorsEnabled, clearVendorErrors}; +export type {SetPolicyVendorsEnabledArgs}; diff --git a/src/pages/iou/request/step/IOURequestStepVendor.tsx b/src/pages/iou/request/step/IOURequestStepVendor.tsx index af98674fe953..b138cccaa592 100644 --- a/src/pages/iou/request/step/IOURequestStepVendor.tsx +++ b/src/pages/iou/request/step/IOURequestStepVendor.tsx @@ -15,7 +15,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 {getMatchingVendors, getVendorEmptyState, hasVendorFeature, isXeroActiveMatchingSource, sortVendors} from '@libs/PolicyUtils'; +import {getVendorEmptyState, hasVendorFeature, isXeroActiveMatchingSource, sortVendors} from '@libs/PolicyUtils'; import {isPerDiemRequest} from '@libs/TransactionUtils'; import variables from '@styles/variables'; @@ -61,6 +61,7 @@ function IOURequestStepVendor({ }); const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID)}`); const [transactionViolations] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${getNonEmptyStringOnyxID(transactionID)}`); + const [policyVendors] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_VENDORS}${policy?.id}`); const delegateAccountID = useDelegateAccountID(); const isVendorMatchingBetaEnabled = isBetaEnabled(CONST.BETAS.VENDOR_MATCHING); @@ -71,36 +72,53 @@ 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 vendor-matching flow). const isReimbursable = !!transaction?.reimbursable; const isInvoice = iouType === CONST.IOU.TYPE.INVOICE; - const vendors = getMatchingVendors(policy); - const sortedVendors = sortVendors(vendors, localeCompare); + const currentVendorID = transaction?.comment?.vendor?.externalID; + const currentVendor = currentVendorID ? policyVendors?.[currentVendorID] : undefined; + const isCurrentVendorDisabled = !!currentVendorID && !currentVendor?.enabled; + + const enabledVendors = Object.values(policyVendors ?? {}).filter((vendor) => vendor.enabled); + const sortedEnabledVendors = sortVendors(enabledVendors, localeCompare); const vendorLabel = isOnXero ? translate('common.supplier') : translate('common.vendor'); const trimmedSearch = searchValue.trim().toLowerCase(); - const vendorRows: VendorListItem[] = sortedVendors + const enabledRows: VendorListItem[] = sortedEnabledVendors .filter((vendor) => !trimmedSearch || vendor.name.toLowerCase().includes(trimmedSearch)) .map((vendor) => ({ - value: vendor.id, + value: vendor.externalID, text: vendor.name, - keyForList: vendor.id, - isSelected: vendor.id === currentVendorID, + keyForList: vendor.externalID, + isSelected: vendor.externalID === currentVendorID, searchText: vendor.name, })); - // When a vendor is currently set, offer a "None" row so the user can clear a stale (e.g. removed from the accounting integration) vendor without picking a replacement, which resolves an inactiveVendor violation. Hidden during search to keep results clean. - const shouldShowNoneRow = !!currentVendorID && !trimmedSearch; - const data: VendorListItem[] = shouldShowNoneRow - ? [ - { - value: '', - text: translate('common.none'), - keyForList: 'clear-vendor', - isSelected: false, - searchText: '', - }, - ...vendorRows, - ] - : vendorRows; + const disabledCurrentVendorRow: VendorListItem | undefined = + !trimmedSearch && isCurrentVendorDisabled + ? { + value: currentVendorID, + text: currentVendor?.name ?? transaction?.comment?.vendor?.name ?? '', + keyForList: currentVendorID, + isSelected: true, + searchText: currentVendor?.name ?? transaction?.comment?.vendor?.name ?? '', + alternateText: translate('common.disabled'), + } + : undefined; + + const data: VendorListItem[] = [ + ...(currentVendorID && !trimmedSearch + ? [ + { + value: '', + text: translate('common.none'), + keyForList: 'clear-vendor', + isSelected: false, + searchText: '', + }, + ] + : []), + ...(disabledCurrentVendorRow ? [disabledCurrentVendorRow] : []), + ...enabledRows, + ]; const shouldShowNotFoundPage = useShowNotFoundPageInIOUStep(action, iouType, reportActionID, report, transaction) || !isFeatureAvailable || isReimbursable || isInvoice; @@ -130,7 +148,7 @@ function IOURequestStepVendor({ const headerMessage = searchValue && data.length === 0 ? translate('common.noResultsFound') : ''; const listEmptyContent = - vendors.length === 0 ? ( + enabledVendors.length === 0 && !disabledCurrentVendorRow ? ( ; @@ -31,26 +46,82 @@ function WorkspaceVendorsPage({policy, route}: WorkspaceVendorsPageProps) { const {policyID} = route.params; const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout(); const {isBetaEnabled} = usePermissions(); + const {isOffline} = useNetwork(); + const shouldDisplayButtonsInSeparateLine = useShouldDisplayButtonsInSeparateLine(); + const isMobileSelectionModeEnabled = useMobileSelectionMode(); + const {canWrite: canWriteVendors, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.VENDORS); + const icons = useMemoizedLazyExpensifyIcons(['Checkmark', 'Close']); + + const [policyVendors] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_VENDORS}${policyID}`); + const [selectedVendorKeys, setSelectedVendorKeys] = useState([]); useWorkspaceDocumentTitle(policy?.name, 'workspace.common.vendors'); const isVendorMatchingBetaEnabled = isBetaEnabled(CONST.BETAS.VENDOR_MATCHING); const isFeatureAvailable = hasVendorFeature(policy, isVendorMatchingBetaEnabled); - const vendors = getMatchingVendors(policy); - const sortedVendors = sortVendors(vendors, localeCompare); const connectedIntegration = getActiveVendorMatchingIntegration(policy); const currentConnectionName = connectedIntegration ? CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectedIntegration] : undefined; + const defaultVendorID = getDefaultVendorID(policy, connectedIntegration); - const vendorRows: WorkspaceVendorTableRowData[] = useMemo( - () => - sortedVendors.map((vendor) => ({ - keyForList: vendor.id, - name: vendor.name, - })), - [sortedVendors], - ); + const canSelectMultiple = canWriteVendors && (isSmallScreenWidth ? isMobileSelectionModeEnabled : true); + + const clearTableSelection = () => { + setSelectedVendorKeys((prev) => (prev.length > 0 ? [] : prev)); + }; + + useCleanupSelectedOptions(clearTableSelection); + + useSearchBackPress({ + onClearSelection: clearTableSelection, + onNavigationCallBack: () => Navigation.goBack(), + }); + + useEffect(() => { + if (selectedVendorKeys.length === 0 || !canSelectMultiple) { + return; + } + + setSelectedVendorKeys((prev) => prev.filter((key) => !!policyVendors?.[key])); + }, [canSelectMultiple, policyVendors, selectedVendorKeys.length]); + + const handleVendorToggle = (enabled: boolean, vendorID: string) => { + if (!canWriteVendors) { + showReadOnlyModal(); + return; + } + + if (!enabled && vendorID === defaultVendorID) { + return; + } + + setPolicyVendorsEnabled({ + policyID, + vendorIDs: [vendorID], + enabled, + policyVendors, + }); + }; + + const sortedVendors = sortVendors(Object.values(policyVendors ?? {}), localeCompare); + + const vendorRows: WorkspaceVendorTableRowData[] = sortedVendors.map((vendor) => { + const isDefaultVendor = vendor.externalID === defaultVendorID; + const isLocked = (vendor.enabled && isDefaultVendor) || !canWriteVendors; + + return { + keyForList: vendor.externalID, + name: vendor.name, + enabled: vendor.enabled, + disabled: !canWriteVendors, + isLocked, + errors: vendor.errors ?? undefined, + pendingAction: vendor.pendingAction, + onToggleEnabled: (enabled: boolean) => handleVendorToggle(enabled, vendor.externalID), + dismissError: () => clearVendorErrors(policyID, vendor.externalID), + }; + }); const headerContent = currentConnectionName ? ( @@ -63,6 +134,74 @@ function WorkspaceVendorsPage({policy, route}: WorkspaceVendorsPageProps) { ) : undefined; + const getHeaderButtons = () => { + if (!canWriteVendors || selectedVendorKeys.length === 0) { + return null; + } + + const options: Array>> = []; + const disabledVendors = selectedVendorKeys.filter((id) => !policyVendors?.[id]?.enabled); + if (disabledVendors.length > 0) { + options.push({ + icon: icons.Checkmark, + text: translate(disabledVendors.length === 1 ? 'workspace.vendors.enableVendor' : 'workspace.vendors.enableVendors'), + value: CONST.POLICY.BULK_ACTION_TYPES.ENABLE, + onSelected: () => { + clearTableSelection(); + setPolicyVendorsEnabled({ + policyID, + vendorIDs: disabledVendors, + enabled: true, + policyVendors, + }); + }, + }); + } + + const disableableVendors = selectedVendorKeys.filter((id) => policyVendors?.[id]?.enabled && id !== defaultVendorID); + if (disableableVendors.length > 0) { + options.push({ + icon: icons.Close, + text: translate(disableableVendors.length === 1 ? 'workspace.vendors.disableVendor' : 'workspace.vendors.disableVendors'), + value: CONST.POLICY.BULK_ACTION_TYPES.DISABLE, + onSelected: () => { + clearTableSelection(); + setPolicyVendorsEnabled({ + policyID, + vendorIDs: disableableVendors, + enabled: false, + policyVendors, + }); + }, + }); + } + + if (options.length === 0) { + return null; + } + + return ( + null} + shouldAlwaysShowDropdownMenu + size={CONST.BUTTON_SIZE.MEDIUM} + customText={translate('workspace.common.selected', {count: selectedVendorKeys.length})} + options={options} + isSplitButton={false} + style={[shouldDisplayButtonsInSeparateLine && styles.flexGrow1, shouldDisplayButtonsInSeparateLine && styles.mb3]} + isDisabled={!selectedVendorKeys.length} + sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.INITIAL.VENDORS} + testID="WorkspaceVendorsPage-header-dropdown-menu-button" + /> + ); + }; + + const headerButtons = getHeaderButtons(); + + const isLoading = !isOffline && policyVendors === undefined; + const selectionModeHeader = isMobileSelectionModeEnabled && shouldUseNarrowLayout; + return ( Navigation.goBack()} - /> - + title={selectionModeHeader ? translate('common.selectMultiple') : translate('workspace.common.vendors')} + onBackButtonPress={() => { + if (isMobileSelectionModeEnabled) { + clearTableSelection(); + turnOffMobileSelectionMode(); + return; + } + Navigation.goBack(); + }} + > + {!shouldDisplayButtonsInSeparateLine && headerButtons} + + {shouldDisplayButtonsInSeparateLine && !!headerButtons && {headerButtons}} + + {isLoading && ( + + )} + + {!isLoading && ( + + )} ); diff --git a/src/types/onyx/PolicyVendor.ts b/src/types/onyx/PolicyVendor.ts index 913978785477..69d185adea46 100644 --- a/src/types/onyx/PolicyVendor.ts +++ b/src/types/onyx/PolicyVendor.ts @@ -1,21 +1,22 @@ +import type * as OnyxCommon from './OnyxCommon'; import type {PolicyConnectionName} from './Policy'; -/** Record of normalized policy vendors, indexed by externalID. */ -type PolicyVendors = Record< - string, - { - /** The vendor identifier scoped to its active accounting connection. */ - externalID: string; +type PolicyVendor = OnyxCommon.OnyxValueWithOfflineFeedback<{ + /** The vendor identifier scoped to its active accounting connection. */ + externalID: string; + + /** The accounting-system display name. */ + name: string; - /** The accounting-system display name. */ - name: string; + /** Whether the policy preference permits this vendor to be selected. */ + enabled: boolean; - /** Whether the policy preference permits this vendor to be selected. */ - enabled: boolean; + /** The active accounting connection that supplied this vendor. */ + origin?: PolicyConnectionName; +}>; - /** The active accounting connection that supplied this vendor. */ - origin?: PolicyConnectionName; - } ->; +/** Record of normalized policy vendors, indexed by externalID. */ +type PolicyVendors = Record; export default PolicyVendors; +export type {PolicyVendor}; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index fed8a4d0d576..641fc66ad74c 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -143,8 +143,8 @@ import type PolicyEmployee from './PolicyEmployee'; import type PolicyJoinMember from './PolicyJoinMember'; import type PolicyOwnershipChangeChecks from './PolicyOwnershipChangeChecks'; import type PolicyRoomsMetadata from './PolicyRoomsMetadata'; -import type {ParticipantsPolicyTags, PolicyTag, PolicyTagLists, PolicyTags, SearchTagFilterItem} from './PolicyTag'; import type PolicyVendors from './PolicyVendor'; +import type {PolicyVendor} from './PolicyVendor'; import type PrivatePersonalDetails from './PrivatePersonalDetails'; import type PrivatePromoDiscount from './PrivatePromoDiscount'; import type PrivateSubscription from './PrivateSubscription'; @@ -319,6 +319,7 @@ export type { Policy, PolicyCategories, PolicyCategory, + PolicyVendor, PolicyVendors, PolicyEmployee, PolicyEmployeeList, diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index d0ceb04707bc..f36547582793 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -105,6 +105,7 @@ import { shouldHideDynamicExternalWorkflowPeople, shouldShowPolicy, sortPoliciesByName, + getDefaultVendorID, sortVendors, sortWorkspacesBySelected, tryNavigateToSubmitWorkspaceUpgrade, @@ -4229,6 +4230,86 @@ describe('PolicyUtils', () => { expect(result).toHaveLength(1); expect(result.at(0)?.name).toBe('Only'); }); + + it('breaks name ties using externalID when id is absent', () => { + const vendors = [ + {externalID: 'vendor_b', name: 'Acme'}, + {externalID: 'vendor_a', name: 'Acme'}, + ]; + + const result = sortVendors(vendors, localeCompare); + expect(result.map((v) => v.externalID)).toEqual(['vendor_a', 'vendor_b']); + }); + }); + + describe('getDefaultVendorID', () => { + it('resolves QBO credit card default vendor when destination is credit_card', () => { + const policy = { + id: '1', + connections: { + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: { + nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD, + nonReimbursableCreditCardDefaultVendor: 'qbo_vendor_1', + }, + }, + }, + } as unknown as Policy; + + expect(getDefaultVendorID(policy, CONST.POLICY.CONNECTIONS.NAME.QBO)).toBe('qbo_vendor_1'); + }); + + it('returns undefined for QBO when destination is not credit_card', () => { + const policy = { + id: '1', + connections: { + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: { + nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD, + nonReimbursableCreditCardDefaultVendor: 'qbo_vendor_1', + }, + }, + }, + } as unknown as Policy; + + expect(getDefaultVendorID(policy, CONST.POLICY.CONNECTIONS.NAME.QBO)).toBeUndefined(); + }); + + it('resolves Sage Intacct credit card charge default vendor', () => { + const policy = { + id: '1', + connections: { + [CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT]: { + config: { + export: { + nonReimbursableCreditCardChargeDefaultVendor: 'intacct_vendor_1', + }, + }, + }, + }, + } as unknown as Policy; + + expect(getDefaultVendorID(policy, CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT)).toBe('intacct_vendor_1'); + }); + + it('resolves Xero default vendor', () => { + const policy = { + id: '1', + connections: { + [CONST.POLICY.CONNECTIONS.NAME.XERO]: { + config: { + defaultVendor: 'xero_contact_1', + }, + }, + }, + } as unknown as Policy; + + expect(getDefaultVendorID(policy, CONST.POLICY.CONNECTIONS.NAME.XERO)).toBe('xero_contact_1'); + }); + + it('returns undefined when policy has no connections', () => { + expect(getDefaultVendorID(undefined, CONST.POLICY.CONNECTIONS.NAME.QBO)).toBeUndefined(); + }); }); describe('getSageIntacctVendors', () => { diff --git a/tests/unit/PolicyVendorActionTest.ts b/tests/unit/PolicyVendorActionTest.ts new file mode 100644 index 000000000000..679323d48106 --- /dev/null +++ b/tests/unit/PolicyVendorActionTest.ts @@ -0,0 +1,162 @@ +import {clearVendorErrors, setPolicyVendorsEnabled} from '@libs/actions/Policy/Vendor'; +import {write} from '@libs/API'; +import {WRITE_COMMANDS} from '@libs/API/types'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PolicyVendors} from '@src/types/onyx'; + +import Onyx from 'react-native-onyx'; + +jest.mock('@libs/API'); +jest.mock('@expensify/react-native-hybrid-app', () => ({ + __esModule: true, + default: {isHybridApp: () => false}, +})); + +const mockWrite = jest.mocked(write); +const policyID = 'test_policy_1'; +const policyVendorsKey = `${ONYXKEYS.COLLECTION.POLICY_VENDORS}${policyID}` as const; + +describe('PolicyVendorAction', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('setPolicyVendorsEnabled', () => { + it('does nothing when vendorIDs is empty', () => { + setPolicyVendorsEnabled({ + policyID, + vendorIDs: [], + enabled: true, + }); + + expect(mockWrite).not.toHaveBeenCalled(); + }); + + it('dispatches SetPolicyVendorsEnabled with optimistic, success, and failure data', () => { + const initialPolicyVendors: PolicyVendors = { + vendor_1: { + externalID: 'vendor_1', + name: 'Acme Corp', + enabled: false, + origin: CONST.POLICY.CONNECTIONS.NAME.QBO, + }, + vendor_2: { + externalID: 'vendor_2', + name: 'Beta LLC', + enabled: false, + origin: CONST.POLICY.CONNECTIONS.NAME.QBO, + }, + }; + + setPolicyVendorsEnabled({ + policyID, + vendorIDs: ['vendor_1', 'vendor_2'], + enabled: true, + policyVendors: initialPolicyVendors, + }); + + expect(mockWrite).toHaveBeenCalledTimes(1); + expect(mockWrite).toHaveBeenCalledWith( + WRITE_COMMANDS.SET_POLICY_VENDORS_ENABLED, + { + policyID, + vendorIDs: JSON.stringify(['vendor_1', 'vendor_2']), + enabled: true, + }, + { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyVendorsKey, + value: { + vendor_1: expect.objectContaining({ + enabled: true, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + pendingFields: { + enabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + errors: null, + }), + vendor_2: expect.objectContaining({ + enabled: true, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + pendingFields: { + enabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + errors: null, + }), + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyVendorsKey, + value: { + vendor_1: expect.objectContaining({ + enabled: true, + pendingAction: null, + pendingFields: { + enabled: null, + }, + errors: null, + }), + vendor_2: expect.objectContaining({ + enabled: true, + pendingAction: null, + pendingFields: { + enabled: null, + }, + errors: null, + }), + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyVendorsKey, + value: { + vendor_1: expect.objectContaining({ + pendingAction: null, + pendingFields: { + enabled: null, + }, + errors: expect.any(Object), + }), + vendor_2: expect.objectContaining({ + pendingAction: null, + pendingFields: { + enabled: null, + }, + errors: expect.any(Object), + }), + }, + }, + ], + }, + ); + }); + }); + + describe('clearVendorErrors', () => { + it('merges null errors and pending fields to Onyx', () => { + const onyxMergeSpy = jest.spyOn(Onyx, 'merge'); + + clearVendorErrors(policyID, 'vendor_1'); + + expect(onyxMergeSpy).toHaveBeenCalledWith(policyVendorsKey, { + vendor_1: { + errors: null, + pendingAction: null, + pendingFields: null, + errorFields: null, + }, + }); + + onyxMergeSpy.mockRestore(); + }); + }); +}); From e2d0ea5136ad2fdef9b48ed4a884b205cc03f96c Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:09:41 +0530 Subject: [PATCH 02/14] Fix PolicyTag import, knip export, and spelling --- src/libs/actions/Policy/Vendor.ts | 1 - src/pages/workspace/vendors/WorkspaceVendorsPage.tsx | 8 ++++---- src/types/onyx/index.ts | 1 + 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/libs/actions/Policy/Vendor.ts b/src/libs/actions/Policy/Vendor.ts index c87782a0b91e..90d9d702e068 100644 --- a/src/libs/actions/Policy/Vendor.ts +++ b/src/libs/actions/Policy/Vendor.ts @@ -108,4 +108,3 @@ function clearVendorErrors(policyID: string, vendorID: string) { } export {setPolicyVendorsEnabled, clearVendorErrors}; -export type {SetPolicyVendorsEnabledArgs}; diff --git a/src/pages/workspace/vendors/WorkspaceVendorsPage.tsx b/src/pages/workspace/vendors/WorkspaceVendorsPage.tsx index ffc7694bab69..df5f82b899a0 100644 --- a/src/pages/workspace/vendors/WorkspaceVendorsPage.tsx +++ b/src/pages/workspace/vendors/WorkspaceVendorsPage.tsx @@ -158,17 +158,17 @@ function WorkspaceVendorsPage({policy, route}: WorkspaceVendorsPageProps) { }); } - const disableableVendors = selectedVendorKeys.filter((id) => policyVendors?.[id]?.enabled && id !== defaultVendorID); - if (disableableVendors.length > 0) { + const vendorsToDisable = selectedVendorKeys.filter((id) => policyVendors?.[id]?.enabled && id !== defaultVendorID); + if (vendorsToDisable.length > 0) { options.push({ icon: icons.Close, - text: translate(disableableVendors.length === 1 ? 'workspace.vendors.disableVendor' : 'workspace.vendors.disableVendors'), + text: translate(vendorsToDisable.length === 1 ? 'workspace.vendors.disableVendor' : 'workspace.vendors.disableVendors'), value: CONST.POLICY.BULK_ACTION_TYPES.DISABLE, onSelected: () => { clearTableSelection(); setPolicyVendorsEnabled({ policyID, - vendorIDs: disableableVendors, + vendorIDs: vendorsToDisable, enabled: false, policyVendors, }); diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 641fc66ad74c..17bb2d175caf 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -143,6 +143,7 @@ import type PolicyEmployee from './PolicyEmployee'; import type PolicyJoinMember from './PolicyJoinMember'; import type PolicyOwnershipChangeChecks from './PolicyOwnershipChangeChecks'; import type PolicyRoomsMetadata from './PolicyRoomsMetadata'; +import type {ParticipantsPolicyTags, PolicyTag, PolicyTagLists, PolicyTags, SearchTagFilterItem} from './PolicyTag'; import type PolicyVendors from './PolicyVendor'; import type {PolicyVendor} from './PolicyVendor'; import type PrivatePersonalDetails from './PrivatePersonalDetails'; From e73f3d41d2d039e29a59fbe8ae1a0f0d0ad8db4e Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:15:07 +0530 Subject: [PATCH 03/14] Fix PolicyVendor error typing and language keys --- src/languages/de.ts | 5 +++++ src/languages/el.ts | 5 +++++ src/languages/fr.ts | 5 +++++ src/languages/it.ts | 5 +++++ src/languages/ja.ts | 5 +++++ src/languages/nl.ts | 5 +++++ src/languages/pl.ts | 5 +++++ src/languages/pt-BR.ts | 5 +++++ src/languages/zh-hans.ts | 12 +++++++++++- src/pages/iou/request/step/IOURequestStepVendor.tsx | 4 +++- src/types/onyx/PolicyVendor.ts | 6 ++++++ 11 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 128cee94f8cf..f18e88e77bb7 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -6854,6 +6854,11 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU emptySubtitle: 'Anbieter werden hier angezeigt, nachdem Ihre Buchhaltungssynchronisierung abgeschlossen ist.', findVendor: 'Lieferanten finden', managedInAccountingSoftware: 'Lieferanten werden verwaltet in Ihrem', + enableVendor: 'Enable vendor', + enableVendors: 'Enable vendors', + disableVendor: 'Disable vendor', + disableVendors: 'Disable vendors', + updateFailureMessage: 'An error occurred while updating the vendor, please try again', }, tags: { tagName: 'Tag-Name', diff --git a/src/languages/el.ts b/src/languages/el.ts index 7ca33101e261..fc1115eab95f 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -9019,6 +9019,11 @@ ${reportName}`, emptySubtitle: 'Οι προμηθευτές θα εμφανιστούν εδώ μετά την ολοκλήρωση του συγχρονισμού λογιστικής σας.', findVendor: 'Εύρεση προμηθευτή', managedInAccountingSoftware: 'Οι προμηθευτές διαχειρίζονται στο', + enableVendor: 'Enable vendor', + enableVendors: 'Enable vendors', + disableVendor: 'Disable vendor', + disableVendors: 'Disable vendors', + updateFailureMessage: 'An error occurred while updating the vendor, please try again', }, campfire: { campfireSetup: 'Ρύθμιση campfire', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 5970d2f44999..4f1ef6fc8aa4 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -6874,6 +6874,11 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. emptySubtitle: 'Les fournisseurs apparaîtront ici une fois la synchronisation comptable terminée.', findVendor: 'Trouver un fournisseur', managedInAccountingSoftware: 'Les fournisseurs sont gérés dans votre', + enableVendor: 'Enable vendor', + enableVendors: 'Enable vendors', + disableVendor: 'Disable vendor', + disableVendors: 'Disable vendors', + updateFailureMessage: 'An error occurred while updating the vendor, please try again', }, tags: { tagName: 'Nom du tag', diff --git a/src/languages/it.ts b/src/languages/it.ts index 1c2ff79405f1..4d14967fb9b8 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -6816,6 +6816,11 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. emptySubtitle: 'I fornitori verranno visualizzati qui al termine della sincronizzazione contabile.', findVendor: 'Trova fornitore', managedInAccountingSoftware: 'I fornitori sono gestiti nel tuo', + enableVendor: 'Enable vendor', + enableVendors: 'Enable vendors', + disableVendor: 'Disable vendor', + disableVendors: 'Disable vendors', + updateFailureMessage: 'An error occurred while updating the vendor, please try again', }, tags: { tagName: 'Nome tag', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 5b15f8c74d0b..b6d60414819a 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -6728,6 +6728,11 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO emptySubtitle: '会計システムとの同期が完了すると、ここに取引先が表示されます。', findVendor: '取引先を検索', managedInAccountingSoftware: '取引先は次の場所で管理されます:', + enableVendor: 'Enable vendor', + enableVendors: 'Enable vendors', + disableVendor: 'Disable vendor', + disableVendors: 'Disable vendors', + updateFailureMessage: 'An error occurred while updating the vendor, please try again', }, tags: { tagName: 'タグ名', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index eab36b5631c7..6a2ba92afe38 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -6801,6 +6801,11 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ emptySubtitle: 'Leveranciers verschijnen hier zodra je boekhoudkundige synchronisatie is voltooid.', findVendor: 'Leverancier zoeken', managedInAccountingSoftware: 'Leveranciers worden beheerd in je', + enableVendor: 'Enable vendor', + enableVendors: 'Enable vendors', + disableVendor: 'Disable vendor', + disableVendors: 'Disable vendors', + updateFailureMessage: 'An error occurred while updating the vendor, please try again', }, tags: { tagName: 'Tagnaam', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 67716487a5d7..850e706338ed 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -6826,6 +6826,11 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy emptySubtitle: 'Dostawcy pojawią się tutaj po zakończeniu synchronizacji z księgowością.', findVendor: 'Znajdź dostawcę', managedInAccountingSoftware: 'Dostawcy są zarządzani w twoim', + enableVendor: 'Enable vendor', + enableVendors: 'Enable vendors', + disableVendor: 'Disable vendor', + disableVendors: 'Disable vendors', + updateFailureMessage: 'An error occurred while updating the vendor, please try again', }, tags: { tagName: 'Nazwa tagu', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 9c89ec260555..e13f0053a7d7 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -6803,6 +6803,11 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS emptySubtitle: 'Os fornecedores vão aparecer aqui depois que a sincronização contábil for concluída.', findVendor: 'Encontrar fornecedor', managedInAccountingSoftware: 'Fornecedores são gerenciados no seu', + enableVendor: 'Enable vendor', + enableVendors: 'Enable vendors', + disableVendor: 'Disable vendor', + disableVendors: 'Disable vendors', + updateFailureMessage: 'An error occurred while updating the vendor, please try again', }, tags: { tagName: 'Nome da tag', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 07830d182638..c75ad8c1a8d2 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -6561,7 +6561,17 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM invoiceFieldInitialValueRequiredError: '请选择发票字段的初始值', addField: '添加字段', }, - vendors: {emptyTitle: '尚无供应商', emptySubtitle: '会计同步完成后,供应商将显示在此处。', findVendor: '查找供应商', managedInAccountingSoftware: '供应商在您的'}, + vendors: { + emptyTitle: '尚无供应商', + emptySubtitle: '会计同步完成后,供应商将显示在此处。', + findVendor: '查找供应商', + managedInAccountingSoftware: '供应商在您的', + enableVendor: 'Enable vendor', + enableVendors: 'Enable vendors', + disableVendor: 'Disable vendor', + disableVendors: 'Disable vendors', + updateFailureMessage: 'An error occurred while updating the vendor, please try again', + }, tags: { tagName: '标签名称', requiresTag: '成员必须为所有报销添加标签', diff --git a/src/pages/iou/request/step/IOURequestStepVendor.tsx b/src/pages/iou/request/step/IOURequestStepVendor.tsx index b138cccaa592..08b637143124 100644 --- a/src/pages/iou/request/step/IOURequestStepVendor.tsx +++ b/src/pages/iou/request/step/IOURequestStepVendor.tsx @@ -104,8 +104,10 @@ function IOURequestStepVendor({ } : undefined; + const shouldShowNoneRow = !!currentVendorID && !trimmedSearch; + const data: VendorListItem[] = [ - ...(currentVendorID && !trimmedSearch + ...(shouldShowNoneRow ? [ { value: '', diff --git a/src/types/onyx/PolicyVendor.ts b/src/types/onyx/PolicyVendor.ts index 69d185adea46..a366af58b589 100644 --- a/src/types/onyx/PolicyVendor.ts +++ b/src/types/onyx/PolicyVendor.ts @@ -13,6 +13,12 @@ type PolicyVendor = OnyxCommon.OnyxValueWithOfflineFeedback<{ /** The active accounting connection that supplied this vendor. */ origin?: PolicyConnectionName; + + /** A list of errors keyed by microtime */ + errors?: OnyxCommon.Errors | null; + + /** Error fields */ + errorFields?: OnyxCommon.ErrorFields | null; }>; /** Record of normalized policy vendors, indexed by externalID. */ From 9d10473284ed000a85a22a40485890299635b7e8 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:56:39 +0530 Subject: [PATCH 04/14] Expand default vendor resolution in PolicyUtils --- src/libs/PolicyUtils.ts | 17 ++++++- tests/unit/PolicyUtilsTest.ts | 85 +++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index fdff5c680c5c..6649e00ebaab 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -2909,7 +2909,7 @@ function sortVendors, origin?: ConnectionName): string | undefined { @@ -2929,6 +2929,21 @@ function getDefaultVendorID(policy: OnyxEntry, origin?: ConnectionName): } else if (integrationOrigin === CONST.POLICY.CONNECTIONS.NAME.XERO) { const xeroConfig = policy.connections[CONST.POLICY.CONNECTIONS.NAME.XERO]?.config; return xeroConfig?.defaultVendor || undefined; + } else if (integrationOrigin === CONST.POLICY.CONNECTIONS.NAME.RILLET) { + const rilletConfig = policy.connections[CONST.POLICY.CONNECTIONS.NAME.RILLET]?.config; + return rilletConfig?.export?.defaultVendorID || undefined; + } else if (integrationOrigin === CONST.POLICY.CONNECTIONS.NAME.DUALENTRY) { + const dualentryConfig = policy.connections[CONST.POLICY.CONNECTIONS.NAME.DUALENTRY]?.config; + return dualentryConfig?.export?.defaultVendorID || undefined; + } else if (integrationOrigin === CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE) { + const campfireConfig = policy.connections[CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE]?.config; + return campfireConfig?.export?.defaultVendorID || undefined; + } else if (integrationOrigin === CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL) { + const businessCentralConfig = policy.connections[CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL]?.config; + return businessCentralConfig?.export?.defaultVendorID || undefined; + } else if (integrationOrigin === CONST.POLICY.CONNECTIONS.NAME.CERTINIA) { + const certiniaConfig = policy.connections[CONST.POLICY.CONNECTIONS.NAME.CERTINIA]?.config; + return certiniaConfig?.export?.vendorAccount || undefined; } return undefined; diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index f36547582793..5b7e3d7e30a4 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -4307,6 +4307,91 @@ describe('PolicyUtils', () => { expect(getDefaultVendorID(policy, CONST.POLICY.CONNECTIONS.NAME.XERO)).toBe('xero_contact_1'); }); + it('resolves Rillet default vendor', () => { + const policy = { + id: '1', + connections: { + [CONST.POLICY.CONNECTIONS.NAME.RILLET]: { + config: { + export: { + defaultVendorID: 'rillet_vendor_1', + }, + }, + }, + }, + } as unknown as Policy; + + expect(getDefaultVendorID(policy, CONST.POLICY.CONNECTIONS.NAME.RILLET)).toBe('rillet_vendor_1'); + }); + + it('resolves DualEntry default vendor', () => { + const policy = { + id: '1', + connections: { + [CONST.POLICY.CONNECTIONS.NAME.DUALENTRY]: { + config: { + export: { + defaultVendorID: 'dualentry_vendor_1', + }, + }, + }, + }, + } as unknown as Policy; + + expect(getDefaultVendorID(policy, CONST.POLICY.CONNECTIONS.NAME.DUALENTRY)).toBe('dualentry_vendor_1'); + }); + + it('resolves Campfire default vendor', () => { + const policy = { + id: '1', + connections: { + [CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE]: { + config: { + export: { + defaultVendorID: 'campfire_vendor_1', + }, + }, + }, + }, + } as unknown as Policy; + + expect(getDefaultVendorID(policy, CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE)).toBe('campfire_vendor_1'); + }); + + it('resolves Business Central default vendor', () => { + const policy = { + id: '1', + connections: { + [CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL]: { + config: { + export: { + defaultVendorID: 'bc_vendor_1', + }, + }, + }, + }, + } as unknown as Policy; + + expect(getDefaultVendorID(policy, CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL)).toBe('bc_vendor_1'); + }); + + it('resolves Certinia default vendor', () => { + const policy = { + id: '1', + connections: { + [CONST.POLICY.CONNECTIONS.NAME.CERTINIA]: { + config: { + export: { + vendorAccount: 'certinia_acc_1', + }, + }, + }, + }, + } as unknown as Policy; + + expect(getDefaultVendorID(policy, CONST.POLICY.CONNECTIONS.NAME.CERTINIA)).toBe('certinia_acc_1'); + }); + it('returns undefined when policy has no connections', () => { expect(getDefaultVendorID(undefined, CONST.POLICY.CONNECTIONS.NAME.QBO)).toBeUndefined(); }); From 11cff87278f7c6388e2e00e51d9546076d45c523 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Fri, 2 Oct 2026 02:46:20 +0530 Subject: [PATCH 05/14] Fix ESLint errors in vendor matching files --- src/libs/PolicyUtils.ts | 23 +++--- src/libs/actions/Policy/Vendor.ts | 8 +-- .../vendors/WorkspaceVendorsPage.tsx | 17 ++--- src/types/onyx/PolicyVendor.ts | 9 +-- tests/unit/PolicyUtilsTest.ts | 72 +++++++++---------- tests/unit/PolicyVendorActionTest.ts | 32 ++++----- 6 files changed, 79 insertions(+), 82 deletions(-) diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 6649e00ebaab..9ca6f992b575 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -2902,8 +2902,11 @@ function sortVendors, origin?: ConnectionName): if (integrationOrigin === CONST.POLICY.CONNECTIONS.NAME.QBO) { const qboConfig = policy.connections[CONST.POLICY.CONNECTIONS.NAME.QBO]?.config; if (qboConfig?.nonReimbursableExpensesExportDestination === CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD) { - return qboConfig.nonReimbursableCreditCardDefaultVendor || undefined; + return qboConfig.nonReimbursableCreditCardDefaultVendor ?? undefined; } } else if (integrationOrigin === CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT) { const intacctConfig = policy.connections[CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT]?.config; - return intacctConfig?.export?.nonReimbursableCreditCardChargeDefaultVendor || undefined; + return intacctConfig?.export?.nonReimbursableCreditCardChargeDefaultVendor ?? undefined; } else if (integrationOrigin === CONST.POLICY.CONNECTIONS.NAME.XERO) { const xeroConfig = policy.connections[CONST.POLICY.CONNECTIONS.NAME.XERO]?.config; - return xeroConfig?.defaultVendor || undefined; + return xeroConfig?.defaultVendor ?? undefined; } else if (integrationOrigin === CONST.POLICY.CONNECTIONS.NAME.RILLET) { const rilletConfig = policy.connections[CONST.POLICY.CONNECTIONS.NAME.RILLET]?.config; - return rilletConfig?.export?.defaultVendorID || undefined; + return rilletConfig?.export?.defaultVendorID ?? undefined; } else if (integrationOrigin === CONST.POLICY.CONNECTIONS.NAME.DUALENTRY) { const dualentryConfig = policy.connections[CONST.POLICY.CONNECTIONS.NAME.DUALENTRY]?.config; - return dualentryConfig?.export?.defaultVendorID || undefined; + return dualentryConfig?.export?.defaultVendorID ?? undefined; } else if (integrationOrigin === CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE) { const campfireConfig = policy.connections[CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE]?.config; - return campfireConfig?.export?.defaultVendorID || undefined; + return campfireConfig?.export?.defaultVendorID ?? undefined; } else if (integrationOrigin === CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL) { const businessCentralConfig = policy.connections[CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL]?.config; - return businessCentralConfig?.export?.defaultVendorID || undefined; + return businessCentralConfig?.export?.defaultVendorID ?? undefined; } else if (integrationOrigin === CONST.POLICY.CONNECTIONS.NAME.CERTINIA) { const certiniaConfig = policy.connections[CONST.POLICY.CONNECTIONS.NAME.CERTINIA]?.config; - return certiniaConfig?.export?.vendorAccount || undefined; + return certiniaConfig?.export?.vendorAccount ?? undefined; } return undefined; diff --git a/src/libs/actions/Policy/Vendor.ts b/src/libs/actions/Policy/Vendor.ts index 90d9d702e068..1c7759eb65a1 100644 --- a/src/libs/actions/Policy/Vendor.ts +++ b/src/libs/actions/Policy/Vendor.ts @@ -1,7 +1,7 @@ -import * as API from '@libs/API'; +import {write} from '@libs/API'; import type {SetPolicyVendorsEnabledParams} from '@libs/API/parameters'; import {WRITE_COMMANDS} from '@libs/API/types'; -import * as ErrorUtils from '@libs/ErrorUtils'; +import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -59,7 +59,7 @@ function setPolicyVendorsEnabled({policyID, vendorIDs, enabled, policyVendors}: enabled: null, }, pendingAction: null, - errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.vendors.updateFailureMessage'), + errors: getMicroSecondOnyxErrorWithTranslationKey('workspace.vendors.updateFailureMessage'), }; } @@ -93,7 +93,7 @@ function setPolicyVendorsEnabled({policyID, vendorIDs, enabled, policyVendors}: enabled, }; - API.write(WRITE_COMMANDS.SET_POLICY_VENDORS_ENABLED, parameters, onyxData); + write(WRITE_COMMANDS.SET_POLICY_VENDORS_ENABLED, parameters, onyxData); } function clearVendorErrors(policyID: string, vendorID: string) { diff --git a/src/pages/workspace/vendors/WorkspaceVendorsPage.tsx b/src/pages/workspace/vendors/WorkspaceVendorsPage.tsx index df5f82b899a0..a09d82e1ecde 100644 --- a/src/pages/workspace/vendors/WorkspaceVendorsPage.tsx +++ b/src/pages/workspace/vendors/WorkspaceVendorsPage.tsx @@ -8,6 +8,7 @@ import type {WorkspaceVendorTableRowData} from '@components/Tables/WorkspaceVend import WorkspaceVendorsTable from '@components/Tables/WorkspaceVendorsTable'; import useCleanupSelectedOptions from '@hooks/useCleanupSelectedOptions'; +import useFilteredSelection from '@hooks/useFilteredSelection'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; @@ -37,7 +38,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; -import React, {useEffect, useState} from 'react'; +import React from 'react'; import {View} from 'react-native'; type WorkspaceVendorsPageProps = WithPolicyConnectionsProps & PlatformStackScreenProps; @@ -46,7 +47,7 @@ function WorkspaceVendorsPage({policy, route}: WorkspaceVendorsPageProps) { const {policyID} = route.params; const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); - const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); const {isBetaEnabled} = usePermissions(); const {isOffline} = useNetwork(); const shouldDisplayButtonsInSeparateLine = useShouldDisplayButtonsInSeparateLine(); @@ -55,7 +56,7 @@ function WorkspaceVendorsPage({policy, route}: WorkspaceVendorsPageProps) { const icons = useMemoizedLazyExpensifyIcons(['Checkmark', 'Close']); const [policyVendors] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_VENDORS}${policyID}`); - const [selectedVendorKeys, setSelectedVendorKeys] = useState([]); + const [selectedVendorKeys, setSelectedVendorKeys] = useFilteredSelection(policyVendors, (vendor) => !!vendor); useWorkspaceDocumentTitle(policy?.name, 'workspace.common.vendors'); @@ -65,7 +66,7 @@ function WorkspaceVendorsPage({policy, route}: WorkspaceVendorsPageProps) { const currentConnectionName = connectedIntegration ? CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectedIntegration] : undefined; const defaultVendorID = getDefaultVendorID(policy, connectedIntegration); - const canSelectMultiple = canWriteVendors && (isSmallScreenWidth ? isMobileSelectionModeEnabled : true); + const canSelectMultiple = canWriteVendors && (shouldUseNarrowLayout ? isMobileSelectionModeEnabled : true); const clearTableSelection = () => { setSelectedVendorKeys((prev) => (prev.length > 0 ? [] : prev)); @@ -78,14 +79,6 @@ function WorkspaceVendorsPage({policy, route}: WorkspaceVendorsPageProps) { onNavigationCallBack: () => Navigation.goBack(), }); - useEffect(() => { - if (selectedVendorKeys.length === 0 || !canSelectMultiple) { - return; - } - - setSelectedVendorKeys((prev) => prev.filter((key) => !!policyVendors?.[key])); - }, [canSelectMultiple, policyVendors, selectedVendorKeys.length]); - const handleVendorToggle = (enabled: boolean, vendorID: string) => { if (!canWriteVendors) { showReadOnlyModal(); diff --git a/src/types/onyx/PolicyVendor.ts b/src/types/onyx/PolicyVendor.ts index a366af58b589..a13ac6cbc4d6 100644 --- a/src/types/onyx/PolicyVendor.ts +++ b/src/types/onyx/PolicyVendor.ts @@ -1,7 +1,8 @@ -import type * as OnyxCommon from './OnyxCommon'; +import type {ErrorFields, Errors, OnyxValueWithOfflineFeedback} from './OnyxCommon'; import type {PolicyConnectionName} from './Policy'; -type PolicyVendor = OnyxCommon.OnyxValueWithOfflineFeedback<{ +/** Model of policy vendor */ +type PolicyVendor = OnyxValueWithOfflineFeedback<{ /** The vendor identifier scoped to its active accounting connection. */ externalID: string; @@ -15,10 +16,10 @@ type PolicyVendor = OnyxCommon.OnyxValueWithOfflineFeedback<{ origin?: PolicyConnectionName; /** A list of errors keyed by microtime */ - errors?: OnyxCommon.Errors | null; + errors?: Errors | null; /** Error fields */ - errorFields?: OnyxCommon.ErrorFields | null; + errorFields?: ErrorFields | null; }>; /** Record of normalized policy vendors, indexed by externalID. */ diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index 5b7e3d7e30a4..18a6bdf4f9c2 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -4244,41 +4244,41 @@ describe('PolicyUtils', () => { describe('getDefaultVendorID', () => { it('resolves QBO credit card default vendor when destination is credit_card', () => { - const policy = { + const policy = createMock({ id: '1', - connections: { + connections: createMock({ [CONST.POLICY.CONNECTIONS.NAME.QBO]: { config: { nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD, nonReimbursableCreditCardDefaultVendor: 'qbo_vendor_1', }, }, - }, - } as unknown as Policy; + }), + }); expect(getDefaultVendorID(policy, CONST.POLICY.CONNECTIONS.NAME.QBO)).toBe('qbo_vendor_1'); }); it('returns undefined for QBO when destination is not credit_card', () => { - const policy = { + const policy = createMock({ id: '1', - connections: { + connections: createMock({ [CONST.POLICY.CONNECTIONS.NAME.QBO]: { config: { nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD, nonReimbursableCreditCardDefaultVendor: 'qbo_vendor_1', }, }, - }, - } as unknown as Policy; + }), + }); expect(getDefaultVendorID(policy, CONST.POLICY.CONNECTIONS.NAME.QBO)).toBeUndefined(); }); it('resolves Sage Intacct credit card charge default vendor', () => { - const policy = { + const policy = createMock({ id: '1', - connections: { + connections: createMock({ [CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT]: { config: { export: { @@ -4286,31 +4286,31 @@ describe('PolicyUtils', () => { }, }, }, - }, - } as unknown as Policy; + }), + }); expect(getDefaultVendorID(policy, CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT)).toBe('intacct_vendor_1'); }); it('resolves Xero default vendor', () => { - const policy = { + const policy = createMock({ id: '1', - connections: { + connections: createMock({ [CONST.POLICY.CONNECTIONS.NAME.XERO]: { config: { defaultVendor: 'xero_contact_1', }, }, - }, - } as unknown as Policy; + }), + }); expect(getDefaultVendorID(policy, CONST.POLICY.CONNECTIONS.NAME.XERO)).toBe('xero_contact_1'); }); it('resolves Rillet default vendor', () => { - const policy = { + const policy = createMock({ id: '1', - connections: { + connections: createMock({ [CONST.POLICY.CONNECTIONS.NAME.RILLET]: { config: { export: { @@ -4318,16 +4318,16 @@ describe('PolicyUtils', () => { }, }, }, - }, - } as unknown as Policy; + }), + }); expect(getDefaultVendorID(policy, CONST.POLICY.CONNECTIONS.NAME.RILLET)).toBe('rillet_vendor_1'); }); it('resolves DualEntry default vendor', () => { - const policy = { + const policy = createMock({ id: '1', - connections: { + connections: createMock({ [CONST.POLICY.CONNECTIONS.NAME.DUALENTRY]: { config: { export: { @@ -4335,16 +4335,16 @@ describe('PolicyUtils', () => { }, }, }, - }, - } as unknown as Policy; + }), + }); expect(getDefaultVendorID(policy, CONST.POLICY.CONNECTIONS.NAME.DUALENTRY)).toBe('dualentry_vendor_1'); }); it('resolves Campfire default vendor', () => { - const policy = { + const policy = createMock({ id: '1', - connections: { + connections: createMock({ [CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE]: { config: { export: { @@ -4352,16 +4352,16 @@ describe('PolicyUtils', () => { }, }, }, - }, - } as unknown as Policy; + }), + }); expect(getDefaultVendorID(policy, CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE)).toBe('campfire_vendor_1'); }); it('resolves Business Central default vendor', () => { - const policy = { + const policy = createMock({ id: '1', - connections: { + connections: createMock({ [CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL]: { config: { export: { @@ -4369,16 +4369,16 @@ describe('PolicyUtils', () => { }, }, }, - }, - } as unknown as Policy; + }), + }); expect(getDefaultVendorID(policy, CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL)).toBe('bc_vendor_1'); }); it('resolves Certinia default vendor', () => { - const policy = { + const policy = createMock({ id: '1', - connections: { + connections: createMock({ [CONST.POLICY.CONNECTIONS.NAME.CERTINIA]: { config: { export: { @@ -4386,8 +4386,8 @@ describe('PolicyUtils', () => { }, }, }, - }, - } as unknown as Policy; + }), + }); expect(getDefaultVendorID(policy, CONST.POLICY.CONNECTIONS.NAME.CERTINIA)).toBe('certinia_acc_1'); }); diff --git a/tests/unit/PolicyVendorActionTest.ts b/tests/unit/PolicyVendorActionTest.ts index 679323d48106..68732ce5b0e2 100644 --- a/tests/unit/PolicyVendorActionTest.ts +++ b/tests/unit/PolicyVendorActionTest.ts @@ -36,14 +36,14 @@ describe('PolicyVendorAction', () => { it('dispatches SetPolicyVendorsEnabled with optimistic, success, and failure data', () => { const initialPolicyVendors: PolicyVendors = { - vendor_1: { - externalID: 'vendor_1', + vendor1: { + externalID: 'vendor1', name: 'Acme Corp', enabled: false, origin: CONST.POLICY.CONNECTIONS.NAME.QBO, }, - vendor_2: { - externalID: 'vendor_2', + vendor2: { + externalID: 'vendor2', name: 'Beta LLC', enabled: false, origin: CONST.POLICY.CONNECTIONS.NAME.QBO, @@ -52,7 +52,7 @@ describe('PolicyVendorAction', () => { setPolicyVendorsEnabled({ policyID, - vendorIDs: ['vendor_1', 'vendor_2'], + vendorIDs: ['vendor1', 'vendor2'], enabled: true, policyVendors: initialPolicyVendors, }); @@ -62,7 +62,7 @@ describe('PolicyVendorAction', () => { WRITE_COMMANDS.SET_POLICY_VENDORS_ENABLED, { policyID, - vendorIDs: JSON.stringify(['vendor_1', 'vendor_2']), + vendorIDs: JSON.stringify(['vendor1', 'vendor2']), enabled: true, }, { @@ -71,7 +71,7 @@ describe('PolicyVendorAction', () => { onyxMethod: Onyx.METHOD.MERGE, key: policyVendorsKey, value: { - vendor_1: expect.objectContaining({ + vendor1: expect.objectContaining({ enabled: true, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, pendingFields: { @@ -79,7 +79,7 @@ describe('PolicyVendorAction', () => { }, errors: null, }), - vendor_2: expect.objectContaining({ + vendor2: expect.objectContaining({ enabled: true, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, pendingFields: { @@ -95,7 +95,7 @@ describe('PolicyVendorAction', () => { onyxMethod: Onyx.METHOD.MERGE, key: policyVendorsKey, value: { - vendor_1: expect.objectContaining({ + vendor1: expect.objectContaining({ enabled: true, pendingAction: null, pendingFields: { @@ -103,7 +103,7 @@ describe('PolicyVendorAction', () => { }, errors: null, }), - vendor_2: expect.objectContaining({ + vendor2: expect.objectContaining({ enabled: true, pendingAction: null, pendingFields: { @@ -119,19 +119,19 @@ describe('PolicyVendorAction', () => { onyxMethod: Onyx.METHOD.MERGE, key: policyVendorsKey, value: { - vendor_1: expect.objectContaining({ + vendor1: expect.objectContaining({ pendingAction: null, pendingFields: { enabled: null, }, - errors: expect.any(Object), + errors: expect.anything() as unknown, }), - vendor_2: expect.objectContaining({ + vendor2: expect.objectContaining({ pendingAction: null, pendingFields: { enabled: null, }, - errors: expect.any(Object), + errors: expect.anything() as unknown, }), }, }, @@ -145,10 +145,10 @@ describe('PolicyVendorAction', () => { it('merges null errors and pending fields to Onyx', () => { const onyxMergeSpy = jest.spyOn(Onyx, 'merge'); - clearVendorErrors(policyID, 'vendor_1'); + clearVendorErrors(policyID, 'vendor1'); expect(onyxMergeSpy).toHaveBeenCalledWith(policyVendorsKey, { - vendor_1: { + vendor1: { errors: null, pendingAction: null, pendingFields: null, From cd0e738978aadf77fafc754548b6d3c2ace94527 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Fri, 2 Oct 2026 03:04:37 +0530 Subject: [PATCH 06/14] Address review bot comments on header and JSDoc --- src/libs/actions/Policy/Vendor.ts | 3 +++ src/types/onyx/PolicyVendor.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/Policy/Vendor.ts b/src/libs/actions/Policy/Vendor.ts index 1c7759eb65a1..972bc219efdc 100644 --- a/src/libs/actions/Policy/Vendor.ts +++ b/src/libs/actions/Policy/Vendor.ts @@ -1,3 +1,6 @@ +/** + * Actions for enabling/disabling policy vendors and clearing their offline errors. + */ import {write} from '@libs/API'; import type {SetPolicyVendorsEnabledParams} from '@libs/API/parameters'; import {WRITE_COMMANDS} from '@libs/API/types'; diff --git a/src/types/onyx/PolicyVendor.ts b/src/types/onyx/PolicyVendor.ts index a13ac6cbc4d6..65cbfbcd2238 100644 --- a/src/types/onyx/PolicyVendor.ts +++ b/src/types/onyx/PolicyVendor.ts @@ -18,7 +18,7 @@ type PolicyVendor = OnyxValueWithOfflineFeedback<{ /** A list of errors keyed by microtime */ errors?: Errors | null; - /** Error fields */ + /** Error objects keyed by field name containing errors keyed by microtime */ errorFields?: ErrorFields | null; }>; From 3e465e9b886e781298f052c6d584179d6955e043 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Fri, 2 Oct 2026 03:09:46 +0530 Subject: [PATCH 07/14] Extract VendorBulkActions into dedicated component --- .../vendors/WorkspaceVendorsPage.tsx | 166 +++++++++++------- 1 file changed, 98 insertions(+), 68 deletions(-) diff --git a/src/pages/workspace/vendors/WorkspaceVendorsPage.tsx b/src/pages/workspace/vendors/WorkspaceVendorsPage.tsx index a09d82e1ecde..d0bc837cbd1b 100644 --- a/src/pages/workspace/vendors/WorkspaceVendorsPage.tsx +++ b/src/pages/workspace/vendors/WorkspaceVendorsPage.tsx @@ -36,11 +36,88 @@ import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; +import type {PolicyVendors} from '@src/types/onyx'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; +import type {OnyxEntry} from 'react-native-onyx'; + import React from 'react'; import {View} from 'react-native'; +type VendorBulkActionsProps = { + policyID: string; + selectedVendorKeys: string[]; + policyVendors?: OnyxEntry; + defaultVendorID?: string; + onClearSelection: () => void; +}; + +function VendorBulkActions({policyID, selectedVendorKeys, policyVendors, defaultVendorID, onClearSelection}: VendorBulkActionsProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const shouldDisplayButtonsInSeparateLine = useShouldDisplayButtonsInSeparateLine(); + const icons = useMemoizedLazyExpensifyIcons(['Checkmark', 'Close']); + + const options: Array>> = []; + const disabledVendors = selectedVendorKeys.filter((id) => !policyVendors?.[id]?.enabled); + if (disabledVendors.length > 0) { + options.push({ + icon: icons.Checkmark, + text: translate(disabledVendors.length === 1 ? 'workspace.vendors.enableVendor' : 'workspace.vendors.enableVendors'), + value: CONST.POLICY.BULK_ACTION_TYPES.ENABLE, + onSelected: () => { + onClearSelection(); + setPolicyVendorsEnabled({ + policyID, + vendorIDs: disabledVendors, + enabled: true, + policyVendors, + }); + }, + }); + } + + const vendorsToDisable = selectedVendorKeys.filter((id) => policyVendors?.[id]?.enabled && id !== defaultVendorID); + if (vendorsToDisable.length > 0) { + options.push({ + icon: icons.Close, + text: translate(vendorsToDisable.length === 1 ? 'workspace.vendors.disableVendor' : 'workspace.vendors.disableVendors'), + value: CONST.POLICY.BULK_ACTION_TYPES.DISABLE, + onSelected: () => { + onClearSelection(); + setPolicyVendorsEnabled({ + policyID, + vendorIDs: vendorsToDisable, + enabled: false, + policyVendors, + }); + }, + }); + } + + if (options.length === 0) { + return null; + } + + return ( + null} + shouldAlwaysShowDropdownMenu + size={CONST.BUTTON_SIZE.MEDIUM} + customText={translate('workspace.common.selected', {count: selectedVendorKeys.length})} + options={options} + isSplitButton={false} + style={[shouldDisplayButtonsInSeparateLine && styles.flexGrow1, shouldDisplayButtonsInSeparateLine && styles.mb3]} + isDisabled={!selectedVendorKeys.length} + sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.INITIAL.VENDORS} + testID="WorkspaceVendorsPage-header-dropdown-menu-button" + /> + ); +} + +VendorBulkActions.displayName = 'VendorBulkActions'; + type WorkspaceVendorsPageProps = WithPolicyConnectionsProps & PlatformStackScreenProps; function WorkspaceVendorsPage({policy, route}: WorkspaceVendorsPageProps) { @@ -53,7 +130,6 @@ function WorkspaceVendorsPage({policy, route}: WorkspaceVendorsPageProps) { const shouldDisplayButtonsInSeparateLine = useShouldDisplayButtonsInSeparateLine(); const isMobileSelectionModeEnabled = useMobileSelectionMode(); const {canWrite: canWriteVendors, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.VENDORS); - const icons = useMemoizedLazyExpensifyIcons(['Checkmark', 'Close']); const [policyVendors] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_VENDORS}${policyID}`); const [selectedVendorKeys, setSelectedVendorKeys] = useFilteredSelection(policyVendors, (vendor) => !!vendor); @@ -127,71 +203,7 @@ function WorkspaceVendorsPage({policy, route}: WorkspaceVendorsPageProps) { ) : undefined; - const getHeaderButtons = () => { - if (!canWriteVendors || selectedVendorKeys.length === 0) { - return null; - } - - const options: Array>> = []; - const disabledVendors = selectedVendorKeys.filter((id) => !policyVendors?.[id]?.enabled); - if (disabledVendors.length > 0) { - options.push({ - icon: icons.Checkmark, - text: translate(disabledVendors.length === 1 ? 'workspace.vendors.enableVendor' : 'workspace.vendors.enableVendors'), - value: CONST.POLICY.BULK_ACTION_TYPES.ENABLE, - onSelected: () => { - clearTableSelection(); - setPolicyVendorsEnabled({ - policyID, - vendorIDs: disabledVendors, - enabled: true, - policyVendors, - }); - }, - }); - } - - const vendorsToDisable = selectedVendorKeys.filter((id) => policyVendors?.[id]?.enabled && id !== defaultVendorID); - if (vendorsToDisable.length > 0) { - options.push({ - icon: icons.Close, - text: translate(vendorsToDisable.length === 1 ? 'workspace.vendors.disableVendor' : 'workspace.vendors.disableVendors'), - value: CONST.POLICY.BULK_ACTION_TYPES.DISABLE, - onSelected: () => { - clearTableSelection(); - setPolicyVendorsEnabled({ - policyID, - vendorIDs: vendorsToDisable, - enabled: false, - policyVendors, - }); - }, - }); - } - - if (options.length === 0) { - return null; - } - - return ( - null} - shouldAlwaysShowDropdownMenu - size={CONST.BUTTON_SIZE.MEDIUM} - customText={translate('workspace.common.selected', {count: selectedVendorKeys.length})} - options={options} - isSplitButton={false} - style={[shouldDisplayButtonsInSeparateLine && styles.flexGrow1, shouldDisplayButtonsInSeparateLine && styles.mb3]} - isDisabled={!selectedVendorKeys.length} - sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.INITIAL.VENDORS} - testID="WorkspaceVendorsPage-header-dropdown-menu-button" - /> - ); - }; - - const headerButtons = getHeaderButtons(); - + const canShowBulkActions = canWriteVendors && selectedVendorKeys.length > 0; const isLoading = !isOffline && policyVendors === undefined; const selectionModeHeader = isMobileSelectionModeEnabled && shouldUseNarrowLayout; @@ -224,9 +236,27 @@ function WorkspaceVendorsPage({policy, route}: WorkspaceVendorsPageProps) { Navigation.goBack(); }} > - {!shouldDisplayButtonsInSeparateLine && headerButtons} + {!shouldDisplayButtonsInSeparateLine && canShowBulkActions && ( + + )} - {shouldDisplayButtonsInSeparateLine && !!headerButtons && {headerButtons}} + {shouldDisplayButtonsInSeparateLine && canShowBulkActions && ( + + + + )} {isLoading && ( Date: Sat, 3 Oct 2026 20:58:51 +0530 Subject: [PATCH 08/14] Update translations --- src/languages/de.ts | 10 +++++----- src/languages/el.ts | 10 +++++----- src/languages/es.ts | 10 +++++----- src/languages/fr.ts | 10 +++++----- src/languages/it.ts | 10 +++++----- src/languages/ja.ts | 10 +++++----- src/languages/nl.ts | 10 +++++----- src/languages/pl.ts | 10 +++++----- src/languages/pt-BR.ts | 10 +++++----- src/languages/zh-hans.ts | 10 +++++----- 10 files changed, 50 insertions(+), 50 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index f18e88e77bb7..6cee9ac181f5 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -6854,11 +6854,11 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU emptySubtitle: 'Anbieter werden hier angezeigt, nachdem Ihre Buchhaltungssynchronisierung abgeschlossen ist.', findVendor: 'Lieferanten finden', managedInAccountingSoftware: 'Lieferanten werden verwaltet in Ihrem', - enableVendor: 'Enable vendor', - enableVendors: 'Enable vendors', - disableVendor: 'Disable vendor', - disableVendors: 'Disable vendors', - updateFailureMessage: 'An error occurred while updating the vendor, please try again', + enableVendor: 'Lieferanten aktivieren', + enableVendors: 'Anbieter aktivieren', + disableVendor: 'Lieferanten deaktivieren', + disableVendors: 'Lieferanten deaktivieren', + updateFailureMessage: 'Beim Aktualisieren des Lieferanten ist ein Fehler aufgetreten, bitte versuchen Sie es erneut.', }, tags: { tagName: 'Tag-Name', diff --git a/src/languages/el.ts b/src/languages/el.ts index fc1115eab95f..e927faa7b52e 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -9019,11 +9019,11 @@ ${reportName}`, emptySubtitle: 'Οι προμηθευτές θα εμφανιστούν εδώ μετά την ολοκλήρωση του συγχρονισμού λογιστικής σας.', findVendor: 'Εύρεση προμηθευτή', managedInAccountingSoftware: 'Οι προμηθευτές διαχειρίζονται στο', - enableVendor: 'Enable vendor', - enableVendors: 'Enable vendors', - disableVendor: 'Disable vendor', - disableVendors: 'Disable vendors', - updateFailureMessage: 'An error occurred while updating the vendor, please try again', + enableVendor: 'Ενεργοποίηση προμηθευτή', + enableVendors: 'Ενεργοποίηση προμηθευτών', + disableVendor: 'Απενεργοποίηση προμηθευτή', + disableVendors: 'Απενεργοποίηση προμηθευτών', + updateFailureMessage: 'Προέκυψε σφάλμα κατά την ενημέρωση του προμηθευτή, δοκιμάστε ξανά', }, campfire: { campfireSetup: 'Ρύθμιση campfire', diff --git a/src/languages/es.ts b/src/languages/es.ts index b09120d924c1..95970d03f9fe 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -6724,11 +6724,11 @@ ${amount} para ${merchant} - ${date}`, emptySubtitle: 'Los proveedores aparecerán aquí una vez que se complete la sincronización de contabilidad.', findVendor: 'Buscar proveedor', managedInAccountingSoftware: 'Los proveedores se gestionan en tu', - enableVendor: 'Activar proveedor', - enableVendors: 'Activar proveedores', - disableVendor: 'Desactivar proveedor', - disableVendors: 'Desactivar proveedores', - updateFailureMessage: 'Se ha producido un error al intentar modificar el proveedor. Por favor, inténtalo más tarde.', + enableVendor: 'Habilitar proveedor', + enableVendors: 'Habilitar proveedores', + disableVendor: 'Deshabilitar proveedor', + disableVendors: 'Deshabilitar proveedores', + updateFailureMessage: 'Se ha producido un error al actualizar el proveedor, inténtalo de nuevo por favor', }, tags: { tagName: 'Nombre de etiqueta', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 4f1ef6fc8aa4..dba0a36ebe01 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -6874,11 +6874,11 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. emptySubtitle: 'Les fournisseurs apparaîtront ici une fois la synchronisation comptable terminée.', findVendor: 'Trouver un fournisseur', managedInAccountingSoftware: 'Les fournisseurs sont gérés dans votre', - enableVendor: 'Enable vendor', - enableVendors: 'Enable vendors', - disableVendor: 'Disable vendor', - disableVendors: 'Disable vendors', - updateFailureMessage: 'An error occurred while updating the vendor, please try again', + enableVendor: 'Activer le fournisseur', + enableVendors: 'Activer les fournisseurs', + disableVendor: 'Désactiver le fournisseur', + disableVendors: 'Désactiver les fournisseurs', + updateFailureMessage: 'Une erreur s’est produite lors de la mise à jour du fournisseur, veuillez réessayer', }, tags: { tagName: 'Nom du tag', diff --git a/src/languages/it.ts b/src/languages/it.ts index 4d14967fb9b8..33e0b13fae5e 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -6816,11 +6816,11 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. emptySubtitle: 'I fornitori verranno visualizzati qui al termine della sincronizzazione contabile.', findVendor: 'Trova fornitore', managedInAccountingSoftware: 'I fornitori sono gestiti nel tuo', - enableVendor: 'Enable vendor', - enableVendors: 'Enable vendors', - disableVendor: 'Disable vendor', - disableVendors: 'Disable vendors', - updateFailureMessage: 'An error occurred while updating the vendor, please try again', + enableVendor: 'Abilita fornitore', + enableVendors: 'Abilita fornitori', + disableVendor: 'Disabilita fornitore', + disableVendors: 'Disattiva fornitori', + updateFailureMessage: "Si è verificato un errore durante l'aggiornamento del fornitore, riprova per favore", }, tags: { tagName: 'Nome tag', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index b6d60414819a..9b662364ce50 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -6728,11 +6728,11 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO emptySubtitle: '会計システムとの同期が完了すると、ここに取引先が表示されます。', findVendor: '取引先を検索', managedInAccountingSoftware: '取引先は次の場所で管理されます:', - enableVendor: 'Enable vendor', - enableVendors: 'Enable vendors', - disableVendor: 'Disable vendor', - disableVendors: 'Disable vendors', - updateFailureMessage: 'An error occurred while updating the vendor, please try again', + enableVendor: 'ベンダーを有効にする', + enableVendors: 'ベンダーを有効にする', + disableVendor: '取引先を無効にする', + disableVendors: '取引先を無効化', + updateFailureMessage: '仕入先の更新中にエラーが発生しました。もう一度お試しください。', }, tags: { tagName: 'タグ名', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 6a2ba92afe38..7d84561b2242 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -6801,11 +6801,11 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ emptySubtitle: 'Leveranciers verschijnen hier zodra je boekhoudkundige synchronisatie is voltooid.', findVendor: 'Leverancier zoeken', managedInAccountingSoftware: 'Leveranciers worden beheerd in je', - enableVendor: 'Enable vendor', - enableVendors: 'Enable vendors', - disableVendor: 'Disable vendor', - disableVendors: 'Disable vendors', - updateFailureMessage: 'An error occurred while updating the vendor, please try again', + enableVendor: 'Leverancier inschakelen', + enableVendors: 'Leveranciers inschakelen', + disableVendor: 'Leverancier uitschakelen', + disableVendors: 'Leveranciers uitschakelen', + updateFailureMessage: 'Er is een fout opgetreden bij het bijwerken van de leverancier, probeer het alsjeblieft opnieuw', }, tags: { tagName: 'Tagnaam', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 850e706338ed..c583018f9bed 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -6826,11 +6826,11 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy emptySubtitle: 'Dostawcy pojawią się tutaj po zakończeniu synchronizacji z księgowością.', findVendor: 'Znajdź dostawcę', managedInAccountingSoftware: 'Dostawcy są zarządzani w twoim', - enableVendor: 'Enable vendor', - enableVendors: 'Enable vendors', - disableVendor: 'Disable vendor', - disableVendors: 'Disable vendors', - updateFailureMessage: 'An error occurred while updating the vendor, please try again', + enableVendor: 'Włącz kontrahenta', + enableVendors: 'Włącz dostawców', + disableVendor: 'Wyłącz dostawcę', + disableVendors: 'Wyłącz dostawców', + updateFailureMessage: 'Wystąpił błąd podczas aktualizowania dostawcy, spróbuj ponownie', }, tags: { tagName: 'Nazwa tagu', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index e13f0053a7d7..691d0394c447 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -6803,11 +6803,11 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS emptySubtitle: 'Os fornecedores vão aparecer aqui depois que a sincronização contábil for concluída.', findVendor: 'Encontrar fornecedor', managedInAccountingSoftware: 'Fornecedores são gerenciados no seu', - enableVendor: 'Enable vendor', - enableVendors: 'Enable vendors', - disableVendor: 'Disable vendor', - disableVendors: 'Disable vendors', - updateFailureMessage: 'An error occurred while updating the vendor, please try again', + enableVendor: 'Ativar fornecedor', + enableVendors: 'Ativar fornecedores', + disableVendor: 'Desativar fornecedor', + disableVendors: 'Desativar fornecedores', + updateFailureMessage: 'Ocorreu um erro ao atualizar o fornecedor, tente novamente por favor', }, tags: { tagName: 'Nome da tag', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index c75ad8c1a8d2..8e6bd2519c63 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -6566,11 +6566,11 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM emptySubtitle: '会计同步完成后,供应商将显示在此处。', findVendor: '查找供应商', managedInAccountingSoftware: '供应商在您的', - enableVendor: 'Enable vendor', - enableVendors: 'Enable vendors', - disableVendor: 'Disable vendor', - disableVendors: 'Disable vendors', - updateFailureMessage: 'An error occurred while updating the vendor, please try again', + enableVendor: '启用供应商', + enableVendors: '启用供应商', + disableVendor: '停用供应商', + disableVendors: '禁用供应商', + updateFailureMessage: '更新供应商时出错,请重试', }, tags: { tagName: '标签名称', From b4f788a636ab50b14a146e56017e085cbb5d9fc7 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:11:39 +0530 Subject: [PATCH 09/14] Respect disabled vendor state in violation check Recompute inactiveVendor violation against policyVendors enabled state so optimistic updates retain the violation when a vendor is disabled. --- src/libs/Violations/ViolationsUtils.ts | 25 ++- src/libs/actions/IOU/UpdateMoneyRequest.ts | 6 +- src/libs/actions/IOU/index.ts | 13 ++ .../actions/IOUTest/UpdateMoneyRequestTest.ts | 176 ++++++++++++++++++ tests/ui/MoneyRequestViewTest.tsx | 79 +++++++- tests/unit/ViolationUtilsTest.ts | 110 ++++++++++- 6 files changed, 402 insertions(+), 7 deletions(-) diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index ec487620da22..0d09f759b626 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -30,7 +30,20 @@ import {hasValidModifiedAmount, isViolationDismissed, shouldShowViolation} from import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Card, CardList, Policy, PolicyCategories, PolicyTagLists, PolicyTags, Report, ReportAction, Transaction, TransactionViolation, ViolationName} from '@src/types/onyx'; +import type { + Card, + CardList, + Policy, + PolicyCategories, + PolicyTagLists, + PolicyTags, + PolicyVendors, + Report, + ReportAction, + Transaction, + TransactionViolation, + ViolationName, +} from '@src/types/onyx'; import type {Errors} from '@src/types/onyx/OnyxCommon'; import type {Unit} from '@src/types/onyx/Policy'; import type {ReceiptError, ReceiptErrors} from '@src/types/onyx/Transaction'; @@ -464,6 +477,7 @@ const ViolationsUtils = { distanceOriginalPolicy, ownerLogin: ownerLoginParam, isVendorMatchingBetaEnabled, + policyVendors: policyVendorsParam, }: { updatedTransaction: Transaction; transactionViolations: TransactionViolation[]; @@ -480,6 +494,7 @@ const ViolationsUtils = { ownerLogin: string | undefined; /** Undefined while the account betas are still loading, which leaves the inactive vendor violation untouched */ isVendorMatchingBetaEnabled: boolean | undefined; + policyVendors?: OnyxEntry; }): OnyxUpdate { const isScanning = TransactionUtils.isScanning(updatedTransaction); const isScanRequest = TransactionUtils.isScanRequest(updatedTransaction); @@ -588,21 +603,23 @@ const ViolationsUtils = { // positive in Onyx, and rejecting it would strip a legitimate one. Leave the // existing violation state untouched until the list arrives. const matchedVendor = getMatchingVendorByID(policy, transactionVendorID); + const isVendorDisabled = policyVendorsParam?.[transactionVendorID]?.enabled === false; + const isVendorInactive = !matchedVendor || isVendorDisabled; // Stamp Xero-specific copy on the violation so the render site can use the // "Supplier" wording the rest of the Xero UI uses; QBO/Intacct keep the default // "Vendor" wording. const isSupplierViolation = isXeroActiveMatchingSource(policy); - if (!matchedVendor && !hasInactiveVendorViolation) { + if (isVendorInactive && !hasInactiveVendorViolation) { newTransactionViolations.push({ name: CONST.VIOLATIONS.INACTIVE_VENDOR, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true, ...(isSupplierViolation ? {data: {isSupplierViolation: true}} : {}), }); - } else if (matchedVendor && hasInactiveVendorViolation) { + } else if (!isVendorInactive && hasInactiveVendorViolation) { newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.INACTIVE_VENDOR}); - } else if (!matchedVendor && hasInactiveVendorViolation) { + } else if (isVendorInactive && hasInactiveVendorViolation) { // Reconcile data.isSupplierViolation with the current active matching source. // Backfills the flag when Xero is now active (server-fired violation, or // persisted from before this code path existed). Strips a stale flag when the diff --git a/src/libs/actions/IOU/UpdateMoneyRequest.ts b/src/libs/actions/IOU/UpdateMoneyRequest.ts index 81152ff8065a..835b4b42558e 100644 --- a/src/libs/actions/IOU/UpdateMoneyRequest.ts +++ b/src/libs/actions/IOU/UpdateMoneyRequest.ts @@ -64,7 +64,7 @@ import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxKey, OnyxUpdate} from ' import lodashUnionBy from 'lodash/unionBy'; import Onyx from 'react-native-onyx'; -import {getAllReports, getAllTransactions, getAllTransactionViolations, getRecentAttendees} from '.'; +import {getAllPolicyVendors, getAllReports, getAllTransactions, getAllTransactionViolations, getRecentAttendees} from '.'; import {getUpdatedMoneyRequestReportData, mergePolicyRecentlyUsedCategories, mergePolicyRecentlyUsedCurrencies} from './MoneyRequestBuilder'; type UpdateMoneyRequestData = { @@ -1830,6 +1830,7 @@ type GetUpdateMoneyRequestParamsType = { getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol']; rules: OnyxCollection; isVendorMatchingBetaEnabled: boolean | undefined; + policyVendors?: OnyxEntry; }; type UpdateMoneyRequestDataKeys = @@ -1883,6 +1884,7 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U getCurrencySymbol, rules, isVendorMatchingBetaEnabled, + policyVendors: policyVendorsParam, } = params; const optimisticData: Array< OnyxUpdate< @@ -2393,6 +2395,7 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U ); } + const policyVendors = policyVendorsParam ?? (policy?.id ? getAllPolicyVendors()?.[`${ONYXKEYS.COLLECTION.POLICY_VENDORS}${policy.id}`] : undefined); const violationsOnyxData = ViolationsUtils.getViolationsOnyxData({ updatedTransaction, transactionViolations: optimisticViolations, @@ -2407,6 +2410,7 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U isFromExpenseReport, distanceOriginalPolicy, isVendorMatchingBetaEnabled, + policyVendors, }); optimisticData.push(violationsOnyxData); failureData.push({ diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 52eaecfaad3f..ec33cbd3d82e 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -58,6 +58,14 @@ Onyx.connect({ }, }); +let allPolicyVendors: OnyxCollection = {}; +Onyx.connectWithoutView({ + key: ONYXKEYS.COLLECTION.POLICY_VENDORS, + callback: (value) => { + allPolicyVendors = value ?? {}; + }, +}); + let allReportNameValuePairs: OnyxCollection; Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, @@ -185,6 +193,10 @@ function getSearchQueryByHash(): Record { return searchQueryByHash; } +function getAllPolicyVendors(): OnyxCollection { + return allPolicyVendors; +} + export { getAllPersonalDetails, getAllTransactions, @@ -199,4 +211,5 @@ export { getRecentAttendees, getAllSnapshots, getSearchQueryByHash, + getAllPolicyVendors, }; diff --git a/tests/actions/IOUTest/UpdateMoneyRequestTest.ts b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts index 148aaec1ae3b..60ef91d14f55 100644 --- a/tests/actions/IOUTest/UpdateMoneyRequestTest.ts +++ b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts @@ -47,6 +47,10 @@ import getOnyxValue from '../../utils/getOnyxValue'; import {createGlobalFetchMock, getCurrencyDecimalsLocal, getCurrencySymbolLocal} from '../../utils/TestHelper'; import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; +jest.mock('@expensify/react-native-hybrid-app', () => ({ + isHybridApp: jest.fn(() => false), +})); + const topMostReportID = '23423423'; jest.mock('@src/libs/Navigation/Navigation', () => ({ navigate: jest.fn(), @@ -3127,4 +3131,176 @@ describe('actions/IOU/UpdateMoneyRequest', () => { expect(modifiedExpenseAction?.delegateAccountID).toBe(DELEGATE_ACCOUNT_ID); }); }); + + describe('vendor matching inactiveVendor violation retention', () => { + it('retains inactiveVendor violation during optimistic expense update when assigned vendor is disabled in policyVendors', async () => { + const transactionID = 'txnVendorDisabled1'; + const transactionThreadReportID = 'threadVendorDisabled1'; + const parentReportID = 'parentVendorDisabled1'; + const policyID = '50'; + const vendorID = 'v-disabled'; + + const policy: Policy = { + ...createRandomPolicy(Number(policyID), CONST.POLICY.TYPE.TEAM), + connections: { + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}, + data: {vendors: [{id: vendorID, name: 'Acme Disabled', currency: 'USD'}]}, + }, + }, + }; + + const parentReport: Report = { + ...createRandomReport(1, undefined), + reportID: parentReportID, + type: CONST.REPORT.TYPE.EXPENSE, + policyID, + ownerAccountID: RORY_ACCOUNT_ID, + }; + const transactionThreadReport: Report = { + ...createRandomReport(2, undefined), + reportID: transactionThreadReportID, + parentReportID, + type: CONST.REPORT.TYPE.CHAT, + }; + const fakeTransaction: Transaction = { + ...createRandomTransaction(3), + transactionID, + reportID: parentReportID, + category: '', + reimbursable: false, + comment: {vendor: {externalID: vendorID, wasManuallySet: false}}, + }; + const inactiveVendorViolation: TransactionViolation = { + name: CONST.VIOLATIONS.INACTIVE_VENDOR, + type: CONST.VIOLATION_TYPES.VIOLATION, + showInReview: true, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`, parentReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, transactionThreadReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, fakeTransaction); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, policy); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_VENDORS}${policyID}`, { + [vendorID]: { + id: vendorID, + name: 'Acme Disabled', + enabled: false, + }, + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, [inactiveVendorViolation]); + + updateMoneyRequestDescription({ + isVendorMatchingBetaEnabled: true, + transactionID, + transactionThreadReport, + parentReport, + rules: undefined, + iouReportOwnerLogin: undefined, + comment: 'Updated description', + policy, + policyTagList: undefined, + policyCategories: undefined, + reportPolicyTags: undefined, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + isASAPSubmitBetaEnabled: false, + delegateAccountID: undefined, + isTrackIntentUser: false, + violations: [inactiveVendorViolation], + getCurrencyDecimals: getCurrencyDecimalsLocal, + getCurrencySymbol: getCurrencySymbolLocal, + }); + + await waitForBatchedUpdates(); + + expect(await getStoredViolationNames(transactionID)).toEqual([CONST.VIOLATIONS.INACTIVE_VENDOR]); + }); + + it('clears inactiveVendor violation during optimistic expense update when assigned vendor is re-enabled in policyVendors', async () => { + const transactionID = 'txnVendorEnabled1'; + const transactionThreadReportID = 'threadVendorEnabled1'; + const parentReportID = 'parentVendorEnabled1'; + const policyID = '51'; + const vendorID = 'v-enabled'; + + const policy: Policy = { + ...createRandomPolicy(Number(policyID), CONST.POLICY.TYPE.TEAM), + requiresCategory: false, + requiresTag: false, + connections: { + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}, + data: {vendors: [{id: vendorID, name: 'Acme Enabled', currency: 'USD'}]}, + }, + }, + }; + + const parentReport: Report = { + ...createRandomReport(1, undefined), + reportID: parentReportID, + type: CONST.REPORT.TYPE.EXPENSE, + policyID, + ownerAccountID: RORY_ACCOUNT_ID, + }; + const transactionThreadReport: Report = { + ...createRandomReport(2, undefined), + reportID: transactionThreadReportID, + parentReportID, + type: CONST.REPORT.TYPE.CHAT, + }; + const fakeTransaction: Transaction = { + ...createRandomTransaction(4), + transactionID, + reportID: parentReportID, + category: '', + reimbursable: false, + comment: {vendor: {externalID: vendorID, wasManuallySet: false}}, + }; + const inactiveVendorViolation: TransactionViolation = { + name: CONST.VIOLATIONS.INACTIVE_VENDOR, + type: CONST.VIOLATION_TYPES.VIOLATION, + showInReview: true, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`, parentReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, transactionThreadReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, fakeTransaction); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, policy); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_VENDORS}${policyID}`, { + [vendorID]: { + id: vendorID, + name: 'Acme Enabled', + enabled: true, + }, + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, [inactiveVendorViolation]); + + updateMoneyRequestDescription({ + isVendorMatchingBetaEnabled: true, + transactionID, + transactionThreadReport, + parentReport, + rules: undefined, + iouReportOwnerLogin: undefined, + comment: 'Updated description', + policy, + policyTagList: undefined, + policyCategories: undefined, + reportPolicyTags: undefined, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + isASAPSubmitBetaEnabled: false, + delegateAccountID: undefined, + isTrackIntentUser: false, + violations: [inactiveVendorViolation], + getCurrencyDecimals: getCurrencyDecimalsLocal, + getCurrencySymbol: getCurrencySymbolLocal, + }); + + await waitForBatchedUpdates(); + + expect(await getStoredViolationNames(transactionID)).toEqual([]); + }); + }); }); diff --git a/tests/ui/MoneyRequestViewTest.tsx b/tests/ui/MoneyRequestViewTest.tsx index 15c42f230253..31f6415f6c10 100644 --- a/tests/ui/MoneyRequestViewTest.tsx +++ b/tests/ui/MoneyRequestViewTest.tsx @@ -22,6 +22,10 @@ import * as LHNTestUtils from '../utils/LHNTestUtils'; import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; +jest.mock('@expensify/react-native-hybrid-app', () => ({ + isHybridApp: jest.fn(() => false), +})); + jest.mock('@hooks/useLocalize', () => jest.fn(() => ({ translate: jest.fn((key: string) => key), @@ -58,7 +62,19 @@ jest.mock('@pages/inbox/report/AnimatedEmptyStateBackground', () => { // the menu-item testID stay strict-equal — they don't pick up the title text. jest.mock('@components/MenuItemWithTopDescription', () => { const RN = jest.requireActual>>('react-native'); - return ({description, title, interactive}: {description?: string; title?: string; interactive?: boolean}) => ( + return ({ + description, + title, + interactive, + errorText, + brickRoadIndicator, + }: { + description?: string; + title?: string; + interactive?: boolean; + errorText?: string; + brickRoadIndicator?: string; + }) => ( <> {interactive ? 'editable' : 'readonly'} @@ -68,6 +84,16 @@ jest.mock('@components/MenuItemWithTopDescription', () => { {title} )} + {errorText !== undefined && ( + + {errorText} + + )} + {brickRoadIndicator !== undefined && ( + + {brickRoadIndicator} + + )} ); }); @@ -895,6 +921,57 @@ describe('MoneyRequestView edit fields', () => { }); }); + it('shows the vendor-field violation error when assigned vendor is disabled in policyVendors', async () => { + const threadReport = { + ...LHNTestUtils.getFakeReport(), + parentReportID: expenseReportID, + parentReportActionID, + }; + + const disabledVendorID = 'disabled-vendor-id'; + + await setupTestData(); + await act(async () => { + await Onyx.merge(ONYXKEYS.BETAS, [CONST.BETAS.VENDOR_MATCHING]); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_VENDORS}${policyID}`, { + [disabledVendorID]: { + id: disabledVendorID, + name: 'Disabled Vendor', + enabled: false, + }, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { + reimbursable: false, + comment: {vendor: {externalID: disabledVendorID, wasManuallySet: false}}, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, [ + { + name: CONST.VIOLATIONS.INACTIVE_VENDOR, + type: CONST.VIOLATION_TYPES.VIOLATION, + showInReview: true, + }, + ]); + }); + await waitForBatchedUpdatesWithAct(); + + renderMoneyRequestView(threadReport, { + connections: { + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}, + data: {vendors: [{id: disabledVendorID, name: 'Disabled Vendor', currency: 'USD'}]}, + }, + }, + }); + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + const vendorError = screen.getByTestId('menu-item-error-common.vendor'); + expect(vendorError).toHaveTextContent('violations.inactiveVendor.'); + const vendorIndicator = screen.getByTestId('menu-item-indicator-common.vendor'); + expect(vendorIndicator).toHaveTextContent(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); + }); + }); + describe('commuter exclusion in the Distance field', () => { const selfDMReportID = 'self_dm_mrv_123'; // `translate` is mocked to return the key, so the commuter description is the plain distance label plus the "Original" key diff --git a/tests/unit/ViolationUtilsTest.ts b/tests/unit/ViolationUtilsTest.ts index 8bb490872efa..9434fd249b56 100644 --- a/tests/unit/ViolationUtilsTest.ts +++ b/tests/unit/ViolationUtilsTest.ts @@ -7,7 +7,7 @@ import ViolationsUtils, {filterReceiptViolations, getIsViolationFixed, isHardVio import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Beta, BetaOverrides, Policy, PolicyCategories, PolicyTagLists, Report, Transaction, TransactionViolation} from '@src/types/onyx'; +import type {Beta, BetaOverrides, Policy, PolicyCategories, PolicyTagLists, PolicyVendors, Report, Transaction, TransactionViolation} from '@src/types/onyx'; import type {SageIntacctExportConfig} from '@src/types/onyx/Policy'; import type {TransactionCollectionDataSet} from '@src/types/onyx/Transaction'; @@ -23,6 +23,10 @@ jest.mock('@libs/actions/Report', () => ({ getCurrentUserEmail: jest.fn(() => MOCK_CURRENT_USER_EMAIL), })); +jest.mock('@expensify/react-native-hybrid-app', () => ({ + isHybridApp: jest.fn(() => false), +})); + const categoryOutOfPolicyViolation = { name: CONST.VIOLATIONS.CATEGORY_OUT_OF_POLICY, type: CONST.VIOLATION_TYPES.VIOLATION, @@ -3261,6 +3265,110 @@ describe('getViolationsOnyxData', () => { expect(result.value).not.toContainEqual(inactiveVendorViolation); }); + it('adds the violation when the vendor remains in the raw integration list but is disabled in policyVendors', () => { + const activeVendorID = 'v-active'; + policy = policyWithQBOVendorFeature(); + transaction.comment = {...transaction.comment, vendor: {externalID: activeVendorID, wasManuallySet: true}}; + const policyVendors: PolicyVendors = { + [activeVendorID]: { + externalID: activeVendorID, + name: 'Acme Co', + enabled: false, + }, + }; + const result = ViolationsUtils.getViolationsOnyxData({ + isVendorMatchingBetaEnabled: true, + ownerLogin: undefined, + updatedTransaction: transaction, + transactionViolations: [], + policy, + policyTagList: policyTags, + policyCategories, + hasDependentTags: false, + isInvoiceTransaction: false, + policyVendors, + }); + expect(result.value).toEqual(expect.arrayContaining([inactiveVendorViolation])); + }); + + it('retains an existing violation through an optimistic update while the vendor remains disabled in policyVendors', () => { + const activeVendorID = 'v-active'; + policy = policyWithQBOVendorFeature(); + transaction.comment = {...transaction.comment, vendor: {externalID: activeVendorID, wasManuallySet: true}}; + const policyVendors: PolicyVendors = { + [activeVendorID]: { + externalID: activeVendorID, + name: 'Acme Co', + enabled: false, + }, + }; + const result = ViolationsUtils.getViolationsOnyxData({ + isVendorMatchingBetaEnabled: true, + ownerLogin: undefined, + updatedTransaction: transaction, + transactionViolations: [inactiveVendorViolation], + policy, + policyTagList: policyTags, + policyCategories, + hasDependentTags: false, + isInvoiceTransaction: false, + policyVendors, + }); + expect(result.value).toContainEqual(inactiveVendorViolation); + }); + + it('removes an existing violation when the vendor is re-enabled in policyVendors', () => { + const activeVendorID = 'v-active'; + policy = policyWithQBOVendorFeature(); + transaction.comment = {...transaction.comment, vendor: {externalID: activeVendorID, wasManuallySet: true}}; + const policyVendors: PolicyVendors = { + [activeVendorID]: { + externalID: activeVendorID, + name: 'Acme Co', + enabled: true, + }, + }; + const result = ViolationsUtils.getViolationsOnyxData({ + isVendorMatchingBetaEnabled: true, + ownerLogin: undefined, + updatedTransaction: transaction, + transactionViolations: [inactiveVendorViolation], + policy, + policyTagList: policyTags, + policyCategories, + hasDependentTags: false, + isInvoiceTransaction: false, + policyVendors, + }); + expect(result.value).not.toContainEqual(inactiveVendorViolation); + }); + + it('leaves the violation untouched when the active integration vendor list is not loaded yet even if policyVendors is present', () => { + const activeVendorID = 'v-active'; + policy = policyWithQBOVendorFeature(null); + transaction.comment = {...transaction.comment, vendor: {externalID: activeVendorID, wasManuallySet: true}}; + const policyVendors: PolicyVendors = { + [activeVendorID]: { + externalID: activeVendorID, + name: 'Acme Co', + enabled: false, + }, + }; + const result = ViolationsUtils.getViolationsOnyxData({ + isVendorMatchingBetaEnabled: true, + ownerLogin: undefined, + updatedTransaction: transaction, + transactionViolations: [inactiveVendorViolation], + policy, + policyTagList: policyTags, + policyCategories, + hasDependentTags: false, + isInvoiceTransaction: false, + policyVendors, + }); + expect(result.value).toContainEqual(inactiveVendorViolation); + }); + it('removes an existing violation when the user clears the vendor while the feature is still active', () => { policy = policyWithQBOVendorFeature(); // transaction.comment has no vendor key — represents a cleared selection From 87ebb3156f823e965743c43a57eef517f68ffd08 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:35:48 +0530 Subject: [PATCH 10/14] Avoid Onyx.connectWithoutView for policyVendors --- src/libs/actions/IOU/UpdateMoneyRequest.ts | 8 ++++-- src/libs/actions/IOU/index.ts | 13 --------- .../actions/IOUTest/UpdateMoneyRequestTest.ts | 28 +++++++++++-------- 3 files changed, 21 insertions(+), 28 deletions(-) diff --git a/src/libs/actions/IOU/UpdateMoneyRequest.ts b/src/libs/actions/IOU/UpdateMoneyRequest.ts index 835b4b42558e..f2a19294be74 100644 --- a/src/libs/actions/IOU/UpdateMoneyRequest.ts +++ b/src/libs/actions/IOU/UpdateMoneyRequest.ts @@ -64,7 +64,7 @@ import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxKey, OnyxUpdate} from ' import lodashUnionBy from 'lodash/unionBy'; import Onyx from 'react-native-onyx'; -import {getAllPolicyVendors, getAllReports, getAllTransactions, getAllTransactionViolations, getRecentAttendees} from '.'; +import {getAllReports, getAllTransactions, getAllTransactionViolations, getRecentAttendees} from '.'; import {getUpdatedMoneyRequestReportData, mergePolicyRecentlyUsedCategories, mergePolicyRecentlyUsedCurrencies} from './MoneyRequestBuilder'; type UpdateMoneyRequestData = { @@ -1456,6 +1456,7 @@ function updateMoneyRequestDescription({ getCurrencySymbol, rules, isVendorMatchingBetaEnabled, + policyVendors, }: { isVendorMatchingBetaEnabled: boolean | undefined; transactionID: string; @@ -1469,6 +1470,7 @@ function updateMoneyRequestDescription({ policy: OnyxEntry; policyTagList: OnyxEntry; policyCategories: OnyxEntry; + policyVendors?: OnyxEntry; currentUserAccountIDParam: number; currentUserEmailParam: string; isASAPSubmitBetaEnabled: boolean; @@ -1511,6 +1513,7 @@ function updateMoneyRequestDescription({ policyTagList, reportPolicyTags, policyCategories, + policyVendors, currentUserAccountIDParam, currentUserEmailParam, isASAPSubmitBetaEnabled, @@ -2395,7 +2398,6 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U ); } - const policyVendors = policyVendorsParam ?? (policy?.id ? getAllPolicyVendors()?.[`${ONYXKEYS.COLLECTION.POLICY_VENDORS}${policy.id}`] : undefined); const violationsOnyxData = ViolationsUtils.getViolationsOnyxData({ updatedTransaction, transactionViolations: optimisticViolations, @@ -2410,7 +2412,7 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U isFromExpenseReport, distanceOriginalPolicy, isVendorMatchingBetaEnabled, - policyVendors, + policyVendors: policyVendorsParam, }); optimisticData.push(violationsOnyxData); failureData.push({ diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index ec33cbd3d82e..52eaecfaad3f 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -58,14 +58,6 @@ Onyx.connect({ }, }); -let allPolicyVendors: OnyxCollection = {}; -Onyx.connectWithoutView({ - key: ONYXKEYS.COLLECTION.POLICY_VENDORS, - callback: (value) => { - allPolicyVendors = value ?? {}; - }, -}); - let allReportNameValuePairs: OnyxCollection; Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, @@ -193,10 +185,6 @@ function getSearchQueryByHash(): Record { return searchQueryByHash; } -function getAllPolicyVendors(): OnyxCollection { - return allPolicyVendors; -} - export { getAllPersonalDetails, getAllTransactions, @@ -211,5 +199,4 @@ export { getRecentAttendees, getAllSnapshots, getSearchQueryByHash, - getAllPolicyVendors, }; diff --git a/tests/actions/IOUTest/UpdateMoneyRequestTest.ts b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts index 60ef91d14f55..3a8a518199d1 100644 --- a/tests/actions/IOUTest/UpdateMoneyRequestTest.ts +++ b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts @@ -3177,17 +3177,18 @@ describe('actions/IOU/UpdateMoneyRequest', () => { showInReview: true, }; - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`, parentReport); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, transactionThreadReport); - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, fakeTransaction); - await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, policy); - await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_VENDORS}${policyID}`, { + const policyVendors = { [vendorID]: { id: vendorID, name: 'Acme Disabled', enabled: false, }, - }); + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`, parentReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, transactionThreadReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, fakeTransaction); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, policy); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_VENDORS}${policyID}`, policyVendors); await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, [inactiveVendorViolation]); updateMoneyRequestDescription({ @@ -3201,6 +3202,7 @@ describe('actions/IOU/UpdateMoneyRequest', () => { policy, policyTagList: undefined, policyCategories: undefined, + policyVendors, reportPolicyTags: undefined, currentUserAccountIDParam: RORY_ACCOUNT_ID, currentUserEmailParam: RORY_EMAIL, @@ -3263,17 +3265,18 @@ describe('actions/IOU/UpdateMoneyRequest', () => { showInReview: true, }; - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`, parentReport); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, transactionThreadReport); - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, fakeTransaction); - await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, policy); - await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_VENDORS}${policyID}`, { + const policyVendors = { [vendorID]: { id: vendorID, name: 'Acme Enabled', enabled: true, }, - }); + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`, parentReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, transactionThreadReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, fakeTransaction); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, policy); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_VENDORS}${policyID}`, policyVendors); await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, [inactiveVendorViolation]); updateMoneyRequestDescription({ @@ -3287,6 +3290,7 @@ describe('actions/IOU/UpdateMoneyRequest', () => { policy, policyTagList: undefined, policyCategories: undefined, + policyVendors, reportPolicyTags: undefined, currentUserAccountIDParam: RORY_ACCOUNT_ID, currentUserEmailParam: RORY_EMAIL, From 0fcfb2eaf7dfccb9adfdb21553ecf529f2ba11dc Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:55:36 +0530 Subject: [PATCH 11/14] Fix TypeScript errors in vendor matching tests --- .../actions/IOUTest/UpdateMoneyRequestTest.ts | 20 +++++++++---------- tests/ui/MoneyRequestViewTest.tsx | 4 ++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/actions/IOUTest/UpdateMoneyRequestTest.ts b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts index 3a8a518199d1..8cd134bb53d7 100644 --- a/tests/actions/IOUTest/UpdateMoneyRequestTest.ts +++ b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts @@ -3140,15 +3140,15 @@ describe('actions/IOU/UpdateMoneyRequest', () => { const policyID = '50'; const vendorID = 'v-disabled'; - const policy: Policy = { + const policy = createMock({ ...createRandomPolicy(Number(policyID), CONST.POLICY.TYPE.TEAM), connections: { [CONST.POLICY.CONNECTIONS.NAME.QBO]: { - config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}, - data: {vendors: [{id: vendorID, name: 'Acme Disabled', currency: 'USD'}]}, + config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD} as any, + data: {vendors: [{id: vendorID, name: 'Acme Disabled', currency: 'USD', email: 'vendor@example.com'}]}, }, }, - }; + }); const parentReport: Report = { ...createRandomReport(1, undefined), @@ -3179,7 +3179,7 @@ describe('actions/IOU/UpdateMoneyRequest', () => { const policyVendors = { [vendorID]: { - id: vendorID, + externalID: vendorID, name: 'Acme Disabled', enabled: false, }, @@ -3226,17 +3226,17 @@ describe('actions/IOU/UpdateMoneyRequest', () => { const policyID = '51'; const vendorID = 'v-enabled'; - const policy: Policy = { + const policy = createMock({ ...createRandomPolicy(Number(policyID), CONST.POLICY.TYPE.TEAM), requiresCategory: false, requiresTag: false, connections: { [CONST.POLICY.CONNECTIONS.NAME.QBO]: { - config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}, - data: {vendors: [{id: vendorID, name: 'Acme Enabled', currency: 'USD'}]}, + config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD} as any, + data: {vendors: [{id: vendorID, name: 'Acme Enabled', currency: 'USD', email: 'vendor@example.com'}]}, }, }, - }; + }); const parentReport: Report = { ...createRandomReport(1, undefined), @@ -3267,7 +3267,7 @@ describe('actions/IOU/UpdateMoneyRequest', () => { const policyVendors = { [vendorID]: { - id: vendorID, + externalID: vendorID, name: 'Acme Enabled', enabled: true, }, diff --git a/tests/ui/MoneyRequestViewTest.tsx b/tests/ui/MoneyRequestViewTest.tsx index 31f6415f6c10..ac01860d3d96 100644 --- a/tests/ui/MoneyRequestViewTest.tsx +++ b/tests/ui/MoneyRequestViewTest.tsx @@ -935,7 +935,7 @@ describe('MoneyRequestView edit fields', () => { await Onyx.merge(ONYXKEYS.BETAS, [CONST.BETAS.VENDOR_MATCHING]); await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_VENDORS}${policyID}`, { [disabledVendorID]: { - id: disabledVendorID, + externalID: disabledVendorID, name: 'Disabled Vendor', enabled: false, }, @@ -958,7 +958,7 @@ describe('MoneyRequestView edit fields', () => { connections: { [CONST.POLICY.CONNECTIONS.NAME.QBO]: { config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}, - data: {vendors: [{id: disabledVendorID, name: 'Disabled Vendor', currency: 'USD'}]}, + data: {vendors: [{id: disabledVendorID, name: 'Disabled Vendor', currency: 'USD', email: 'vendor@example.com'}]}, }, }, }); From 69ecafc1dae3b2e33b1c6ea192df78076dcc373e Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:26:12 +0530 Subject: [PATCH 12/14] Fix lint and typing in vendor matching tests --- .../actions/IOUTest/UpdateMoneyRequestTest.ts | 19 ++++++++++--------- tests/ui/MoneyRequestViewTest.tsx | 2 +- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/tests/actions/IOUTest/UpdateMoneyRequestTest.ts b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts index 8cd134bb53d7..af3d8e0afb69 100644 --- a/tests/actions/IOUTest/UpdateMoneyRequestTest.ts +++ b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts @@ -24,9 +24,10 @@ import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy, PolicyTagLists, RecentlyUsedTags, RecentWaypoint, Report, SearchResults, TransactionViolation} from '@src/types/onyx'; +import type {Policy, PolicyTagLists, PolicyVendors, RecentlyUsedTags, RecentWaypoint, Report, SearchResults, TransactionViolation} from '@src/types/onyx'; import type {Attendee} from '@src/types/onyx/IOU'; import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; +import type {Connections} from '@src/types/onyx/Policy'; import type {Routes} from '@src/types/onyx/Transaction'; import type Transaction from '@src/types/onyx/Transaction'; @@ -3142,12 +3143,12 @@ describe('actions/IOU/UpdateMoneyRequest', () => { const policy = createMock({ ...createRandomPolicy(Number(policyID), CONST.POLICY.TYPE.TEAM), - connections: { + connections: createMock({ [CONST.POLICY.CONNECTIONS.NAME.QBO]: { - config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD} as any, + config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}, data: {vendors: [{id: vendorID, name: 'Acme Disabled', currency: 'USD', email: 'vendor@example.com'}]}, }, - }, + }), }); const parentReport: Report = { @@ -3177,7 +3178,7 @@ describe('actions/IOU/UpdateMoneyRequest', () => { showInReview: true, }; - const policyVendors = { + const policyVendors: PolicyVendors = { [vendorID]: { externalID: vendorID, name: 'Acme Disabled', @@ -3230,12 +3231,12 @@ describe('actions/IOU/UpdateMoneyRequest', () => { ...createRandomPolicy(Number(policyID), CONST.POLICY.TYPE.TEAM), requiresCategory: false, requiresTag: false, - connections: { + connections: createMock({ [CONST.POLICY.CONNECTIONS.NAME.QBO]: { - config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD} as any, + config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}, data: {vendors: [{id: vendorID, name: 'Acme Enabled', currency: 'USD', email: 'vendor@example.com'}]}, }, - }, + }), }); const parentReport: Report = { @@ -3265,7 +3266,7 @@ describe('actions/IOU/UpdateMoneyRequest', () => { showInReview: true, }; - const policyVendors = { + const policyVendors: PolicyVendors = { [vendorID]: { externalID: vendorID, name: 'Acme Enabled', diff --git a/tests/ui/MoneyRequestViewTest.tsx b/tests/ui/MoneyRequestViewTest.tsx index ac01860d3d96..bcd8c3f85d08 100644 --- a/tests/ui/MoneyRequestViewTest.tsx +++ b/tests/ui/MoneyRequestViewTest.tsx @@ -921,7 +921,7 @@ describe('MoneyRequestView edit fields', () => { }); }); - it('shows the vendor-field violation error when assigned vendor is disabled in policyVendors', async () => { + it('shows the vendor-field error when assigned vendor is disabled in policyVendors', async () => { const threadReport = { ...LHNTestUtils.getFakeReport(), parentReportID: expenseReportID, From 90c09bdcc6077d9981ab2753fc950e990e4c4d63 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sun, 4 Oct 2026 02:10:30 +0530 Subject: [PATCH 13/14] Fix vendor violation assertion in MoneyRequestViewTest --- tests/ui/MoneyRequestViewTest.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/ui/MoneyRequestViewTest.tsx b/tests/ui/MoneyRequestViewTest.tsx index 2f36d6811c00..918c1116c49a 100644 --- a/tests/ui/MoneyRequestViewTest.tsx +++ b/tests/ui/MoneyRequestViewTest.tsx @@ -1021,10 +1021,8 @@ describe('MoneyRequestView edit fields', () => { await waitForBatchedUpdatesWithAct(); await waitFor(() => { - const vendorError = screen.getByTestId('menu-item-error-common.vendor'); - expect(vendorError).toHaveTextContent('violations.inactiveVendor.'); - const vendorIndicator = screen.getByTestId('menu-item-indicator-common.vendor'); - expect(vendorIndicator).toHaveTextContent(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); + expect(screen.getByRole('alert')).toHaveTextContent('violations.inactiveVendor.'); + expect(screen.getByTestId('menu-item-brick-road-indicator')).toBeOnTheScreen(); }); }); From c9cbba338570d6eea5965e389037d7f272020d13 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Thu, 8 Oct 2026 02:23:00 +0530 Subject: [PATCH 14/14] Remove unused getVendorFeaturePolicyIDs export --- src/libs/PolicyUtils/vendor.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/libs/PolicyUtils/vendor.ts b/src/libs/PolicyUtils/vendor.ts index 06f134aa6744..20e3487e0563 100644 --- a/src/libs/PolicyUtils/vendor.ts +++ b/src/libs/PolicyUtils/vendor.ts @@ -136,19 +136,6 @@ function hasVendorFeatureOnAnyPolicy(policies: OnyxCollection, isVendorM return Object.values(policies ?? {}).some((policy) => hasVendorFeature(policy, isVendorMatchingBetaEnabled)); } -/** - * IDs of the workspaces that have the vendor feature, so the Search vendor filter only offers their vendor lists. - */ -function getVendorFeaturePolicyIDs(policies: OnyxCollection, isVendorMatchingBetaEnabled: boolean): string[] { - const policyIDs: string[] = []; - for (const policy of Object.values(policies ?? {})) { - if (policy?.id && hasVendorFeature(policy, isVendorMatchingBetaEnabled)) { - policyIDs.push(policy.id); - } - } - return policyIDs; -} - /** * Single source of truth for which connected integration scopes the vendor field for this workspace * (QBO, Sage Intacct, Xero, Rillet, DualEntry, Business Central, Campfire, or Certinia) and what its vendor list looks like. Returns `undefined` when no @@ -593,6 +580,5 @@ export { isXeroVendorMatchingActive, hasVendorFeature, hasVendorFeatureOnAnyPolicy, - getVendorFeaturePolicyIDs, isMatchingVendorListLoaded, };