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; + 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 @@ -517,6 +563,7 @@ export { getActiveVendorMatchingIntegration, getMatchingVendorByID, getMatchingVendors, + getDefaultVendorID, sortVendors, getVendorEmptyState, getVendorRuleDisplayValue, 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..f2a19294be74 100644 --- a/src/libs/actions/IOU/UpdateMoneyRequest.ts +++ b/src/libs/actions/IOU/UpdateMoneyRequest.ts @@ -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..972bc219efdc --- /dev/null +++ b/src/libs/actions/Policy/Vendor.ts @@ -0,0 +1,113 @@ +/** + * 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'; +import {getMicroSecondOnyxErrorWithTranslationKey} 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: 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, + }, + }); +} + +export {setPolicyVendorsEnabled, clearVendorErrors}; diff --git a/src/pages/iou/request/step/IOURequestStepVendor.tsx b/src/pages/iou/request/step/IOURequestStepVendor.tsx index af98674fe953..08b637143124 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,55 @@ 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 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; @@ -130,7 +150,7 @@ function IOURequestStepVendor({ const headerMessage = searchValue && data.length === 0 ? translate('common.noResultsFound') : ''; const listEmptyContent = - vendors.length === 0 ? ( + enabledVendors.length === 0 && !disabledCurrentVendorRow ? ( ; + 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,71 @@ function WorkspaceVendorsPage({policy, route}: WorkspaceVendorsPageProps) { const {translate, localeCompare} = useLocalize(); const {shouldUseNarrowLayout} = 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 [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); 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 && (shouldUseNarrowLayout ? isMobileSelectionModeEnabled : true); + + 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, + 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 +203,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 fad7cf8b5ab5..bd3775139863 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -149,6 +149,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'; @@ -327,6 +328,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 148aaec1ae3b..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'; @@ -47,6 +48,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 +3132,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 e6d7c202320a..7546e6679f08 100644 --- a/tests/ui/MoneyRequestViewTest.tsx +++ b/tests/ui/MoneyRequestViewTest.tsx @@ -23,6 +23,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), @@ -59,7 +63,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'} @@ -69,6 +85,16 @@ jest.mock('@components/MenuItemWithTopDescription', () => { {title} )} + {errorText !== undefined && ( + + {errorText} + + )} + {brickRoadIndicator !== undefined && ( + + {brickRoadIndicator} + + )} ); }); @@ -1003,6 +1029,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 01b4c246eaa8..9f63acc87605 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -112,6 +112,7 @@ import { shouldHideDynamicExternalWorkflowPeople, shouldShowPolicy, sortPoliciesByName, + getDefaultVendorID, sortVendors, sortWorkspacesBySelected, tryNavigateToSubmitWorkspaceUpgrade, @@ -4500,6 +4501,218 @@ describe('PolicyUtils', () => { }); }); + describe('sortVendors', () => { + const localeCompare = (a: string, b: string) => a.localeCompare(b); + + it('sorts vendors alphabetically by name using localeCompare', () => { + const vendors = [ + {id: '1', name: 'Zebra'}, + {id: '2', name: 'Apple'}, + {id: '3', name: 'Banana'}, + ]; + + const result = sortVendors(vendors, localeCompare); + expect(result.map((v) => v.name)).toEqual(['Apple', 'Banana', 'Zebra']); + }); + + it('breaks name ties using vendor id', () => { + const vendors = [ + {id: 'vendor_b', name: 'Acme'}, + {id: 'vendor_a', name: 'Acme'}, + ]; + + const result = sortVendors(vendors, localeCompare); + expect(result.map((v) => v.id)).toEqual(['vendor_a', 'vendor_b']); + }); + + it('does not sort the input array in place', () => { + const vendors = [ + {id: '2', name: 'Zebra'}, + {id: '1', name: 'Alpha'}, + ]; + + const result = sortVendors(vendors, localeCompare); + expect(result).not.toBe(vendors); + expect(vendors.map((v) => v.name)).toEqual(['Zebra', 'Alpha']); + }); + + it('returns empty array for empty input', () => { + expect(sortVendors([], localeCompare)).toEqual([]); + }); + + it('returns single-element array as-is', () => { + const vendors = [{id: '1', name: 'Only'}]; + const result = sortVendors(vendors, localeCompare); + 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(); + }); + }); + describe('getSageIntacctVendors', () => { const localeCompare = (a: string, b: string) => a.localeCompare(b); diff --git a/tests/unit/PolicyVendorActionTest.ts b/tests/unit/PolicyVendorActionTest.ts new file mode 100644 index 000000000000..68732ce5b0e2 --- /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 = { + 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(); + }); + }); +}); diff --git a/tests/unit/ViolationUtilsTest.ts b/tests/unit/ViolationUtilsTest.ts index b2bae70db2b7..03c25527d669 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