From 09418769ba66d0eea98585f7c48a54b8e07057b3 Mon Sep 17 00:00:00 2001 From: Alex Beaman Date: Wed, 20 May 2026 12:10:49 -0600 Subject: [PATCH 01/23] Add vendor-matching types + constants (Track D1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for Vendor matching CC R1 App work. No UI changes; purely type system + constant additions that the upcoming chunks (actions, utils, pages) hang off. - src/types/onyx/Policy.ts: - `QBOConnectionConfig.nonReimbursableCreditCardDefaultVendor?: string` — workspace fallback vendor for QBO Credit/Debit card export. - `Policy.areVendorsEnabled?: boolean` — required by the MORE_FEATURES plumbing (pendingFields / errorFields type machinery). The actual enablement is derived at read time from the QBO config (see `PolicyUtils.hasVendorFeature` in a follow-up chunk), so this field is never persisted independently — it just satisfies the type contract. - src/types/onyx/Transaction.ts: - `Comment.vendor?: { externalID: string; isManuallySet: boolean }` — auto-match writes from PHP (`isManuallySet=false`) and user picks or merchant rules (`isManuallySet=true`). The flag is what stops a later auto-match from overwriting a deliberate selection. - src/CONST/index.ts: - `QUICKBOOKS_CONFIG.NON_REIMBURSABLE_CREDIT_CARD_DEFAULT_VENDOR` sibling to the existing `NON_REIMBURSABLE_BILL_DEFAULT_VENDOR`. - `POLICY.MORE_FEATURES.ARE_VENDORS_ENABLED` — used for the locked Vendors card on the workspace More features page. - `VIOLATIONS.INACTIVE_VENDOR: 'inactiveVendor'` — string matches the Auth-side constant from PR #21725. - src/hooks/useViolations.ts: - Add `vendor` to the validation field tuple + `inactiveVendor` to the violationNameToField map. Mirrors how `tagOutOfPolicy` -> 'tag'. - src/libs/DebugUtils.ts: - Add `vendor` entries to the two ObjectType maps for the transaction `comment` field (pendingFields + comment types). Required by the debug type machinery now that `Comment.vendor` exists. typecheck-tsgo clean against these changes (the 2 pre-existing MapView / GPSPoint errors on main are untouched). Prettier clean. Issue: https://github.com/Expensify/Expensify/issues/638653 --- src/CONST/index.ts | 3 +++ src/hooks/useViolations.ts | 3 ++- src/libs/DebugUtils.ts | 2 ++ src/types/onyx/Policy.ts | 11 +++++++++++ src/types/onyx/Transaction.ts | 12 ++++++++++++ 5 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 83f7ae288a97..8bd305c00f7c 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2718,6 +2718,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', @@ -3783,6 +3784,7 @@ const CONST = { ARE_EXPENSIFY_CARDS_ENABLED: 'areExpensifyCardsEnabled', ARE_INVOICES_ENABLED: 'areInvoicesEnabled', ARE_TAXES_ENABLED: 'tax', + ARE_VENDORS_ENABLED: 'areVendorsEnabled', ARE_RULES_ENABLED: 'areRulesEnabled', ARE_PER_DIEM_RATES_ENABLED: 'arePerDiemRatesEnabled', IS_ATTENDEE_TRACKING_ENABLED: 'isAttendeeTrackingEnabled', @@ -6767,6 +6769,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/libs/DebugUtils.ts b/src/libs/DebugUtils.ts index 55db6e20867a..5989eb2134d2 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/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index 288d77ad5444..5ff0fa71c179 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -503,6 +503,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; @@ -2237,6 +2240,14 @@ type Policy = OnyxCommon.OnyxValueWithOfflineFeedback< /** Whether the Tags feature is enabled */ areTagsEnabled?: boolean; + /** + * Whether the Vendors feature is shown for this workspace. Derived at read time from the QBO + * connection config (Credit/Debit card non-reimbursable export) — see PolicyUtils.hasVendorFeature. + * Persisted here as the standard MORE_FEATURES key so optimistic-update plumbing and the + * pendingFields/errorFields type machinery treat it like any other feature toggle. + */ + areVendorsEnabled?: boolean; + /** Whether the Accounting feature is enabled */ areAccountingEnabled?: boolean; diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts index d33dd6e280de..b5c62cb90455 100644 --- a/src/types/onyx/Transaction.ts +++ b/src/types/onyx/Transaction.ts @@ -122,6 +122,18 @@ type Comment = { /** Defines the type of liability for the transaction */ liabilityType?: ValueOf; + /** + * Accounting-system vendor matched to this expense (Vendor matching CC R1, QBO). + * 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?: { + externalID: string; + isManuallySet: boolean; + }; + /** Timestamp when auto-categorization was initiated (format: "YYYY-MM-DD HH:MM:SS") */ pendingAutoCategorizationTime?: string; From 45b0d7707edf6f6e3bf707902709417d47123605 Mon Sep 17 00:00:00 2001 From: Alex Beaman Date: Wed, 20 May 2026 12:41:55 -0600 Subject: [PATCH 02/23] Add QBO default-vendor action for non-reimbursable Credit/Debit card export (Track D2a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App-side wrapper for the new Web-Expensify command `UpdateQuickbooksOnlineNonReimbursableCreditCardDefaultVendor`. Direct mirror of the existing `…BillDefaultVendor` action — same Onyx config update pattern, just keyed on the new `NON_REIMBURSABLE_CREDIT_CARD_DEFAULT_VENDOR` constant introduced in the previous chunk. - src/libs/API/types.ts: WRITE_COMMANDS entry + Parameters type registration for the new write command. - src/libs/actions/connections/QuickbooksOnline.ts: - `updateQuickbooksOnlineNonReimbursableCreditCardDefaultVendor` function (mirrors the bill variant). - Exported in the default export sibling list. The standalone `updateMoneyRequestVendor` action lives in the next chunk (D2b) — it has different optimistic-update semantics since the vendor is a comment NVP rather than a workspace-config field. typecheck clean against changed files (only pre-existing MapView / GPSPoint errors remain on main). Prettier clean. Issue: https://github.com/Expensify/Expensify/issues/638653 --- src/libs/API/types.ts | 2 ++ src/libs/actions/connections/QuickbooksOnline.ts | 16 ++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 07900aef2b45..17194b6b76e4 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -324,6 +324,7 @@ const WRITE_COMMANDS = { UPDATE_QUICKBOOKS_ONLINE_SYNC_CLASSES: 'UpdateQuickbooksOnlineSyncClasses', UPDATE_QUICKBOOKS_ONLINE_MAPPING: 'UpdateQuickbooksOnlineMapping', 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', @@ -956,6 +957,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.UPDATE_QUICKBOOKS_ONLINE_SYNC_CUSTOMERS]: Parameters.UpdateQuickbooksOnlineGenericTypeParams; [WRITE_COMMANDS.UPDATE_QUICKBOOKS_ONLINE_SYNC_CLASSES]: Parameters.UpdateQuickbooksOnlineGenericTypeParams; [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/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, From d81bc201e1585c50c7433096ce0907d657c2d142 Mon Sep 17 00:00:00 2001 From: Alex Beaman Date: Wed, 20 May 2026 12:46:24 -0600 Subject: [PATCH 03/23] Add updateMoneyRequestVendor action (Track D2b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standalone App-side action for user-driven vendor selection on a non-reimbursable expense. Writes the vendor as `{ externalID, isManuallySet: true }` to the transaction's `comment.vendor` NVP via the new Web-Expensify `UpdateMoneyRequestVendor` command from [#53009](https://github.com/Expensify/Web-Expensify/pull/53009). `isManuallySet` is hard-coded `true` because this action only fires from user-driven flows (the App vendor picker, etc.); the PHP fuzzy matcher writes auto-matches directly via the same Web-E command with `isManuallySet=false`. Auth's defense-in-depth then prevents auto-match from overwriting a manual selection. Passing `vendorID=''` clears the vendor (Auth erases the NVP key). Doesn't go through `getUpdateMoneyRequestParams` — vendor lives on the comment NVP, not a top-level Transaction field, and doesn't trigger the violation/category/tag cascade the helper handles. The optimistic Onyx update merges `transaction.comment.vendor` directly. failureData restores the previous vendor (or null if none was set). Adds: - `UpdateMoneyRequestVendorParams` type - `WRITE_COMMANDS.UPDATE_MONEY_REQUEST_VENDOR` entry - `updateMoneyRequestVendor` function + export typecheck clean (only pre-existing MapView/GPSPoint errors remain). Prettier clean. Issue: https://github.com/Expensify/Expensify/issues/638653 --- .../UpdateMoneyRequestVendorParams.ts | 8 +++ src/libs/API/parameters/index.ts | 1 + src/libs/API/types.ts | 2 + src/libs/actions/IOU/UpdateMoneyRequest.ts | 53 +++++++++++++++++++ 4 files changed, 64 insertions(+) create mode 100644 src/libs/API/parameters/UpdateMoneyRequestVendorParams.ts 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 d5aac94c32d0..27ec9f4365d9 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -202,6 +202,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 17194b6b76e4..5a5f0c3c5f6f 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -323,6 +323,7 @@ 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', @@ -956,6 +957,7 @@ 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; diff --git a/src/libs/actions/IOU/UpdateMoneyRequest.ts b/src/libs/actions/IOU/UpdateMoneyRequest.ts index 4585aa2b945a..f426eab592b8 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 { @@ -412,6 +413,57 @@ 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) { + const previousVendor = transaction?.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> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}` as const, + value: { + comment: { + vendor: previousVendor ?? null, + }, + }, + }, + ]; + + API.write( + WRITE_COMMANDS.UPDATE_MONEY_REQUEST_VENDOR, + { + transactionID, + reportActionID: optimisticReportActionID, + vendorID, + isManuallySet: true, + }, + {optimisticData, failureData}, + ); +} + type UpdateMoneyRequestTagParams = { transactionID: string; transactionThreadReport: OnyxEntry; @@ -1884,6 +1936,7 @@ export { updateMoneyRequestReimbursable, updateMoneyRequestMerchant, updateMoneyRequestAttendees, + updateMoneyRequestVendor, updateMoneyRequestTag, updateMoneyRequestTaxAmount, updateMoneyRequestTaxRate, From b9c019672e5e6e0ccd627c31f04a4b656260e8b4 Mon Sep 17 00:00:00 2001 From: Alex Beaman Date: Wed, 20 May 2026 12:49:48 -0600 Subject: [PATCH 04/23] Add PolicyUtils vendor helpers (Track D3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small read-only helpers that the upcoming UI chunks (workspace Vendors tab, default vendor RHP, vendor selector, MoneyRequestView field) all need. Read-only on Policy / OnyxEntry; no Onyx writes. - `hasVendorFeature(policy)` — mirrors `QuickbooksOnline::hasVendorFeature` on the PHP side: true when QBO is connected AND the workspace's non-reimbursable export is one of credit_card / debit_card. Used to gate the Vendor field on the expense, the Vendors tab in workspace settings, the Vendors locked card on the More features page, and the Default Vendor row on the QBO export page. - `getQBOVendors(policy)` — returns the imported vendor list. Source of truth for the Vendors tab + vendor selector RHP. Enable/disable filtering is post-R1 polish. - `getQBOVendorByID(policy, vendorID)` — resolves a single vendor by external ID. Used to display the vendor name on the expense when only the ID is stored on the transaction NVP. Returns undefined when the ID isn't found (which is the inactive-vendor case the upcoming ViolationsUtils logic checks for). Also exports `Vendor` from `src/types/onyx/Policy.ts` (it was already defined but not exported, so the helpers can reference the return type). typecheck clean against changed files. Prettier clean. Issue: https://github.com/Expensify/Expensify/issues/638653 --- src/libs/PolicyUtils.ts | 43 ++++++++++++++++++++++++++++++++++++++++ src/types/onyx/Policy.ts | 1 + 2 files changed, 44 insertions(+) diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 3f0574ad50c1..352aac6a21c1 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -36,6 +36,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'; @@ -1990,6 +1991,45 @@ function getConnectedIntegration(policy: Policy | undefined, connectionNames: re return connectionNames.find((integration) => !!policy?.connections?.[integration]); } +/** + * Vendor matching CC R1 (QBO) feature gate. Returns true when QBO is connected and the workspace's + * non-reimbursable export type is an individual card transaction 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): boolean { + if (!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. Enable/disable filtering is post-R1 polish. + */ +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)); } @@ -2375,6 +2415,9 @@ export { getConnectedIntegration, getConnectedIntegrationNamesForPolicies, getConnectionExporters, + getQBOVendorByID, + getQBOVendors, + hasVendorFeature, getValidConnectedIntegration, getCountOfEnabledTagsOfList, getIneligibleInvitees, diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index 5ff0fa71c179..19f508709ce6 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -2453,4 +2453,5 @@ export type { HRConnectionConfigBase, MergeHRConnectionConfig, MergeHRConnectionData, + Vendor, }; From 570611e782ffdc5ed20c929fb5d4bbce05385e63 Mon Sep 17 00:00:00 2001 From: Alex Beaman Date: Wed, 20 May 2026 12:55:57 -0600 Subject: [PATCH 05/23] Add inactive-vendor violation logic to ViolationsUtils (Track D4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client-side computation of `inactiveVendor` violations on non-reimbursable card expenses (Vendor matching CC R1, QBO). Mirrors the `categoryOutOfPolicy` / `tagOutOfPolicy` pattern — entirely client-derived from the policy's imported QBO vendor list, no server roundtrip needed once the list is in Onyx. `ViolationsUtils.getViolationsOnyxData()` now folds in three vendor- related branches: 1. **Feature disabled** — if `hasVendorFeature(policy)` is false (admin switched the export type away from credit/debit card), strip any stale `INACTIVE_VENDOR` violation. The `vendor` object on the transaction is intentionally left alone — clearing it would lose the user's prior selection if they ever switch back. 2. **Vendor not in list** — if the transaction has a `vendor.externalID` that doesn't exist in `policy.connections.quickbooksOnline.data.vendors` (post-deletion in QBO, or QBO disconnected), push a new `INACTIVE_VENDOR` violation so the admin knows to re-pick. 3. **Vendor restored** — if the ID IS found in the list, remove any existing `INACTIVE_VENDOR` violation. Uses the `hasVendorFeature` and `getQBOVendorByID` helpers added in the D3 chunk. The two `pushTransactionViolationsOnyxData` call sites the design doc calls out (after vendor-list sync; after export-type change) live in Phase 2 (workspace-settings PR) where the natural callers already exist — keeping Phase 1 to pure foundations. typecheck clean against changed files (only pre-existing MapView / GPSPoint errors remain on main). Prettier clean. Issue: https://github.com/Expensify/Expensify/issues/638653 --- src/libs/Violations/ViolationsUtils.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index 9d279e329c1a..0a4232dab10d 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -16,7 +16,9 @@ import Parser from '@libs/Parser'; import { getDistanceRateCustomUnitRate, getPerDiemRateCustomUnitRate, + getQBOVendorByID, getSortedTagKeys, + hasVendorFeature, isAttendeeTrackingEnabled as isAttendeeTrackingEnabledForPolicy, isDefaultTagName, isTaxTrackingEnabled, @@ -430,6 +432,29 @@ const ViolationsUtils = { : getTagViolationsForMultiLevelTags(updatedTransaction, newTransactionViolations, policyTagList, hasDependentTags); } + // Inactive vendor violation (Vendor matching CC R1, QBO). 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. + const hasInactiveVendorViolation = newTransactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.INACTIVE_VENDOR); + const isVendorFeatureActive = hasVendorFeature(policy); + 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}); + } + } + const customUnitRateID = updatedTransaction?.comment?.customUnit?.customUnitRateID; if (customUnitRateID && customUnitRateID.length > 0 && !isSelfDM) { const isPerDiem = TransactionUtils.isPerDiemRequest(updatedTransaction); From 5111e7032d15b78c4edd67b705b717e4baece39d Mon Sep 17 00:00:00 2001 From: Alex Beaman Date: Thu, 21 May 2026 15:46:33 -0600 Subject: [PATCH 06/23] Fix ESLint failures on Transaction.ts and ViolationsUtils.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two trivial lint fixes blocking the PR's ESLint check: - src/types/onyx/Transaction.ts: add JSDoc comments on the two inner properties of the new Comment.vendor object (externalID, isManuallySet). jsdoc/require-jsdoc was firing because the outer property had docs but the inner properties didn't. - src/libs/Violations/ViolationsUtils.ts: remove an unused eslint-disable-next-line for @typescript-eslint/no-unnecessary-type-assertion. The rule isn't firing on 'violation.name as never' anymore — adding INACTIVE_VENDOR to the switch made the type narrowing exhaustive so the cast is no longer flagged, and the suppression became dead. --- src/libs/Violations/ViolationsUtils.ts | 1 - src/types/onyx/Transaction.ts | 3 +++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index 0a4232dab10d..aa52a7b81787 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -854,7 +854,6 @@ const ViolationsUtils = { // 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; } }, diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts index b5c62cb90455..aeef0d175ebc 100644 --- a/src/types/onyx/Transaction.ts +++ b/src/types/onyx/Transaction.ts @@ -130,7 +130,10 @@ type Comment = { * 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; }; From 4fc3e44bfdd40f8a9d24909b9fbd0708772009ad Mon Sep 17 00:00:00 2001 From: Alex Beaman Date: Thu, 21 May 2026 16:03:29 -0600 Subject: [PATCH 07/23] Add inactiveVendor translation to getViolationTranslation + all locales Codex caught that the switch in getViolationTranslation had no case for inactiveVendor, so once Phase 2 wires up the violation push, every UI that renders violation messages would show the raw 'inactiveVendor' constant name (via the default-branch 'violation.name as never' cast). Adds: - ViolationsUtils.ts: case 'inactiveVendor' between 'futureDate' and 'invoiceMarkup', mirroring the categoryOutOfPolicy / tagOutOfPolicy shape exactly. - src/languages/en.ts (source of truth) + all 9 other locales: inactiveVendor key with pattern-matched translation, derived from the existing categoryOutOfPolicy / tagOutOfPolicy entries per language. Translation strings match the 'X no longer valid' pattern already established for the sibling out-of-policy violations. --- src/languages/de.ts | 1 + src/languages/en.ts | 1 + src/languages/es.ts | 1 + src/languages/fr.ts | 1 + src/languages/it.ts | 1 + src/languages/ja.ts | 1 + src/languages/nl.ts | 1 + src/languages/pl.ts | 1 + src/languages/pt-BR.ts | 1 + src/languages/zh-hans.ts | 1 + src/libs/Violations/ViolationsUtils.ts | 2 ++ 11 files changed, 12 insertions(+) diff --git a/src/languages/de.ts b/src/languages/de.ts index 9d2181f9c371..22ae6cfde537 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -8551,6 +8551,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 047a706bcbd3..3efeec7e8507 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -8570,6 +8570,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 27db271a77de..ff1d6b7afade 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -8731,6 +8731,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 76c7ab077d8e..e7acae3483bf 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -8575,6 +8575,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 f4e72a6a1cdf..69c536b220ce 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -8544,6 +8544,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 b2e0e4c57232..200c8147079f 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -8435,6 +8435,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 8821e9ab9934..6faf6dbd392d 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -8513,6 +8513,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 cefba7c69737..ecb1e20343aa 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -8500,6 +8500,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 55b2c640a639..ef47429373f3 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -8506,6 +8506,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 6a2a77bab1c0..7c98925addda 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -8291,6 +8291,7 @@ ${reportName} duplicatedTransaction: '可能重复', fieldRequired: '报表字段为必填项', futureDate: '不允许使用未来日期', + inactiveVendor: '供应商不再有效', invoiceMarkup: (invoiceMarkup: number) => `加价 ${invoiceMarkup}%`, maxAge: (maxAge: number) => `日期早于 ${maxAge} 天`, missingCategory: '缺少类别', diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index aa52a7b81787..8a10815f46f8 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -765,6 +765,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': From b7fbbc469a68b1c6a9180cb3d28bd22acf99822d Mon Sep 17 00:00:00 2001 From: Alex Beaman Date: Thu, 21 May 2026 16:15:56 -0600 Subject: [PATCH 08/23] Remove now-redundant 'as never' cast in violation switch default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding 'inactiveVendor' to the switch made it exhaustive again, so TypeScript narrows violation.name to 'never' in the default branch on its own — the explicit cast is no longer doing any work and is now genuinely flagged by @typescript-eslint/no-unnecessary-type-assertion. --- src/libs/Violations/ViolationsUtils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index 8a10815f46f8..f8d341b6c6ae 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -854,9 +854,9 @@ const ViolationsUtils = { return translate('violations.noRoute'); 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 + // If typescript is showing an error below it means the switch statement is out of // sync with the `ViolationNames` type, and one or the other needs to be updated. - return violation.name as never; + return violation.name; } }, From 7a0bf97b32535e9be83b4c40730881fd854b423e Mon Sep 17 00:00:00 2001 From: Alex Beaman Date: Fri, 22 May 2026 07:13:14 -0600 Subject: [PATCH 09/23] Clear inactive-vendor violation when user clears the vendor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Codex P1 r3284834993: the inactive-vendor block had three explicit state branches but only handled two — when the vendor feature is still active and the user clears their vendor selection (transactionVendorID becomes empty), the existing INACTIVE_VENDOR violation was never removed, leaving a stale error on a transaction with no vendor set. Adds the missing else-if branch: when no vendor is selected but a stale violation is present, drop it. --- src/libs/Violations/ViolationsUtils.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index f8d341b6c6ae..2c7091b97804 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -453,6 +453,9 @@ const ViolationsUtils = { } 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; From 9283f9833a6b0fd8e561a3288e913aadcc7ee5fb Mon Sep 17 00:00:00 2001 From: Alex Beaman Date: Mon, 25 May 2026 10:41:37 -0600 Subject: [PATCH 10/23] Defer areVendorsEnabled / ARE_VENDORS_ENABLED to Phase 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both were added in Phase 1 as paired type-contract additions, but nothing in this PR actually consumes either: - hasVendorFeature(policy) reads exclusively from the QBO connection config (nonReimbursableExpensesExportDestination), never from policy.areVendorsEnabled — so the field is genuinely dead. - No code path in Phase 1 passes ARE_VENDORS_ENABLED to isPolicyFeatureEnabled or indexes pendingFields with it. The "MORE_FEATURES plumbing requires it" justification was circular: the field was needed because the constant was added, and the constant was added because of MORE_FEATURES type machinery — but nothing else was wired up. Phase 2 will introduce both alongside the locked Vendors More Features card that actually consumes them, plus an early-return branch in isPolicyFeatureEnabled mirroring IS_ATTENDEE_TRACKING_ENABLED: if (featureName === CONST.POLICY.MORE_FEATURES.ARE_VENDORS_ENABLED) { return hasVendorFeature(policy); } --- src/CONST/index.ts | 1 - src/types/onyx/Policy.ts | 8 -------- 2 files changed, 9 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 38a24857953a..90d8fdff04b5 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -3825,7 +3825,6 @@ const CONST = { ARE_EXPENSIFY_CARDS_ENABLED: 'areExpensifyCardsEnabled', ARE_INVOICES_ENABLED: 'areInvoicesEnabled', ARE_TAXES_ENABLED: 'tax', - ARE_VENDORS_ENABLED: 'areVendorsEnabled', ARE_RULES_ENABLED: 'areRulesEnabled', ARE_PER_DIEM_RATES_ENABLED: 'arePerDiemRatesEnabled', IS_ATTENDEE_TRACKING_ENABLED: 'isAttendeeTrackingEnabled', diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index b22b25865b77..d68baa5f69b5 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -2255,14 +2255,6 @@ type Policy = OnyxCommon.OnyxValueWithOfflineFeedback< /** Whether the Tags feature is enabled */ areTagsEnabled?: boolean; - /** - * Whether the Vendors feature is shown for this workspace. Derived at read time from the QBO - * connection config (Credit/Debit card non-reimbursable export) — see PolicyUtils.hasVendorFeature. - * Persisted here as the standard MORE_FEATURES key so optimistic-update plumbing and the - * pendingFields/errorFields type machinery treat it like any other feature toggle. - */ - areVendorsEnabled?: boolean; - /** Whether the Accounting feature is enabled */ areAccountingEnabled?: boolean; From 3a8a17f0c6b58c668eb6ae06d8c59ed8ee8effa8 Mon Sep 17 00:00:00 2001 From: Alex Beaman Date: Mon, 25 May 2026 10:56:01 -0600 Subject: [PATCH 11/23] Add unit tests for vendor matching foundations Covers the new logic added in Phase 1 so regressions get caught before Phase 2 wires up the UI call sites. tests/unit/ViolationUtilsTest.ts - new "inactiveVendor violation" describe inside getViolationsOnyxData, six cases covering every branch of the new logic: - adds violation when transaction vendor is not in the policy vendor list - does not duplicate when one is already present - removes existing violation when vendor is restored in the policy list - removes existing violation when user clears the vendor (Codex P1 case) - removes existing violation when feature is disabled (export type changed) - does not add violation when no QBO connection exists tests/unit/PolicyUtilsTest.ts - new "Vendor matching helpers" describe covering the three new pure-function helpers (12 cases): - hasVendorFeature: true for CREDIT_CARD/DEBIT_CARD export, false for VENDOR_BILL, unset, no-connection, undefined-policy - getQBOVendors: returns vendor list, [] when no connection, [] when policy is undefined - getQBOVendorByID: returns matching vendor, undefined when ID missing (the inactive-vendor case), undefined with no connection All 18 new tests pass; the 362-test ViolationUtils + PolicyUtils suites remain green. --- tests/unit/PolicyUtilsTest.ts | 86 ++++++++++++++++++++++++++++++++ tests/unit/ViolationUtilsTest.ts | 70 ++++++++++++++++++++++++++ 2 files changed, 156 insertions(+) diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index b955934aab05..4719381fa82f 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,87 @@ 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 QBO non-reimbursable export is Credit Card', () => { + expect(hasVendorFeature(buildQBOPolicy(CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD))).toBe(true); + }); + + it('returns true when QBO non-reimbursable export is Debit Card', () => { + expect(hasVendorFeature(buildQBOPolicy(CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD))).toBe(true); + }); + + it('returns false when QBO non-reimbursable export is Vendor Bill', () => { + expect(hasVendorFeature(buildQBOPolicy(CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL))).toBe(false); + }); + + it('returns false when QBO export destination is not set', () => { + expect(hasVendorFeature(buildQBOPolicy(undefined))).toBe(false); + }); + + it('returns false when no QBO connection exists on the policy', () => { + const policy = {...createRandomPolicy(0), connections: {}} as Policy; + expect(hasVendorFeature(policy)).toBe(false); + }); + + it('returns false when policy is undefined', () => { + expect(hasVendorFeature(undefined)).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: 'Initech', 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: 'Initech', currency: 'USD'}, + ]); + expect(getQBOVendorByID(policy, 'v-2')).toEqual({id: 'v-2', name: 'Initech', 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..7053015a35f4 100644 --- a/tests/unit/ViolationUtilsTest.ts +++ b/tests/unit/ViolationUtilsTest.ts @@ -100,6 +100,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 +1305,70 @@ describe('getViolationsOnyxData', () => { expect(result.value).toEqual([]); }); }); + + describe('inactiveVendor violation', () => { + 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; + + 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); + }); + }); }); const getFakeTransaction = (transactionID: string, comment?: Transaction['comment']) => ({ From f621634159b10aa8d60dfd3d134847f02fa0fbfd Mon Sep 17 00:00:00 2001 From: Alex Beaman Date: Mon, 25 May 2026 11:11:04 -0600 Subject: [PATCH 12/23] Fix spellcheck: replace 'Initech' vendor name with generic 'Other Co' --- tests/unit/PolicyUtilsTest.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index 4719381fa82f..110e8e034674 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -3017,7 +3017,7 @@ describe('PolicyUtils', () => { it('returns the vendor list from the QBO connection', () => { const vendors = [ {id: 'v-1', name: 'Acme', currency: 'USD'}, - {id: 'v-2', name: 'Initech', 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); @@ -3037,9 +3037,9 @@ describe('PolicyUtils', () => { 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: 'Initech', currency: 'USD'}, + {id: 'v-2', name: 'Other Co', currency: 'USD'}, ]); - expect(getQBOVendorByID(policy, 'v-2')).toEqual({id: 'v-2', name: 'Initech', 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)', () => { From 6275b2978347ffc0ac82961876739c3a8c4a3141 Mon Sep 17 00:00:00 2001 From: Alex Beaman Date: Mon, 25 May 2026 11:31:47 -0600 Subject: [PATCH 13/23] Drop project/release tags from vendor-matching code comments Code comments should describe what the code does, not which project or release introduced it. References like "(Vendor matching CC R1, QBO)" and "post-R1 polish" belong in the PR description and rot as the codebase evolves. Removes four such tags across PolicyUtils.ts (hasVendorFeature, getQBOVendors), ViolationsUtils.ts (inactive-vendor block comment), and Transaction.ts (Comment.vendor JSDoc). No logic changes; 362 ViolationUtils+PolicyUtils tests still pass. --- src/libs/PolicyUtils.ts | 10 +++++----- src/libs/Violations/ViolationsUtils.ts | 10 +++++----- src/types/onyx/Transaction.ts | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index eef9ed0801d9..e0326c7480f0 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -2001,10 +2001,10 @@ function getConnectedIntegration(policy: Policy | undefined, connectionNames: re } /** - * Vendor matching CC R1 (QBO) feature gate. Returns true when QBO is connected and the workspace's - * non-reimbursable export type is an individual card transaction 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. + * QBO vendor feature gate. Returns true when QBO is connected and the workspace's non-reimbursable + * export type is an individual card transaction 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): boolean { if (!policy) { @@ -2021,7 +2021,7 @@ function hasVendorFeature(policy: OnyxEntry): boolean { /** * 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. Enable/disable filtering is post-R1 polish. + * vendor selector RHP. */ function getQBOVendors(policy: OnyxEntry): Vendor[] { return policy?.connections?.[CONST.POLICY.CONNECTIONS.NAME.QBO]?.data?.vendors ?? []; diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index 2c7091b97804..2f82eddfd352 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -432,11 +432,11 @@ const ViolationsUtils = { : getTagViolationsForMultiLevelTags(updatedTransaction, newTransactionViolations, policyTagList, hasDependentTags); } - // Inactive vendor violation (Vendor matching CC R1, QBO). 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. + // 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. const hasInactiveVendorViolation = newTransactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.INACTIVE_VENDOR); const isVendorFeatureActive = hasVendorFeature(policy); const transactionVendorID = updatedTransaction.comment?.vendor?.externalID; diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts index 0005c2b0614e..6d4fb3888440 100644 --- a/src/types/onyx/Transaction.ts +++ b/src/types/onyx/Transaction.ts @@ -123,7 +123,7 @@ type Comment = { liabilityType?: ValueOf; /** - * Accounting-system vendor matched to this expense (Vendor matching CC R1, QBO). + * 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 From ebc4ea10e00872b07f8f057403d76cf6747adb08 Mon Sep 17 00:00:00 2001 From: Alex Beaman Date: Mon, 25 May 2026 12:10:47 -0600 Subject: [PATCH 14/23] Add VENDOR_MATCHING_CC beta gate so backend can ship independently The inactive-vendor violation runs purely client-side off Onyx data, so once Web-Expensify #53009 ships the PHP fuzzy matcher writes will arrive at the App via Onyx sync and trigger the violation immediately - with no App-side change needed. Without an App-side gate, that couples merge ordering: Web-E #53009 cannot ship until Phase 2 introduces the gate. This change decouples the two PR streams by adding the gate now. - src/CONST/index.ts: add BETAS.VENDOR_MATCHING_CC = 'vendorMatchingCC' - src/libs/PolicyUtils.ts: hasVendorFeature now takes isVendorMatchingCCBetaEnabled as a required boolean param, returning false when the beta is off regardless of QBO config. Mirrors the existing canAccessSubmitWorkspaceFeatures(policy, isSubmit2026BetaEnabled) pattern. - src/libs/Violations/ViolationsUtils.ts: add a module-level Onyx.connectWithoutView subscription to BETAS (matching TransactionInlineEdit.ts), compute the beta state inside getViolationsOnyxData and pass it to hasVendorFeature. No signature change to getViolationsOnyxData (already at the 10-param eslint ceiling). - tests/unit/PolicyUtilsTest.ts: update the six existing hasVendorFeature cases to pass the beta param explicitly; add a new case proving beta-off forces false even with Credit Card export configured. - tests/unit/ViolationUtilsTest.ts: spy on Permissions.isBetaEnabled to default-enable the beta for the existing six branch tests; add a new case proving the violation block stays inert when the beta is off. All 364 ViolationUtils + PolicyUtils tests pass. ESLint, Prettier, cspell clean. typecheck-tsgo unchanged (only the pre-existing main errors and the inherited ADD_WORK_EMAIL duplicate from origin/main). Web-Expensify #53009 can now merge at any time. --- src/CONST/index.ts | 1 + src/libs/PolicyUtils.ts | 14 ++++++++------ src/libs/Violations/ViolationsUtils.ts | 18 +++++++++++++++--- tests/unit/PolicyUtilsTest.ts | 20 ++++++++++++-------- tests/unit/ViolationUtilsTest.ts | 21 +++++++++++++++++++++ 5 files changed, 57 insertions(+), 17 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 90d8fdff04b5..b0809ea87c44 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_CC: 'vendorMatchingCC', }, BUTTON_STATES: { DEFAULT: 'default', diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index e0326c7480f0..8c2ac417f6fe 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -2001,13 +2001,15 @@ function getConnectedIntegration(policy: Policy | undefined, connectionNames: re } /** - * QBO vendor feature gate. Returns true when QBO is connected and the workspace's non-reimbursable - * export type is an individual card transaction 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. + * QBO vendor feature gate. Returns true when the workspace has the `vendorMatchingCC` 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. + * + * @param isVendorMatchingCCBetaEnabled - Prefer `isBetaEnabled(CONST.BETAS.VENDOR_MATCHING_CC)` from `usePermissions()`, not raw betas from Onyx. */ -function hasVendorFeature(policy: OnyxEntry): boolean { - if (!policy) { +function hasVendorFeature(policy: OnyxEntry, isVendorMatchingCCBetaEnabled: boolean): boolean { + if (!isVendorMatchingCCBetaEnabled || !policy) { return false; } const qboConnection = policy.connections?.[CONST.POLICY.CONNECTIONS.NAME.QBO]; diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index 2f82eddfd352..402f15fc3353 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -13,6 +13,7 @@ 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, @@ -28,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']; @@ -436,9 +445,12 @@ const ViolationsUtils = { // 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. + // needs to see what was previously set so they can re-pick. Gated behind the + // `vendorMatchingCC` beta so Web-Expensify can ship the auto-match write path + // independently — no production workspace sees the violation until the beta is enabled. + const isVendorMatchingCCBetaEnabled = Permissions.isBetaEnabled(CONST.BETAS.VENDOR_MATCHING_CC, allBetas); const hasInactiveVendorViolation = newTransactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.INACTIVE_VENDOR); - const isVendorFeatureActive = hasVendorFeature(policy); + const isVendorFeatureActive = hasVendorFeature(policy, isVendorMatchingCCBetaEnabled); const transactionVendorID = updatedTransaction.comment?.vendor?.externalID; if (!isVendorFeatureActive) { // Feature off (e.g. admin switched export type away from credit/debit card) — clear any diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index 110e8e034674..172dfaa56ac6 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -2987,29 +2987,33 @@ describe('PolicyUtils', () => { }) as Policy; describe('hasVendorFeature', () => { - it('returns true when QBO non-reimbursable export is Credit Card', () => { - expect(hasVendorFeature(buildQBOPolicy(CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD))).toBe(true); + 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 QBO non-reimbursable export is Debit Card', () => { - expect(hasVendorFeature(buildQBOPolicy(CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.DEBIT_CARD))).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))).toBe(false); + 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))).toBe(false); + 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)).toBe(false); + expect(hasVendorFeature(policy, true)).toBe(false); }); it('returns false when policy is undefined', () => { - expect(hasVendorFeature(undefined)).toBe(false); + expect(hasVendorFeature(undefined, true)).toBe(false); }); }); diff --git a/tests/unit/ViolationUtilsTest.ts b/tests/unit/ViolationUtilsTest.ts index 7053015a35f4..13464d6ad8aa 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'; @@ -1307,6 +1308,8 @@ describe('getViolationsOnyxData', () => { }); 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, @@ -1319,6 +1322,16 @@ describe('getViolationsOnyxData', () => { }, }) as unknown as Policy; + beforeEach(() => { + // 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_CC); + }); + + 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}}; @@ -1368,6 +1381,14 @@ describe('getViolationsOnyxData', () => { 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 vendorMatchingCC 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); + }); }); }); From da962e0f99904efb70661bf4c1bb8d1419b9b6d3 Mon Sep 17 00:00:00 2001 From: Alex Beaman Date: Mon, 25 May 2026 12:25:07 -0600 Subject: [PATCH 15/23] Drop misleading @param JSDoc on hasVendorFeature Copied verbatim from canAccessSubmitWorkspaceFeatures, but that function is called from React components via usePermissions(). hasVendorFeature is called from ViolationsUtils.getViolationsOnyxData, a non-component module that reads betas via Onyx.connectWithoutView - usePermissions() isn't available there. The advice told the actual caller to use a pattern it cannot use. --- src/libs/PolicyUtils.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 8c2ac417f6fe..f8c372abfa06 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -2005,8 +2005,6 @@ function getConnectedIntegration(policy: Policy | undefined, connectionNames: re * 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. - * - * @param isVendorMatchingCCBetaEnabled - Prefer `isBetaEnabled(CONST.BETAS.VENDOR_MATCHING_CC)` from `usePermissions()`, not raw betas from Onyx. */ function hasVendorFeature(policy: OnyxEntry, isVendorMatchingCCBetaEnabled: boolean): boolean { if (!isVendorMatchingCCBetaEnabled || !policy) { From adaf5faca14b717f3c6b865474b2dad42ca2c52d Mon Sep 17 00:00:00 2001 From: Alex Beaman Date: Mon, 25 May 2026 13:29:25 -0600 Subject: [PATCH 16/23] Clear inactive-vendor violation optimistically in updateMoneyRequestVendor Addresses Codex P1 r3299495523: the action only mutated comment.vendor but never touched TRANSACTION_VIOLATIONS, so a transaction with an existing inactiveVendor violation would keep the stale violation in Onyx until some unrelated recalculation triggered ViolationsUtils. getViolationsOnyxData. Users would see the red brick road / RBR indicator persist even after picking a valid vendor or clearing the selection. 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 (which resolves the violation), and clearing the vendor likewise resolves it (no vendor -> no inactive-vendor violation). Both flows can optimistically drop the violation. A targeted update (drop only inactiveVendor) is preferred over a full ViolationsUtils.getViolationsOnyxData call because: - Full recompute would require threading policy, policyTagList, policyCategories, iouReport, etc. into the action. Vendor isn't a top-level transaction field so it doesn't go through getUpdateMoneyRequestParams (which has all that plumbing). - Passing empty policyTagList / policyCategories would mis-fire other violation branches (e.g. categoryOutOfPolicy if the transaction has a category but the policy is partially loaded). failureData restores the original violation list so a server rejection cleanly rolls back to the pre-optimistic state. New tests in tests/actions/IOUTest/UpdateMoneyRequestVendorTest.ts cover four cases: vendor picked clears the violation, vendor cleared clears the violation, failureData restores the original list, and no-op when there was no inactiveVendor violation to clear. All 4 new tests pass. ESLint, Prettier, cspell clean. --- src/libs/actions/IOU/UpdateMoneyRequest.ts | 24 ++++- .../IOUTest/UpdateMoneyRequestVendorTest.ts | 101 ++++++++++++++++++ 2 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 tests/actions/IOUTest/UpdateMoneyRequestVendorTest.ts diff --git a/src/libs/actions/IOU/UpdateMoneyRequest.ts b/src/libs/actions/IOU/UpdateMoneyRequest.ts index 377ea7a56a58..b4fc4a18235a 100644 --- a/src/libs/actions/IOU/UpdateMoneyRequest.ts +++ b/src/libs/actions/IOU/UpdateMoneyRequest.ts @@ -430,7 +430,7 @@ function updateMoneyRequestVendor(transactionID: string, vendorID: string, trans const newVendorOptimisticValue = isClearing ? null : {externalID: vendorID, isManuallySet: true}; - const optimisticData: Array> = [ + const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}` as const, @@ -442,7 +442,7 @@ function updateMoneyRequestVendor(transactionID: string, vendorID: string, trans }, ]; - const failureData: Array> = [ + const failureData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}` as const, @@ -454,6 +454,26 @@ function updateMoneyRequestVendor(transactionID: string, vendorID: string, trans }, ]; + // 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, { diff --git a/tests/actions/IOUTest/UpdateMoneyRequestVendorTest.ts b/tests/actions/IOUTest/UpdateMoneyRequestVendorTest.ts new file mode 100644 index 000000000000..cc8817f9d230 --- /dev/null +++ b/tests/actions/IOUTest/UpdateMoneyRequestVendorTest.ts @@ -0,0 +1,101 @@ +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(); + }); +}); From 7fa99c3dfe89292cd63d01e0fe66dc7d46a68f1f Mon Sep 17 00:00:00 2001 From: Alex Beaman Date: Mon, 25 May 2026 14:50:17 -0600 Subject: [PATCH 17/23] Align beta name with PHP: VENDOR_MATCHING_CC -> VENDOR_MATCHING The PHP track (Web-Expensify #53009) registers the beta as 'vendorMatching' (BetaManager::BETA_VENDOR_MATCHING). The App's 'vendorMatchingCC' would never match the server-side list, so the App's hasVendorFeature(policy, isBetaEnabled(BETAS.VENDOR_MATCHING_CC)) gate would stay false forever even after a workspace was enrolled, breaking the end-to-end feature. Renames the beta on the App side to match PHP: - BETAS.VENDOR_MATCHING_CC -> BETAS.VENDOR_MATCHING - 'vendorMatchingCC' -> 'vendorMatching' - isVendorMatchingCCBetaEnabled -> isVendorMatchingBetaEnabled (in hasVendorFeature param + the ViolationsUtils local variable) - Test descriptions and JSDoc comment references updated to match. The _CC suffix on the App side was speculative disambiguation against a future "vendor matching family" that hasn't materialized; dropping it now is cheaper than coordinating a PHP rename. All 368 ViolationUtils + PolicyUtils + UpdateMoneyRequestVendor tests still pass. ESLint, Prettier, cspell clean. --- src/CONST/index.ts | 2 +- src/libs/PolicyUtils.ts | 6 +++--- src/libs/Violations/ViolationsUtils.ts | 6 +++--- tests/unit/ViolationUtilsTest.ts | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index b0809ea87c44..550dc9f4bc55 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -930,7 +930,7 @@ const CONST = { WORKSPACE_ROOMS_PAGE: 'workspaceRoomsPage', CERTINIA: 'financialForceNewDot', MERGE_HR: 'mergeHRConnections', - VENDOR_MATCHING_CC: 'vendorMatchingCC', + VENDOR_MATCHING: 'vendorMatching', }, BUTTON_STATES: { DEFAULT: 'default', diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index f8c372abfa06..f6a1be7bd7af 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -2001,13 +2001,13 @@ function getConnectedIntegration(policy: Policy | undefined, connectionNames: re } /** - * QBO vendor feature gate. Returns true when the workspace has the `vendorMatchingCC` beta enabled + * 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, isVendorMatchingCCBetaEnabled: boolean): boolean { - if (!isVendorMatchingCCBetaEnabled || !policy) { +function hasVendorFeature(policy: OnyxEntry, isVendorMatchingBetaEnabled: boolean): boolean { + if (!isVendorMatchingBetaEnabled || !policy) { return false; } const qboConnection = policy.connections?.[CONST.POLICY.CONNECTIONS.NAME.QBO]; diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index 402f15fc3353..3f19e028b16f 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -446,11 +446,11 @@ const ViolationsUtils = { // 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 - // `vendorMatchingCC` beta so Web-Expensify can ship the auto-match write path + // `vendorMatching` beta so Web-Expensify can ship the auto-match write path // independently — no production workspace sees the violation until the beta is enabled. - const isVendorMatchingCCBetaEnabled = Permissions.isBetaEnabled(CONST.BETAS.VENDOR_MATCHING_CC, allBetas); + const isVendorMatchingBetaEnabled = Permissions.isBetaEnabled(CONST.BETAS.VENDOR_MATCHING, allBetas); const hasInactiveVendorViolation = newTransactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.INACTIVE_VENDOR); - const isVendorFeatureActive = hasVendorFeature(policy, isVendorMatchingCCBetaEnabled); + 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 diff --git a/tests/unit/ViolationUtilsTest.ts b/tests/unit/ViolationUtilsTest.ts index 13464d6ad8aa..b2470a09aac9 100644 --- a/tests/unit/ViolationUtilsTest.ts +++ b/tests/unit/ViolationUtilsTest.ts @@ -1325,7 +1325,7 @@ describe('getViolationsOnyxData', () => { beforeEach(() => { // 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_CC); + isBetaEnabledSpy = jest.spyOn(Permissions, 'isBetaEnabled').mockImplementation((beta) => beta === CONST.BETAS.VENDOR_MATCHING); }); afterEach(() => { @@ -1382,7 +1382,7 @@ describe('getViolationsOnyxData', () => { expect(result.value).not.toContainEqual(inactiveVendorViolation); }); - it('does not add the violation when the vendorMatchingCC beta is disabled, even with QBO configured', () => { + 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}}; From 43d488ce094a8b31fa9f7b5561d6e064c5a618e8 Mon Sep 17 00:00:00 2001 From: Alex Beaman Date: Tue, 26 May 2026 13:23:14 -0600 Subject: [PATCH 18/23] Restore exhaustiveness guard on getViolationTranslation switch --- src/libs/Violations/ViolationsUtils.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index 3f19e028b16f..e1e387f47dde 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -867,11 +867,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 below it means the switch statement is out of - // sync with the `ViolationNames` type, and one or the other needs to be updated. - return violation.name; + // 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; + } } }, From 0289f820cc5d3b1d7876b6c5f08f67415545c6de Mon Sep 17 00:00:00 2001 From: Alex Beaman Date: Tue, 26 May 2026 13:23:15 -0600 Subject: [PATCH 19/23] updateMoneyRequestVendor: fall back to Onyx transaction lookup for failure rollback --- src/libs/actions/IOU/UpdateMoneyRequest.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/IOU/UpdateMoneyRequest.ts b/src/libs/actions/IOU/UpdateMoneyRequest.ts index b4fc4a18235a..dc2cc1702b9b 100644 --- a/src/libs/actions/IOU/UpdateMoneyRequest.ts +++ b/src/libs/actions/IOU/UpdateMoneyRequest.ts @@ -424,7 +424,10 @@ function updateMoneyRequestAttendees({ * Passing `vendorID=''` clears the vendor from the transaction. */ function updateMoneyRequestVendor(transactionID: string, vendorID: string, transaction?: OnyxEntry) { - const previousVendor = transaction?.comment?.vendor; + // 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; From 85a13b1f106a178ff8a2ceb2c12fb2fdbe9d2017 Mon Sep 17 00:00:00 2001 From: Alex Beaman Date: Tue, 26 May 2026 14:09:58 -0600 Subject: [PATCH 20/23] updateMoneyRequestVendor: skip vendor rollback when prior state is unknown --- src/libs/actions/IOU/UpdateMoneyRequest.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/libs/actions/IOU/UpdateMoneyRequest.ts b/src/libs/actions/IOU/UpdateMoneyRequest.ts index dc2cc1702b9b..7f5816247f40 100644 --- a/src/libs/actions/IOU/UpdateMoneyRequest.ts +++ b/src/libs/actions/IOU/UpdateMoneyRequest.ts @@ -445,8 +445,13 @@ function updateMoneyRequestVendor(transactionID: string, vendorID: string, trans }, ]; - const failureData: Array> = [ - { + 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: { @@ -454,8 +459,8 @@ function updateMoneyRequestVendor(transactionID: string, vendorID: string, trans 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 From c99ed35d32720c4a327554322ea8cf925664aa63 Mon Sep 17 00:00:00 2001 From: Alex Beaman Date: Tue, 26 May 2026 14:09:59 -0600 Subject: [PATCH 21/23] ViolationsUtils: gate inactiveVendor reconcile on betas loaded --- src/libs/Violations/ViolationsUtils.ts | 43 +++++++++++++++----------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index e1e387f47dde..db94678d008c 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -448,26 +448,33 @@ const ViolationsUtils = { // 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. - 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) { + // + // 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}); } - } 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; From 108d26528ca1a77c72abcfffa8e3026a885dce08 Mon Sep 17 00:00:00 2001 From: Alex Beaman Date: Tue, 26 May 2026 14:09:59 -0600 Subject: [PATCH 22/23] Test updateMoneyRequestVendor Onyx fallback + missing-snapshot guard --- .../IOUTest/UpdateMoneyRequestVendorTest.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/actions/IOUTest/UpdateMoneyRequestVendorTest.ts b/tests/actions/IOUTest/UpdateMoneyRequestVendorTest.ts index cc8817f9d230..273857b89b8c 100644 --- a/tests/actions/IOUTest/UpdateMoneyRequestVendorTest.ts +++ b/tests/actions/IOUTest/UpdateMoneyRequestVendorTest.ts @@ -98,4 +98,29 @@ describe('updateMoneyRequestVendor', () => { 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(); + }); }); From 35662234a7ab9b8e474962bfbd3cb100a772c1a3 Mon Sep 17 00:00:00 2001 From: Alex Beaman Date: Tue, 26 May 2026 14:25:31 -0600 Subject: [PATCH 23/23] Seed ONYXKEYS.BETAS in inactiveVendor tests so allBetas gate is exercised --- tests/unit/ViolationUtilsTest.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/unit/ViolationUtilsTest.ts b/tests/unit/ViolationUtilsTest.ts index b2470a09aac9..8132385b19d7 100644 --- a/tests/unit/ViolationUtilsTest.ts +++ b/tests/unit/ViolationUtilsTest.ts @@ -1322,10 +1322,18 @@ describe('getViolationsOnyxData', () => { }, }) as unknown as Policy; - beforeEach(() => { + 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(() => {