diff --git a/src/components/Tables/WorkspaceVendorsTable/index.tsx b/src/components/Tables/WorkspaceVendorsTable/index.tsx index 7f3cd7444099..7b3e7e981e2e 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,46 @@ 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; + 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 +57,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 +83,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 +131,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; + if (!keyA || !keyB) { + return 0; + } + return localeCompare(keyA, keyB); }); } +/** + * Resolves the configured default vendor ID for the active integration that supports vendor enablement. + * 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; + } 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; +} + /** * 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 @@ -502,6 +548,7 @@ export { getActiveVendorMatchingIntegration, getMatchingVendorByID, getMatchingVendors, + getDefaultVendorID, sortVendors, getVendorEmptyState, getVendorRuleDisplayValue, diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index f8001ae2eb16..77b3d382105a 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -687,6 +687,7 @@ const ViolationsUtils = { distanceOriginalPolicy, ownerLogin: ownerLoginParam, isVendorMatchingBetaEnabled, + policyVendors: policyVendorsParam, }: { updatedTransaction: Transaction; transactionViolations: TransactionViolation[]; @@ -703,6 +704,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); @@ -811,14 +813,17 @@ 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; + const canConfirmVendorEnabled = policyVendorsParam !== undefined && !isVendorDisabled; - if (!matchedVendor && !hasInactiveVendorViolation) { + if (isVendorInactive && !hasInactiveVendorViolation) { newTransactionViolations.push({ name: CONST.VIOLATIONS.INACTIVE_VENDOR, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true, }); - } else if (matchedVendor && hasInactiveVendorViolation) { + } else if (!isVendorInactive && canConfirmVendorEnabled && hasInactiveVendorViolation) { newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.INACTIVE_VENDOR}); } } else if (!transactionVendorID && hasInactiveVendorViolation) { diff --git a/src/libs/actions/IOU/UpdateMoneyRequest.ts b/src/libs/actions/IOU/UpdateMoneyRequest.ts index 81152ff8065a..f04261c5efbb 100644 --- a/src/libs/actions/IOU/UpdateMoneyRequest.ts +++ b/src/libs/actions/IOU/UpdateMoneyRequest.ts @@ -864,7 +864,7 @@ function updateMoneyRequestVendor({ } // Optimistically clear any existing inactive-vendor violation. This is the user-driven write - // path: the vendor selector RHP only offers vendors from `getMatchingVendors(policy)`, so a user + // path: the vendor selector RHP only offers vendors that are enabled on the policy, so a user // pick is always a valid vendor (resolving the violation); clearing the vendor likewise // resolves it (no vendor → no inactive-vendor). Without this, the stale violation persists // in Onyx until some unrelated recalculation fires, keeping the expense incorrectly flagged. @@ -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, @@ -1830,6 +1833,7 @@ type GetUpdateMoneyRequestParamsType = { getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol']; rules: OnyxCollection; isVendorMatchingBetaEnabled: boolean | undefined; + policyVendors?: OnyxEntry; }; type UpdateMoneyRequestDataKeys = @@ -1883,6 +1887,7 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U getCurrencySymbol, rules, isVendorMatchingBetaEnabled, + policyVendors: policyVendorsParam, } = params; const optimisticData: Array< OnyxUpdate< @@ -2407,6 +2412,7 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U isFromExpenseReport, distanceOriginalPolicy, isVendorMatchingBetaEnabled, + policyVendors: policyVendorsParam, }); optimisticData.push(violationsOnyxData); failureData.push({ diff --git a/src/libs/actions/Policy/Vendor.ts b/src/libs/actions/Policy/Vendor.ts new file mode 100644 index 000000000000..50adfabcde1b --- /dev/null +++ b/src/libs/actions/Policy/Vendor.ts @@ -0,0 +1,123 @@ +/** + * Actions for enabling/disabling policy vendors and clearing their offline errors. + */ +import {write, read} from '@libs/API'; +import type {SetPolicyVendorsEnabledParams} from '@libs/API/parameters'; +import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; +import Log from '@libs/Log'; + +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: 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, + }; + + 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, + }, + }); +} + +function openPolicyVendorsPage(policyID: string) { + if (!policyID) { + Log.warn('openPolicyVendorsPage invalid params', {policyID}); + return; + } + + read(READ_COMMANDS.OPEN_POLICY_VENDORS_PAGE, {policyID}); +} + +export {setPolicyVendorsEnabled, clearVendorErrors, openPolicyVendorsPage}; diff --git a/src/pages/iou/request/step/DynamicIOURequestStepDescription.tsx b/src/pages/iou/request/step/DynamicIOURequestStepDescription.tsx index 1966103dbaf9..bf627403c9b7 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepDescription.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepDescription.tsx @@ -75,6 +75,7 @@ function DynamicIOURequestStepDescription({ const [splitDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`); const allTransactionViolations = useAllTransactionViolations(transaction?.transactionID); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report?.policyID}`); + const [policyVendors] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_VENDORS}${report?.policyID}`); const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID)}`); const [iouReportOwnerLogin] = usePersonalDetail(parentReport?.ownerAccountID, loginSelector); const [reportPolicyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(parentReport?.policyID)}`); @@ -167,6 +168,7 @@ function DynamicIOURequestStepDescription({ policy, policyTagList: policyTags, policyCategories, + policyVendors, currentUserAccountIDParam, currentUserEmailParam, isASAPSubmitBetaEnabled, diff --git a/src/pages/iou/request/step/IOURequestStepVendor.tsx b/src/pages/iou/request/step/IOURequestStepVendor.tsx index d03afe4f1eba..2dca9fd0201d 100644 --- a/src/pages/iou/request/step/IOURequestStepVendor.tsx +++ b/src/pages/iou/request/step/IOURequestStepVendor.tsx @@ -1,3 +1,4 @@ +import ActivityIndicator from '@components/ActivityIndicator'; import BlockingView from '@components/BlockingViews/BlockingView'; import SelectionList from '@components/SelectionList'; import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; @@ -6,6 +7,7 @@ import type {ListItem} from '@components/SelectionList/types'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePolicyForTransaction from '@hooks/usePolicyForTransaction'; @@ -13,9 +15,10 @@ import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep'; import useThemeStyles from '@hooks/useThemeStyles'; import {updateMoneyRequestVendor} from '@libs/actions/IOU/UpdateMoneyRequest'; +import {openPolicyVendorsPage} from '@libs/actions/Policy/Vendor'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Navigation from '@libs/Navigation/Navigation'; -import {getMatchingVendors, getVendorEmptyState, hasVendorFeature, sortVendors} from '@libs/PolicyUtils'; +import {getVendorEmptyState, hasVendorFeature, sortVendors} from '@libs/PolicyUtils'; import {isPerDiemRequest} from '@libs/TransactionUtils'; import variables from '@styles/variables'; @@ -24,7 +27,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; -import React, {useState} from 'react'; +import React, {useEffect, useState} from 'react'; import type {WithFullTransactionOrNotFoundProps} from './withFullTransactionOrNotFound'; import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; @@ -61,44 +64,81 @@ 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); const isFeatureAvailable = hasVendorFeature(policy, isVendorMatchingBetaEnabled); + // Stale sessions may never have received the policyVendors_ collection, so fetch it if missing. + const fetchVendorsIfMissing = () => { + if (!isFeatureAvailable || !policy?.id || policyVendors !== undefined) { + return; + } + openPolicyVendorsPage(policy.id); + }; + + const {isOffline} = useNetwork({onReconnect: fetchVendorsIfMissing}); + + useEffect(() => { + fetchVendorsIfMissing(); + // Re-run when the policy loads or the collection is cleared, so a missing fetch retries. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isFeatureAvailable, policy?.id, policyVendors]); const emptyState = getVendorEmptyState(policy, translate); // 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 && policyVendors !== undefined && !currentVendor?.enabled; + + const isLoading = !isOffline && policyVendors === undefined; + + const enabledVendors = Object.values(policyVendors ?? {}).filter((vendor) => vendor.enabled); + const sortedEnabledVendors = sortVendors(enabledVendors, localeCompare); 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 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 shouldShowNoneRow = !!currentVendorID && !trimmedSearch; - const data: VendorListItem[] = shouldShowNoneRow - ? [ - { - value: '', - text: translate('common.none'), - keyForList: 'clear-vendor', - isSelected: false, - searchText: '', - }, - ...vendorRows, - ] - : vendorRows; + + const data: VendorListItem[] = [ + ...(shouldShowNoneRow + ? [ + { + 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; @@ -128,7 +168,7 @@ function IOURequestStepVendor({ const headerMessage = searchValue && data.length === 0 ? translate('common.noResultsFound') : ''; const listEmptyContent = - vendors.length === 0 ? ( + enabledVendors.length === 0 && !disabledCurrentVendorRow ? ( + ); + } return ( ; + 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) { @@ -33,24 +126,84 @@ function WorkspaceVendorsPage({policy, route}: WorkspaceVendorsPageProps) { const {translate, localeCompare} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {isBetaEnabled} = usePermissions(); + const shouldDisplayButtonsInSeparateLine = useShouldDisplayButtonsInSeparateLine(); + const isMobileSelectionModeEnabled = useMobileSelectionMode(); + const {canWrite: canWriteVendors, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.VENDORS); + + const [policyVendors] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_VENDORS}${policyID}`); + const [selectedVendorKeys, setSelectedVendorKeys] = useFilteredSelection(policyVendors, (vendor) => !!vendor); 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); + + // Stale sessions may never have received the policyVendors_ collection, so fetch it if missing. + const fetchVendors = () => { + if (!isFeatureAvailable) { + return; + } + openPolicyVendorsPage(policyID); + }; + + const {isOffline} = useNetwork({onReconnect: fetchVendors}); + + useEffect(() => { + fetchVendors(); + // Run only on mount because reconnect re-fetches are handled by useNetwork above. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + 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 clearTableSelection = () => { + setSelectedVendorKeys((prev) => (prev.length > 0 ? [] : prev)); + }; + + useCleanupSelectedOptions(clearTableSelection); + + useSearchBackPress({ + onClearSelection: clearTableSelection, + onNavigationCallBack: () => Navigation.goBack(), + }); + + 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, + 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 +216,10 @@ function WorkspaceVendorsPage({policy, route}: WorkspaceVendorsPageProps) { ) : undefined; + const canShowBulkActions = canWriteVendors && selectedVendorKeys.length > 0; + 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 && canShowBulkActions && ( + + )} + + {shouldDisplayButtonsInSeparateLine && canShowBulkActions && ( + + + + )} + + {isLoading && ( + + )} + + {!isLoading && ( + + )} ); diff --git a/src/types/onyx/PolicyVendor.ts b/src/types/onyx/PolicyVendor.ts index 913978785477..65cbfbcd2238 100644 --- a/src/types/onyx/PolicyVendor.ts +++ b/src/types/onyx/PolicyVendor.ts @@ -1,21 +1,29 @@ +import type {ErrorFields, Errors, OnyxValueWithOfflineFeedback} 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; +/** Model of policy vendor */ +type PolicyVendor = OnyxValueWithOfflineFeedback<{ + /** The vendor identifier scoped to its active accounting connection. */ + externalID: string; + + /** The accounting-system display name. */ + name: string; + + /** Whether the policy preference permits this vendor to be selected. */ + enabled: boolean; - /** The accounting-system display name. */ - name: string; + /** The active accounting connection that supplied this vendor. */ + origin?: PolicyConnectionName; - /** Whether the policy preference permits this vendor to be selected. */ - enabled: boolean; + /** A list of errors keyed by microtime */ + errors?: Errors | null; - /** The active accounting connection that supplied this vendor. */ - origin?: PolicyConnectionName; - } ->; + /** Error objects keyed by field name containing errors keyed by microtime */ + errorFields?: ErrorFields | null; +}>; + +/** 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 aa2cc9f68ebb..42fa16949a78 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -172,6 +172,7 @@ 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'; @@ -355,6 +356,7 @@ export type { PolicyCategories, PolicyCategory, PolicyDataLoadingState, + PolicyVendor, PolicyVendors, PolicyEmployee, PolicyEmployeeList, diff --git a/tests/actions/IOUTest/UpdateMoneyRequestTest.ts b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts index 4dfc4ba45877..ef04c2a94672 100644 --- a/tests/actions/IOUTest/UpdateMoneyRequestTest.ts +++ b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts @@ -25,9 +25,10 @@ import IntlStore from '@src/languages/IntlStore'; import type {TranslationParameters, TranslationPaths} from '@src/languages/types'; 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'; @@ -48,6 +49,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(), @@ -3140,4 +3145,180 @@ 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 = createMock({ + ...createRandomPolicy(Number(policyID), CONST.POLICY.TYPE.TEAM), + connections: createMock({ + [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', email: 'vendor@example.com'}]}, + }, + }), + }); + + 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, + }; + + const policyVendors: PolicyVendors = { + [vendorID]: { + externalID: 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({ + isVendorMatchingBetaEnabled: true, + transactionID, + transactionThreadReport, + parentReport, + rules: undefined, + iouReportOwnerLogin: undefined, + comment: 'Updated description', + policy, + policyTagList: undefined, + policyCategories: undefined, + policyVendors, + 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 = createMock({ + ...createRandomPolicy(Number(policyID), CONST.POLICY.TYPE.TEAM), + requiresCategory: false, + requiresTag: false, + connections: createMock({ + [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', email: 'vendor@example.com'}]}, + }, + }), + }); + + 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, + }; + + const policyVendors: PolicyVendors = { + [vendorID]: { + externalID: 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({ + isVendorMatchingBetaEnabled: true, + transactionID, + transactionThreadReport, + parentReport, + rules: undefined, + iouReportOwnerLogin: undefined, + comment: 'Updated description', + policy, + policyTagList: undefined, + policyCategories: undefined, + policyVendors, + 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 d57315a712ad..dc44cddac2df 100644 --- a/tests/ui/MoneyRequestViewTest.tsx +++ b/tests/ui/MoneyRequestViewTest.tsx @@ -1108,6 +1108,55 @@ describe('MoneyRequestView edit fields', () => { }); }); + it('shows the vendor-field 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]: { + externalID: 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', email: 'vendor@example.com'}]}, + }, + }, + }); + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent('violations.inactiveVendor.'); + expect(screen.getByTestId('menu-item-brick-road-indicator')).toBeOnTheScreen(); + }); + }); + 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/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index cdd63ff5d2bc..47c5f1f0797a 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -135,6 +135,7 @@ import { shouldHideDynamicExternalWorkflowPeople, shouldShowPolicy, sortPoliciesByName, + getDefaultVendorID, sortVendors, sortWorkspacesBySelected, tryNavigateToSubmitWorkspaceUpgrade, @@ -7411,6 +7412,171 @@ 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 = createMock({ + id: '1', + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: { + nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD, + nonReimbursableCreditCardDefaultVendor: 'qbo_vendor_1', + }, + }, + }), + }); + + 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 = createMock({ + id: '1', + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: { + nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD, + nonReimbursableCreditCardDefaultVendor: 'qbo_vendor_1', + }, + }, + }), + }); + + expect(getDefaultVendorID(policy, CONST.POLICY.CONNECTIONS.NAME.QBO)).toBeUndefined(); + }); + + it('resolves Sage Intacct credit card charge default vendor', () => { + const policy = createMock({ + id: '1', + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT]: { + config: { + export: { + nonReimbursableCreditCardChargeDefaultVendor: 'intacct_vendor_1', + }, + }, + }, + }), + }); + + expect(getDefaultVendorID(policy, CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT)).toBe('intacct_vendor_1'); + }); + + it('resolves Xero default vendor', () => { + const policy = createMock({ + id: '1', + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.XERO]: { + config: { + defaultVendor: 'xero_contact_1', + }, + }, + }), + }); + + expect(getDefaultVendorID(policy, CONST.POLICY.CONNECTIONS.NAME.XERO)).toBe('xero_contact_1'); + }); + + it('resolves Rillet default vendor', () => { + const policy = createMock({ + id: '1', + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.RILLET]: { + config: { + export: { + defaultVendorID: 'rillet_vendor_1', + }, + }, + }, + }), + }); + + expect(getDefaultVendorID(policy, CONST.POLICY.CONNECTIONS.NAME.RILLET)).toBe('rillet_vendor_1'); + }); + + it('resolves DualEntry default vendor', () => { + const policy = createMock({ + id: '1', + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.DUALENTRY]: { + config: { + export: { + defaultVendorID: 'dualentry_vendor_1', + }, + }, + }, + }), + }); + + expect(getDefaultVendorID(policy, CONST.POLICY.CONNECTIONS.NAME.DUALENTRY)).toBe('dualentry_vendor_1'); + }); + + it('resolves Campfire default vendor', () => { + const policy = createMock({ + id: '1', + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE]: { + config: { + export: { + defaultVendorID: 'campfire_vendor_1', + }, + }, + }, + }), + }); + + expect(getDefaultVendorID(policy, CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE)).toBe('campfire_vendor_1'); + }); + + it('resolves Business Central default vendor', () => { + const policy = createMock({ + id: '1', + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL]: { + config: { + export: { + defaultVendorID: 'bc_vendor_1', + }, + }, + }, + }), + }); + + expect(getDefaultVendorID(policy, CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL)).toBe('bc_vendor_1'); + }); + + it('resolves Certinia default vendor', () => { + const policy = createMock({ + id: '1', + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.CERTINIA]: { + config: { + export: { + vendorAccount: 'certinia_acc_1', + }, + }, + }, + }), + }); + + 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(); + }); }); }); diff --git a/tests/unit/PolicyVendorActionTest.ts b/tests/unit/PolicyVendorActionTest.ts new file mode 100644 index 000000000000..bac101974290 --- /dev/null +++ b/tests/unit/PolicyVendorActionTest.ts @@ -0,0 +1,184 @@ +import {clearVendorErrors, openPolicyVendorsPage, setPolicyVendorsEnabled} from '@libs/actions/Policy/Vendor'; +import {read, write} from '@libs/API'; +import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import Log from '@libs/Log'; + +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 mockRead = jest.mocked(read); +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 = { + vendor1: { + externalID: 'vendor1', + name: 'Acme Corp', + enabled: false, + origin: CONST.POLICY.CONNECTIONS.NAME.QBO, + }, + vendor2: { + externalID: 'vendor2', + name: 'Beta LLC', + enabled: false, + origin: CONST.POLICY.CONNECTIONS.NAME.QBO, + }, + }; + + setPolicyVendorsEnabled({ + policyID, + vendorIDs: ['vendor1', 'vendor2'], + enabled: true, + policyVendors: initialPolicyVendors, + }); + + expect(mockWrite).toHaveBeenCalledTimes(1); + expect(mockWrite).toHaveBeenCalledWith( + WRITE_COMMANDS.SET_POLICY_VENDORS_ENABLED, + { + policyID, + vendorIDs: JSON.stringify(['vendor1', 'vendor2']), + enabled: true, + }, + { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyVendorsKey, + value: { + vendor1: expect.objectContaining({ + enabled: true, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + pendingFields: { + enabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + errors: null, + }), + vendor2: 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: { + vendor1: expect.objectContaining({ + enabled: true, + pendingAction: null, + pendingFields: { + enabled: null, + }, + errors: null, + }), + vendor2: expect.objectContaining({ + enabled: true, + pendingAction: null, + pendingFields: { + enabled: null, + }, + errors: null, + }), + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyVendorsKey, + value: { + vendor1: expect.objectContaining({ + pendingAction: null, + pendingFields: { + enabled: null, + }, + errors: expect.anything() as unknown, + }), + vendor2: expect.objectContaining({ + pendingAction: null, + pendingFields: { + enabled: null, + }, + errors: expect.anything() as unknown, + }), + }, + }, + ], + }, + ); + }); + }); + + describe('clearVendorErrors', () => { + it('merges null errors and pending fields to Onyx', () => { + const onyxMergeSpy = jest.spyOn(Onyx, 'merge'); + + clearVendorErrors(policyID, 'vendor1'); + + expect(onyxMergeSpy).toHaveBeenCalledWith(policyVendorsKey, { + vendor1: { + errors: null, + pendingAction: null, + pendingFields: null, + errorFields: null, + }, + }); + + onyxMergeSpy.mockRestore(); + }); + }); + + describe('openPolicyVendorsPage', () => { + it('dispatches OpenPolicyVendorsPage read command with policyID', () => { + openPolicyVendorsPage(policyID); + + expect(mockRead).toHaveBeenCalledTimes(1); + expect(mockRead).toHaveBeenCalledWith(READ_COMMANDS.OPEN_POLICY_VENDORS_PAGE, {policyID}); + }); + + it('logs a warning and does not call read when policyID is empty', () => { + const logWarnSpy = jest.spyOn(Log, 'warn').mockImplementation(() => {}); + + openPolicyVendorsPage(''); + + expect(logWarnSpy).toHaveBeenCalledWith('openPolicyVendorsPage invalid params', {policyID: ''}); + expect(mockRead).not.toHaveBeenCalled(); + + logWarnSpy.mockRestore(); + }); + }); +}); diff --git a/tests/unit/ViolationUtilsTest.ts b/tests/unit/ViolationUtilsTest.ts index 3bb58707fcff..6f11cd81e60b 100644 --- a/tests/unit/ViolationUtilsTest.ts +++ b/tests/unit/ViolationUtilsTest.ts @@ -24,6 +24,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, @@ -3173,7 +3177,7 @@ describe('getViolationsOnyxData', () => { // Pass a `vendors` array to control the synced list, or `null` to simulate the list still // hydrating (`data.vendors` absent, so `isMatchingVendorListLoaded` returns false). - const policyWithQBOVendorFeature = (vendors: Array<{id: string; name: string; currency: string}> | null = [{id: 'v-active', name: 'Acme Co', currency: 'USD'}]) => + const policyWithQBOVendorFeature = (vendors: Array<{id: string; name: string; currency: string}> | null = [{id: 'vActive', name: 'Acme Co', currency: 'USD'}]) => createMock({ requiresTag: false, requiresCategory: false, @@ -3240,7 +3244,7 @@ describe('getViolationsOnyxData', () => { it('removes an existing violation when the vendor is restored in the policy list', () => { policy = policyWithQBOVendorFeature(); - transaction.comment = {...transaction.comment, vendor: {externalID: 'v-active', wasManuallySet: true}}; + transaction.comment = {...transaction.comment, vendor: {externalID: 'vActive', wasManuallySet: true}}; const result = ViolationsUtils.getViolationsOnyxData({ isVendorMatchingBetaEnabled: true, ownerLogin: undefined, @@ -3251,10 +3255,138 @@ describe('getViolationsOnyxData', () => { policyCategories, hasDependentTags: false, isInvoiceTransaction: false, + policyVendors: { + vActive: { + externalID: 'vActive', + name: 'Acme Co', + enabled: true, + }, + }, }); expect(result.value).not.toContainEqual(inactiveVendorViolation); }); + it('preserves an existing violation when policyVendors is not provided even if the vendor is in the raw integration list', () => { + policy = policyWithQBOVendorFeature(); + transaction.comment = {...transaction.comment, vendor: {externalID: 'vActive', wasManuallySet: true}}; + const result = ViolationsUtils.getViolationsOnyxData({ + isVendorMatchingBetaEnabled: true, + ownerLogin: undefined, + updatedTransaction: transaction, + transactionViolations: [inactiveVendorViolation], + policy, + policyTagList: policyTags, + policyCategories, + hasDependentTags: false, + isInvoiceTransaction: false, + }); + expect(result.value).toContainEqual(inactiveVendorViolation); + }); + + it('adds the violation when the vendor remains in the raw integration list but is disabled in policyVendors', () => { + const activeVendorID = 'vActive'; + 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 = 'vActive'; + 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 = 'vActive'; + 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 = 'vActive'; + 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 @@ -3279,11 +3411,11 @@ describe('getViolationsOnyxData', () => { connections: { [CONST.POLICY.CONNECTIONS.NAME.QBO]: { config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL}, - data: {vendors: [{id: 'v-active', name: 'Acme Co', currency: 'USD'}]}, + data: {vendors: [{id: 'vActive', name: 'Acme Co', currency: 'USD'}]}, }, }, }); - transaction.comment = {...transaction.comment, vendor: {externalID: 'v-active', wasManuallySet: true}}; + transaction.comment = {...transaction.comment, vendor: {externalID: 'vActive', wasManuallySet: true}}; const result = ViolationsUtils.getViolationsOnyxData({ isVendorMatchingBetaEnabled: true, ownerLogin: undefined, @@ -3357,7 +3489,7 @@ describe('getViolationsOnyxData', () => { it('preserves an existing violation while the QBO vendor list is still hydrating', () => { // Given the vendor list is still hydrating but an inactive-vendor violation already exists from a prior real check policy = policyWithQBOVendorFeature(null); - transaction.comment = {...transaction.comment, vendor: {externalID: 'v-active', wasManuallySet: true}}; + transaction.comment = {...transaction.comment, vendor: {externalID: 'vActive', wasManuallySet: true}}; // When violations are recomputed const result = ViolationsUtils.getViolationsOnyxData({ @@ -3381,7 +3513,7 @@ describe('getViolationsOnyxData', () => { // hydrating. Intacct vendors carry the display name in `value`. const policyWithIntacctVendorFeature = ( nonReimbursable: SageIntacctExportConfig['nonReimbursable'] = CONST.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSE_TYPE.CREDIT_CARD_CHARGE, - vendors: Array<{id: string; name: string; value: string}> | null = [{id: 'iv-active', name: 'V001', value: 'Acme Intacct'}], + vendors: Array<{id: string; name: string; value: string}> | null = [{id: 'ivActive', name: 'V001', value: 'Acme Intacct'}], ) => createMock({ requiresTag: false, @@ -3415,7 +3547,7 @@ describe('getViolationsOnyxData', () => { it('does not add the violation when the Intacct vendor is in the synced list', () => { isBetaEnabledSpy.mockImplementation(() => false); policy = policyWithIntacctVendorFeature(); - transaction.comment = {...transaction.comment, vendor: {externalID: 'iv-active', wasManuallySet: true}}; + transaction.comment = {...transaction.comment, vendor: {externalID: 'ivActive', wasManuallySet: true}}; const result = ViolationsUtils.getViolationsOnyxData({ ownerLogin: undefined, isVendorMatchingBetaEnabled: false, @@ -3522,6 +3654,13 @@ describe('getViolationsOnyxData', () => { policyCategories, hasDependentTags: false, isInvoiceTransaction: false, + policyVendors: { + xcActive: { + externalID: 'xcActive', + name: 'Acme Xero', + enabled: true, + }, + }, }); expect(result.value).not.toContainEqual(inactiveVendorViolation); }); @@ -3634,7 +3773,7 @@ describe('getViolationsOnyxData', () => { connections: { [CONST.POLICY.CONNECTIONS.NAME.QBO]: { config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}, - data: {vendors: [{id: 'v-active', name: 'Acme QBO', currency: 'USD'}]}, + data: {vendors: [{id: 'vActive', name: 'Acme QBO', currency: 'USD'}]}, }, [CONST.POLICY.CONNECTIONS.NAME.XERO]: { config: {},