[NO QA] [Payment due @abzokhattab] Vendor matching CC - R1: App Phase 1 (foundations)#91235
Conversation
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: Expensify/Expensify#638653
…export (Track D2a)
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: Expensify/Expensify#638653
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](Expensify/Web-Expensify#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: Expensify/Expensify#638653
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: Expensify/Expensify#638653
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: Expensify/Expensify#638653
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.
Codecov Report❌ Looks like you've decreased code coverage for some files. Please write tests to increase, or at least maintain, the existing level of code coverage. See our documentation here for how to interpret this table.
|
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.
🦜 Polyglot Parrot! 🦜Squawk! Looks like you added some shiny new English strings. Allow me to parrot them back to you in other tongues: View the translation diffdiff --git a/src/languages/de.ts b/src/languages/de.ts
index 22ae6cfd..ffef46cc 100644
--- a/src/languages/de.ts
+++ b/src/languages/de.ts
@@ -8551,7 +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',
+ inactiveVendor: 'Lieferant 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/es.ts b/src/languages/es.ts
index ff1d6b7a..1919ecc4 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -8731,7 +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',
+ inactiveVendor: 'Proveedor ya no 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 e7acae34..fbf1be09 100644
--- a/src/languages/fr.ts
+++ b/src/languages/fr.ts
@@ -8575,7 +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',
+ inactiveVendor: 'Fournisseur non 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/ja.ts b/src/languages/ja.ts
index 200c8147..2c4fc800 100644
--- a/src/languages/ja.ts
+++ b/src/languages/ja.ts
@@ -8435,7 +8435,7 @@ ${reportName}
duplicatedTransaction: '重複の可能性',
fieldRequired: 'レポートの項目は必須です',
futureDate: '将来の日付は使用できません',
- inactiveVendor: 'ベンダーは無効です',
+ inactiveVendor: '取引先は無効になっています',
invoiceMarkup: (invoiceMarkup: number) => `${invoiceMarkup}%値上げ済み`,
maxAge: (maxAge: number) => `日付が${maxAge}日より前です`,
missingCategory: 'カテゴリが未選択です',
Note You can apply these changes to your branch by copying the patch to your clipboard, then running |
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.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b7fbbc469a
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
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.
|
@codex review |
|
Codex Review: Didn't find any major issues. Breezy! ℹ️ About Codex in GitHubCodex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback". |
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);
}
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.
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.
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.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ebc4ea10e0
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
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.
…endor 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.
|
@codex review |
|
Codex Review: Didn't find any major issues. You're on a roll. ℹ️ About Codex in GitHubCodex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback". |
|
@abzokhattab did you review as well? 🙏 |
abzokhattab
left a comment
There was a problem hiding this comment.
Just did the review again ... it all looks good to me .. I didn't find any regressions or issues 👍
Reviewer Checklist
Screenshots/VideosAndroid: HybridAppAndroid: mWeb ChromeiOS: HybridAppiOS: mWeb SafariMacOS: Chrome / SafariScreen.Recording.2026-05-27.at.03.37.58.mov |
|
🎯 @abzokhattab, thanks for reviewing and testing this PR! 🎉 A payment issue will be created for your review once this PR is deployed to production. If payment is not needed (e.g., regression PR review fix etc), react with 👎 to this comment to prevent the payment issue from being created. |
|
Validated the error shows correctly in the transaction preview by hadcoding onyx values in the offline case .... updated the web screenshots |
amyevans
left a comment
There was a problem hiding this comment.
Looks mostly good, some of the comments are overly verbose IMO, but not a blocker if you want to merge without updating. Could you also add No QA to the title please?
|
🚧 @Beamanator has triggered a test Expensify/App build. You can view the workflow run here. |
|
🧪🧪 Use the links below to test this adhoc build on Android, iOS, and Web. Happy testing! 🧪🧪
|
|
✋ This PR was not deployed to staging yet because QA is ongoing. It will be automatically deployed to staging after the next production release. |
|
🚀 Deployed to staging by https://github.com/Beamanator in version: 9.3.84-0 🚀
Bundle Size Analysis (Sentry): |
|
No help site changes are required for this PR. This is Phase 1 foundations only — it adds types, constants, actions, utility functions, client-side violation logic (gated behind a beta), and localized violation strings. There are no user-visible UI or behavior changes. The user-facing features that would warrant help site updates (Vendor field on expenses, Vendors tab in workspace settings, Default vendor row in QBO export settings, Concierge auto-match rendering) are all deferred to Phase 2 and Phase 3 App PRs, which should each be reviewed for help site impact when they land. @Beamanator, please review the linked help site PR and confirm it reflects the current behavior. Then mark the linked help site PR |
|
Hi @Beamanator Can I check it off the list if it's no QA? Thanks |
|
@izarutskaya done! |
Explanation of Change
Phase 1 of Vendor matching CC - R1: App — pure foundations, no UI changes yet. Adds the types, constants, actions, utilities, and violation-computation logic that the next two phases (workspace settings; expense vendor field + selector) hang off.
Five foundational chunks, one commit each:
Add vendor-matching types + constants (Track D1)) —Comment.vendor(transaction NVP shape),QBOConnectionConfig.nonReimbursableCreditCardDefaultVendor,VIOLATIONS.INACTIVE_VENDOR,QUICKBOOKS_CONFIG.NON_REIMBURSABLE_CREDIT_CARD_DEFAULT_VENDOR. Plus the surface bumps the new types caused ('vendor'added touseViolations.validationFields+violationNameToField['inactiveVendor'],DebugUtilsObjectType entries).Policy.areVendorsEnabledandMORE_FEATURES.ARE_VENDORS_ENABLEDwere originally part of this chunk but have been deferred to Phase 2, where the locked Vendors More Features card actually consumes them — see follow-up below.Track D2a) —updateQuickbooksOnlineNonReimbursableCreditCardDefaultVendor, mirror of the existing…BillDefaultVendorone-liner.updateMoneyRequestVendoraction (Track D2b) — standalone action (doesn't go throughgetUpdateMoneyRequestParams) that writes the user's manual vendor pick. Hard-codedisManuallySet=truebecause this is the user-driven path; the PHP fuzzy matcher writes auto-matches withisManuallySet=falsedirectly. PassingvendorID=''clears the vendor. Also optimistically drops any existinginactiveVendorviolation fromTRANSACTION_VIOLATIONS: the vendor selector RHP only offers vendors fromgetQBOVendors(policy)so a pick is always valid (resolves the violation), and clearing the vendor likewise resolves it. A targeted drop is preferred over a fullViolationsUtils.getViolationsOnyxDatarecompute because vendor changes don't route throughgetUpdateMoneyRequestParams(vendor lives oncomment.vendor, not a top-level Transaction field), and passing emptypolicyTagList/policyCategorieswould mis-fire other violation branches.failureDatarestores the original violation list on server rejection.Track D3) —hasVendorFeature,getQBOVendors,getQBOVendorByID. MirrorQuickbooksOnline::hasVendorFeatureon the PHP side so App and backend agree. Also exportsVendorfromsrc/types/onyx/Policy.tsso callers can reference the return type. ThehasVendorFeature(policy, isVendorMatchingBetaEnabled)signature gained the second boolean param in a later follow-up — see below.Track D4) — client-sideinactiveVendorviolation logic ingetViolationsOnyxData. Four branches: feature disabled → drop stale violation; vendor present but not in policy's vendor list → add violation; vendor present and matched → drop violation; vendor cleared while feature still active → drop violation. The twopushTransactionViolationsOnyxDatacallers the design doc calls out (after vendor-list sync; after export-type change) live in Phase 2 where the natural call sites already exist.Plus a few additional pieces of work on the same branch:
inactiveVendortranslation across all locales — case ingetViolationTranslationplus matching strings in all 10 locale files (en,de,es,fr,it,ja,nl,pl,pt-BR,zh-hans), following thecategoryOutOfPolicy/tagOutOfPolicy"X no longer valid" pattern. Without this, the rendered violation message would fall through to the raw'inactiveVendor'constant once Phase 2 wires up the push.BETAS.VENDOR_MATCHINGbeta gate — the inactive-vendor violation runs purely client-side off Onyx data, so once the Web-Expensify commands and event hooks PR ships, the PHP fuzzy matcher writes will arrive at the App via Onyx sync and trigger the violation immediately. Without an App-side gate, that couples merge ordering.hasVendorFeature(policy, isVendorMatchingBetaEnabled)now takes the beta state as a required boolean param (mirroring the existingcanAccessSubmitWorkspaceFeatures(policy, isSubmit2026BetaEnabled)precedent in the same file).ViolationsUtilsreads the beta from a module-levelOnyx.connectWithoutViewsubscription so the existinggetViolationsOnyxDatasignature is unchanged (already at the 10-param eslint ceiling). Net effect: the violation block is fully inert until the beta is flipped on, regardless of when the Web-Expensify PR ships.areVendorsEnabled/ARE_VENDORS_ENABLEDto Phase 2 — both were originally added as Phase 1 type-contract additions but had no consumers in this PR.hasVendorFeature(policy)derives from the QBO config and never readspolicy.areVendorsEnabled; no code path passedARE_VENDORS_ENABLEDtoisPolicyFeatureEnabled. Phase 2 will introduce both with the locked Vendors More Features card that actually consumes them, plus an early-return inisPolicyFeatureEnabledmirroringIS_ATTENDEE_TRACKING_ENABLED.Comment.vendorinner properties; removed two deadeslint-disable-next-linelines inViolationsUtils.ts(theinactiveVendorswitch case made the previously-suppressed@typescript-eslint/no-unnecessary-type-assertionrule a true positive on theas nevercast, so the cast and its suppression both went away).Cross-PR dependencies
UpdateMoneyRequestVendorcommand +inactiveVendorviolation type +ACTION_CONCIERGE_AUTO_MATCH_VENDOR. Auth ships the command and violation type but nothing dispatches them — Auth's PR confirms "this command is not connected to Web-E anywhere yet."VENDOR_MATCHINGbeta gate above — even if PHP starts writingcomment.vendorto live transactions, the App'sinactiveVendorblock stays inert until the beta is flipped on per workspace.Both must ship before the App's vendor flows actually function end-to-end. This PR's compiled output is harmless without them — the new actions just fail at the API boundary, and the violation logic is gated off by the
VENDOR_MATCHINGbeta.Fixed Issues
$ https://github.com/Expensify/Expensify/issues/638653
PROPOSAL: N/A (internal feature, no external proposal)
Tests
No user-visible UI yet — the Vendor field, Vendors tab, Default vendor row, and Concierge auto-match rendering all land in Phase 2 / Phase 3. Phase 1 is foundations only (types, constants, actions, utilities, client-side violation logic, localized violation string, beta gate).
New unit-test coverage in this PR:
tests/unit/ViolationUtilsTest.ts— newinactiveVendor violationdescribe block insidegetViolationsOnyxData, seven cases: adds when missing from vendor list, deduplicates when already present, removes on restore, removes on user-cleared while feature still active, removes when feature disabled via export-type change, no-op when no QBO connection, and no-op when thevendorMatchingbeta is disabled even with QBO + Credit/Debit-card export configured. Usesjest.spyOn(Permissions, 'isBetaEnabled')to default the beta on for the branch tests.tests/unit/PolicyUtilsTest.ts— newVendor matching helpersdescribe with 13 cases across three sub-describes:hasVendorFeature(7 — Credit Card / Debit Card true with beta enabled; beta disabled forces false even with Credit Card export; Vendor Bill / unset / no-connection / undefined-policy all false),getQBOVendors(3 — returns list / empty for missing / undefined-safe), andgetQBOVendorByID(3 — returns match / undefined for the inactive-vendor case / undefined for no-connection).tests/actions/IOUTest/UpdateMoneyRequestVendorTest.ts— new dedicated test file with 4 cases for the optimistic-violation fix: vendor picked clears the violation, vendor cleared clears the violation,failureDatarestores the original list, and no-op when there's noinactiveVendorviolation to clear. Usesjest.spyOn(API, 'write')to inspect theoptimisticData/failureDatashape.CI gates: all 25+ required checks (typecheck, test x8, spellcheck, lint, validateSchemas, validateImmutableActionRefs, dryRun, verifySignedCommits, codecov, snyk, etc.) were green on the prior HEAD; CI is re-running on the latest commit.
Offline tests
N/A — this PR adds types, constants, action stubs, utility functions, client-side violation-derivation logic (gated off behind a beta), and a localized violation string. None of the new code runs without Phase 2 / Phase 3 wiring it into UI, and none of the new actions are dispatched from any user flow yet. There is no behavior change to exercise online vs offline.
QA Steps
[No QA] — Phase 1 foundations only, no user-visible behavior. The Vendor field, Vendors tab, Default vendor row, and Concierge auto-match rendering all ship in subsequent App PRs (Phase 2 + Phase 3) and will carry their own QA steps.
PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectiontoggleReportand notonIconClick)src/languages/*files and using the translation methodSTYLE.md) were followedAvatar, I verified the components usingAvatarare working as expected)StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))npm run compress-svg)Avataris modified, I verified thatAvataris working as expected in all cases)Designlabel and/or tagged@Expensify/designso the design team can review the changes.ScrollViewcomponent to make it scrollable when more elements are added to the page.mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps.Screenshots/Videos
N/A — Phase 1 foundations only. No UI changes in this PR. Screenshots will accompany Phase 2 (workspace settings) and Phase 3 (expense flow) App PRs.
What's next
pushTransactionViolationsOnyxDatacalls (export-type change + vendor-list sync listener). Also introducesPolicy.areVendorsEnabledandMORE_FEATURES.ARE_VENDORS_ENABLEDalongside the locked card that consumes them. TheBETAS.VENDOR_MATCHINGgate is already in place from Phase 1, so Phase 2 only needs to threadusePermissions().isBetaEnabled(BETAS.VENDOR_MATCHING)into the new UI affordances.MoneyRequestView, vendor selector RHP (IOURequestStepVendor),ConciergeAutoMatchVendoraction rendering with Explain link, inactive-vendor violation rendering.