From 8089615e3fbedf6bc14804be673347012e021ef8 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Thu, 15 Jan 2026 20:51:45 -0800 Subject: [PATCH 01/11] chore: enable attendees required feature --- src/CONST/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 123cbe00b6307..3958a64a5db0d 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -5736,7 +5736,7 @@ const CONST = { * - Removes existing missingAttendees violations from transaction lists * - Hides "Require attendees" toggle in category settings */ - IS_ATTENDEES_REQUIRED_FEATURE_DISABLED: true, + IS_ATTENDEES_REQUIRED_FEATURE_DISABLED: false, /** * Constants for types of violation. From 4ae0212a2a79740178c26583a74480913a3e6ae4 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Thu, 15 Jan 2026 20:59:05 -0800 Subject: [PATCH 02/11] fix: 79707 - 'Multiple attendees required for this category' appears on invoice row --- .../SelectionListWithSections/Search/TransactionListItem.tsx | 3 +++ src/libs/AttendeeUtils.ts | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/SelectionListWithSections/Search/TransactionListItem.tsx b/src/components/SelectionListWithSections/Search/TransactionListItem.tsx index 7568e399d04e6..4871abffef7cb 100644 --- a/src/components/SelectionListWithSections/Search/TransactionListItem.tsx +++ b/src/components/SelectionListWithSections/Search/TransactionListItem.tsx @@ -23,6 +23,7 @@ import type {TransactionPreviewData} from '@libs/actions/Search'; import {handleActionButtonPress as handleActionButtonPressUtil} from '@libs/actions/Search'; import {syncMissingAttendeesViolation} from '@libs/AttendeeUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import {isInvoiceReport} from '@libs/ReportUtils'; import {isViolationDismissed, mergeProhibitedViolations, shouldShowViolation} from '@libs/TransactionUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -154,6 +155,7 @@ function TransactionListItem({ shouldShowViolation(reportForViolations, policyForViolations, violation.name, currentUserDetails.email ?? '', false, transactionItem), ); + const isInvoice = isInvoiceReport(reportForViolations) || reportForViolations.type === CONST.REPORT.TYPE.INVOICE; // Sync missingAttendees violation with current policy category settings (can be removed later when BE handles this) // Use live transaction data (attendees, category) to ensure we check against current state, not stale snapshot const attendeeOnyxViolations = syncMissingAttendeesViolation( @@ -164,6 +166,7 @@ function TransactionListItem({ currentUserDetails, policyForViolations?.isAttendeeTrackingEnabled ?? false, policyForViolations?.type === CONST.POLICY.TYPE.CORPORATE, + isInvoice, ); return mergeProhibitedViolations(attendeeOnyxViolations); diff --git a/src/libs/AttendeeUtils.ts b/src/libs/AttendeeUtils.ts index 693497ce4135c..b539f29ecdb90 100644 --- a/src/libs/AttendeeUtils.ts +++ b/src/libs/AttendeeUtils.ts @@ -76,10 +76,12 @@ function syncMissingAttendeesViolation( userPersonalDetails: CurrentUserPersonalDetails, isAttendeeTrackingEnabled: boolean, isControlPolicy: boolean, + isInvoice = false, ): T[] { // Feature flag to quickly disable the attendees required feature // When disabled, remove any existing missingAttendees violations and don't add new ones - if (CONST.IS_ATTENDEES_REQUIRED_FEATURE_DISABLED) { + // Never add missingAttendees violation for invoices + if (CONST.IS_ATTENDEES_REQUIRED_FEATURE_DISABLED || isInvoice) { return violations.filter((v) => v.name !== CONST.VIOLATIONS.MISSING_ATTENDEES); } From 3e7f6647f8f30bbf15dc8f975a8022d6b3987938 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Thu, 15 Jan 2026 21:00:44 -0800 Subject: [PATCH 03/11] fix: sync Report > Reports violations live --- .../Search/ExpenseReportListItem.tsx | 42 ++++++++++++++++++- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx b/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx index 5d7dbe67bb814..78fe21a75339c 100644 --- a/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx +++ b/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx @@ -1,5 +1,7 @@ import React, {useCallback, useContext, useMemo} from 'react'; import {View} from 'react-native'; +// eslint-disable-next-line no-restricted-imports +import {useOnyx as originalUseOnyx} from 'react-native-onyx'; import {DelegateNoAccessContext} from '@components/DelegateNoAccessModalProvider'; import Icon from '@components/Icon'; import {useSearchContext} from '@components/Search/SearchContext'; @@ -7,6 +9,7 @@ import BaseListItem from '@components/SelectionListWithSections/BaseListItem'; import type {ExpenseReportListItemProps, ExpenseReportListItemType, ListItem} from '@components/SelectionListWithSections/types'; import Text from '@components/Text'; import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -14,8 +17,11 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {handleActionButtonPress} from '@libs/actions/Search'; +import {syncMissingAttendeesViolation} from '@libs/AttendeeUtils'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {isOpenExpenseReport, isProcessingReport} from '@libs/ReportUtils'; import variables from '@styles/variables'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {isActionLoadingSelector} from '@src/selectors/ReportMetaData'; import type {Policy, Report} from '@src/types/onyx'; @@ -46,6 +52,11 @@ function ExpenseReportListItem({ const [personalPolicyID] = useOnyx(ONYXKEYS.PERSONAL_POLICY_ID, {canBeMissing: true}); const [isActionLoading] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportItem.reportID}`, {canBeMissing: true, selector: isActionLoadingSelector}); const expensifyIcons = useMemoizedLazyExpensifyIcons(['DotIndicator']); + const currentUserDetails = useCurrentUserPersonalDetails(); + + // Fetch live policy categories from Onyx to sync violations at render time + const [parentPolicy] = originalUseOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(reportItem.policyID)}`, {canBeMissing: true}); + const [policyCategories] = originalUseOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(reportItem.policyID)}`, {canBeMissing: true}); const searchData = currentSearchResults?.data; @@ -67,6 +78,28 @@ function ExpenseReportListItem({ return isEmpty ?? reportItem.isDisabled ?? reportItem.isDisabledCheckbox; }, [reportItem.isDisabled, reportItem.isDisabledCheckbox, reportItem.transactions.length]); + // Sync missingAttendees violation at render time for each transaction in the report + // This ensures violations show immediately when category settings change, without needing to click the row + const hasSyncedMissingAttendeesViolation = useMemo(() => { + const policy = parentPolicy ?? snapshotPolicy; + if (!policy?.isAttendeeTrackingEnabled || policy?.type !== CONST.POLICY.TYPE.CORPORATE) { + return false; + } + + return reportItem?.transactions?.some((transaction) => { + const violations = syncMissingAttendeesViolation( + transaction.violations ?? [], + policyCategories, + transaction.category ?? '', + transaction.attendees, + currentUserDetails, + policy.isAttendeeTrackingEnabled ?? false, + policy.type === CONST.POLICY.TYPE.CORPORATE, + ); + return violations.some((violation) => violation.name === CONST.VIOLATIONS.MISSING_ATTENDEES); + }); + }, [reportItem.transactions, policyCategories, parentPolicy, snapshotPolicy, currentUserDetails]); + const {isDelegateAccessRestricted, showDelegateNoAccessModal} = useContext(DelegateNoAccessContext); const handleOnButtonPress = useCallback(() => { @@ -134,8 +167,13 @@ function ExpenseReportListItem({ const shouldShowViolationDescription = isOpenExpenseReport(reportItem) || isProcessingReport(reportItem); + // Show violation description if either: + // 1. Pre-computed hasVisibleViolations from search data, OR + // 2. Synced missingAttendees violation computed at render time (for stale data) + const hasAnyVisibleViolations = reportItem?.hasVisibleViolations || hasSyncedMissingAttendeesViolation; + const getDescription = useMemo(() => { - if (!reportItem?.hasVisibleViolations || !shouldShowViolationDescription) { + if (!hasAnyVisibleViolations || !shouldShowViolationDescription) { return; } return ( @@ -151,7 +189,7 @@ function ExpenseReportListItem({ ); }, [ - reportItem?.hasVisibleViolations, + hasAnyVisibleViolations, shouldShowViolationDescription, styles.flexRow, styles.alignItemsCenter, From 5bc25bf33e9c7abcb9fddf2526efea1ecfda92fc Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Thu, 15 Jan 2026 21:01:27 -0800 Subject: [PATCH 04/11] test: add debug logs for #79710 --- src/components/ReportActionItem/MoneyRequestView.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index a28f245497e61..aba3c3b2cb26b 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -443,6 +443,16 @@ function MoneyRequestView({ ); }; + console.log('Debug Logs:', { + 'policy': policy, + 'policy?.id': policy?.id, + 'policy?.type': policy?.type, + 'policy?.isAttendeeTrackingEnabled': policy?.isAttendeeTrackingEnabled, + 'iouType': iouType, + 'shouldShowAttendees result': shouldShowAttendees, + 'actualAttendees': actualAttendees, + }); + const saveReimbursable = (newReimbursable: boolean) => { // If the value hasn't changed, don't request to save changes on the server and just close the modal if (newReimbursable === getReimbursable(transaction) || !transaction?.transactionID || !transactionThreadReport?.reportID) { From 2606186527b57ea4c1acbbbb6be93affd8974787 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Thu, 15 Jan 2026 21:22:00 -0800 Subject: [PATCH 05/11] fix: pass isInvoice to Reports --- src/components/ReportActionItem/MoneyRequestView.tsx | 6 +++--- .../Search/ExpenseReportListItem.tsx | 8 ++++++-- .../Search/TransactionListItem.tsx | 2 +- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index aba3c3b2cb26b..31f3c1512454b 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -444,13 +444,13 @@ function MoneyRequestView({ }; console.log('Debug Logs:', { - 'policy': policy, + policy: policy, 'policy?.id': policy?.id, 'policy?.type': policy?.type, 'policy?.isAttendeeTrackingEnabled': policy?.isAttendeeTrackingEnabled, - 'iouType': iouType, + iouType: iouType, 'shouldShowAttendees result': shouldShowAttendees, - 'actualAttendees': actualAttendees, + actualAttendees: actualAttendees, }); const saveReimbursable = (newReimbursable: boolean) => { diff --git a/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx b/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx index 78fe21a75339c..8e3d8dec7bb77 100644 --- a/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx +++ b/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx @@ -19,7 +19,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {handleActionButtonPress} from '@libs/actions/Search'; import {syncMissingAttendeesViolation} from '@libs/AttendeeUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {isOpenExpenseReport, isProcessingReport} from '@libs/ReportUtils'; +import {isInvoiceReport, isOpenExpenseReport, isProcessingReport} from '@libs/ReportUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -86,6 +86,7 @@ function ExpenseReportListItem({ return false; } + const isInvoice = isInvoiceReport(reportItem) || reportItem.type === CONST.REPORT.TYPE.INVOICE; return reportItem?.transactions?.some((transaction) => { const violations = syncMissingAttendeesViolation( transaction.violations ?? [], @@ -95,10 +96,11 @@ function ExpenseReportListItem({ currentUserDetails, policy.isAttendeeTrackingEnabled ?? false, policy.type === CONST.POLICY.TYPE.CORPORATE, + isInvoice, ); return violations.some((violation) => violation.name === CONST.VIOLATIONS.MISSING_ATTENDEES); }); - }, [reportItem.transactions, policyCategories, parentPolicy, snapshotPolicy, currentUserDetails]); + }, [reportItem, policyCategories, parentPolicy, snapshotPolicy, currentUserDetails]); const {isDelegateAccessRestricted, showDelegateNoAccessModal} = useContext(DelegateNoAccessContext); @@ -170,6 +172,8 @@ function ExpenseReportListItem({ // Show violation description if either: // 1. Pre-computed hasVisibleViolations from search data, OR // 2. Synced missingAttendees violation computed at render time (for stale data) + // We're using || instead of ?? because the variables are boolean + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const hasAnyVisibleViolations = reportItem?.hasVisibleViolations || hasSyncedMissingAttendeesViolation; const getDescription = useMemo(() => { diff --git a/src/components/SelectionListWithSections/Search/TransactionListItem.tsx b/src/components/SelectionListWithSections/Search/TransactionListItem.tsx index 4871abffef7cb..1a25e9f10d988 100644 --- a/src/components/SelectionListWithSections/Search/TransactionListItem.tsx +++ b/src/components/SelectionListWithSections/Search/TransactionListItem.tsx @@ -170,7 +170,7 @@ function TransactionListItem({ ); return mergeProhibitedViolations(attendeeOnyxViolations); - }, [policyForViolations, reportForViolations, policyCategories, transactionItem, currentUserDetails, violations]); + }, [policyForViolations, reportForViolations, policyCategories, transactionItem, currentUserDetails, transaction?.category, transaction?.comment, violations]); const {isDelegateAccessRestricted, showDelegateNoAccessModal} = useContext(DelegateNoAccessContext); From 30904cf1b00a7e1794b2fbe9d16d1f3cbd279493 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Fri, 16 Jan 2026 13:32:55 -0800 Subject: [PATCH 06/11] fix: eslint and lock feature to staging / dev --- src/CONST/index.ts | 15 +++++++++------ .../ReportActionItem/MoneyRequestView.tsx | 15 ++++++++------- src/components/TransactionItemRow/index.tsx | 2 +- src/hooks/useTransactionViolations.ts | 2 +- src/libs/AttendeeUtils.ts | 6 +++--- src/libs/TransactionUtils/index.ts | 4 ++-- src/libs/Violations/ViolationsUtils.ts | 4 ++-- .../categories/CategoryRequiredFieldsPage.tsx | 2 +- tests/unit/ViolationUtilsTest.ts | 12 ++++++------ 9 files changed, 33 insertions(+), 29 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 3958a64a5db0d..41f49a47b5e8a 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -5729,14 +5729,17 @@ const CONST = { }, /** - * Feature flag to disable the missingAttendees violation feature. - * Set to true to disable the feature if regressions are found. + * Feature flag to enable the missingAttendees violation feature. + * Currently enabled only on staging for testing. * When true: - * - Prevents new missingAttendees violations from being created - * - Removes existing missingAttendees violations from transaction lists - * - Hides "Require attendees" toggle in category settings + * - Enables new missingAttendees violations to be created + * - Shows existing missingAttendees violations in transaction lists + * - Shows "Require attendees" toggle in category settings + * Note: Config?.ENVIRONMENT is undefined in local dev when .env doesn't set it, so we treat undefined as dev */ - IS_ATTENDEES_REQUIRED_FEATURE_DISABLED: false, + // We can't use nullish coalescing for boolean comparison + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + IS_ATTENDEES_REQUIRED_ENABLED: !Config?.ENVIRONMENT || Config?.ENVIRONMENT === 'staging' || Config?.ENVIRONMENT === 'development', /** * Constants for types of violation. diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 31f3c1512454b..a198c6ef05321 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -443,14 +443,15 @@ function MoneyRequestView({ ); }; + // eslint-disable-next-line no-console console.log('Debug Logs:', { - policy: policy, - 'policy?.id': policy?.id, - 'policy?.type': policy?.type, - 'policy?.isAttendeeTrackingEnabled': policy?.isAttendeeTrackingEnabled, - iouType: iouType, - 'shouldShowAttendees result': shouldShowAttendees, - actualAttendees: actualAttendees, + policy, + policyID: policy?.id, + policyType: policy?.type, + policyIsAttendeeTrackingEnabled: policy?.isAttendeeTrackingEnabled, + iouType, + shouldShowAttendeesResult: shouldShowAttendees, + actualAttendees, }); const saveReimbursable = (newReimbursable: boolean) => { diff --git a/src/components/TransactionItemRow/index.tsx b/src/components/TransactionItemRow/index.tsx index 5a60c3fbd0f0c..89aab6834e057 100644 --- a/src/components/TransactionItemRow/index.tsx +++ b/src/components/TransactionItemRow/index.tsx @@ -203,7 +203,7 @@ function TransactionItemRow({ if (!violations) { return undefined; } - if (CONST.IS_ATTENDEES_REQUIRED_FEATURE_DISABLED) { + if (!CONST.IS_ATTENDEES_REQUIRED_ENABLED) { return violations.filter((violation) => violation.name !== CONST.VIOLATIONS.MISSING_ATTENDEES); } return violations; diff --git a/src/hooks/useTransactionViolations.ts b/src/hooks/useTransactionViolations.ts index eaaf3a5ac24db..e36e9f9b4afc1 100644 --- a/src/hooks/useTransactionViolations.ts +++ b/src/hooks/useTransactionViolations.ts @@ -30,7 +30,7 @@ function useTransactionViolations(transactionID?: string, shouldShowRterForSettl (violation: TransactionViolation) => !isViolationDismissed(transaction, violation, currentUserDetails.email ?? '', currentUserDetails.accountID, iouReport, policy) && shouldShowViolation(iouReport, policy, violation.name, currentUserDetails.email ?? '', shouldShowRterForSettledReport, transaction) && - (!CONST.IS_ATTENDEES_REQUIRED_FEATURE_DISABLED || violation.name !== CONST.VIOLATIONS.MISSING_ATTENDEES), + (CONST.IS_ATTENDEES_REQUIRED_ENABLED || violation.name !== CONST.VIOLATIONS.MISSING_ATTENDEES), ), ), [transaction, transactionViolations, iouReport, policy, shouldShowRterForSettledReport, currentUserDetails.email, currentUserDetails.accountID], diff --git a/src/libs/AttendeeUtils.ts b/src/libs/AttendeeUtils.ts index b539f29ecdb90..bf0a076fbe1c3 100644 --- a/src/libs/AttendeeUtils.ts +++ b/src/libs/AttendeeUtils.ts @@ -10,7 +10,7 @@ function formatRequiredFieldsTitle(translate: LocaleContextProps['translate'], p // Attendees field should show first when both are selected and attendee tracking is enabled // Respect feature flag - don't show attendees in title when feature is disabled - if (!CONST.IS_ATTENDEES_REQUIRED_FEATURE_DISABLED && isAttendeeTrackingEnabled && policyCategory.areAttendeesRequired) { + if (CONST.IS_ATTENDEES_REQUIRED_ENABLED && isAttendeeTrackingEnabled && policyCategory.areAttendeesRequired) { enabledFields.push(translate('iou.attendees')); } @@ -37,7 +37,7 @@ function getIsMissingAttendeesViolation( isAttendeeTrackingEnabled = false, ) { // Feature flag to quickly disable the attendees required feature - if (CONST.IS_ATTENDEES_REQUIRED_FEATURE_DISABLED) { + if (!CONST.IS_ATTENDEES_REQUIRED_ENABLED) { return false; } @@ -81,7 +81,7 @@ function syncMissingAttendeesViolation( // Feature flag to quickly disable the attendees required feature // When disabled, remove any existing missingAttendees violations and don't add new ones // Never add missingAttendees violation for invoices - if (CONST.IS_ATTENDEES_REQUIRED_FEATURE_DISABLED || isInvoice) { + if (!CONST.IS_ATTENDEES_REQUIRED_ENABLED || isInvoice) { return violations.filter((v) => v.name !== CONST.VIOLATIONS.MISSING_ATTENDEES); } diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 5d1858e273467..9b83adff4aad8 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -1431,7 +1431,7 @@ function getTransactionViolations( (violation) => !isViolationDismissed(transaction, violation, currentUserEmail, currentUserAccountID, iouReport, policy), ) ?? []; - if (CONST.IS_ATTENDEES_REQUIRED_FEATURE_DISABLED) { + if (!CONST.IS_ATTENDEES_REQUIRED_ENABLED) { return violations.filter((violation) => violation.name !== CONST.VIOLATIONS.MISSING_ATTENDEES); } @@ -1856,7 +1856,7 @@ function hasViolation( violation.type === CONST.VIOLATION_TYPES.VIOLATION && (showInReview === undefined || showInReview === (violation.showInReview ?? false)) && !isViolationDismissed(transaction, violation, currentUserEmail, currentUserAccountID, iouReport, policy) && - (!CONST.IS_ATTENDEES_REQUIRED_FEATURE_DISABLED || violation.name !== CONST.VIOLATIONS.MISSING_ATTENDEES), + (CONST.IS_ATTENDEES_REQUIRED_ENABLED || violation.name !== CONST.VIOLATIONS.MISSING_ATTENDEES), ); } diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index b1b2fccd6fa16..616b02724ed4b 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -502,7 +502,7 @@ const ViolationsUtils = { newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.MISSING_COMMENT}); } - const shouldProcessMissingAttendees = !CONST.IS_ATTENDEES_REQUIRED_FEATURE_DISABLED; + const shouldProcessMissingAttendees = CONST.IS_ATTENDEES_REQUIRED_ENABLED; if (shouldProcessMissingAttendees) { if (!hasMissingAttendeesViolation && shouldShowMissingAttendees) { @@ -723,7 +723,7 @@ const ViolationsUtils = { return ( !isViolationDismissed(transaction, violation, currentUserEmail, currentUserAccountID, report, policy) && shouldShowViolation(report, policy, violation.name, currentUserEmail, true, transaction) && - (!CONST.IS_ATTENDEES_REQUIRED_FEATURE_DISABLED || violation.name !== CONST.VIOLATIONS.MISSING_ATTENDEES) + (CONST.IS_ATTENDEES_REQUIRED_ENABLED || violation.name !== CONST.VIOLATIONS.MISSING_ATTENDEES) ); }); }); diff --git a/src/pages/workspace/categories/CategoryRequiredFieldsPage.tsx b/src/pages/workspace/categories/CategoryRequiredFieldsPage.tsx index c6086e463ba1f..db9c11fce0648 100644 --- a/src/pages/workspace/categories/CategoryRequiredFieldsPage.tsx +++ b/src/pages/workspace/categories/CategoryRequiredFieldsPage.tsx @@ -76,7 +76,7 @@ function CategoryRequiredFieldsPage({ - {isAttendeeTrackingEnabled && !CONST.IS_ATTENDEES_REQUIRED_FEATURE_DISABLED && ( + {isAttendeeTrackingEnabled && CONST.IS_ATTENDEES_REQUIRED_ENABLED && ( diff --git a/tests/unit/ViolationUtilsTest.ts b/tests/unit/ViolationUtilsTest.ts index 1f30b815ec445..9d98942bcf709 100644 --- a/tests/unit/ViolationUtilsTest.ts +++ b/tests/unit/ViolationUtilsTest.ts @@ -635,13 +635,13 @@ describe('getViolationsOnyxData', () => { } as Report; }); - (CONST.IS_ATTENDEES_REQUIRED_FEATURE_DISABLED ? it.skip : it)('should add missingAttendees violation when no attendees are present', () => { + (!CONST.IS_ATTENDEES_REQUIRED_ENABLED ? it.skip : it)('should add missingAttendees violation when no attendees are present', () => { transaction.comment = {attendees: []}; const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policy, policyTags, policyCategories, false, false, false, iouReport); expect(result.value).toEqual(expect.arrayContaining([missingAttendeesViolation])); }); - (CONST.IS_ATTENDEES_REQUIRED_FEATURE_DISABLED ? it.skip : it)('should add missingAttendees violation when only owner is an attendee', () => { + (!CONST.IS_ATTENDEES_REQUIRED_ENABLED ? it.skip : it)('should add missingAttendees violation when only owner is an attendee', () => { transaction.comment = { attendees: [{email: 'owner@example.com', displayName: 'Owner', avatarUrl: '', accountID: ownerAccountID}], }; @@ -689,7 +689,7 @@ describe('getViolationsOnyxData', () => { describe('optimistic / offline scenarios (iouReport is undefined)', () => { // In offline scenarios, iouReport is undefined so we can't get ownerAccountID. // The code falls back to using getCurrentUserEmail() to identify the owner by login/email. - (CONST.IS_ATTENDEES_REQUIRED_FEATURE_DISABLED ? it.skip : it)('should correctly calculate violation when iouReport is undefined but attendees have matching email', () => { + (!CONST.IS_ATTENDEES_REQUIRED_ENABLED ? it.skip : it)('should correctly calculate violation when iouReport is undefined but attendees have matching email', () => { // When iouReport is undefined, we use getCurrentUserEmail() as fallback // If only the current user (matching MOCK_CURRENT_USER_EMAIL) is an attendee, violation should show transactionViolations = []; @@ -729,7 +729,7 @@ describe('getViolationsOnyxData', () => { expect(result.value).not.toEqual(expect.arrayContaining([missingAttendeesViolation])); }); - (CONST.IS_ATTENDEES_REQUIRED_FEATURE_DISABLED ? it.skip : it)('should preserve violation when only owner attendee remains (offline)', () => { + (!CONST.IS_ATTENDEES_REQUIRED_ENABLED ? it.skip : it)('should preserve violation when only owner attendee remains (offline)', () => { // If violation existed and only owner attendee remains, violation stays transactionViolations = [missingAttendeesViolation]; transaction.comment = { @@ -757,7 +757,7 @@ describe('getViolationsOnyxData', () => { jest.restoreAllMocks(); }); - (CONST.IS_ATTENDEES_REQUIRED_FEATURE_DISABLED ? it.skip : it)("should add missingAttendees violation when no attendees are present (can't identify owner)", () => { + (!CONST.IS_ATTENDEES_REQUIRED_ENABLED ? it.skip : it)("should add missingAttendees violation when no attendees are present (can't identify owner)", () => { transactionViolations = []; transaction.comment = {attendees: []}; const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policy, policyTags, policyCategories, false, false, false, undefined); @@ -765,7 +765,7 @@ describe('getViolationsOnyxData', () => { expect(result.value).toEqual(expect.arrayContaining([missingAttendeesViolation])); }); - (CONST.IS_ATTENDEES_REQUIRED_FEATURE_DISABLED ? it.skip : it)('should add missingAttendees violation when only 1 attendee exists (assumed to be owner)', () => { + (!CONST.IS_ATTENDEES_REQUIRED_ENABLED ? it.skip : it)('should add missingAttendees violation when only 1 attendee exists (assumed to be owner)', () => { transactionViolations = []; transaction.comment = { attendees: [{email: 'anyone@example.com', displayName: 'Someone', avatarUrl: ''}], From ab8e0c2f4f51f32930dedef6c3d648b89ea86bbb Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Wed, 21 Jan 2026 16:51:26 -0800 Subject: [PATCH 07/11] chore: resolve submodule --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 4086ba4d3ee2f..056474e9aae44 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 4086ba4d3ee2f9dac2b7dc49edf435e6b2ceb2eb +Subproject commit 056474e9aae4453a822e51aab49bc7f3391e1a42 From 91dc73ef2fa44fbef0dd06881362ee3748e268e4 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Wed, 21 Jan 2026 18:33:38 -0800 Subject: [PATCH 08/11] fix: address ai reviewers comments --- .../ReportActionItem/MoneyRequestView.tsx | 11 ------- .../Search/ExpenseReportListItem.tsx | 29 +++++++++++++++---- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 9faa2bd7aa8ae..6e97dfa1e63dd 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -468,17 +468,6 @@ function MoneyRequestView({ }); }; - // eslint-disable-next-line no-console - console.log('Debug Logs:', { - policy, - policyID: policy?.id, - policyType: policy?.type, - policyIsAttendeeTrackingEnabled: policy?.isAttendeeTrackingEnabled, - iouType, - shouldShowAttendeesResult: shouldShowAttendees, - actualAttendees, - }); - const saveReimbursable = (newReimbursable: boolean) => { // If the value hasn't changed, don't request to save changes on the server and just close the modal if (newReimbursable === getReimbursable(transaction) || !transaction?.transactionID || !transactionThreadReport?.reportID) { diff --git a/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx b/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx index 8e3d8dec7bb77..c4d7a7e4ad08a 100644 --- a/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx +++ b/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx @@ -1,5 +1,8 @@ import React, {useCallback, useContext, useMemo} from 'react'; import {View} from 'react-native'; +// We need direct access to useOnyx to fetch live policy data at render time +// without triggering the wrapper's additional logic, ensuring violations +// sync immediately when category settings change // eslint-disable-next-line no-restricted-imports import {useOnyx as originalUseOnyx} from 'react-native-onyx'; import {DelegateNoAccessContext} from '@components/DelegateNoAccessModalProvider'; @@ -20,6 +23,7 @@ import {handleActionButtonPress} from '@libs/actions/Search'; import {syncMissingAttendeesViolation} from '@libs/AttendeeUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {isInvoiceReport, isOpenExpenseReport, isProcessingReport} from '@libs/ReportUtils'; +import {isViolationDismissed, shouldShowViolation} from '@libs/TransactionUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -56,6 +60,7 @@ function ExpenseReportListItem({ // Fetch live policy categories from Onyx to sync violations at render time const [parentPolicy] = originalUseOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(reportItem.policyID)}`, {canBeMissing: true}); + const [parentReport] = originalUseOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportItem.reportID)}`, {canBeMissing: true}); const [policyCategories] = originalUseOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(reportItem.policyID)}`, {canBeMissing: true}); const searchData = currentSearchResults?.data; @@ -78,29 +83,41 @@ function ExpenseReportListItem({ return isEmpty ?? reportItem.isDisabled ?? reportItem.isDisabledCheckbox; }, [reportItem.isDisabled, reportItem.isDisabledCheckbox, reportItem.transactions.length]); + // Prefer live Onyx policy data over snapshot to ensure fresh policy settings + // like isAttendeeTrackingEnabled is not missing + // Use snapshotReport/snapshotPolicy as fallbacks to fix offline issues where + // newly created reports aren't in the search snapshot yet + const policyForViolations = parentPolicy ?? snapshotPolicy; + const reportForViolations = parentReport ?? snapshotReport; + // Sync missingAttendees violation at render time for each transaction in the report // This ensures violations show immediately when category settings change, without needing to click the row const hasSyncedMissingAttendeesViolation = useMemo(() => { - const policy = parentPolicy ?? snapshotPolicy; - if (!policy?.isAttendeeTrackingEnabled || policy?.type !== CONST.POLICY.TYPE.CORPORATE) { + if (!policyForViolations?.isAttendeeTrackingEnabled || policyForViolations?.type !== CONST.POLICY.TYPE.CORPORATE) { return false; } const isInvoice = isInvoiceReport(reportItem) || reportItem.type === CONST.REPORT.TYPE.INVOICE; return reportItem?.transactions?.some((transaction) => { + const relevantViolations = (transaction.violations ?? []).filter( + (violation) => + !isViolationDismissed(transaction, violation, currentUserDetails.email ?? '', currentUserDetails.accountID, reportForViolations, policyForViolations) && + shouldShowViolation(reportForViolations, policyForViolations, violation.name, currentUserDetails.email ?? '', false, transaction), + ); + const violations = syncMissingAttendeesViolation( - transaction.violations ?? [], + relevantViolations, policyCategories, transaction.category ?? '', transaction.attendees, currentUserDetails, - policy.isAttendeeTrackingEnabled ?? false, - policy.type === CONST.POLICY.TYPE.CORPORATE, + policyForViolations.isAttendeeTrackingEnabled ?? false, + policyForViolations.type === CONST.POLICY.TYPE.CORPORATE, isInvoice, ); return violations.some((violation) => violation.name === CONST.VIOLATIONS.MISSING_ATTENDEES); }); - }, [reportItem, policyCategories, parentPolicy, snapshotPolicy, currentUserDetails]); + }, [reportItem, policyCategories, policyForViolations, reportForViolations, currentUserDetails]); const {isDelegateAccessRestricted, showDelegateNoAccessModal} = useContext(DelegateNoAccessContext); From a0b5705f1aa87c28855fb4c459b18856dab67c4f Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Fri, 23 Jan 2026 17:30:25 -0800 Subject: [PATCH 09/11] fix: 79902 - stuck with 'Category no longer valid' on confirm page after changing to a valid category --- .../MoneyRequestConfirmationList.tsx | 24 +- src/libs/Violations/ViolationsUtils.ts | 42 ++++ src/libs/Violations/types.ts | 17 ++ .../categories/CategorySettingsPage.tsx | 2 +- tests/unit/ViolationUtilsTest.ts | 211 +++++++++++++++++- 5 files changed, 289 insertions(+), 7 deletions(-) create mode 100644 src/libs/Violations/types.ts diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index d5e1ebbf7ff6a..0f5723f3f6072 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -58,6 +58,7 @@ import { isMerchantMissing, isScanRequest as isScanRequestUtil, } from '@libs/TransactionUtils'; +import {getIsViolationFixed} from '@libs/Violations/ViolationsUtils'; import {hasInvoicingDetails} from '@userActions/Policy/Policy'; import type {IOUAction, IOUType} from '@src/CONST'; import CONST from '@src/CONST'; @@ -438,6 +439,18 @@ function MoneyRequestConfirmationList({ [iouCategory, policyCategories, policy?.areRulesEnabled], ); + const isViolationFixed = getIsViolationFixed(formError, { + category: iouCategory, + tag: getTag(transaction), + taxCode: transaction?.taxCode, + policyCategories, + policyTagLists: policyTags, + policyTaxRates: policy?.taxRates?.taxes, + iouAttendees, + currentUserPersonalDetails, + isAttendeeTrackingEnabled: policy?.isAttendeeTrackingEnabled, + }); + useEffect(() => { if (shouldDisplayFieldError && didConfirmSplit) { setFormError('iou.error.genericSmartscanFailureMessage'); @@ -447,20 +460,21 @@ function MoneyRequestConfirmationList({ setFormError('iou.receiptScanningFailed'); return; } + // Check 1: If formError does NOT start with "violations.", clear it and return // Reset the form error whenever the screen gains or loses focus // but preserve violation-related errors since those represent real validation issues - // that can only be fixed by changing the underlying data + // that can only be resolved by fixing the underlying issue if (!formError.startsWith(CONST.VIOLATIONS_PREFIX)) { setFormError(''); return; } - // Clear missingAttendees violation if user fixed it by changing category or attendees - const isMissingAttendeesViolation = getIsMissingAttendeesViolation(policyCategories, iouCategory, iouAttendees, currentUserPersonalDetails, policy?.isAttendeeTrackingEnabled); - if (formError === 'violations.missingAttendees' && !isMissingAttendeesViolation) { + // Check 2: Only reached if formError STARTS with "violations." + // Clear any violation error if the user has fixed the underlying issue + if (isViolationFixed) { setFormError(''); } // eslint-disable-next-line react-hooks/exhaustive-deps -- we don't want this effect to run if it's just setFormError that changes - }, [isFocused, shouldDisplayFieldError, hasSmartScanFailed, didConfirmSplit, iouCategory, iouAttendees, policyCategories, currentUserPersonalDetails, policy?.isAttendeeTrackingEnabled]); + }, [isFocused, shouldDisplayFieldError, hasSmartScanFailed, didConfirmSplit, isViolationFixed]); useEffect(() => { // We want this effect to run only when the transaction is moving from Self DM to a expense chat diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index 26f84d3f729cb..f127e24029a38 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -4,6 +4,7 @@ import reject from 'lodash/reject'; import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {LocaleContextProps} from '@components/LocaleContextProvider'; +import {getIsMissingAttendeesViolation} from '@libs/AttendeeUtils'; import {getDecodedCategoryName, isCategoryMissing} from '@libs/CategoryUtils'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import DateUtils from '@libs/DateUtils'; @@ -19,6 +20,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, PolicyCategories, PolicyTagLists, Report, ReportAction, Transaction, TransactionViolation, ViolationName} from '@src/types/onyx'; import type {Errors} from '@src/types/onyx/OnyxCommon'; import type {ReceiptError, ReceiptErrors} from '@src/types/onyx/Transaction'; +import type {ViolationFixParams} from './types'; /** * Calculates tag out of policy and missing tag violations for the given transaction @@ -221,6 +223,44 @@ function extractErrorMessages(errors: Errors | ReceiptErrors, errorActions: Repo return Array.from(uniqueMessages); } +/** + * Checks if a violation has been fixed by the user changing the underlying data. + * Returns true if the violation should be cleared, false if it should persist. + */ +function getIsViolationFixed(violationError: string, params: ViolationFixParams): boolean { + const {category, tag, taxCode, policyCategories, policyTagLists, policyTaxRates, iouAttendees, currentUserPersonalDetails, isAttendeeTrackingEnabled} = params; + + const violationValidators: Record boolean> = { + [`${CONST.VIOLATIONS_PREFIX}${CONST.VIOLATIONS.CATEGORY_OUT_OF_POLICY}`]: () => { + // Category is fixed if it exists and is enabled in policy + return !!(category && policyCategories?.[category]?.enabled); + }, + [`${CONST.VIOLATIONS_PREFIX}${CONST.VIOLATIONS.TAG_OUT_OF_POLICY}`]: () => { + // Tag is fixed if it's empty or matches a valid tag in policy + if (!tag) { + return true; + } + if (!policyTagLists) { + return false; + } + const hasEnabledTags = Object.values(policyTagLists).some((tagList) => tagList.tags && Object.values(tagList.tags).some((t) => t.enabled)); + const hasMatchingTag = Object.values(policyTagLists).some((tagList) => tagList.tags && Object.values(tagList.tags).some((t) => t.name === tag && t.enabled)); + return hasEnabledTags && hasMatchingTag; + }, + [`${CONST.VIOLATIONS_PREFIX}${CONST.VIOLATIONS.TAX_OUT_OF_POLICY}`]: () => { + // Tax is fixed if it's empty or exists in policy tax rates + return !taxCode || Object.keys(policyTaxRates ?? {}).some((key) => key === taxCode); + }, + [`${CONST.VIOLATIONS_PREFIX}${CONST.VIOLATIONS.MISSING_ATTENDEES}`]: () => { + // Attendees violation is fixed if getIsMissingAttendeesViolation returns false + return !getIsMissingAttendeesViolation(policyCategories, category, iouAttendees, currentUserPersonalDetails, isAttendeeTrackingEnabled); + }, + }; + + const validator = violationValidators[violationError]; + return validator ? validator() : false; +} + const ViolationsUtils = { /** * Checks a transaction for policy violations and returns an object with Onyx method, key and updated transaction @@ -732,4 +772,6 @@ const ViolationsUtils = { }, }; +export {getIsViolationFixed}; +export type {ViolationFixParams}; export default ViolationsUtils; diff --git a/src/libs/Violations/types.ts b/src/libs/Violations/types.ts new file mode 100644 index 0000000000000..9bb7677e3c966 --- /dev/null +++ b/src/libs/Violations/types.ts @@ -0,0 +1,17 @@ +import type {PolicyCategories, PolicyTagLists} from '@src/types/onyx'; +import type {Attendee} from '@src/types/onyx/IOU'; +import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; + +type ViolationFixParams = { + category: string; + tag: string; + taxCode: string | undefined; + policyCategories: PolicyCategories | undefined; + policyTagLists: PolicyTagLists | undefined; + policyTaxRates: Record | undefined; + iouAttendees: Attendee[] | undefined; + currentUserPersonalDetails: CurrentUserPersonalDetails; + isAttendeeTrackingEnabled: boolean | undefined; +}; + +export type {ViolationFixParams}; diff --git a/src/pages/workspace/categories/CategorySettingsPage.tsx b/src/pages/workspace/categories/CategorySettingsPage.tsx index f7c66a2ab7e75..1ada9a3ad52a8 100644 --- a/src/pages/workspace/categories/CategorySettingsPage.tsx +++ b/src/pages/workspace/categories/CategorySettingsPage.tsx @@ -180,7 +180,7 @@ function CategorySettingsPage({ setIsCannotDeleteOrDisableLastCategoryModalVisible, shouldPreventDisableOrDelete, policyData, - policyCategory.name, + policyCategory?.name, isSetupCategoryTaskParentReportArchived, setupCategoryTaskReport, setupCategoryTaskParentReport, diff --git a/tests/unit/ViolationUtilsTest.ts b/tests/unit/ViolationUtilsTest.ts index 9d98942bcf709..d193d92954477 100644 --- a/tests/unit/ViolationUtilsTest.ts +++ b/tests/unit/ViolationUtilsTest.ts @@ -2,7 +2,7 @@ import {beforeEach} from '@jest/globals'; import Onyx from 'react-native-onyx'; import {convertAmountToDisplayString} from '@libs/CurrencyUtils'; import {getTransactionViolations, hasWarningTypeViolation, isViolationDismissed} from '@libs/TransactionUtils'; -import ViolationsUtils from '@libs/Violations/ViolationsUtils'; +import ViolationsUtils, {getIsViolationFixed} from '@libs/Violations/ViolationsUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, PolicyCategories, PolicyTagLists, Report, Transaction, TransactionViolation} from '@src/types/onyx'; @@ -1249,3 +1249,212 @@ describe('hasVisibleViolationsForUser', () => { expect(result).toBe(true); }); }); + +describe('getIsViolationFixed', () => { + const mockCurrentUserPersonalDetails = { + accountID: 1, + login: 'user@example.com', + email: 'user@example.com', + }; + + const defaultParams = { + category: '', + tag: '', + taxCode: undefined, + policyCategories: undefined, + policyTagLists: undefined, + policyTaxRates: undefined, + iouAttendees: undefined, + currentUserPersonalDetails: mockCurrentUserPersonalDetails, + isAttendeeTrackingEnabled: false, + }; + + const createPolicyTagList = (tagName: string, enabled: boolean) => ({ + Meals: { + name: 'Meals', + required: true, + orderWeight: 1, + tags: {[tagName]: {name: tagName, enabled}}, + }, + }); + + const createAttendee = (email: string) => ({ + email, + displayName: email.split('@')?.at(0) ?? '', + avatarUrl: '', + }); + + describe('violations.categoryOutOfPolicy', () => { + it('should return false when category is empty', () => { + const result = getIsViolationFixed('violations.categoryOutOfPolicy', { + ...defaultParams, + category: '', + policyCategories: {Food: {name: 'Food', enabled: true}}, + }); + expect(result).toBe(false); + }); + + it('should return false when category is not in policy', () => { + const result = getIsViolationFixed('violations.categoryOutOfPolicy', { + ...defaultParams, + category: 'Travel', + policyCategories: {Food: {name: 'Food', enabled: true}}, + }); + expect(result).toBe(false); + }); + + it('should return false when category exists but is disabled', () => { + const result = getIsViolationFixed('violations.categoryOutOfPolicy', { + ...defaultParams, + category: 'Food', + policyCategories: {Food: {name: 'Food', enabled: false}}, + }); + expect(result).toBe(false); + }); + + it('should return true when category exists and is enabled', () => { + const result = getIsViolationFixed('violations.categoryOutOfPolicy', { + ...defaultParams, + category: 'Food', + policyCategories: {Food: {name: 'Food', enabled: true}}, + }); + expect(result).toBe(true); + }); + }); + + describe('violations.tagOutOfPolicy', () => { + it('should return true when tag is empty', () => { + const result = getIsViolationFixed('violations.tagOutOfPolicy', { + ...defaultParams, + tag: '', + }); + expect(result).toBe(true); + }); + + it('should return false when policyTagLists is undefined', () => { + const result = getIsViolationFixed('violations.tagOutOfPolicy', { + ...defaultParams, + tag: 'Lunch', + policyTagLists: undefined, + }); + expect(result).toBe(false); + }); + + it('should return false when tag is not in policy', () => { + const result = getIsViolationFixed('violations.tagOutOfPolicy', { + ...defaultParams, + tag: 'Breakfast', + policyTagLists: createPolicyTagList('Lunch', true), + }); + expect(result).toBe(false); + }); + + it('should return false when tag exists but is disabled', () => { + const result = getIsViolationFixed('violations.tagOutOfPolicy', { + ...defaultParams, + tag: 'Lunch', + policyTagLists: createPolicyTagList('Lunch', false), + }); + expect(result).toBe(false); + }); + + it('should return true when tag exists and is enabled', () => { + const result = getIsViolationFixed('violations.tagOutOfPolicy', { + ...defaultParams, + tag: 'Lunch', + policyTagLists: createPolicyTagList('Lunch', true), + }); + expect(result).toBe(true); + }); + }); + + describe('violations.taxOutOfPolicy', () => { + it('should return true when taxCode is empty', () => { + const result = getIsViolationFixed('violations.taxOutOfPolicy', { + ...defaultParams, + taxCode: undefined, + }); + expect(result).toBe(true); + }); + + it('should return false when taxCode is not in policy tax rates', () => { + const result = getIsViolationFixed('violations.taxOutOfPolicy', { + ...defaultParams, + taxCode: 'TAX_20', + policyTaxRates: {TAX_10: {name: '10%', value: '10'}}, + }); + expect(result).toBe(false); + }); + + it('should return true when taxCode exists in policy tax rates', () => { + const result = getIsViolationFixed('violations.taxOutOfPolicy', { + ...defaultParams, + taxCode: 'TAX_10', + policyTaxRates: {TAX_10: {name: '10%', value: '10'}}, + }); + expect(result).toBe(true); + }); + }); + + describe('violations.missingAttendees', () => { + it('should return true when attendee tracking is disabled', () => { + const result = getIsViolationFixed('violations.missingAttendees', { + ...defaultParams, + isAttendeeTrackingEnabled: false, + category: 'Meals', + policyCategories: {Meals: {name: 'Meals', enabled: true, areAttendeesRequired: true}}, + }); + expect(result).toBe(true); + }); + + it('should return true when category does not require attendees', () => { + const result = getIsViolationFixed('violations.missingAttendees', { + ...defaultParams, + isAttendeeTrackingEnabled: true, + category: 'Meals', + policyCategories: {Meals: {name: 'Meals', enabled: true, areAttendeesRequired: false}}, + }); + expect(result).toBe(true); + }); + + it('should return false when no attendees are present and category requires them', () => { + const result = getIsViolationFixed('violations.missingAttendees', { + ...defaultParams, + isAttendeeTrackingEnabled: true, + category: 'Meals', + policyCategories: {Meals: {name: 'Meals', enabled: true, areAttendeesRequired: true}}, + iouAttendees: [], + }); + expect(result).toBe(false); + }); + + it('should return false when only the creator is an attendee', () => { + const result = getIsViolationFixed('violations.missingAttendees', { + ...defaultParams, + isAttendeeTrackingEnabled: true, + category: 'Meals', + policyCategories: {Meals: {name: 'Meals', enabled: true, areAttendeesRequired: true}}, + iouAttendees: [createAttendee('user@example.com')], + }); + expect(result).toBe(false); + }); + + it('should return true when there is a non-creator attendee', () => { + const result = getIsViolationFixed('violations.missingAttendees', { + ...defaultParams, + isAttendeeTrackingEnabled: true, + category: 'Meals', + policyCategories: {Meals: {name: 'Meals', enabled: true, areAttendeesRequired: true}}, + iouAttendees: [createAttendee('user@example.com'), createAttendee('other@example.com')], + }); + expect(result).toBe(true); + }); + }); + + describe('unknown violations', () => { + it('should return false for unknown violation types', () => { + const result = getIsViolationFixed('violations.unknownViolation', defaultParams); + expect(result).toBe(false); + }); + }); +}); From 24428d341bef0d1c9c831e4f38db6e409e88218e Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Fri, 23 Jan 2026 17:40:58 -0800 Subject: [PATCH 10/11] fix: eslint - default export --- src/libs/Violations/ViolationsUtils.ts | 2 +- src/libs/Violations/types.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index f127e24029a38..c741f2facc011 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -20,7 +20,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, PolicyCategories, PolicyTagLists, Report, ReportAction, Transaction, TransactionViolation, ViolationName} from '@src/types/onyx'; import type {Errors} from '@src/types/onyx/OnyxCommon'; import type {ReceiptError, ReceiptErrors} from '@src/types/onyx/Transaction'; -import type {ViolationFixParams} from './types'; +import type ViolationFixParams from './types'; /** * Calculates tag out of policy and missing tag violations for the given transaction diff --git a/src/libs/Violations/types.ts b/src/libs/Violations/types.ts index 9bb7677e3c966..2f51b4efe44ee 100644 --- a/src/libs/Violations/types.ts +++ b/src/libs/Violations/types.ts @@ -14,4 +14,4 @@ type ViolationFixParams = { isAttendeeTrackingEnabled: boolean | undefined; }; -export type {ViolationFixParams}; +export default ViolationFixParams; From 0ac39e65d3a1e1cfb40bde64a9aff38d02d229c2 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Mon, 26 Jan 2026 15:04:06 -0800 Subject: [PATCH 11/11] chore: submodule sync --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 963ca845b88f7..e7391ba16ed5a 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 963ca845b88f751e41b9ffc0707bb7281b0d5f7a +Subproject commit e7391ba16ed5a6bb6046aad2512830b60a8eb837