diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 3b8a9ad07f7b..550dc9f4bc55 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -930,6 +930,7 @@ const CONST = { WORKSPACE_ROOMS_PAGE: 'workspaceRoomsPage', CERTINIA: 'financialForceNewDot', MERGE_HR: 'mergeHRConnections', + VENDOR_MATCHING: 'vendorMatching', }, BUTTON_STATES: { DEFAULT: 'default', @@ -2746,6 +2747,7 @@ const CONST = { REIMBURSABLE_EXPENSES_ACCOUNT: 'reimbursableExpensesAccount', REIMBURSABLE_EXPENSES_EXPORT_DESTINATION: 'reimbursableExpensesExportDestination', NON_REIMBURSABLE_BILL_DEFAULT_VENDOR: 'nonReimbursableBillDefaultVendor', + NON_REIMBURSABLE_CREDIT_CARD_DEFAULT_VENDOR: 'nonReimbursableCreditCardDefaultVendor', NON_REIMBURSABLE_EXPENSE_EXPORT_DESTINATION: 'nonReimbursableExpensesExportDestination', NON_REIMBURSABLE_EXPENSE_ACCOUNT: 'nonReimbursableExpensesAccount', RECEIVABLE_ACCOUNT: 'receivableAccount', @@ -6821,6 +6823,7 @@ const CONST = { MODIFIED_AMOUNT: 'modifiedAmount', MODIFIED_DATE: 'modifiedDate', INCREASED_DISTANCE: 'increasedDistance', + INACTIVE_VENDOR: 'inactiveVendor', PROHIBITED_EXPENSE: 'prohibitedExpense', NON_EXPENSIWORKS_EXPENSE: 'nonExpensiworksExpense', OVER_AUTO_APPROVAL_LIMIT: 'overAutoApprovalLimit', diff --git a/src/hooks/useViolations.ts b/src/hooks/useViolations.ts index 55c1f2d6ce17..2dc3aa1decfa 100644 --- a/src/hooks/useViolations.ts +++ b/src/hooks/useViolations.ts @@ -6,7 +6,7 @@ import type {TransactionViolation, ViolationName} from '@src/types/onyx'; /** * Names of Fields where violations can occur. */ -const validationFields = ['amount', 'billable', 'category', 'comment', 'date', 'merchant', 'receipt', 'tag', 'tax', 'attendees', 'customUnitRateID', 'waypoints', 'none'] as const; +const validationFields = ['amount', 'billable', 'category', 'comment', 'date', 'merchant', 'receipt', 'tag', 'tax', 'attendees', 'customUnitRateID', 'vendor', 'waypoints', 'none'] as const; type ViolationField = TupleToUnion; @@ -24,6 +24,7 @@ const violationNameToField: Record 'merchant', fieldRequired: () => 'merchant', futureDate: () => 'date', + inactiveVendor: () => 'vendor', invoiceMarkup: () => 'amount', maxAge: () => 'date', missingCategory: () => 'category', diff --git a/src/languages/de.ts b/src/languages/de.ts index 26d5ace68bc6..216f2305ca15 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -8607,6 +8607,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc duplicatedTransaction: 'Möglicherweise dupliziert', fieldRequired: 'Berichtsfelder sind erforderlich', futureDate: 'Zukünftiges Datum nicht erlaubt', + inactiveVendor: 'Anbieter nicht mehr gültig', invoiceMarkup: (invoiceMarkup: number) => `Um ${invoiceMarkup}% erhöht`, maxAge: (maxAge: number) => `Datum ist älter als ${maxAge} Tage`, missingCategory: 'Fehlende Kategorie', diff --git a/src/languages/en.ts b/src/languages/en.ts index f3b4a91426d3..c9f782b729ba 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -8611,6 +8611,7 @@ const translations = { duplicatedTransaction: 'Potential duplicate', fieldRequired: 'Report fields are required', futureDate: 'Future date not allowed', + inactiveVendor: 'Vendor no longer valid', invoiceMarkup: (invoiceMarkup: number) => `Marked up by ${invoiceMarkup}%`, maxAge: (maxAge: number) => `Date older than ${maxAge} days`, missingCategory: 'Missing category', diff --git a/src/languages/es.ts b/src/languages/es.ts index 671fa76a7440..09b083da76f0 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -8780,6 +8780,7 @@ ${amount} para ${merchant} - ${date}`, duplicatedTransaction: 'Posible duplicado', fieldRequired: 'Los campos del informe son obligatorios', futureDate: 'Fecha futura no permitida', + inactiveVendor: 'El proveedor ya no es válido', invoiceMarkup: (invoiceMarkup) => `Incrementado un ${invoiceMarkup}%`, maxAge: (maxAge) => `Fecha de más de ${maxAge} días`, missingCategory: 'Falta categoría', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 079293757c40..85d022d83c4d 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -8639,6 +8639,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e duplicatedTransaction: 'Doublon potentiel', fieldRequired: 'Les champs de note de frais sont obligatoires', futureDate: 'Date future non autorisée', + inactiveVendor: 'Fournisseur plus valide', invoiceMarkup: (invoiceMarkup: number) => `Majoration de ${invoiceMarkup} %`, maxAge: (maxAge: number) => `Date antérieure de plus de ${maxAge} jours`, missingCategory: 'Catégorie manquante', diff --git a/src/languages/it.ts b/src/languages/it.ts index 3feb94469cc6..aebf6a0b6380 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -8600,6 +8600,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, duplicatedTransaction: 'Duplice potenziale', fieldRequired: 'I campi del report sono obbligatori', futureDate: 'Data futura non consentita', + inactiveVendor: 'Fornitore non più valido', invoiceMarkup: (invoiceMarkup: number) => `Maggiorato del ${invoiceMarkup}%`, maxAge: (maxAge: number) => `Data precedente a ${maxAge} giorni`, missingCategory: 'Categoria mancante', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 78c68061cdb7..69eb3e7b9727 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -8491,6 +8491,7 @@ ${reportName} duplicatedTransaction: '重複の可能性', fieldRequired: 'レポートの項目は必須です', futureDate: '将来の日付は使用できません', + inactiveVendor: 'ベンダーは無効です', invoiceMarkup: (invoiceMarkup: number) => `${invoiceMarkup}%値上げ済み`, maxAge: (maxAge: number) => `日付が${maxAge}日より前です`, missingCategory: 'カテゴリが未選択です', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 1a3fc48f9068..4f5bff051d13 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -8569,6 +8569,7 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, duplicatedTransaction: 'Mogelijke duplicaat', fieldRequired: 'Rapportvelden zijn verplicht', futureDate: 'Toekomstige datum niet toegestaan', + inactiveVendor: 'Leverancier niet meer geldig', invoiceMarkup: (invoiceMarkup: number) => `Met ${invoiceMarkup}% verhoogd`, maxAge: (maxAge: number) => `Datum ouder dan ${maxAge} dagen`, missingCategory: 'Ontbrekende categorie', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 995aa727aeb1..bafe6a825576 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -8556,6 +8556,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, duplicatedTransaction: 'Potencjalny duplikat', fieldRequired: 'Pola raportu są wymagane', futureDate: 'Przyszła data jest niedozwolona', + inactiveVendor: 'Dostawca nie jest już prawidłowy', invoiceMarkup: (invoiceMarkup: number) => `Podwyższono o ${invoiceMarkup}%`, maxAge: (maxAge: number) => `Data starsza niż ${maxAge} dni`, missingCategory: 'Brak kategorii', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 6fa49367a329..c0bb95c60786 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -8562,6 +8562,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, duplicatedTransaction: 'Possível duplicata', fieldRequired: 'Os campos do relatório são obrigatórios', futureDate: 'Data futura não permitida', + inactiveVendor: 'Fornecedor não é mais válido', invoiceMarkup: (invoiceMarkup: number) => `Reajustado em ${invoiceMarkup}%`, maxAge: (maxAge: number) => `Data anterior a ${maxAge} dias`, missingCategory: 'Categoria ausente', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 22ed75b0da0c..d69abc152dbe 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -8339,6 +8339,7 @@ ${reportName} duplicatedTransaction: '可能重复', fieldRequired: '报表字段为必填项', futureDate: '不允许使用未来日期', + inactiveVendor: '供应商不再有效', invoiceMarkup: (invoiceMarkup: number) => `加价 ${invoiceMarkup}%`, maxAge: (maxAge: number) => `日期早于 ${maxAge} 天`, missingCategory: '缺少类别', diff --git a/src/libs/API/parameters/UpdateMoneyRequestVendorParams.ts b/src/libs/API/parameters/UpdateMoneyRequestVendorParams.ts new file mode 100644 index 000000000000..acd419d595fb --- /dev/null +++ b/src/libs/API/parameters/UpdateMoneyRequestVendorParams.ts @@ -0,0 +1,8 @@ +type UpdateMoneyRequestVendorParams = { + transactionID: string; + reportActionID: string; + vendorID: string; + isManuallySet: boolean; +}; + +export default UpdateMoneyRequestVendorParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 51e87764cee1..36ef16657115 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -204,6 +204,7 @@ export type {default as SetReportNameParams} from './SetReportNameParams'; export type {default as DeleteReportFieldParams} from './DeleteReportFieldParams'; export type {default as CompleteSplitBillParams} from './CompleteSplitBillParams'; export type {default as UpdateMoneyRequestParams} from './UpdateMoneyRequestParams'; +export type {default as UpdateMoneyRequestVendorParams} from './UpdateMoneyRequestVendorParams'; export type {default as RequestMoneyParams} from './RequestMoneyParams'; export type {default as SplitBillParams} from './SplitBillParams'; export type {SplitTransactionParams, SplitTransactionSplitsParam, RevertSplitTransactionParams} from './SplitTransactionParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 539c5b285b6d..b4fd87dcd581 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -324,7 +324,9 @@ const WRITE_COMMANDS = { UPDATE_QUICKBOOKS_ONLINE_SYNC_CUSTOMERS: 'UpdateQuickbooksOnlineSyncCustomers', UPDATE_QUICKBOOKS_ONLINE_SYNC_CLASSES: 'UpdateQuickbooksOnlineSyncClasses', UPDATE_QUICKBOOKS_ONLINE_MAPPING: 'UpdateQuickbooksOnlineMapping', + UPDATE_MONEY_REQUEST_VENDOR: 'UpdateMoneyRequestVendor', UPDATE_QUICKBOOKS_ONLINE_NON_REIMBURSABLE_BILL_DEFAULT_VENDOR: 'UpdateQuickbooksOnlineNonReimbursableBillDefaultVendor', + UPDATE_QUICKBOOKS_ONLINE_NON_REIMBURSABLE_CREDIT_CARD_DEFAULT_VENDOR: 'UpdateQuickbooksOnlineNonReimbursableCreditCardDefaultVendor', UPDATE_QUICKBOOKS_ONLINE_AUTO_SYNC: 'UpdateQuickbooksOnlineAutoSync', UPDATE_QUICKBOOKS_ONLINE_SYNC_PEOPLE: 'UpdateQuickbooksOnlineSyncPeople', UPDATE_QUICKBOOKS_ONLINE_REIMBURSEMENT_ACCOUNT_ID: 'UpdateQuickbooksOnlineReimbursementAccountID', @@ -957,7 +959,9 @@ type WriteCommandParameters = { [WRITE_COMMANDS.UPDATE_QUICKBOOKS_ONLINE_SYNC_LOCATIONS]: Parameters.UpdateQuickbooksOnlineGenericTypeParams; [WRITE_COMMANDS.UPDATE_QUICKBOOKS_ONLINE_SYNC_CUSTOMERS]: Parameters.UpdateQuickbooksOnlineGenericTypeParams; [WRITE_COMMANDS.UPDATE_QUICKBOOKS_ONLINE_SYNC_CLASSES]: Parameters.UpdateQuickbooksOnlineGenericTypeParams; + [WRITE_COMMANDS.UPDATE_MONEY_REQUEST_VENDOR]: Parameters.UpdateMoneyRequestVendorParams; [WRITE_COMMANDS.UPDATE_QUICKBOOKS_ONLINE_NON_REIMBURSABLE_BILL_DEFAULT_VENDOR]: Parameters.UpdateQuickbooksOnlineGenericTypeParams; + [WRITE_COMMANDS.UPDATE_QUICKBOOKS_ONLINE_NON_REIMBURSABLE_CREDIT_CARD_DEFAULT_VENDOR]: Parameters.UpdateQuickbooksOnlineGenericTypeParams; [WRITE_COMMANDS.UPDATE_QUICKBOOKS_ONLINE_REIMBURSABLE_EXPENSES_ACCOUNT]: Parameters.UpdateQuickbooksOnlineGenericTypeParams; [WRITE_COMMANDS.UPDATE_QUICKBOOKS_ONLINE_AUTO_SYNC]: Parameters.UpdateQuickbooksOnlineGenericTypeParams; [WRITE_COMMANDS.UPDATE_QUICKBOOKS_ONLINE_SYNC_PEOPLE]: Parameters.UpdateQuickbooksOnlineGenericTypeParams; diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts index 60add514f522..e1823b267035 100644 --- a/src/libs/DebugUtils.ts +++ b/src/libs/DebugUtils.ts @@ -1052,6 +1052,7 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) subRates: CONST.RED_BRICK_ROAD_PENDING_ACTION, comment: CONST.RED_BRICK_ROAD_PENDING_ACTION, hold: CONST.RED_BRICK_ROAD_PENDING_ACTION, + vendor: CONST.RED_BRICK_ROAD_PENDING_ACTION, waypoints: CONST.RED_BRICK_ROAD_PENDING_ACTION, isLoading: CONST.RED_BRICK_ROAD_PENDING_ACTION, type: CONST.RED_BRICK_ROAD_PENDING_ACTION, @@ -1179,6 +1180,7 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) source: 'string', originalTransactionID: 'string', liabilityType: CONST.TRANSACTION.LIABILITY_TYPE, + vendor: 'object', splits: 'array', dismissedViolations: 'object', splitExpenses: 'array', diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 72141f81ca5f..f6a1be7bd7af 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -37,6 +37,7 @@ import type { PolicyFeatureName, Rate, Tenant, + Vendor, } from '@src/types/onyx/Policy'; import type PolicyEmployee from '@src/types/onyx/PolicyEmployee'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -1999,6 +2000,45 @@ function getConnectedIntegration(policy: Policy | undefined, connectionNames: re return connectionNames.find((integration) => !!policy?.connections?.[integration]); } +/** + * QBO vendor feature gate. Returns true when the workspace has the `vendorMatching` beta enabled + * AND QBO is connected with an individual card transaction non-reimbursable export type — the only + * scope the Vendor field is shown for. Mirrors `QuickbooksOnline::hasVendorFeature` on the PHP side + * so the App and backend agree on which workspaces see the field. + */ +function hasVendorFeature(policy: OnyxEntry, isVendorMatchingBetaEnabled: boolean): boolean { + if (!isVendorMatchingBetaEnabled || !policy) { + return false; + } + const qboConnection = policy.connections?.[CONST.POLICY.CONNECTIONS.NAME.QBO]; + if (!qboConnection) { + return false; + } + const exportDestination = qboConnection.config?.nonReimbursableExpensesExportDestination; + return exportDestination === CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD || exportDestination === CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD; +} + +/** + * Returns the QBO vendor list imported into the workspace (empty array when QBO isn't connected or + * the sync hasn't populated vendors yet). Source of truth for the workspace Vendors tab and the + * vendor selector RHP. + */ +function getQBOVendors(policy: OnyxEntry): Vendor[] { + return policy?.connections?.[CONST.POLICY.CONNECTIONS.NAME.QBO]?.data?.vendors ?? []; +} + +/** + * Look up a single QBO vendor by `externalID`. Used to resolve the vendor name for display when + * only the ID is stored on the transaction NVP. Returns undefined when the ID isn't found + * (which happens after a vendor is deleted from QBO — see the inactive-vendor violation). + */ +function getQBOVendorByID(policy: OnyxEntry, vendorID: string | undefined): Vendor | undefined { + if (!vendorID) { + return undefined; + } + return getQBOVendors(policy).find((vendor) => vendor.id === vendorID); +} + function getValidConnectedIntegration(policy: Policy | undefined, connectionNames: readonly ConnectionName[] = getAccountingConnectionNames()) { return connectionNames.find((integration) => !!policy?.connections?.[integration] && !isConnectionUnverified(policy, integration)); } @@ -2384,6 +2424,9 @@ export { getConnectedIntegration, getConnectedIntegrationNamesForPolicies, getConnectionExporters, + getQBOVendorByID, + getQBOVendors, + hasVendorFeature, getValidConnectedIntegration, getCountOfEnabledTagsOfList, getIneligibleInvitees, diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index 9d279e329c1a..db94678d008c 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -13,10 +13,13 @@ import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import {isReceiptError} from '@libs/ErrorUtils'; import {getCurrentUserEmail} from '@libs/Network/NetworkStore'; import Parser from '@libs/Parser'; +import Permissions from '@libs/Permissions'; import { getDistanceRateCustomUnitRate, getPerDiemRateCustomUnitRate, + getQBOVendorByID, getSortedTagKeys, + hasVendorFeature, isAttendeeTrackingEnabled as isAttendeeTrackingEnabledForPolicy, isDefaultTagName, isTaxTrackingEnabled, @@ -26,12 +29,20 @@ import * as TransactionUtils from '@libs/TransactionUtils'; import {hasValidModifiedAmount, isViolationDismissed, shouldShowViolation} from '@libs/TransactionUtils'; 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 {Beta, Card, CardList, Policy, PolicyCategories, PolicyTagLists, PolicyTags, 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'; import type ViolationFixParams from './types'; +let allBetas: OnyxEntry; +Onyx.connectWithoutView({ + key: ONYXKEYS.BETAS, + callback: (value) => { + allBetas = value; + }, +}); + type ViolationTranslationParams = { violation: TransactionViolation; translate: LocaleContextProps['translate']; @@ -430,6 +441,42 @@ const ViolationsUtils = { : getTagViolationsForMultiLevelTags(updatedTransaction, newTransactionViolations, policyTagList, hasDependentTags); } + // Inactive vendor violation. Mirrors `categoryOutOfPolicy` / `tagOutOfPolicy` — computed + // entirely client-side from the policy's imported vendor list. The vendor object on the + // transaction is left as-is when the violation fires (or when the feature is disabled) — + // we never clear the user's selection just because the vendor list changed; the admin + // needs to see what was previously set so they can re-pick. Gated behind the + // `vendorMatching` beta so Web-Expensify can ship the auto-match write path + // independently — no production workspace sees the violation until the beta is enabled. + // + // Skip the reconcile entirely while `allBetas` is still loading (the module-level Onyx + // subscription populates it asynchronously). Treating undefined as "no betas" would surface + // as "feature off" here and silently strip a valid server-set `inactiveVendor` violation + // during the startup window. The next recompute settles the state once betas land. + if (allBetas !== undefined) { + const isVendorMatchingBetaEnabled = Permissions.isBetaEnabled(CONST.BETAS.VENDOR_MATCHING, allBetas); + const hasInactiveVendorViolation = newTransactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.INACTIVE_VENDOR); + const isVendorFeatureActive = hasVendorFeature(policy, isVendorMatchingBetaEnabled); + const transactionVendorID = updatedTransaction.comment?.vendor?.externalID; + if (!isVendorFeatureActive) { + // Feature off (e.g. admin switched export type away from credit/debit card) — clear any + // stale inactive-vendor violation. + if (hasInactiveVendorViolation) { + newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.INACTIVE_VENDOR}); + } + } else if (transactionVendorID) { + const matchedVendor = getQBOVendorByID(policy, transactionVendorID); + if (!matchedVendor && !hasInactiveVendorViolation) { + newTransactionViolations.push({name: CONST.VIOLATIONS.INACTIVE_VENDOR, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true}); + } else if (matchedVendor && hasInactiveVendorViolation) { + newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.INACTIVE_VENDOR}); + } + } else if (hasInactiveVendorViolation) { + // Vendor was cleared while the feature is still active — drop the now-stale violation. + newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.INACTIVE_VENDOR}); + } + } + const customUnitRateID = updatedTransaction?.comment?.customUnit?.customUnitRateID; if (customUnitRateID && customUnitRateID.length > 0 && !isSelfDM) { const isPerDiem = TransactionUtils.isPerDiemRequest(updatedTransaction); @@ -740,6 +787,8 @@ const ViolationsUtils = { return translate('violations.fieldRequired'); case 'futureDate': return translate('violations.futureDate'); + case 'inactiveVendor': + return translate('violations.inactiveVendor'); case 'invoiceMarkup': return translate('violations.invoiceMarkup', invoiceMarkup); case 'maxAge': @@ -825,12 +874,13 @@ const ViolationsUtils = { return translate('violations.receiptGeneratedWithAI'); case CONST.VIOLATIONS.NO_ROUTE: return translate('violations.noRoute'); - default: + default: { // The interpreter should never get here because the switch cases should be exhaustive. - // If typescript is showing an error on the assertion below it means the switch statement is out of - // sync with the `ViolationNames` type, and one or the other needs to be updated. - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - return violation.name as never; + // If typescript is showing an error below, the switch is out of sync with the + // `ViolationNames` type — add the missing case (or remove the obsolete one). + const exhaustiveCheck: never = violation.name; + return exhaustiveCheck; + } } }, diff --git a/src/libs/actions/IOU/UpdateMoneyRequest.ts b/src/libs/actions/IOU/UpdateMoneyRequest.ts index c7f94fb7fbd5..7f5816247f40 100644 --- a/src/libs/actions/IOU/UpdateMoneyRequest.ts +++ b/src/libs/actions/IOU/UpdateMoneyRequest.ts @@ -8,6 +8,7 @@ import {WRITE_COMMANDS} from '@libs/API/types'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import {buildNextStepNew, buildOptimisticNextStep} from '@libs/NextStepUtils'; +import {rand64} from '@libs/NumberUtils'; import {hasDependentTags, isPaidGroupPolicy} from '@libs/PolicyUtils'; import type {TransactionDetails} from '@libs/ReportUtils'; import { @@ -414,6 +415,85 @@ function updateMoneyRequestAttendees({ API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_ATTENDEES, params, onyxData); } +/** + * Update the QBO vendor matched to a non-reimbursable expense. The vendor lives on the transaction's + * `comment.vendor` NVP as `{ externalID, isManuallySet }`. `isManuallySet` is hard-coded to `true` here + * because this action is only called from user-driven flows (the App vendor picker, etc.); the PHP + * fuzzy matcher writes auto-matches directly via `UpdateMoneyRequestVendor` with `isManuallySet=false`. + * + * Passing `vendorID=''` clears the vendor from the transaction. + */ +function updateMoneyRequestVendor(transactionID: string, vendorID: string, transaction?: OnyxEntry) { + // Fall back to the cached Onyx transaction when the caller doesn't pass one so failureData can + // restore the actual previous vendor on API failure instead of clearing it. + const resolvedTransaction = transaction ?? getAllTransactions()?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + const previousVendor = resolvedTransaction?.comment?.vendor; + const optimisticReportActionID = rand64(); + const isClearing = !vendorID; + + const newVendorOptimisticValue = isClearing ? null : {externalID: vendorID, isManuallySet: true}; + + const optimisticData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}` as const, + value: { + comment: { + vendor: newVendorOptimisticValue, + }, + }, + }, + ]; + + const failureData: Array> = []; + + // Only roll back the vendor when we have a known prior snapshot. If the transaction isn't passed + // in AND isn't cached in Onyx yet, we don't know what to restore — writing null here would silently + // clear a vendor we don't know about. The next server sync will reconcile. + if (resolvedTransaction) { + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}` as const, + value: { + comment: { + vendor: previousVendor ?? null, + }, + }, + }); + } + + // Optimistically clear any existing inactive-vendor violation. This is the user-driven write + // path: the vendor selector RHP only offers vendors from `getQBOVendors(policy)`, so a user + // 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. + const violationsKey = `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}` as const; + const currentViolations = getAllTransactionViolations()[violationsKey] ?? []; + if (currentViolations.some((violation) => violation.name === CONST.VIOLATIONS.INACTIVE_VENDOR)) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.SET, + key: violationsKey, + value: currentViolations.filter((violation) => violation.name !== CONST.VIOLATIONS.INACTIVE_VENDOR), + }); + failureData.push({ + onyxMethod: Onyx.METHOD.SET, + key: violationsKey, + value: currentViolations, + }); + } + + API.write( + WRITE_COMMANDS.UPDATE_MONEY_REQUEST_VENDOR, + { + transactionID, + reportActionID: optimisticReportActionID, + vendorID, + isManuallySet: true, + }, + {optimisticData, failureData}, + ); +} + type UpdateMoneyRequestTagParams = { transactionID: string; transactionThreadReport: OnyxEntry; @@ -1895,6 +1975,7 @@ export { updateMoneyRequestReimbursable, updateMoneyRequestMerchant, updateMoneyRequestAttendees, + updateMoneyRequestVendor, updateMoneyRequestTag, updateMoneyRequestTaxAmount, updateMoneyRequestTaxRate, diff --git a/src/libs/actions/connections/QuickbooksOnline.ts b/src/libs/actions/connections/QuickbooksOnline.ts index a369bb6e1771..3b3240b3a704 100644 --- a/src/libs/actions/connections/QuickbooksOnline.ts +++ b/src/libs/actions/connections/QuickbooksOnline.ts @@ -325,6 +325,21 @@ function updateQuickbooksOnlineNonReimbursableBillDefaultVendor( + policyID: string, + settingValue: TSettingValue, + oldSettingValue?: TSettingValue, +) { + const onyxData = buildOnyxDataForQuickbooksConfiguration(policyID, CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_CREDIT_CARD_DEFAULT_VENDOR, settingValue, oldSettingValue); + + const parameters: UpdateQuickbooksOnlineGenericTypeParams = { + policyID, + settingValue: JSON.stringify(settingValue), + idempotencyKey: String(CONST.QUICKBOOKS_CONFIG.NON_REIMBURSABLE_CREDIT_CARD_DEFAULT_VENDOR), + }; + API.write(WRITE_COMMANDS.UPDATE_QUICKBOOKS_ONLINE_NON_REIMBURSABLE_CREDIT_CARD_DEFAULT_VENDOR, parameters, onyxData); +} + function updateQuickbooksOnlineReceivableAccount( policyID: string | undefined, settingValue: TSettingValue, @@ -514,6 +529,7 @@ export { updateQuickbooksOnlineCollectionAccountID, updateQuickbooksOnlineSyncReimbursedReports, updateQuickbooksOnlineNonReimbursableBillDefaultVendor, + updateQuickbooksOnlineNonReimbursableCreditCardDefaultVendor, updateQuickbooksOnlineSyncTax, updateQuickbooksOnlineSyncClasses, updateQuickbooksOnlineSyncLocations, diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index 387a95474da3..d68baa5f69b5 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -518,6 +518,9 @@ type QBOConnectionConfig = OnyxCommon.OnyxValueWithOfflineFeedback<{ /** Default vendor of non reimbursable bill */ nonReimbursableBillDefaultVendor: string; + /** Default vendor used as a fallback when a non-reimbursable Credit/Debit card expense has no vendor set on the expense itself. */ + nonReimbursableCreditCardDefaultVendor?: string; + /** ID of the invoice collection account */ collectionAccountID?: string; @@ -2460,4 +2463,5 @@ export type { GustoConnectionConfig, ZenefitsConnectionConfig, MergeHRConnectionData, + Vendor, }; diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts index f986e517a450..6d4fb3888440 100644 --- a/src/types/onyx/Transaction.ts +++ b/src/types/onyx/Transaction.ts @@ -122,6 +122,21 @@ type Comment = { /** Defines the type of liability for the transaction */ liabilityType?: ValueOf; + /** + * Accounting-system vendor matched to this expense. + * Stored on non-reimbursable card expenses when a vendor is set either by the + * PHP fuzzy matcher (`isManuallySet=false`) or by the user / a merchant rule + * (`isManuallySet=true`). The flag prevents auto-match from overwriting a + * deliberate selection. + */ + vendor?: { + /** Vendor ID in the connected accounting integration (e.g. QBO vendor ID) */ + externalID: string; + + /** `true` when set by the user or a merchant rule; `false` when set by the PHP fuzzy auto-matcher */ + isManuallySet: boolean; + }; + /** Timestamp when auto-categorization was initiated (format: "YYYY-MM-DD HH:MM:SS") */ pendingAutoCategorizationTime?: string; diff --git a/tests/actions/IOUTest/UpdateMoneyRequestVendorTest.ts b/tests/actions/IOUTest/UpdateMoneyRequestVendorTest.ts new file mode 100644 index 000000000000..273857b89b8c --- /dev/null +++ b/tests/actions/IOUTest/UpdateMoneyRequestVendorTest.ts @@ -0,0 +1,126 @@ +import Onyx from 'react-native-onyx'; +import {updateMoneyRequestVendor} from '@libs/actions/IOU/UpdateMoneyRequest'; +import * as API from '@libs/API'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Transaction, TransactionViolation} from '@src/types/onyx'; +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; + +const TRANSACTION_ID = 'txn-vendor-test'; + +const baseTransaction: Transaction = { + transactionID: TRANSACTION_ID, + reportID: '1234', + amount: 100, + comment: {}, + created: '2026-05-25 13:46:20', + merchant: 'Coffee Shop', + currency: CONST.CURRENCY.USD, +}; + +const inactiveVendorViolation: TransactionViolation = { + name: CONST.VIOLATIONS.INACTIVE_VENDOR, + type: CONST.VIOLATION_TYPES.VIOLATION, + showInReview: true, +}; + +const otherViolation: TransactionViolation = { + name: CONST.VIOLATIONS.MISSING_CATEGORY, + type: CONST.VIOLATION_TYPES.VIOLATION, + showInReview: true, +}; + +describe('updateMoneyRequestVendor', () => { + let writeSpy: jest.SpyInstance; + + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(() => { + writeSpy = jest.spyOn(API, 'write').mockImplementation(jest.fn()); + }); + + afterEach(async () => { + writeSpy.mockRestore(); + await Onyx.clear(); + }); + + type OnyxDataArg = {optimisticData: Array<{key: string; value: unknown}>; failureData: Array<{key: string; value: unknown}>}; + const getOnyxDataArg = (): OnyxDataArg | undefined => { + const firstCall = writeSpy.mock.calls.at(0) as unknown[] | undefined; + return firstCall?.at(2) as OnyxDataArg | undefined; + }; + + it('clears an existing inactive-vendor violation optimistically when a vendor is picked', async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${TRANSACTION_ID}`, [otherViolation, inactiveVendorViolation]); + await waitForBatchedUpdates(); + + updateMoneyRequestVendor(TRANSACTION_ID, 'v-active', baseTransaction); + + const onyxData = getOnyxDataArg(); + const violationsUpdate = onyxData?.optimisticData.find((entry) => entry.key === `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${TRANSACTION_ID}`); + expect(violationsUpdate).toBeDefined(); + expect(violationsUpdate?.value).toEqual([otherViolation]); + }); + + it('clears an existing inactive-vendor violation optimistically when the vendor is cleared', async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${TRANSACTION_ID}`, [inactiveVendorViolation]); + await waitForBatchedUpdates(); + + updateMoneyRequestVendor(TRANSACTION_ID, '', baseTransaction); + + const onyxData = getOnyxDataArg(); + const violationsUpdate = onyxData?.optimisticData.find((entry) => entry.key === `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${TRANSACTION_ID}`); + expect(violationsUpdate).toBeDefined(); + expect(violationsUpdate?.value).toEqual([]); + }); + + it('restores the original violation list in failureData so a server rejection rolls back cleanly', async () => { + const original = [otherViolation, inactiveVendorViolation]; + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${TRANSACTION_ID}`, original); + await waitForBatchedUpdates(); + + updateMoneyRequestVendor(TRANSACTION_ID, 'v-active', baseTransaction); + + const onyxData = getOnyxDataArg(); + const failureViolations = onyxData?.failureData.find((entry) => entry.key === `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${TRANSACTION_ID}`); + expect(failureViolations?.value).toEqual(original); + }); + + it('does not write a violations update when there was no inactive-vendor violation to clear', async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${TRANSACTION_ID}`, [otherViolation]); + await waitForBatchedUpdates(); + + updateMoneyRequestVendor(TRANSACTION_ID, 'v-active', baseTransaction); + + const onyxData = getOnyxDataArg(); + const violationsUpdate = onyxData?.optimisticData.find((entry) => entry.key === `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${TRANSACTION_ID}`); + expect(violationsUpdate).toBeUndefined(); + }); + + it('falls back to the Onyx-cached transaction for vendor rollback when caller omits transaction', async () => { + const previousVendor = {externalID: 'v-old', isManuallySet: true}; + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`, { + ...baseTransaction, + comment: {vendor: previousVendor}, + }); + await waitForBatchedUpdates(); + + updateMoneyRequestVendor(TRANSACTION_ID, 'v-new'); + + const onyxData = getOnyxDataArg(); + const vendorRollback = onyxData?.failureData.find((entry) => entry.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`); + expect(vendorRollback?.value).toEqual({comment: {vendor: previousVendor}}); + }); + + it('omits vendor rollback from failureData when no prior transaction snapshot exists', async () => { + // No transaction arg + nothing in Onyx — the prior vendor is unknown, so we must not + // write `vendor: null` and silently clear whatever the server actually has. + updateMoneyRequestVendor(TRANSACTION_ID, 'v-new'); + + const onyxData = getOnyxDataArg(); + const vendorRollback = onyxData?.failureData.find((entry) => entry.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`); + expect(vendorRollback).toBeUndefined(); + }); +}); diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index b955934aab05..172dfaa56ac6 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -23,6 +23,8 @@ import { getHRApprovalMode, getManagerAccountID, getPolicyEmployeeAccountIDs, + getQBOVendorByID, + getQBOVendors, getRateDisplayValue, getSubmitToAccountID, getTagApproverRule, @@ -37,6 +39,7 @@ import { hasOnlyPersonalPolicies, hasOtherControlWorkspaces, hasPolicyWithXeroConnection, + hasVendorFeature, isAnyHRConnected, isAnyHRReadOnlyWorkflowMode, isMergeHRConnected, @@ -2967,4 +2970,91 @@ describe('PolicyUtils', () => { }); }); }); + + describe('Vendor matching helpers', () => { + const buildQBOPolicy = ( + exportDestination: string | undefined, + vendors: Array<{id: string; name: string; currency: string}> = [{id: 'v-1', name: 'Acme Co', currency: 'USD'}], + ): Policy => + ({ + ...createRandomPolicy(0), + connections: { + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: exportDestination ? {nonReimbursableExpensesExportDestination: exportDestination} : {}, + data: {vendors}, + }, + } as unknown as Connections, + }) as Policy; + + describe('hasVendorFeature', () => { + it('returns true when beta is enabled and QBO non-reimbursable export is Credit Card', () => { + expect(hasVendorFeature(buildQBOPolicy(CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD), true)).toBe(true); + }); + + it('returns true when beta is enabled and QBO non-reimbursable export is Debit Card', () => { + expect(hasVendorFeature(buildQBOPolicy(CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD), true)).toBe(true); + }); + + it('returns false when beta is disabled, even with Credit Card export configured', () => { + expect(hasVendorFeature(buildQBOPolicy(CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD), false)).toBe(false); + }); + + it('returns false when QBO non-reimbursable export is Vendor Bill', () => { + expect(hasVendorFeature(buildQBOPolicy(CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL), true)).toBe(false); + }); + + it('returns false when QBO export destination is not set', () => { + expect(hasVendorFeature(buildQBOPolicy(undefined), true)).toBe(false); + }); + + it('returns false when no QBO connection exists on the policy', () => { + const policy = {...createRandomPolicy(0), connections: {}} as Policy; + expect(hasVendorFeature(policy, true)).toBe(false); + }); + + it('returns false when policy is undefined', () => { + expect(hasVendorFeature(undefined, true)).toBe(false); + }); + }); + + describe('getQBOVendors', () => { + it('returns the vendor list from the QBO connection', () => { + const vendors = [ + {id: 'v-1', name: 'Acme', currency: 'USD'}, + {id: 'v-2', name: 'Other Co', currency: 'USD'}, + ]; + const policy = buildQBOPolicy(CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD, vendors); + expect(getQBOVendors(policy)).toEqual(vendors); + }); + + it('returns an empty array when no QBO connection exists', () => { + const policy = {...createRandomPolicy(0), connections: {}} as Policy; + expect(getQBOVendors(policy)).toEqual([]); + }); + + it('returns an empty array when policy is undefined', () => { + expect(getQBOVendors(undefined)).toEqual([]); + }); + }); + + describe('getQBOVendorByID', () => { + it('returns the matching vendor when the ID exists in the list', () => { + const policy = buildQBOPolicy(CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD, [ + {id: 'v-1', name: 'Acme', currency: 'USD'}, + {id: 'v-2', name: 'Other Co', currency: 'USD'}, + ]); + expect(getQBOVendorByID(policy, 'v-2')).toEqual({id: 'v-2', name: 'Other Co', currency: 'USD'}); + }); + + it('returns undefined when the ID is not in the list (the inactive-vendor case)', () => { + const policy = buildQBOPolicy(CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD); + expect(getQBOVendorByID(policy, 'v-missing')).toBeUndefined(); + }); + + it('returns undefined when no QBO connection exists', () => { + const policy = {...createRandomPolicy(0), connections: {}} as Policy; + expect(getQBOVendorByID(policy, 'v-1')).toBeUndefined(); + }); + }); + }); }); diff --git a/tests/unit/ViolationUtilsTest.ts b/tests/unit/ViolationUtilsTest.ts index 3e7004653e96..8132385b19d7 100644 --- a/tests/unit/ViolationUtilsTest.ts +++ b/tests/unit/ViolationUtilsTest.ts @@ -1,6 +1,7 @@ import {beforeEach} from '@jest/globals'; import Onyx from 'react-native-onyx'; import {convertAmountToDisplayString} from '@libs/CurrencyUtils'; +import Permissions from '@libs/Permissions'; import {getTransactionViolations, hasWarningTypeViolation, isViolationDismissed} from '@libs/TransactionUtils'; import ViolationsUtils, {filterReceiptViolations, getIsViolationFixed} from '@libs/Violations/ViolationsUtils'; import CONST from '@src/CONST'; @@ -100,6 +101,12 @@ const tagOutOfPolicyViolation = { showInReview: true, }; +const inactiveVendorViolation = { + name: CONST.VIOLATIONS.INACTIVE_VENDOR, + type: CONST.VIOLATION_TYPES.VIOLATION, + showInReview: true, +}; + const smartScanFailedViolation = { name: CONST.VIOLATIONS.SMARTSCAN_FAILED, type: CONST.VIOLATION_TYPES.WARNING, @@ -1299,6 +1306,98 @@ describe('getViolationsOnyxData', () => { expect(result.value).toEqual([]); }); }); + + describe('inactiveVendor violation', () => { + let isBetaEnabledSpy: jest.SpyInstance; + + const policyWithQBOVendorFeature = (vendors: Array<{id: string; name: string; currency: string}> = [{id: 'v-active', name: 'Acme Co', currency: 'USD'}]) => + ({ + requiresTag: false, + requiresCategory: false, + connections: { + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}, + data: {vendors}, + }, + }, + }) as unknown as Policy; + + beforeEach(async () => { + // Default to beta-enabled so the four branches of the violation logic are reachable. + // The "beta disabled" test overrides this below. + isBetaEnabledSpy = jest.spyOn(Permissions, 'isBetaEnabled').mockImplementation((beta) => beta === CONST.BETAS.VENDOR_MATCHING); + // Seed ONYXKEYS.BETAS so the module-level `allBetas` in ViolationsUtils transitions + // from undefined (startup) to defined. The production code skips the reconcile block + // entirely when `allBetas === undefined` to avoid stripping valid server-set violations + // during the startup window; without seeding here the tests would never reach the + // branches they're trying to exercise. The actual contents don't matter — the spy on + // `Permissions.isBetaEnabled` decides the beta result — we just need `allBetas` defined. + await Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.VENDOR_MATCHING]); + await waitForBatchedUpdates(); + }); + + afterEach(() => { + isBetaEnabledSpy.mockRestore(); + }); + + it('adds the violation when the transaction vendor is not in the policy vendor list', () => { + policy = policyWithQBOVendorFeature(); + transaction.comment = {...transaction.comment, vendor: {externalID: 'v-missing', isManuallySet: true}}; + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policy, policyTags, policyCategories, false, false); + expect(result.value).toEqual(expect.arrayContaining([inactiveVendorViolation])); + }); + + it('does not duplicate the violation when one is already present and the vendor is still missing', () => { + policy = policyWithQBOVendorFeature(); + transaction.comment = {...transaction.comment, vendor: {externalID: 'v-missing', isManuallySet: true}}; + const result = ViolationsUtils.getViolationsOnyxData(transaction, [inactiveVendorViolation], policy, policyTags, policyCategories, false, false); + expect((result.value as TransactionViolation[]).filter((v) => v.name === CONST.VIOLATIONS.INACTIVE_VENDOR)).toHaveLength(1); + }); + + it('removes an existing violation when the vendor is restored in the policy list', () => { + policy = policyWithQBOVendorFeature(); + transaction.comment = {...transaction.comment, vendor: {externalID: 'v-active', isManuallySet: true}}; + const result = ViolationsUtils.getViolationsOnyxData(transaction, [inactiveVendorViolation], policy, policyTags, policyCategories, false, false); + expect(result.value).not.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 + const result = ViolationsUtils.getViolationsOnyxData(transaction, [inactiveVendorViolation], policy, policyTags, policyCategories, false, false); + expect(result.value).not.toContainEqual(inactiveVendorViolation); + }); + + it('removes an existing violation when the vendor feature is disabled (QBO export type changed)', () => { + policy = { + requiresTag: false, + requiresCategory: false, + 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'}]}, + }, + }, + } as unknown as Policy; + transaction.comment = {...transaction.comment, vendor: {externalID: 'v-active', isManuallySet: true}}; + const result = ViolationsUtils.getViolationsOnyxData(transaction, [inactiveVendorViolation], policy, policyTags, policyCategories, false, false); + expect(result.value).not.toContainEqual(inactiveVendorViolation); + }); + + it('does not add the violation when the feature is inactive (no QBO connection)', () => { + transaction.comment = {...transaction.comment, vendor: {externalID: 'v-anything', isManuallySet: true}}; + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policy, policyTags, policyCategories, false, false); + expect(result.value).not.toContainEqual(inactiveVendorViolation); + }); + + it('does not add the violation when the vendorMatching beta is disabled, even with QBO configured', () => { + isBetaEnabledSpy.mockImplementation(() => false); + policy = policyWithQBOVendorFeature(); + transaction.comment = {...transaction.comment, vendor: {externalID: 'v-missing', isManuallySet: true}}; + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policy, policyTags, policyCategories, false, false); + expect(result.value).not.toContainEqual(inactiveVendorViolation); + }); + }); }); const getFakeTransaction = (transactionID: string, comment?: Transaction['comment']) => ({