From 62314b5bbf66d83f6eed52a0116ace02af5718f7 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Wed, 7 Jan 2026 15:15:41 -0800 Subject: [PATCH 01/16] feat: add ability to require attendees based on category selection --- src/CONST/index.ts | 1 + src/ROUTES.ts | 4 + src/SCREENS.ts | 1 + .../MoneyRequestConfirmationList.tsx | 13 ++ .../MoneyRequestConfirmationListFooter.tsx | 7 +- .../ReportActionItem/MoneyRequestView.tsx | 25 ++- .../Search/TransactionListItem.tsx | 20 +- src/hooks/useViolations.ts | 3 +- src/languages/de.ts | 5 + src/languages/en.ts | 5 + src/languages/es.ts | 5 + src/languages/fr.ts | 5 + src/languages/it.ts | 5 + src/languages/ja.ts | 5 + src/languages/nl.ts | 5 + src/languages/pl.ts | 5 + src/languages/pt-BR.ts | 5 + src/languages/zh-hans.ts | 5 + ...etPolicyCategoryAttendeesRequiredParams.ts | 7 + src/libs/API/parameters/index.ts | 1 + src/libs/API/types.ts | 2 + src/libs/AttendeeUtils.ts | 92 ++++++++ .../ModalStackNavigators/index.tsx | 1 + .../RELATIONS/WORKSPACE_TO_RHP.ts | 1 + src/libs/Navigation/linkingConfig/config.ts | 3 + src/libs/Navigation/types.ts | 4 + src/libs/TransactionUtils/index.ts | 7 +- src/libs/Violations/ViolationsUtils.ts | 50 +++++ src/libs/actions/IOU/index.ts | 4 +- src/libs/actions/Policy/Category.ts | 64 ++++++ .../request/step/IOURequestStepAttendees.tsx | 23 +- .../categories/CategoryRequiredFieldsPage.tsx | 103 +++++++++ .../categories/CategorySettingsPage.tsx | 104 ++++----- .../rules/IndividualExpenseRulesSection.tsx | 27 ++- src/types/onyx/PolicyCategory.ts | 3 + tests/unit/ViolationUtilsTest.ts | 203 ++++++++++++++++++ 36 files changed, 742 insertions(+), 81 deletions(-) create mode 100644 src/libs/API/parameters/SetPolicyCategoryAttendeesRequiredParams.ts create mode 100644 src/libs/AttendeeUtils.ts create mode 100644 src/pages/workspace/categories/CategoryRequiredFieldsPage.tsx diff --git a/src/CONST/index.ts b/src/CONST/index.ts index cd678f72d1cd8..1063913a28ea6 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -5765,6 +5765,7 @@ const CONST = { RECEIPT_GENERATED_WITH_AI: 'receiptGeneratedWithAI', OVER_TRIP_LIMIT: 'overTripLimit', COMPANY_CARD_REQUIRED: 'companyCardRequired', + MISSING_ATTENDEES: 'missingAttendees', }, RTER_VIOLATION_TYPES: { BROKEN_CARD_CONNECTION: 'brokenCardConnection', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index f057aad291208..592a2a840c662 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1973,6 +1973,10 @@ const ROUTES = { route: 'workspaces/:policyID/category/:categoryName/require-receipts-over', getRoute: (policyID: string, categoryName: string) => `workspaces/${policyID}/category/${encodeURIComponent(categoryName)}/require-receipts-over` as const, }, + WORKSPACE_CATEGORY_REQUIRED_FIELDS: { + route: 'workspaces/:policyID/category/:categoryName/required-fields', + getRoute: (policyID: string, categoryName: string) => `workspaces/${policyID}/category/${encodeURIComponent(categoryName)}/required-fields` as const, + }, WORKSPACE_CATEGORY_APPROVER: { route: 'workspaces/:policyID/category/:categoryName/approver', getRoute: (policyID: string, categoryName: string) => `workspaces/${policyID}/category/${encodeURIComponent(categoryName)}/approver` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 4643456897cac..86a0d2e5c8d99 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -687,6 +687,7 @@ const SCREENS = { CATEGORY_DESCRIPTION_HINT: 'Category_Description_Hint', CATEGORY_APPROVER: 'Category_Approver', CATEGORY_REQUIRE_RECEIPTS_OVER: 'Category_Require_Receipts_Over', + CATEGORY_REQUIRED_FIELDS: 'Category_Required_Fields', CATEGORIES_SETTINGS: 'Categories_Settings', CATEGORIES_IMPORT: 'Categories_Import', CATEGORIES_IMPORTED: 'Categories_Imported', diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 76c09481b5d86..11306dddfd6c4 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -30,6 +30,7 @@ import { setMoneyRequestTaxRate, setSplitShares, } from '@libs/actions/IOU'; +import {getIsMissingAttendeesViolation} from '@libs/AttendeeUtils'; import {isCategoryDescriptionRequired} from '@libs/CategoryUtils'; import {convertToBackendAmount, convertToDisplayString, convertToDisplayStringWithoutCurrency, getCurrencyDecimals} from '@libs/CurrencyUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; @@ -923,6 +924,12 @@ function MoneyRequestConfirmationList({ return; } + const isMissingAttendeesViolation = getIsMissingAttendeesViolation(policyCategories, iouCategory, iouAttendees, currentUserPersonalDetails, policy?.isAttendeeTrackingEnabled); + if (isMissingAttendeesViolation) { + setFormError('violations.missingAttendees'); + return; + } + if (isPerDiemRequest && (transaction.comment?.customUnit?.subRates ?? []).length === 0) { setFormError('iou.error.invalidSubrateLength'); return; @@ -996,6 +1003,8 @@ function MoneyRequestConfirmationList({ showDelegateNoAccessModal, iouCategory, policyCategories, + iouAttendees, + currentUserPersonalDetails, ], ); @@ -1019,6 +1028,10 @@ function MoneyRequestConfirmationList({ if (isTypeSplit && !shouldShowReadOnlySplits) { return debouncedFormError && translate(debouncedFormError); } + // Don't show error at the bottom of the form for missing attendees + if (formError === 'violations.missingAttendees') { + return; + } return formError && translate(formError); }, [routeError, isTypeSplit, shouldShowReadOnlySplits, debouncedFormError, formError, translate]); diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index 4e4acba9a7df8..da70f62205cdb 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -380,6 +380,7 @@ function MoneyRequestConfirmationListFooter({ const shouldDisplayDistanceRateError = formError === 'iou.error.invalidRate'; const shouldDisplayTagError = formError === 'violations.tagOutOfPolicy'; const shouldDisplayCategoryError = formError === 'violations.categoryOutOfPolicy'; + const shouldDisplayAttendeesError = formError === 'violations.missingAttendees'; const showReceiptEmptyState = shouldShowReceiptEmptyState(iouType, action, policy, isPerDiemRequest); // The per diem custom unit @@ -727,7 +728,7 @@ function MoneyRequestConfirmationListFooter({ item: ( item?.displayName ?? item?.login).join(', ')} description={`${translate('iou.attendees')} ${ iouAttendees?.length && iouAttendees.length > 1 && formattedAmountPerAttendee ? `\u00B7 ${formattedAmountPerAttendee} ${translate('common.perPerson')}` : '' @@ -741,8 +742,10 @@ function MoneyRequestConfirmationListFooter({ Navigation.navigate(ROUTES.MONEY_REQUEST_ATTENDEE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRoute())); }} - interactive + interactive={!isReadOnly} shouldRenderAsHTML + brickRoadIndicator={shouldDisplayAttendeesError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + errorText={shouldDisplayAttendeesError ? translate(formError) : ''} /> ), shouldShow: shouldShowAttendees, diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 98a40b4b5263f..33e5c0926e199 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -32,6 +32,8 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionViolations from '@hooks/useTransactionViolations'; import type {ViolationField} from '@hooks/useViolations'; import useViolations from '@hooks/useViolations'; +import {initSplitExpense, updateMoneyRequestBillable, updateMoneyRequestReimbursable} from '@libs/actions/IOU/index'; +import {getIsMissingAttendeesViolation} from '@libs/AttendeeUtils'; import {filterPersonalCards, getCompanyCardDescription} from '@libs/CardUtils'; import {getDecodedCategoryName, isCategoryMissing} from '@libs/CategoryUtils'; import {convertToDisplayString} from '@libs/CurrencyUtils'; @@ -51,13 +53,12 @@ import { isTaxTrackingEnabled, } from '@libs/PolicyUtils'; import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {computeReportName} from '@libs/ReportNameUtils'; import {isSplitAction} from '@libs/ReportSecondaryActionUtils'; import { canEditFieldOfMoneyRequest, canEditMoneyRequest, canUserPerformWriteAction as canUserPerformWriteActionReportUtils, - // eslint-disable-next-line @typescript-eslint/no-deprecated - getReportName, getTransactionDetails, getTripIDFromTransactionParentReportID, isExpenseReport, @@ -98,7 +99,6 @@ import { import ViolationsUtils from '@libs/Violations/ViolationsUtils'; import Navigation from '@navigation/Navigation'; import AnimatedEmptyStateBackground from '@pages/home/report/AnimatedEmptyStateBackground'; -import {initSplitExpense, updateMoneyRequestBillable, updateMoneyRequestReimbursable} from '@userActions/IOU'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -168,6 +168,7 @@ function MoneyRequestView({ const {getReportRHPActiveRoute} = useActiveRoute(); const [lastVisitedPath] = useOnyx(ONYXKEYS.LAST_VISITED_PATH, {canBeMissing: true}); + const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); const [allTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, {canBeMissing: false}); const searchContext = useSearchContext(); @@ -475,6 +476,13 @@ function MoneyRequestView({ const hasErrors = hasMissingSmartscanFields(transaction); // Need to return undefined when we have pendingAction to avoid the duplicate pending action const getPendingFieldAction = (fieldPath: TransactionPendingFieldsKey) => (pendingAction ? undefined : transaction?.pendingFields?.[fieldPath]); + const isMissingAttendeesViolation = getIsMissingAttendeesViolation( + policyCategories, + updatedTransaction?.category ?? categoryForDisplay, + actualAttendees, + currentUserPersonalDetails, + policy?.isAttendeeTrackingEnabled, + ); const getErrorForField = (field: ViolationField, data?: OnyxTypes.TransactionViolation['data'], policyHasDependentTags = false, tagValue?: string) => { // Checks applied when creating a new expense @@ -515,6 +523,10 @@ function MoneyRequestView({ return `${violations.map((violation) => ViolationsUtils.getViolationTranslation(violation, translate, canEdit, undefined, companyCardPageURL)).join('. ')}.`; } + if (field === 'attendees' && isMissingAttendeesViolation) { + return translate('violations.missingAttendees'); + } + return ''; }; @@ -723,8 +735,9 @@ function MoneyRequestView({ ); }); - // eslint-disable-next-line @typescript-eslint/no-deprecated - const reportNameToDisplay = isFromMergeTransaction ? (updatedTransaction?.reportName ?? translate('common.none')) : getReportName(parentReport) || parentReport?.reportName; + const reportNameToDisplay = isFromMergeTransaction + ? (updatedTransaction?.reportName ?? translate('common.none')) + : (parentReport?.reportName ?? computeReportName(parentReport, allReports, allPolicies, allTransactions)); const shouldShowReport = !!parentReportID || (isFromMergeTransaction && !!reportNameToDisplay); const reportCopyValue = !canEditReport && reportNameToDisplay !== translate('common.none') ? reportNameToDisplay : undefined; const shouldShowCategoryAnalyzing = isCategoryBeingAnalyzed(updatedTransaction ?? transaction); @@ -1015,6 +1028,8 @@ function MoneyRequestView({ onPress={() => { Navigation.navigate(ROUTES.MONEY_REQUEST_ATTENDEE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID)); }} + brickRoadIndicator={getErrorForField('attendees') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + errorText={getErrorForField('attendees')} interactive={canEdit} shouldShowRightIcon={canEdit} shouldRenderAsHTML diff --git a/src/components/SelectionListWithSections/Search/TransactionListItem.tsx b/src/components/SelectionListWithSections/Search/TransactionListItem.tsx index ef398b51994a7..30c9da177f412 100644 --- a/src/components/SelectionListWithSections/Search/TransactionListItem.tsx +++ b/src/components/SelectionListWithSections/Search/TransactionListItem.tsx @@ -21,6 +21,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; 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 {isViolationDismissed, shouldShowViolation} from '@libs/TransactionUtils'; import variables from '@styles/variables'; @@ -65,6 +66,8 @@ function TransactionListItem({ const snapshotPolicy = useMemo(() => { return (snapshot?.data?.[`${ONYXKEYS.COLLECTION.POLICY}${transactionItem.policyID}`] ?? {}) as Policy; }, [snapshot, transactionItem.policyID]); + // Fetch policy categories directly from Onyx since they are not included in the search snapshot + const [policyCategories] = originalUseOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${transactionItem.policyID}`, {canBeMissing: true}); const [lastPaymentMethod] = useOnyx(`${ONYXKEYS.NVP_LAST_PAYMENT_METHOD}`, {canBeMissing: true}); const [personalPolicyID] = useOnyx(ONYXKEYS.PERSONAL_POLICY_ID, {canBeMissing: true}); @@ -121,12 +124,25 @@ function TransactionListItem({ ]); const transactionViolations = useMemo(() => { - return (violations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionItem.transactionID}`] ?? []).filter( + const onyxViolations = (violations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionItem.transactionID}`] ?? []).filter( (violation: TransactionViolation) => !isViolationDismissed(transactionItem, violation, currentUserDetails.email ?? '', currentUserDetails.accountID, snapshotReport, snapshotPolicy) && shouldShowViolation(snapshotReport, snapshotPolicy, violation.name, currentUserDetails.email ?? '', false, transactionItem), ); - }, [snapshotPolicy, snapshotReport, transactionItem, violations, currentUserDetails.email, currentUserDetails.accountID]); + + // Sync missingAttendees violation with current policy category settings (can be removed later when BE handles this) + const attendeeOnyxViolations = syncMissingAttendeesViolation( + onyxViolations, + policyCategories, + transactionItem.category ?? '', + transactionItem.attendees, + currentUserDetails, + snapshotPolicy?.isAttendeeTrackingEnabled ?? false, + snapshotPolicy?.type === CONST.POLICY.TYPE.CORPORATE, + ); + + return [...onyxViolations, ...attendeeOnyxViolations]; + }, [snapshotPolicy, policyCategories, snapshotReport, transactionItem, violations, currentUserDetails.email, currentUserDetails.accountID, currentUserDetails]); const {isDelegateAccessRestricted, showDelegateNoAccessModal} = useContext(DelegateNoAccessContext); diff --git a/src/hooks/useViolations.ts b/src/hooks/useViolations.ts index f86a875b35edd..acd96ca0987a8 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', 'customUnitRateID', 'none'] as const; +const validationFields = ['amount', 'billable', 'category', 'comment', 'date', 'merchant', 'receipt', 'tag', 'tax', 'attendees', 'customUnitRateID', 'none'] as const; type ViolationField = TupleToUnion; @@ -28,6 +28,7 @@ const violationNameToField: Record 'date', missingCategory: () => 'category', missingComment: () => 'comment', + missingAttendees: () => 'attendees', missingTag: () => 'tag', modifiedAmount: () => 'amount', modifiedDate: () => 'date', diff --git a/src/languages/de.ts b/src/languages/de.ts index 2af81f0497500..e775c00687cab 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -6224,6 +6224,10 @@ Fordere Spesendetails wie Belege und Beschreibungen an, lege Limits und Standard title: 'Kategorierichtlinien', approver: 'Genehmiger', requireDescription: 'Beschreibung erforderlich', + requireFields: 'Felder verpflichtend machen', + requiredFieldsTitle: 'Pflichtfelder', + requiredFieldsDescription: (categoryName: string) => `Dies gilt für alle Ausgaben, die als ${categoryName} kategorisiert sind.`, + requireAttendees: 'Teilnehmer erforderlich machen', descriptionHint: 'Hinweis zur Beschreibung', descriptionHintDescription: (categoryName: string) => `Mitarbeitende daran erinnern, zusätzliche Informationen für Ausgaben der Kategorie „${categoryName}“ anzugeben. Dieser Hinweis erscheint im Beschreibungsfeld von Ausgaben.`, @@ -7208,6 +7212,7 @@ Fordere Spesendetails wie Belege und Beschreibungen an, lege Limits und Standard maxAge: ({maxAge}: ViolationsMaxAgeParams) => `Datum älter als ${maxAge} Tage`, missingCategory: 'Fehlende Kategorie', missingComment: 'Beschreibung für ausgewählte Kategorie erforderlich', + missingAttendees: 'Für diese Kategorie sind mehrere Teilnehmer erforderlich', missingTag: ({tagName}: ViolationsMissingTagParams = {}) => `Fehlende ${tagName ?? 'Tag'}`, modifiedAmount: ({type, displayPercentVariance}: ViolationsModifiedAmountParams) => { switch (type) { diff --git a/src/languages/en.ts b/src/languages/en.ts index a6ae3381f7c93..c844358b407c8 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -6092,6 +6092,10 @@ const translations = { title: 'Category rules', approver: 'Approver', requireDescription: 'Require description', + requireFields: 'Require fields', + requiredFieldsTitle: 'Required fields', + requiredFieldsDescription: (categoryName: string) => `This will apply to all expenses categorized as ${categoryName}.`, + requireAttendees: 'Require attendees', descriptionHint: 'Description hint', descriptionHintDescription: (categoryName: string) => `Remind employees to provide additional information for “${categoryName}” spend. This hint appears in the description field on expenses.`, @@ -7108,6 +7112,7 @@ const translations = { maxAge: ({maxAge}: ViolationsMaxAgeParams) => `Date older than ${maxAge} days`, missingCategory: 'Missing category', missingComment: 'Description required for selected category', + missingAttendees: 'Multiple attendees required for this category', missingTag: ({tagName}: ViolationsMissingTagParams = {}) => `Missing ${tagName ?? 'tag'}`, modifiedAmount: ({type, displayPercentVariance}: ViolationsModifiedAmountParams) => { switch (type) { diff --git a/src/languages/es.ts b/src/languages/es.ts index 987865a8e2c9e..8fa62b1199a29 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -5830,6 +5830,10 @@ ${amount} para ${merchant} - ${date}`, title: 'Reglas de categoría', approver: 'Aprobador', requireDescription: 'Requerir descripción', + requireFields: 'Requerir campos', + requiredFieldsTitle: 'Campos obligatorios', + requiredFieldsDescription: (categoryName) => `Esto se aplicará a todos los gastos categorizados como ${categoryName}.`, + requireAttendees: 'Requerir asistentes', descriptionHint: 'Sugerencia de descripción', descriptionHintDescription: (categoryName) => `Recuerda a los empleados que deben proporcionar información adicional para los gastos de “${categoryName}”. Esta sugerencia aparece en el campo de descripción en los gastos.`, @@ -7245,6 +7249,7 @@ ${amount} para ${merchant} - ${date}`, maxAge: ({maxAge}) => `Fecha de más de ${maxAge} días`, missingCategory: 'Falta categoría', missingComment: 'Descripción obligatoria para la categoría seleccionada', + missingAttendees: 'Se requieren múltiples asistentes para esta categoría', missingTag: ({tagName} = {}) => `Falta ${tagName ?? 'etiqueta'}`, modifiedAmount: ({type, displayPercentVariance}) => { switch (type) { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 21a796cf2851a..fb1d2b48f564f 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -6232,6 +6232,10 @@ Exigez des informations de dépense comme les reçus et les descriptions, défin title: 'Règles de catégorie', approver: 'Approbateur', requireDescription: 'Description requise', + requireFields: 'Rendre les champs obligatoires', + requiredFieldsTitle: 'Champs obligatoires', + requiredFieldsDescription: (categoryName: string) => `Cela s’appliquera à toutes les dépenses classées dans la catégorie ${categoryName}.`, + requireAttendees: 'Exiger des participants', descriptionHint: 'Indice de description', descriptionHintDescription: (categoryName: string) => `Rappelez aux employés de fournir des informations supplémentaires pour les dépenses « ${categoryName} ». Cet indice apparaît dans le champ de description des dépenses.`, @@ -7218,6 +7222,7 @@ Exigez des informations de dépense comme les reçus et les descriptions, défin maxAge: ({maxAge}: ViolationsMaxAgeParams) => `Date de plus de ${maxAge} jours`, missingCategory: 'Catégorie manquante', missingComment: 'Description requise pour la catégorie sélectionnée', + missingAttendees: 'Plusieurs participants sont requis pour cette catégorie', missingTag: ({tagName}: ViolationsMissingTagParams = {}) => `Manquant ${tagName ?? 'étiquette'}`, modifiedAmount: ({type, displayPercentVariance}: ViolationsModifiedAmountParams) => { switch (type) { diff --git a/src/languages/it.ts b/src/languages/it.ts index d71c71e2e5e30..91d5ffe792e41 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -6206,6 +6206,10 @@ Richiedi dettagli di spesa come ricevute e descrizioni, imposta limiti e valori title: 'Regole di categoria', approver: 'Approvatore', requireDescription: 'Richiedi descrizione', + requireFields: 'Rendi obbligatori i campi', + requiredFieldsTitle: 'Campi obbligatori', + requiredFieldsDescription: (categoryName: string) => `Questo si applicherà a tutte le spese classificate come ${categoryName}.`, + requireAttendees: 'Richiedi partecipanti', descriptionHint: 'Suggerimento per la descrizione', descriptionHintDescription: (categoryName: string) => `Ricorda ai dipendenti di fornire informazioni aggiuntive per la spesa in “${categoryName}”. Questo suggerimento appare nel campo descrizione sulle spese.`, @@ -7194,6 +7198,7 @@ Richiedi dettagli di spesa come ricevute e descrizioni, imposta limiti e valori maxAge: ({maxAge}: ViolationsMaxAgeParams) => `Data precedente a ${maxAge} giorni`, missingCategory: 'Categoria mancante', missingComment: 'Descrizione richiesta per la categoria selezionata', + missingAttendees: 'Più partecipanti obbligatori per questa categoria', missingTag: ({tagName}: ViolationsMissingTagParams = {}) => `Manca ${tagName ?? 'etichetta'}`, modifiedAmount: ({type, displayPercentVariance}: ViolationsModifiedAmountParams) => { switch (type) { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 4869fc09946dd..f7bedf1e1a6dc 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -6164,6 +6164,10 @@ ${reportName} title: 'カテゴリルール', approver: '承認者', requireDescription: '説明を必須にする', + requireFields: 'フィールドを必須にする', + requiredFieldsTitle: '必須項目', + requiredFieldsDescription: (categoryName: string) => `これは${categoryName}として分類されたすべての経費に適用されます。`, + requireAttendees: '参加者の入力を必須にする', descriptionHint: '説明のヒント', descriptionHintDescription: (categoryName: string) => `従業員に「${categoryName}」での支出について追加情報を提供するよう促します。このヒントは経費の説明欄に表示されます。`, descriptionHintLabel: 'ヒント', @@ -7137,6 +7141,7 @@ ${reportName} maxAge: ({maxAge}: ViolationsMaxAgeParams) => `${maxAge}日より前の日付`, missingCategory: 'カテゴリ未設定', missingComment: '選択したカテゴリーには説明が必要です', + missingAttendees: 'このカテゴリには複数の参加者が必要です', missingTag: ({tagName}: ViolationsMissingTagParams = {}) => `${tagName ?? 'タグ'} が見つかりません`, modifiedAmount: ({type, displayPercentVariance}: ViolationsModifiedAmountParams) => { switch (type) { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 732c249339f36..2a1a025d3454d 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -6195,6 +6195,10 @@ Vraag verplichte uitgavedetails zoals bonnetjes en beschrijvingen, stel limieten title: 'Categorisatieregels', approver: 'Fiatteur', requireDescription: 'Beschrijving vereist', + requireFields: 'Velden verplicht stellen', + requiredFieldsTitle: 'Verplichte velden', + requiredFieldsDescription: (categoryName: string) => `Dit is van toepassing op alle uitgaven die zijn gecategoriseerd als ${categoryName}.`, + requireAttendees: 'Aanwezigen verplicht stellen', descriptionHint: 'Beschrijvingstip', descriptionHintDescription: (categoryName: string) => `Herinner medewerkers eraan om extra informatie te geven voor uitgaven in de categorie “${categoryName}”. Deze tip verschijnt in het omschrijvingsveld van uitgaven.`, @@ -7180,6 +7184,7 @@ Vraag verplichte uitgavedetails zoals bonnetjes en beschrijvingen, stel limieten maxAge: ({maxAge}: ViolationsMaxAgeParams) => `Datum ouder dan ${maxAge} dagen`, missingCategory: 'Ontbrekende categorie', missingComment: 'Beschrijving vereist voor geselecteerde categorie', + missingAttendees: 'Meerdere deelnemers vereist voor deze categorie', missingTag: ({tagName}: ViolationsMissingTagParams = {}) => `Ontbreekt ${tagName ?? 'label'}`, modifiedAmount: ({type, displayPercentVariance}: ViolationsModifiedAmountParams) => { switch (type) { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index a98256ba5c088..f8003393af996 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -6186,6 +6186,10 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i title: 'Zasady kategorii', approver: 'Akceptujący', requireDescription: 'Wymagaj opisu', + requireFields: 'Wymagaj pól', + requiredFieldsTitle: 'Wymagane pola', + requiredFieldsDescription: (categoryName: string) => `To będzie miało zastosowanie do wszystkich wydatków skategoryzowanych jako ${categoryName}.`, + requireAttendees: 'Wymagaj uczestników', descriptionHint: 'Podpowiedź opisu', descriptionHintDescription: (categoryName: string) => `Przypominaj pracownikom o podaniu dodatkowych informacji dotyczących wydatków w kategorii „${categoryName}”. Ta podpowiedź pojawia się w polu opisu przy wydatkach.`, @@ -7168,6 +7172,7 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i maxAge: ({maxAge}: ViolationsMaxAgeParams) => `Data starsza niż ${maxAge} dni`, missingCategory: 'Brak kategorii', missingComment: 'Opis jest wymagany dla wybranej kategorii', + missingAttendees: 'Wymaganych jest wielu uczestników dla tej kategorii', missingTag: ({tagName}: ViolationsMissingTagParams = {}) => `Brakujące ${tagName ?? 'tag'}`, modifiedAmount: ({type, displayPercentVariance}: ViolationsModifiedAmountParams) => { switch (type) { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 6a6f5ff4e489c..a0f932c44dd82 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -6188,6 +6188,10 @@ Exija detalhes de despesas como recibos e descrições, defina limites e padrõe title: 'Regras de categoria', approver: 'Aprovador', requireDescription: 'Exigir descrição', + requireFields: 'Exigir campos', + requiredFieldsTitle: 'Campos obrigatórios', + requiredFieldsDescription: (categoryName: string) => `Isso será aplicado a todas as despesas categorizadas como ${categoryName}.`, + requireAttendees: 'Exigir participantes', descriptionHint: 'Dica de descrição', descriptionHintDescription: (categoryName: string) => `Lembre os funcionários de fornecer informações adicionais para gastos em “${categoryName}”. Essa dica aparece no campo de descrição das despesas.`, @@ -7172,6 +7176,7 @@ Exija detalhes de despesas como recibos e descrições, defina limites e padrõe maxAge: ({maxAge}: ViolationsMaxAgeParams) => `Data anterior a ${maxAge} dias`, missingCategory: 'Categoria ausente', missingComment: 'Descrição obrigatória para a categoria selecionada', + missingAttendees: 'Vários participantes são obrigatórios para esta categoria', missingTag: ({tagName}: ViolationsMissingTagParams = {}) => `Faltando ${tagName ?? 'Tag'}`, modifiedAmount: ({type, displayPercentVariance}: ViolationsModifiedAmountParams) => { switch (type) { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index e713f9474007f..a42cc8a4165c3 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -6065,6 +6065,10 @@ ${reportName} title: '类别规则', approver: '审批人', requireDescription: '要求描述', + requireFields: '必填字段', + requiredFieldsTitle: '必填项', + requiredFieldsDescription: (categoryName: string) => `这将适用于所有被归类为 ${categoryName} 的费用。`, + requireAttendees: '要求与会者', descriptionHint: '描述提示', descriptionHintDescription: (categoryName: string) => `提醒员工为“${categoryName}”支出提供更多信息。此提示将显示在报销单的描述字段中。`, descriptionHintLabel: '提示', @@ -7021,6 +7025,7 @@ ${reportName} maxAge: ({maxAge}: ViolationsMaxAgeParams) => `日期早于 ${maxAge} 天`, missingCategory: '缺少类别', missingComment: '所选类别需要填写描述', + missingAttendees: '此类别需要多个参与者', missingTag: ({tagName}: ViolationsMissingTagParams = {}) => `缺少 ${tagName ?? '标签'}`, modifiedAmount: ({type, displayPercentVariance}: ViolationsModifiedAmountParams) => { switch (type) { diff --git a/src/libs/API/parameters/SetPolicyCategoryAttendeesRequiredParams.ts b/src/libs/API/parameters/SetPolicyCategoryAttendeesRequiredParams.ts new file mode 100644 index 0000000000000..abaa94f241cc5 --- /dev/null +++ b/src/libs/API/parameters/SetPolicyCategoryAttendeesRequiredParams.ts @@ -0,0 +1,7 @@ +type SetPolicyCategoryAttendeesRequiredParams = { + policyID: string; + categoryName: string; + areAttendeesRequired: boolean; +}; + +export default SetPolicyCategoryAttendeesRequiredParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index bece054a02378..2738d1fda1e94 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -345,6 +345,7 @@ export type {default as AddDelegateParams} from './AddDelegateParams'; export type {default as UpdateDelegateRoleParams} from './UpdateDelegateRoleParams'; export type {default as OpenCardDetailsPageParams} from './OpenCardDetailsPageParams'; export type {default as SetPolicyCategoryDescriptionRequiredParams} from './SetPolicyCategoryDescriptionRequiredParams'; +export type {default as SetPolicyCategoryAttendeesRequiredParams} from './SetPolicyCategoryAttendeesRequiredParams'; export type {default as SetPolicyCategoryApproverParams} from './SetPolicyCategoryApproverParams'; export type {default as SetWorkspaceCategoryDescriptionHintParams} from './SetWorkspaceCategoryDescriptionHintParams'; export type {default as SetPolicyCategoryTaxParams} from './SetPolicyCategoryTaxParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 0e2fca6d06cd1..aa3b948ae8784 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -263,6 +263,7 @@ const WRITE_COMMANDS = { SET_POLICY_ATTENDEE_TRACKING_ENABLED: 'SetPolicyAttendeeTrackingEnabled', SET_POLICY_REQUIRE_COMPANY_CARDS_ENABLED: 'SetPolicyRequireCompanyCardsEnabled', SET_POLICY_CATEGORY_DESCRIPTION_REQUIRED: 'SetPolicyCategoryDescriptionRequired', + SET_POLICY_CATEGORY_ATTENDEES_REQUIRED: 'SetPolicyCategoryAttendeesRequired', SET_WORKSPACE_CATEGORY_DESCRIPTION_HINT: 'SetWorkspaceCategoryDescriptionHint', SET_POLICY_CATEGORY_RECEIPTS_REQUIRED: 'SetPolicyCategoryReceiptsRequired', REMOVE_POLICY_CATEGORY_RECEIPTS_REQUIRED: 'RemoveWorkspaceCategoryReceiptsRequired', @@ -789,6 +790,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.SET_POLICY_RULES_ENABLED]: Parameters.SetPolicyRulesEnabledParams; [WRITE_COMMANDS.SET_POLICY_REQUIRE_COMPANY_CARDS_ENABLED]: Parameters.SetPolicyRequireCompanyCardsEnabledParams; [WRITE_COMMANDS.SET_POLICY_CATEGORY_DESCRIPTION_REQUIRED]: Parameters.SetPolicyCategoryDescriptionRequiredParams; + [WRITE_COMMANDS.SET_POLICY_CATEGORY_ATTENDEES_REQUIRED]: Parameters.SetPolicyCategoryAttendeesRequiredParams; [WRITE_COMMANDS.SET_WORKSPACE_CATEGORY_DESCRIPTION_HINT]: Parameters.SetWorkspaceCategoryDescriptionHintParams; [WRITE_COMMANDS.SET_POLICY_CATEGORY_RECEIPTS_REQUIRED]: Parameters.SetPolicyCategoryReceiptsRequiredParams; [WRITE_COMMANDS.REMOVE_POLICY_CATEGORY_RECEIPTS_REQUIRED]: Parameters.RemovePolicyCategoryReceiptsRequiredParams; diff --git a/src/libs/AttendeeUtils.ts b/src/libs/AttendeeUtils.ts new file mode 100644 index 0000000000000..a3ada3de4fe49 --- /dev/null +++ b/src/libs/AttendeeUtils.ts @@ -0,0 +1,92 @@ +import type {LocaleContextProps} from '@components/LocaleContextProvider'; +import CONST from '@src/CONST'; +import type {PolicyCategories, PolicyCategory} from '@src/types/onyx'; +import type {Attendee} from '@src/types/onyx/IOU'; +import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; + +/** Formats the title for requiredFields menu item based on which fields are enabled in the policy category */ +function formatRequiredFieldsTitle(translate: LocaleContextProps['translate'], policyCategory: PolicyCategory, isAttendeeTrackingEnabled = false): string { + const enabledFields: string[] = []; + + // Attendees field should show first when both are selected and attendee tracking is enabled + if (isAttendeeTrackingEnabled && policyCategory.areAttendeesRequired) { + enabledFields.push(translate('iou.attendees')); + } + + if (policyCategory.areCommentsRequired) { + enabledFields.push(translate('common.description')); + } + + if (enabledFields.length === 0) { + return ''; + } + + const [first, ...rest] = enabledFields; + const capitalizedFirst = first.charAt(0).toUpperCase() + first.slice(1); + const lowercasedRest = rest.map((field) => field.charAt(0).toLowerCase() + field.slice(1)); + return [capitalizedFirst, ...lowercasedRest].join(', '); +} + +/** Returns whether there are missing attendees for the given category */ +function getIsMissingAttendeesViolation( + policyCategories: PolicyCategories | undefined, + category: string, + iouAttendees: Attendee[] | string | undefined, + userPersonalDetails: CurrentUserPersonalDetails, + isAttendeeTrackingEnabled = false, +) { + const areAttendeesRequired = !!policyCategories?.[category ?? '']?.areAttendeesRequired; + // If attendee tracking is disabled at the policy level, don't enforce attendee requirement + if (!isAttendeeTrackingEnabled || !areAttendeesRequired) { + return false; + } + + const creatorLogin = userPersonalDetails.login ?? ''; + const attendees = Array.isArray(iouAttendees) ? iouAttendees : []; + const attendeesMinusCreatorCount = attendees.filter((a) => a?.login !== creatorLogin).length; + + if (attendees.length === 0 || attendeesMinusCreatorCount === 0) { + return true; + } + + return false; +} + +/** + * Syncs the missingAttendees violation with current policy settings. + * - Adds the violation when it should show but isn't present from BE + * - Removes stale BE violation when policy settings changed (e.g., category no longer requires attendees) + */ +function syncMissingAttendeesViolation( + violations: T[], + policyCategories: PolicyCategories | undefined, + category: string, + attendees: Attendee[] | undefined, + userPersonalDetails: CurrentUserPersonalDetails, + isAttendeeTrackingEnabled: boolean, + isControlPolicy: boolean, +): T[] { + const hasMissingAttendeesViolation = violations.some((v) => v.name === CONST.VIOLATIONS.MISSING_ATTENDEES); + const shouldShowMissingAttendees = + isControlPolicy && getIsMissingAttendeesViolation(policyCategories ?? {}, category ?? '', attendees ?? [], userPersonalDetails, isAttendeeTrackingEnabled); + + if (!hasMissingAttendeesViolation && shouldShowMissingAttendees) { + // Add violation when it should show but isn't present from BE + return [ + ...violations, + { + name: CONST.VIOLATIONS.MISSING_ATTENDEES, + type: CONST.VIOLATION_TYPES.VIOLATION, + showInReview: true, + } as unknown as T, + ]; + } + if (hasMissingAttendeesViolation && !shouldShowMissingAttendees) { + // Remove stale BE violation when policy settings changed + return violations.filter((v) => v.name !== CONST.VIOLATIONS.MISSING_ATTENDEES); + } + + return violations; +} + +export {formatRequiredFieldsTitle, getIsMissingAttendeesViolation, syncMissingAttendeesViolation}; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 59e3251ff9059..5b6efd0a59ac5 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -430,6 +430,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/WorkspaceOverviewSharePage').default, [SCREENS.WORKSPACE.CURRENCY]: () => require('../../../../pages/workspace/WorkspaceOverviewCurrencyPage').default, [SCREENS.WORKSPACE.CATEGORY_SETTINGS]: () => require('../../../../pages/workspace/categories/CategorySettingsPage').default, + [SCREENS.WORKSPACE.CATEGORY_REQUIRED_FIELDS]: () => require('../../../../pages/workspace/categories/CategoryRequiredFieldsPage').default, [SCREENS.WORKSPACE.ADDRESS]: () => require('../../../../pages/workspace/WorkspaceOverviewAddressPage').default, [SCREENS.WORKSPACE.PLAN]: () => require('../../../../pages/workspace/WorkspaceOverviewPlanTypePage').default, [SCREENS.WORKSPACE.CATEGORIES_SETTINGS]: () => require('../../../../pages/workspace/categories/WorkspaceCategoriesSettingsPage').default, diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts index 82742ec57e4f7..e762868d3488f 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts @@ -213,6 +213,7 @@ const WORKSPACE_TO_RHP: Partial['config'] = { [SCREENS.WORKSPACE.CATEGORY_REQUIRE_RECEIPTS_OVER]: { path: ROUTES.WORKSPACE_CATEGORY_REQUIRE_RECEIPTS_OVER.route, }, + [SCREENS.WORKSPACE.CATEGORY_REQUIRED_FIELDS]: { + path: ROUTES.WORKSPACE_CATEGORY_REQUIRED_FIELDS.route, + }, [SCREENS.WORKSPACE.CREATE_DISTANCE_RATE]: { path: ROUTES.WORKSPACE_CREATE_DISTANCE_RATE.route, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index b956be04f75e5..e28784906e458 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -335,6 +335,10 @@ type SettingsNavigatorParamList = { // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md backTo?: Routes; }; + [SCREENS.WORKSPACE.CATEGORY_REQUIRED_FIELDS]: { + policyID: string; + categoryName: string; + }; [SCREENS.WORKSPACE.UPGRADE]: { policyID?: string; featureName?: string; diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 62213616a4407..60a5d81515028 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -486,7 +486,7 @@ function shouldShowAttendees(iouType: IOUType, policy: OnyxEntry): boole // For backwards compatibility with Expensify Classic, we assume that Attendee Tracking is enabled by default on // Control policies if the policy does not contain the attribute - return policy?.isAttendeeTrackingEnabled ?? true; + return policy?.isAttendeeTrackingEnabled ?? false; } /** @@ -1483,6 +1483,7 @@ function shouldShowViolation( const isSubmitter = isCurrentUserSubmitter(iouReport); const isPolicyMember = isPolicyMemberPolicyUtils(policy, currentUserEmail); const isReportOpen = isOpenExpenseReport(iouReport); + const isAttendeeTrackingEnabled = policy?.isAttendeeTrackingEnabled ?? false; if (violationName === CONST.VIOLATIONS.AUTO_REPORTED_REJECTED_EXPENSE) { return isSubmitter || isPolicyAdmin(policy); @@ -1500,6 +1501,10 @@ function shouldShowViolation( return isPolicyMember && !isSubmitter && !isReportOpen; } + if (violationName === CONST.VIOLATIONS.MISSING_ATTENDEES) { + return isAttendeeTrackingEnabled; + } + if (violationName === CONST.VIOLATIONS.MISSING_CATEGORY && isCategoryBeingAnalyzed(transaction)) { return false; } diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index ebca1020208a0..fa4efe03f9f91 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 {getCurrentUserEmail} from '@libs/actions/Report'; import {getDecodedCategoryName, isCategoryMissing} from '@libs/CategoryUtils'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import DateUtils from '@libs/DateUtils'; @@ -335,6 +336,7 @@ const ViolationsUtils = { // const hasOverTripLimitViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.OVER_TRIP_LIMIT); const hasCategoryOverLimitViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.OVER_CATEGORY_LIMIT); const hasMissingCommentViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.MISSING_COMMENT); + const hasMissingAttendeesViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.MISSING_ATTENDEES); const hasTaxOutOfPolicyViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.TAX_OUT_OF_POLICY); const isDistanceRequest = TransactionUtils.isDistanceRequest(updatedTransaction); const isPerDiemRequest = TransactionUtils.isPerDiemRequest(updatedTransaction); @@ -383,6 +385,40 @@ const ViolationsUtils = { const shouldCategoryShowOverLimitViolation = canCalculateAmountViolations && !isInvoiceTransaction && typeof categoryOverLimit === 'number' && expenseAmount > categoryOverLimit && isControlPolicy; const shouldShowMissingComment = !isInvoiceTransaction && policyCategories?.[categoryName ?? '']?.areCommentsRequired && !updatedTransaction.comment?.comment && isControlPolicy; + const attendees = updatedTransaction.modifiedAttendees ?? updatedTransaction.comment?.attendees ?? []; + const isAttendeeTrackingEnabled = policy.isAttendeeTrackingEnabled ?? false; + // Filter out the owner/creator when checking attendance count - expense is valid if at least one non-owner attendee is present + const ownerAccountID = iouReport?.ownerAccountID; + // Calculate attendees minus owner. When ownerAccountID is known, filter by accountID. + // When ownerAccountID is undefined (offline split where iouReport is unavailable), + // fallback to using login/email to identify the owner (similar to AttendeeUtils approach). + let attendeesMinusOwnerCount: number; + if (ownerAccountID !== undefined) { + // Normal case: filter by accountID + attendeesMinusOwnerCount = attendees.filter((a) => a?.accountID !== ownerAccountID).length; + } else { + // Offline scenario: ownerAccountID unavailable, use login/email as fallback + const currentUserEmail = getCurrentUserEmail(); + if (currentUserEmail) { + // Filter by login or email to identify owner + attendeesMinusOwnerCount = attendees.filter((a) => { + const attendeeIdentifier = a?.login ?? a?.email; + return attendeeIdentifier !== currentUserEmail; + }).length; + } else { + // Can't identify owner at all - if there are attendees, assume owner is one of them + // This means we need at least 2 attendees to have a non-owner attendee + attendeesMinusOwnerCount = Math.max(0, attendees.length - 1); + } + } + + const shouldShowMissingAttendees = + !isInvoiceTransaction && + isAttendeeTrackingEnabled && + policyCategories?.[categoryName ?? '']?.areAttendeesRequired && + isControlPolicy && + (attendees.length === 0 || attendeesMinusOwnerCount === 0); + const hasFutureDateViolation = transactionViolations.some((violation) => violation.name === 'futureDate'); // Add 'futureDate' violation if transaction date is in the future and policy type is corporate if (!hasFutureDateViolation && shouldDisplayFutureDateViolation) { @@ -465,6 +501,18 @@ const ViolationsUtils = { newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.MISSING_COMMENT}); } + if (!hasMissingAttendeesViolation && shouldShowMissingAttendees) { + newTransactionViolations.push({ + name: CONST.VIOLATIONS.MISSING_ATTENDEES, + type: CONST.VIOLATION_TYPES.VIOLATION, + showInReview: true, + }); + } + + if (hasMissingAttendeesViolation && !shouldShowMissingAttendees) { + newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.MISSING_ATTENDEES}); + } + if (isPolicyTrackTaxEnabled && !hasTaxOutOfPolicyViolation && !isTaxInPolicy) { newTransactionViolations.push({name: CONST.VIOLATIONS.TAX_OUT_OF_POLICY, type: CONST.VIOLATION_TYPES.VIOLATION, showInReview: true}); } @@ -534,6 +582,8 @@ const ViolationsUtils = { return translate('violations.missingCategory'); case 'missingComment': return translate('violations.missingComment'); + case 'missingAttendees': + return translate('violations.missingAttendees'); case 'missingTag': return translate('violations.missingTag', {tagName}); case 'modifiedAmount': diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 72aab3add116c..520acfa4c662c 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -4279,6 +4279,7 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U const hasModifiedTaxCode = 'taxCode' in transactionChanges; const hasModifiedDate = 'date' in transactionChanges; const hasModifiedMerchant = 'merchant' in transactionChanges; + const hasModifiedAttendees = 'attendees' in transactionChanges; const isInvoice = isInvoiceReportReportUtils(iouReport); if ( @@ -4297,7 +4298,8 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U hasModifiedAmount || hasModifiedCreated || hasModifiedReimbursable || - hasModifiedTaxCode) + hasModifiedTaxCode || + hasModifiedAttendees) ) { const currentTransactionViolations = allTransactionViolations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`] ?? []; // If the amount, currency or date have been modified, we remove the duplicate violations since they would be out of date as the transaction has changed diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts index a404d2854d4a6..27b700b2970c1 100644 --- a/src/libs/actions/Policy/Category.ts +++ b/src/libs/actions/Policy/Category.ts @@ -11,6 +11,7 @@ import type { OpenPolicyCategoriesPageParams, RemovePolicyCategoryReceiptsRequiredParams, SetPolicyCategoryApproverParams, + SetPolicyCategoryAttendeesRequiredParams, SetPolicyCategoryDescriptionRequiredParams, SetPolicyCategoryMaxAmountParams, SetPolicyCategoryReceiptsRequiredParams, @@ -1520,6 +1521,68 @@ function setPolicyCategoryTax(policy: OnyxEntry, categoryName: string, t API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_TAX, parameters, onyxData); } +function setPolicyCategoryAttendeesRequired(policyID: string, categoryName: string, areAttendeesRequired: boolean, policyCategories: PolicyCategories = {}) { + const policyCategoryToUpdate = policyCategories?.[categoryName]; + const originalAreAttendeesRequired = policyCategoryToUpdate?.areAttendeesRequired; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + pendingFields: { + areAttendeesRequired: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + areAttendeesRequired, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + pendingAction: null, + pendingFields: { + areAttendeesRequired: null, + }, + areAttendeesRequired, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + pendingAction: null, + pendingFields: { + areAttendeesRequired: null, + }, + areAttendeesRequired: originalAreAttendeesRequired, + }, + }, + }, + ], + }; + + const parameters: SetPolicyCategoryAttendeesRequiredParams = { + policyID, + categoryName, + areAttendeesRequired, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_CATEGORY_ATTENDEES_REQUIRED, parameters, onyxData); +} + export { buildOptimisticPolicyCategories, buildOptimisticMccGroup, @@ -1534,6 +1597,7 @@ export { removePolicyCategoryReceiptsRequired, renamePolicyCategory, setPolicyCategoryApprover, + setPolicyCategoryAttendeesRequired, setPolicyCategoryDescriptionRequired, buildOptimisticPolicyWithExistingCategories, setPolicyCategoryGLCode, diff --git a/src/pages/iou/request/step/IOURequestStepAttendees.tsx b/src/pages/iou/request/step/IOURequestStepAttendees.tsx index e2f4c024fb57a..5258f40e7b89d 100644 --- a/src/pages/iou/request/step/IOURequestStepAttendees.tsx +++ b/src/pages/iou/request/step/IOURequestStepAttendees.tsx @@ -1,6 +1,5 @@ import {deepEqual} from 'fast-equals'; import React, {useCallback, useState} from 'react'; -import type {OnyxEntry} from 'react-native-onyx'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -15,37 +14,27 @@ import MoneyRequestAttendeeSelector from '@pages/iou/request/MoneyRequestAttende import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; -import type * as OnyxTypes from '@src/types/onyx'; import type {Attendee} from '@src/types/onyx/IOU'; import StepScreenWrapper from './StepScreenWrapper'; import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; import withWritableReportOrNotFound from './withWritableReportOrNotFound'; -type IOURequestStepAttendeesOnyxProps = { - /** The policy of the report */ - policy: OnyxEntry; - - /** Collection of categories attached to a policy */ - policyCategories: OnyxEntry; - - /** Collection of tags attached to a policy */ - policyTags: OnyxEntry; -}; - -type IOURequestStepAttendeesProps = IOURequestStepAttendeesOnyxProps & WithWritableReportOrNotFoundProps; +type IOURequestStepAttendeesProps = WithWritableReportOrNotFoundProps; function IOURequestStepAttendees({ route: { params: {transactionID, reportID, iouType, backTo, action}, }, - policy, - policyTags, - policyCategories, }: IOURequestStepAttendeesProps) { const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const isEditing = action === CONST.IOU.ACTION.EDIT; // eslint-disable-next-line rulesdir/no-default-id-values const [transaction] = useOnyx(`${isEditing ? ONYXKEYS.COLLECTION.TRANSACTION : ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID || CONST.DEFAULT_NUMBER_ID}`, {canBeMissing: true}); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {canBeMissing: true}); + const policyID = report?.policyID; + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {canBeMissing: true}); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, {canBeMissing: true}); + const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, {canBeMissing: true}); const [attendees, setAttendees] = useState(() => getOriginalAttendees(transaction, currentUserPersonalDetails)); const previousAttendees = usePrevious(attendees); const {translate} = useLocalize(); diff --git a/src/pages/workspace/categories/CategoryRequiredFieldsPage.tsx b/src/pages/workspace/categories/CategoryRequiredFieldsPage.tsx new file mode 100644 index 0000000000000..728e9792dd387 --- /dev/null +++ b/src/pages/workspace/categories/CategoryRequiredFieldsPage.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import {View} from 'react-native'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import RenderHTML from '@components/RenderHTML'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; +import Switch from '@components/Switch'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {getDecodedCategoryName} from '@libs/CategoryUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@navigation/types'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import {setPolicyCategoryAttendeesRequired, setPolicyCategoryDescriptionRequired} from '@userActions/Policy/Category'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +type CategoryRequiredFieldsPageProps = PlatformStackScreenProps; + +function CategoryRequiredFieldsPage({ + route: { + params: {policyID, categoryName}, + }, +}: CategoryRequiredFieldsPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, {canBeMissing: true}); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {canBeMissing: true}); + const decodedCategoryName = getDecodedCategoryName(categoryName); + + const policyCategory = policyCategories?.[categoryName]; + const areCommentsRequired = policyCategory?.areCommentsRequired ?? false; + const areAttendeesRequired = policyCategory?.areAttendeesRequired ?? false; + const isAttendeeTrackingEnabled = policy?.isAttendeeTrackingEnabled ?? false; + + return ( + + + Navigation.goBack(ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(policyID, categoryName))} + /> + + + + + + + + {translate('workspace.rules.categoryRules.requireDescription')} + { + setPolicyCategoryDescriptionRequired(policyID, categoryName, !areCommentsRequired, policyCategories); + }} + /> + + + + {isAttendeeTrackingEnabled && ( + + + + {translate('workspace.rules.categoryRules.requireAttendees')} + { + setPolicyCategoryAttendeesRequired(policyID, categoryName, !areAttendeesRequired, policyCategories); + }} + /> + + + + )} + + + + ); +} + +CategoryRequiredFieldsPage.displayName = 'CategoryRequiredFieldsPage'; + +export default CategoryRequiredFieldsPage; diff --git a/src/pages/workspace/categories/CategorySettingsPage.tsx b/src/pages/workspace/categories/CategorySettingsPage.tsx index e22c8bc46aa1c..1cb19e136ca61 100644 --- a/src/pages/workspace/categories/CategorySettingsPage.tsx +++ b/src/pages/workspace/categories/CategorySettingsPage.tsx @@ -17,6 +17,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnboardingTaskInformation from '@hooks/useOnboardingTaskInformation'; import usePolicyData from '@hooks/usePolicyData'; import useThemeStyles from '@hooks/useThemeStyles'; +import {formatRequiredFieldsTitle} from '@libs/AttendeeUtils'; import {formatDefaultTaxRateText, formatRequireReceiptsOverText, getCategoryApproverRule, getCategoryDefaultTaxRate, getDecodedCategoryName} from '@libs/CategoryUtils'; import {convertToDisplayString} from '@libs/CurrencyUtils'; import {getLatestErrorMessageField} from '@libs/ErrorUtils'; @@ -28,13 +29,7 @@ import {getWorkflowApprovalsUnavailable, isControlPolicy} from '@libs/PolicyUtil import type {SettingsNavigatorParamList} from '@navigation/types'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; -import { - clearCategoryErrors, - deleteWorkspaceCategories, - setPolicyCategoryDescriptionRequired, - setWorkspaceCategoryDescriptionHint, - setWorkspaceCategoryEnabled, -} from '@userActions/Policy/Category'; +import {clearCategoryErrors, deleteWorkspaceCategories, setWorkspaceCategoryEnabled} from '@userActions/Policy/Category'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; @@ -127,6 +122,22 @@ function CategorySettingsPage({ return formatRequireReceiptsOverText(translate, policy, policyCategory?.maxAmountNoReceipt); }, [policy, policyCategory?.maxAmountNoReceipt, translate]); + const requiredFieldsTitle = useMemo(() => { + if (!policyCategory) { + return ''; + } + return formatRequiredFieldsTitle(translate, policyCategory, policy?.isAttendeeTrackingEnabled); + }, [policyCategory, translate, policy?.isAttendeeTrackingEnabled]); + + const requireFieldsPendingAction = useMemo(() => { + if (policy?.isAttendeeTrackingEnabled) { + // Pending fields are objects so we can't use nullish coalescing + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + return policyCategory?.pendingFields?.areAttendeesRequired || policyCategory?.pendingFields?.areCommentsRequired; + } + return policyCategory?.pendingFields?.areCommentsRequired; + }, [policyCategory?.pendingFields, policy?.isAttendeeTrackingEnabled]); + if (!policyCategory) { return ; } @@ -288,40 +299,38 @@ function CategorySettingsPage({ /> + {areCommentsRequired && ( + + { + Navigation.navigate(ROUTES.WORKSPACE_CATEGORY_DESCRIPTION_HINT.getRoute(policyID, policyCategory.name)); + }} + shouldShowRightIcon + /> + + )} + + {!isThereAnyAccountingConnection && ( + { + if (shouldPreventDisableOrDelete) { + setIsCannotDeleteOrDisableLastCategoryModalVisible(true); + return; + } + setDeleteCategoryConfirmModalVisible(true); + }} + /> + )} + {!!policy?.areRulesEnabled && ( <> {translate('workspace.rules.categoryRules.title')} - - - - {translate('workspace.rules.categoryRules.requireDescription')} - { - if (policyCategory.commentHint && areCommentsRequired) { - setWorkspaceCategoryDescriptionHint(policyID, categoryName, '', policyCategories); - } - setPolicyCategoryDescriptionRequired(policyID, categoryName, !areCommentsRequired, policyCategories); - }} - /> - - - - {!!policyCategory?.areCommentsRequired && ( - - { - Navigation.navigate(ROUTES.WORKSPACE_CATEGORY_DESCRIPTION_HINT.getRoute(policyID, policyCategory.name)); - }} - shouldShowRightIcon - /> - - )} + + { + Navigation.navigate(ROUTES.WORKSPACE_CATEGORY_REQUIRED_FIELDS.getRoute(policyID, policyCategory.name)); + }} + shouldShowRightIcon + /> + )} - {!isThereAnyAccountingConnection && ( - { - if (shouldPreventDisableOrDelete) { - setIsCannotDeleteOrDisableLastCategoryModalVisible(true); - return; - } - setDeleteCategoryConfirmModalVisible(true); - }} - /> - )} diff --git a/src/pages/workspace/rules/IndividualExpenseRulesSection.tsx b/src/pages/workspace/rules/IndividualExpenseRulesSection.tsx index c79e070306e2a..668e90f4d6ccb 100644 --- a/src/pages/workspace/rules/IndividualExpenseRulesSection.tsx +++ b/src/pages/workspace/rules/IndividualExpenseRulesSection.tsx @@ -1,4 +1,4 @@ -import React, {useMemo} from 'react'; +import React, {useCallback, useMemo} from 'react'; import {View} from 'react-native'; import type {LocaleContextProps} from '@components/LocaleContextProvider'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; @@ -8,8 +8,10 @@ import Section from '@components/Section'; import useCardFeeds from '@hooks/useCardFeeds'; import useEnvironment from '@hooks/useEnvironment'; import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; +import {setPolicyCategoryAttendeesRequired} from '@libs/actions/Policy/Category'; import {getCashExpenseReimbursableMode, setPolicyAttendeeTrackingEnabled, setPolicyRequireCompanyCardsEnabled, setWorkspaceEReceiptsEnabled} from '@libs/actions/Policy/Policy'; import {convertToDisplayString} from '@libs/CurrencyUtils'; import Navigation from '@libs/Navigation/Navigation'; @@ -17,6 +19,7 @@ import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOpt import type {ThemeStyles} from '@styles/index'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; +import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Policy} from '@src/types/onyx'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; @@ -72,9 +75,27 @@ function IndividualExpenseRulesSection({policyID}: IndividualExpenseRulesSection const policy = usePolicy(policyID); const [cardFeeds] = useCardFeeds(policyID); const {environmentURL} = useEnvironment(); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, {canBeMissing: true}); const policyCurrency = policy?.outputCurrency ?? CONST.CURRENCY.USD; + const handleAttendeeTrackingToggle = useCallback( + (newValue: boolean) => { + // When disabling attendee tracking, disable areAttendeesRequired on all categories + // that have it enabled in order to avoid BE validation errors + if (!newValue && policyCategories) { + for (const [categoryName, category] of Object.entries(policyCategories)) { + if (!category?.areAttendeesRequired) { + continue; + } + setPolicyCategoryAttendeesRequired(policyID, categoryName, false, policyCategories); + } + } + setPolicyAttendeeTrackingEnabled(policyID, newValue); + }, + [policyID, policyCategories], + ); + const maxExpenseAmountNoReceiptText = useMemo(() => { if (policy?.maxExpenseAmountNoReceipt === CONST.DISABLED_MAX_EXPENSE_VALUE) { return ''; @@ -180,7 +201,7 @@ function IndividualExpenseRulesSection({policyID}: IndividualExpenseRulesSection // For backwards compatibility with Expensify Classic, we assume that Attendee Tracking is enabled by default on // Control policies if the policy does not contain the attribute - const isAttendeeTrackingEnabled = policy?.isAttendeeTrackingEnabled ?? true; + const isAttendeeTrackingEnabled = policy?.isAttendeeTrackingEnabled ?? false; return (
setPolicyAttendeeTrackingEnabled(policyID, !isAttendeeTrackingEnabled)} + onToggle={() => handleAttendeeTrackingToggle(!isAttendeeTrackingEnabled)} pendingAction={policy?.pendingFields?.isAttendeeTrackingEnabled} /> diff --git a/src/types/onyx/PolicyCategory.ts b/src/types/onyx/PolicyCategory.ts index 90aa92ca93672..4f0848dde72fa 100644 --- a/src/types/onyx/PolicyCategory.ts +++ b/src/types/onyx/PolicyCategory.ts @@ -48,6 +48,9 @@ type PolicyCategory = OnyxCommon.OnyxValueWithOfflineFeedback<{ /** Max expense amount with no receipt violation */ maxAmountNoReceipt?: number | null; + + /** If true, not providing attendees for a transaction with this category will trigger a violation */ + areAttendeesRequired?: boolean; }>; /** Record of policy categories, indexed by their name */ diff --git a/tests/unit/ViolationUtilsTest.ts b/tests/unit/ViolationUtilsTest.ts index 43e0798470c89..cfa7378775932 100644 --- a/tests/unit/ViolationUtilsTest.ts +++ b/tests/unit/ViolationUtilsTest.ts @@ -9,6 +9,12 @@ import type {Policy, PolicyCategories, PolicyTagLists, Report, Transaction, Tran import type {TransactionCollectionDataSet} from '@src/types/onyx/Transaction'; import {translateLocal} from '../utils/TestHelper'; +// Mock getCurrentUserEmail from Report actions +const MOCK_CURRENT_USER_EMAIL = 'test@expensify.com'; +jest.mock('@libs/actions/Report', () => ({ + getCurrentUserEmail: jest.fn(() => MOCK_CURRENT_USER_EMAIL), +})); + const categoryOutOfPolicyViolation = { name: CONST.VIOLATIONS.CATEGORY_OUT_OF_POLICY, type: CONST.VIOLATION_TYPES.VIOLATION, @@ -597,6 +603,203 @@ describe('getViolationsOnyxData', () => { expect(result.value).toEqual(expect.arrayContaining([missingDepartmentTag, missingRegionTag, missingProjectTag])); }); }); + + describe('missingAttendees violation', () => { + const missingAttendeesViolation = { + name: CONST.VIOLATIONS.MISSING_ATTENDEES, + type: CONST.VIOLATION_TYPES.VIOLATION, + showInReview: true, + }; + + const ownerAccountID = 123; + const otherAccountID = 456; + + let iouReport: Report; + + beforeEach(() => { + policy.type = CONST.POLICY.TYPE.CORPORATE; + policy.isAttendeeTrackingEnabled = true; + policyCategories = { + Meals: { + name: 'Meals', + enabled: true, + areAttendeesRequired: true, + }, + }; + transaction.category = 'Meals'; + iouReport = { + reportID: '1234', + ownerAccountID, + } as Report; + }); + + 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])); + }); + + it('should add missingAttendees violation when only owner is an attendee', () => { + transaction.comment = { + attendees: [{email: 'owner@example.com', displayName: 'Owner', avatarUrl: '', accountID: ownerAccountID}], + }; + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policy, policyTags, policyCategories, false, false, false, iouReport); + expect(result.value).toEqual(expect.arrayContaining([missingAttendeesViolation])); + }); + + it('should not add missingAttendees violation when there is at least one non-owner attendee', () => { + transaction.comment = { + attendees: [ + {email: 'owner@example.com', displayName: 'Owner', avatarUrl: '', accountID: ownerAccountID}, + {email: 'other@example.com', displayName: 'Other', avatarUrl: '', accountID: otherAccountID}, + ], + }; + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policy, policyTags, policyCategories, false, false, false, iouReport); + expect(result.value).not.toEqual(expect.arrayContaining([missingAttendeesViolation])); + }); + + it('should remove missingAttendees violation when attendees are added', () => { + transactionViolations = [missingAttendeesViolation]; + transaction.comment = { + attendees: [ + {email: 'owner@example.com', displayName: 'Owner', avatarUrl: '', accountID: ownerAccountID}, + {email: 'other@example.com', displayName: 'Other', avatarUrl: '', accountID: otherAccountID}, + ], + }; + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policy, policyTags, policyCategories, false, false, false, iouReport); + expect(result.value).not.toEqual(expect.arrayContaining([missingAttendeesViolation])); + }); + + it('should not add missingAttendees violation when attendee tracking is disabled', () => { + policy.isAttendeeTrackingEnabled = false; + transaction.comment = {attendees: []}; + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policy, policyTags, policyCategories, false, false, false, iouReport); + expect(result.value).not.toEqual(expect.arrayContaining([missingAttendeesViolation])); + }); + + it('should not add missingAttendees violation when category does not require attendees', () => { + policyCategories.Meals.areAttendeesRequired = false; + transaction.comment = {attendees: []}; + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policy, policyTags, policyCategories, false, false, false, iouReport); + expect(result.value).not.toEqual(expect.arrayContaining([missingAttendeesViolation])); + }); + + 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. + 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 = []; + transaction.comment = { + attendees: [{email: MOCK_CURRENT_USER_EMAIL, displayName: 'Test User', avatarUrl: ''}], + }; + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policy, policyTags, policyCategories, false, false, false, undefined); + // Violation should be added since the only attendee is the current user (owner) + expect(result.value).toEqual(expect.arrayContaining([missingAttendeesViolation])); + }); + + it('should not add violation when iouReport is undefined but there are non-owner attendees (by email)', () => { + // When there are attendees with different emails than the current user, no violation + transactionViolations = []; + transaction.comment = { + attendees: [ + {email: MOCK_CURRENT_USER_EMAIL, displayName: 'Test User', avatarUrl: ''}, + {email: 'other@example.com', displayName: 'Other User', avatarUrl: ''}, + ], + }; + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policy, policyTags, policyCategories, false, false, false, undefined); + // Violation should NOT be added since there's a non-owner attendee + expect(result.value).not.toEqual(expect.arrayContaining([missingAttendeesViolation])); + }); + + it('should remove violation when non-owner attendee is added (offline)', () => { + // If violation existed and a non-owner attendee is added, violation should be removed + transactionViolations = [missingAttendeesViolation]; + transaction.comment = { + attendees: [ + {email: MOCK_CURRENT_USER_EMAIL, displayName: 'Test User', avatarUrl: ''}, + {email: 'other@example.com', displayName: 'Other User', avatarUrl: ''}, + ], + }; + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policy, policyTags, policyCategories, false, false, false, undefined); + // Violation should be removed + expect(result.value).not.toEqual(expect.arrayContaining([missingAttendeesViolation])); + }); + + it('should preserve violation when only owner attendee remains (offline)', () => { + // If violation existed and only owner attendee remains, violation stays + transactionViolations = [missingAttendeesViolation]; + transaction.comment = { + attendees: [{email: MOCK_CURRENT_USER_EMAIL, displayName: 'Test User', avatarUrl: ''}], + }; + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policy, policyTags, policyCategories, false, false, false, undefined); + // Violation should be preserved + expect(result.value).toEqual(expect.arrayContaining([missingAttendeesViolation])); + }); + }); + + describe('fallback case (iouReport undefined AND getCurrentUserEmail returns falsy)', () => { + // This tests the edge case where we cannot identify the owner at all: + // - ownerAccountID is undefined (iouReport unavailable) + // - getCurrentUserEmail() returns falsy (no current user email) + // In this case, we assume owner is one of the attendees, so we need at least 2 attendees + // for there to be a non-owner attendee. + + beforeEach(() => { + // Mock getCurrentUserEmail to return empty string + jest.spyOn(require('@libs/actions/Report'), 'getCurrentUserEmail').mockReturnValue(''); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + 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); + // With 0 attendees, attendeesMinusOwnerCount = Math.max(0, 0 - 1) = 0, violation should be added + expect(result.value).toEqual(expect.arrayContaining([missingAttendeesViolation])); + }); + + 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: ''}], + }; + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policy, policyTags, policyCategories, false, false, false, undefined); + // With 1 attendee, attendeesMinusOwnerCount = Math.max(0, 1 - 1) = 0, violation should be added + expect(result.value).toEqual(expect.arrayContaining([missingAttendeesViolation])); + }); + + it('should not add missingAttendees violation when 2+ attendees exist (assumes owner is one of them)', () => { + transactionViolations = []; + transaction.comment = { + attendees: [ + {email: 'person1@example.com', displayName: 'Person 1', avatarUrl: ''}, + {email: 'person2@example.com', displayName: 'Person 2', avatarUrl: ''}, + ], + }; + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policy, policyTags, policyCategories, false, false, false, undefined); + // With 2 attendees, attendeesMinusOwnerCount = Math.max(0, 2 - 1) = 1, no violation + expect(result.value).not.toEqual(expect.arrayContaining([missingAttendeesViolation])); + }); + + it('should remove missingAttendees violation when second attendee is added', () => { + transactionViolations = [missingAttendeesViolation]; + transaction.comment = { + attendees: [ + {email: 'person1@example.com', displayName: 'Person 1', avatarUrl: ''}, + {email: 'person2@example.com', displayName: 'Person 2', avatarUrl: ''}, + ], + }; + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policy, policyTags, policyCategories, false, false, false, undefined); + // Violation should be removed since we now have 2 attendees + expect(result.value).not.toEqual(expect.arrayContaining([missingAttendeesViolation])); + }); + }); + }); }); const getFakeTransaction = (transactionID: string, comment?: Transaction['comment']) => ({ From 95096748e4c642c25c96fb0b2c02635466abc6d1 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Wed, 7 Jan 2026 15:37:03 -0800 Subject: [PATCH 02/16] fix: 79003 - app crashes after removing expense from report --- .../SelectionListWithSections/Search/TransactionListItem.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/SelectionListWithSections/Search/TransactionListItem.tsx b/src/components/SelectionListWithSections/Search/TransactionListItem.tsx index 30c9da177f412..6133f247a0880 100644 --- a/src/components/SelectionListWithSections/Search/TransactionListItem.tsx +++ b/src/components/SelectionListWithSections/Search/TransactionListItem.tsx @@ -67,7 +67,7 @@ function TransactionListItem({ return (snapshot?.data?.[`${ONYXKEYS.COLLECTION.POLICY}${transactionItem.policyID}`] ?? {}) as Policy; }, [snapshot, transactionItem.policyID]); // Fetch policy categories directly from Onyx since they are not included in the search snapshot - const [policyCategories] = originalUseOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${transactionItem.policyID}`, {canBeMissing: true}); + const [policyCategories] = originalUseOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(transactionItem.policyID)}`, {canBeMissing: true}); const [lastPaymentMethod] = useOnyx(`${ONYXKEYS.NVP_LAST_PAYMENT_METHOD}`, {canBeMissing: true}); const [personalPolicyID] = useOnyx(ONYXKEYS.PERSONAL_POLICY_ID, {canBeMissing: true}); From 97d5baf95bf588b670e8756eb3b5a34154ec2bc6 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Wed, 7 Jan 2026 15:41:21 -0800 Subject: [PATCH 03/16] fix: 79007 - violation appears twice --- .../SelectionListWithSections/Search/TransactionListItem.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/SelectionListWithSections/Search/TransactionListItem.tsx b/src/components/SelectionListWithSections/Search/TransactionListItem.tsx index 6133f247a0880..6232580507eb1 100644 --- a/src/components/SelectionListWithSections/Search/TransactionListItem.tsx +++ b/src/components/SelectionListWithSections/Search/TransactionListItem.tsx @@ -141,7 +141,7 @@ function TransactionListItem({ snapshotPolicy?.type === CONST.POLICY.TYPE.CORPORATE, ); - return [...onyxViolations, ...attendeeOnyxViolations]; + return attendeeOnyxViolations; }, [snapshotPolicy, policyCategories, snapshotReport, transactionItem, violations, currentUserDetails.email, currentUserDetails.accountID, currentUserDetails]); const {isDelegateAccessRestricted, showDelegateNoAccessModal} = useContext(DelegateNoAccessContext); From 8de058de66a7979ffbd20c947102e1b59518b196 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Wed, 7 Jan 2026 16:00:35 -0800 Subject: [PATCH 04/16] fix: 79008 - invoice cannot be sent when user selects a category that requires attendees --- src/components/MoneyRequestConfirmationList.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 11306dddfd6c4..1ace6ca6b3da7 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -924,7 +924,10 @@ function MoneyRequestConfirmationList({ return; } - const isMissingAttendeesViolation = getIsMissingAttendeesViolation(policyCategories, iouCategory, iouAttendees, currentUserPersonalDetails, policy?.isAttendeeTrackingEnabled); + // Since invoices are not expense reports that need attendee tracking, this validation should not apply to invoices + const isMissingAttendeesViolation = + iouType !== CONST.IOU.TYPE.INVOICE && + getIsMissingAttendeesViolation(policyCategories, iouCategory, iouAttendees, currentUserPersonalDetails, policy?.isAttendeeTrackingEnabled); if (isMissingAttendeesViolation) { setFormError('violations.missingAttendees'); return; From 7c8a86311fbfede5695276cb5f8650ceddda52fe Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Wed, 7 Jan 2026 16:21:51 -0800 Subject: [PATCH 05/16] fix: 79011 - no attendee require violation on confirm page when there is no additional attendee --- src/pages/iou/request/step/IOURequestStepConfirmation.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 419849767be35..fdf7a3c062116 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -60,8 +60,8 @@ import { } from '@libs/ReportUtils'; import {endSpan} from '@libs/telemetry/activeSpans'; import { + getAttendees, getDefaultTaxCode, - getOriginalAttendees, getRateID, getRequestType, getValidWaypoints, @@ -1348,7 +1348,7 @@ function IOURequestStepConfirmation({ transaction={transaction} selectedParticipants={participants} iouAmount={transaction?.amount ?? 0} - iouAttendees={getOriginalAttendees(transaction, currentUserPersonalDetails)} + iouAttendees={getAttendees(transaction, currentUserPersonalDetails)} iouComment={transaction?.comment?.comment ?? ''} iouCurrencyCode={transaction?.currency} iouIsBillable={transaction?.billable} From 5e6c0a5915ae385d10f6a3efef4e305b4d5d9275 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Wed, 7 Jan 2026 16:32:05 -0800 Subject: [PATCH 06/16] fix: 79011 - additional follow-up --- src/libs/ReportUtils.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index d74ac180b90ba..13178e55eb7f0 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -11107,7 +11107,12 @@ function createDraftTransactionAndNavigateToParticipantSelector( .find((action) => isMoneyRequestAction(action) && getOriginalMessage(action)?.IOUTransactionID === transactionID); const {created, amount, currency, merchant, mccGroup} = getTransactionDetails(transaction) ?? {}; - const comment = getTransactionCommentObject(transaction); + const baseComment = getTransactionCommentObject(transaction); + // Use modifiedAttendees if present (for edited transactions), otherwise use the attendees from comment + const comment = { + ...baseComment, + attendees: transaction?.modifiedAttendees ?? baseComment.attendees, + }; removeDraftTransactions(); @@ -11125,6 +11130,7 @@ function createDraftTransactionAndNavigateToParticipantSelector( comment, merchant, modifiedMerchant: '', + modifiedAttendees: undefined, mccGroup, } as Transaction); From 4e8a2740964478456026cd0f6af7ae5a6db46255 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Wed, 7 Jan 2026 17:02:58 -0800 Subject: [PATCH 07/16] fix: 79013 - violation for required attendee disappears after opening 'Attendee' field --- src/CONST/index.ts | 5 +++++ src/components/MoneyRequestConfirmationList.tsx | 17 +++++++++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 1063913a28ea6..722a950e3a5a6 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -5711,6 +5711,11 @@ const CONST = { WARNING: 'warning', }, + /** + * Constant for prefix of violations. + */ + VIOLATIONS_PREFIX: 'violations.', + /** * Constants with different types for the modifiedAmount violation */ diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 1ace6ca6b3da7..4009672aa67ed 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -426,12 +426,21 @@ function MoneyRequestConfirmationList({ setFormError('iou.receiptScanningFailed'); return; } - // reset the form error whenever the screen gains or loses focus - setFormError(''); - - // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- we don't want this effect to run if it's just setFormError that changes + // 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 + if (!formError.startsWith(CONST.VIOLATIONS_PREFIX)) { + setFormError(''); + } + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- we don't want this effect to run if formError or setFormError changes }, [isFocused, shouldDisplayFieldError, hasSmartScanFailed, didConfirmSplit]); + // Clear the missingAttendees violation when attendees change (user fixed the issue) + useEffect(() => { + clearFormErrors(['violations.missingAttendees']); + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- we only want to run this when iouAttendees changes + }, [iouAttendees]); + useEffect(() => { // We want this effect to run only when the transaction is moving from Self DM to a expense chat if (!transactionID || !isDistanceRequest || !isMovingTransactionFromTrackExpense || !isPolicyExpenseChat) { From 452b3bbfd075b16780586bfec75a0682913420ff Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Thu, 8 Jan 2026 14:17:24 -0800 Subject: [PATCH 08/16] fix: 79017 - violation does not show up on expense row when additional attendee is not added --- .../MoneyRequestConfirmationList.tsx | 6 +-- src/components/Search/index.tsx | 10 ++-- .../Search/TransactionListItem.tsx | 47 ++++++++++++++----- src/libs/AttendeeUtils.ts | 7 ++- src/libs/SearchUIUtils.ts | 8 +++- src/libs/Violations/ViolationsUtils.ts | 2 +- src/libs/actions/IOU/index.ts | 2 + 7 files changed, 61 insertions(+), 21 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 4009672aa67ed..a0bae07977775 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -435,11 +435,11 @@ function MoneyRequestConfirmationList({ // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- we don't want this effect to run if formError or setFormError changes }, [isFocused, shouldDisplayFieldError, hasSmartScanFailed, didConfirmSplit]); - // Clear the missingAttendees violation when attendees change (user fixed the issue) + // Clear the missingAttendees violation when category or attendees change (user fixed the issue) useEffect(() => { clearFormErrors(['violations.missingAttendees']); - // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- we only want to run this when iouAttendees changes - }, [iouAttendees]); + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- we only want to run this when iouCategory or iouAttendees changes + }, [iouCategory, iouAttendees]); useEffect(() => { // We want this effect to run only when the transaction is moving from Self DM to a expense chat diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index c02c8db81fb60..4f434e1368b6c 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -244,6 +244,7 @@ function Search({ const [reportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS, {canBeMissing: true}); const [outstandingReportsByPolicyID] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID, {canBeMissing: true}); const [violations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, {canBeMissing: true}); + const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); const {accountID, email} = useCurrentUserPersonalDetails(); const [isActionLoadingSet = new Set()] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}`, {canBeMissing: true, selector: isActionLoadingSetSelector}); const [visibleColumns] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {canBeMissing: true, selector: columnsSelector}); @@ -268,7 +269,9 @@ function Search({ } const report = searchResults.data[`${ONYXKEYS.COLLECTION.REPORT}${transaction.reportID}`]; - const policy = searchResults.data[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]; + const snapshotPolicy = searchResults.data[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]; + const onyxPolicy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]; + const policy = onyxPolicy ?? snapshotPolicy; if (report && policy) { const transactionViolations = violations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}`]; @@ -283,9 +286,8 @@ function Search({ } } } - return filtered; - }, [violations, searchResults, email]); + }, [violations, searchResults, email, policies]); const archivedReportsIdSet = useArchivedReportsIdSet(); @@ -420,6 +422,7 @@ function Search({ const [filteredData1, allLength] = getSections({ type, data: searchResults.data, + policies, currentAccountID: accountID, currentUserEmail: email ?? '', translate, @@ -448,6 +451,7 @@ function Search({ email, isActionLoadingSet, cardFeeds, + policies, ]); useEffect(() => { diff --git a/src/components/SelectionListWithSections/Search/TransactionListItem.tsx b/src/components/SelectionListWithSections/Search/TransactionListItem.tsx index 6232580507eb1..d3582d3f11bdf 100644 --- a/src/components/SelectionListWithSections/Search/TransactionListItem.tsx +++ b/src/components/SelectionListWithSections/Search/TransactionListItem.tsx @@ -63,11 +63,23 @@ function TransactionListItem({ const [isActionLoading] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${transactionItem.reportID}`, {canBeMissing: true, selector: isActionLoadingSelector}); + // Use active policy (user's current workspace) as fallback for self DM tracking expenses + // This matches MoneyRequestView's approach via usePolicyForMovingExpenses() + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID, {canBeMissing: true}); + // Use report's policyID as fallback when transaction doesn't have policyID directly + // Use active policy as final fallback for SelfDM (tracking expenses) + // NOTE: Using || instead of ?? to treat empty string "" as falsy + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const policyID = transactionItem.policyID || snapshotReport?.policyID || activePolicyID; + const [onyxPolicy] = originalUseOnyx(ONYXKEYS.COLLECTION.POLICY, { + canBeMissing: true, + selector: (policy) => policy?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`], + }); const snapshotPolicy = useMemo(() => { - return (snapshot?.data?.[`${ONYXKEYS.COLLECTION.POLICY}${transactionItem.policyID}`] ?? {}) as Policy; - }, [snapshot, transactionItem.policyID]); + return (snapshot?.data?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`] ?? {}) as Policy; + }, [snapshot, policyID]); // Fetch policy categories directly from Onyx since they are not included in the search snapshot - const [policyCategories] = originalUseOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(transactionItem.policyID)}`, {canBeMissing: true}); + const [policyCategories] = originalUseOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(policyID)}`, {canBeMissing: true}); const [lastPaymentMethod] = useOnyx(`${ONYXKEYS.NVP_LAST_PAYMENT_METHOD}`, {canBeMissing: true}); const [personalPolicyID] = useOnyx(ONYXKEYS.PERSONAL_POLICY_ID, {canBeMissing: true}); @@ -124,25 +136,38 @@ function TransactionListItem({ ]); const transactionViolations = useMemo(() => { + const policy = onyxPolicy ?? snapshotPolicy; const onyxViolations = (violations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionItem.transactionID}`] ?? []).filter( (violation: TransactionViolation) => - !isViolationDismissed(transactionItem, violation, currentUserDetails.email ?? '', currentUserDetails.accountID, snapshotReport, snapshotPolicy) && - shouldShowViolation(snapshotReport, snapshotPolicy, violation.name, currentUserDetails.email ?? '', false, transactionItem), + !isViolationDismissed(transactionItem, violation, currentUserDetails.email ?? '', currentUserDetails.accountID, snapshotReport, policy) && + shouldShowViolation(snapshotReport, policy, violation.name, currentUserDetails.email ?? '', false, transactionItem), ); // 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( onyxViolations, policyCategories, - transactionItem.category ?? '', - transactionItem.attendees, + transaction?.category ?? transactionItem.category ?? '', + transaction?.comment?.attendees ?? transactionItem.attendees, currentUserDetails, - snapshotPolicy?.isAttendeeTrackingEnabled ?? false, - snapshotPolicy?.type === CONST.POLICY.TYPE.CORPORATE, + policy?.isAttendeeTrackingEnabled ?? false, + policy?.type === CONST.POLICY.TYPE.CORPORATE, ); - return attendeeOnyxViolations; - }, [snapshotPolicy, policyCategories, snapshotReport, transactionItem, violations, currentUserDetails.email, currentUserDetails.accountID, currentUserDetails]); + }, [ + onyxPolicy, + snapshotPolicy, + policyCategories, + snapshotReport, + transactionItem, + violations, + currentUserDetails.email, + currentUserDetails.accountID, + currentUserDetails, + transaction?.category, + transaction?.comment?.attendees, + ]); const {isDelegateAccessRestricted, showDelegateNoAccessModal} = useContext(DelegateNoAccessContext); diff --git a/src/libs/AttendeeUtils.ts b/src/libs/AttendeeUtils.ts index a3ada3de4fe49..0da831478833e 100644 --- a/src/libs/AttendeeUtils.ts +++ b/src/libs/AttendeeUtils.ts @@ -42,8 +42,13 @@ function getIsMissingAttendeesViolation( } const creatorLogin = userPersonalDetails.login ?? ''; + const creatorEmail = userPersonalDetails.email ?? ''; const attendees = Array.isArray(iouAttendees) ? iouAttendees : []; - const attendeesMinusCreatorCount = attendees.filter((a) => a?.login !== creatorLogin).length; + // Check both login and email since attendee objects may have identifier in either property + const attendeesMinusCreatorCount = attendees.filter((a) => { + const attendeeIdentifier = a?.login ?? a?.email; + return attendeeIdentifier !== creatorLogin && attendeeIdentifier !== creatorEmail; + }).length; if (attendees.length === 0 || attendeesMinusCreatorCount === 0) { return true; diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index f29187b07395e..1e5f2daff0b62 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -350,6 +350,7 @@ type ArchivedReportsIDSet = ReadonlySet; type GetSectionsParams = { type: SearchDataTypes; data: OnyxTypes.SearchResults['data']; + policies: OnyxCollection; currentAccountID: number | undefined; currentUserEmail: string; translate: LocalizedTranslate; @@ -1667,6 +1668,7 @@ function getReportActionsSections(data: OnyxTypes.SearchResults['data']): [Repor */ function getReportSections( data: OnyxTypes.SearchResults['data'], + policies: OnyxCollection, currentSearch: SearchKey, currentAccountID: number | undefined, currentUserEmail: string, @@ -1762,7 +1764,8 @@ function getReportSections( const formattedStatus = getReportStatusTranslation({stateNum: reportItem.stateNum, statusNum: reportItem.statusNum, translate}); const allReportTransactions = getTransactionsForReport(data, reportItem.reportID); - const policy = getPolicyFromKey(data, reportItem); + const policyFromKey = getPolicyFromKey(data, reportItem); + const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${reportItem?.policyID ?? String(CONST.DEFAULT_NUMBER_ID)}`] ?? policyFromKey; const hasAnyViolationsForReport = hasAnyViolations( reportItem.reportID, @@ -2035,6 +2038,7 @@ function getListItem(type: SearchDataTypes, status: SearchStatus, groupBy?: Sear function getSections({ type, data, + policies, currentAccountID, currentUserEmail, translate, @@ -2055,7 +2059,7 @@ function getSections({ } if (type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT) { - return getReportSections(data, currentSearch, currentAccountID, currentUserEmail, translate, formatPhoneNumber, isActionLoadingSet, reportActions); + return getReportSections(data, policies, currentSearch, currentAccountID, currentUserEmail, translate, formatPhoneNumber, isActionLoadingSet, reportActions); } if (groupBy) { diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index fa4efe03f9f91..ac7dfe1b46550 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -415,7 +415,7 @@ const ViolationsUtils = { const shouldShowMissingAttendees = !isInvoiceTransaction && isAttendeeTrackingEnabled && - policyCategories?.[categoryName ?? '']?.areAttendeesRequired && + !!policyCategories?.[categoryName ?? '']?.areAttendeesRequired && isControlPolicy && (attendees.length === 0 || attendeesMinusOwnerCount === 0); diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 520acfa4c662c..db137a957f218 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -5277,6 +5277,7 @@ type AddTrackedExpenseToPolicyParam = { moneyRequestCreatedReportActionID: string | undefined; moneyRequestPreviewReportActionID: string; distance: number | undefined; + attendees: string | undefined; } & ConvertTrackedWorkspaceParams; type ConvertTrackedExpenseToRequestParams = { @@ -5360,6 +5361,7 @@ function convertTrackedExpenseToRequest(convertTrackedExpenseParams: ConvertTrac comment, created, merchant, + attendees: attendees ? JSON.stringify(attendees) : undefined, reimbursable: true, transactionID, actionableWhisperReportActionID, From 1bba24d2d97c75932644863a5ce8eb615aeed187 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Thu, 8 Jan 2026 14:22:02 -0800 Subject: [PATCH 09/16] resolve submodule - ready for review --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 41f65e6100cc7..3770805e73aa8 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 41f65e6100cc7a2c731fb1ed3358b3fafd923609 +Subproject commit 3770805e73aa8e6c04d2743d6dd8956b963c8aa5 From cf2e0fb05d66cac2c21bffd7ad7c66f8320809b0 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Thu, 8 Jan 2026 14:35:18 -0800 Subject: [PATCH 10/16] fix: typecheck --- src/libs/SearchUIUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 6919c52d96e3c..4b5ffe40927a8 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -350,7 +350,7 @@ type ArchivedReportsIDSet = ReadonlySet; type GetSectionsParams = { type: SearchDataTypes; data: OnyxTypes.SearchResults['data']; - policies: OnyxCollection; + policies?: OnyxCollection; currentAccountID: number | undefined; currentUserEmail: string; translate: LocalizedTranslate; From 586e011e8bd81b87d7246c337d8212eacc4daa24 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Mon, 12 Jan 2026 11:32:46 -0800 Subject: [PATCH 11/16] chore: resolve submodule --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 3770805e73aa8..24ad08565f129 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 3770805e73aa8e6c04d2743d6dd8956b963c8aa5 +Subproject commit 24ad08565f1298eaa08ce29e6e7bc44b30a98def From 8f17f9ef508d4b3ae51b5db21f4edc8b680145f5 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Mon, 12 Jan 2026 12:14:17 -0800 Subject: [PATCH 12/16] fix: corrected getCurrentUserEmail import and eslint --- src/components/MoneyRequestConfirmationList.tsx | 2 +- src/libs/Violations/ViolationsUtils.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 17bb811a0cb5a..4f6237099539e 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -442,7 +442,7 @@ function MoneyRequestConfirmationList({ // Clear the missingAttendees violation when category or attendees change (user fixed the issue) useEffect(() => { clearFormErrors(['violations.missingAttendees']); - // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- we only want to run this when iouCategory or iouAttendees changes + // eslint-disable-next-line react-hooks/exhaustive-deps -- we only want to run this when iouCategory or iouAttendees changes }, [iouCategory, iouAttendees]); useEffect(() => { diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index 47672303a423d..ffdb379f42471 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -4,11 +4,11 @@ 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 {getCurrentUserEmail} from '@libs/actions/Report'; import {getDecodedCategoryName, isCategoryMissing} from '@libs/CategoryUtils'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import DateUtils from '@libs/DateUtils'; import {isReceiptError} from '@libs/ErrorUtils'; +import {getCurrentUserEmail} from '@libs/Network/NetworkStore'; import Parser from '@libs/Parser'; import {getDistanceRateCustomUnitRate, getPerDiemRateCustomUnitRate, getSortedTagKeys, isDefaultTagName, isTaxTrackingEnabled} from '@libs/PolicyUtils'; import {isCurrentUserSubmitter} from '@libs/ReportUtils'; From 3c44ad31fc1de09328736fd6d46c94fe67952a57 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Tue, 13 Jan 2026 15:45:30 -0800 Subject: [PATCH 13/16] fix: adjusted missingAttendees violation clearing logic --- src/components/MoneyRequestConfirmationList.tsx | 14 +++++++------- .../Search/TransactionListItem.tsx | 14 +------------- 2 files changed, 8 insertions(+), 20 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 1adfe17f3976c..ecc196fb38688 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -439,15 +439,15 @@ function MoneyRequestConfirmationList({ // that can only be fixed by changing the underlying data 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) { + 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]); - - // Clear the missingAttendees violation when category or attendees change (user fixed the issue) - useEffect(() => { - clearFormErrors(['violations.missingAttendees']); - // eslint-disable-next-line react-hooks/exhaustive-deps -- we only want to run this when iouCategory or iouAttendees changes - }, [iouCategory, iouAttendees]); + }, [isFocused, shouldDisplayFieldError, hasSmartScanFailed, didConfirmSplit, iouCategory, iouAttendees, policyCategories, currentUserPersonalDetails, policy?.isAttendeeTrackingEnabled]); useEffect(() => { // We want this effect to run only when the transaction is moving from Self DM to a expense chat diff --git a/src/components/SelectionListWithSections/Search/TransactionListItem.tsx b/src/components/SelectionListWithSections/Search/TransactionListItem.tsx index d3582d3f11bdf..390ae110c2e70 100644 --- a/src/components/SelectionListWithSections/Search/TransactionListItem.tsx +++ b/src/components/SelectionListWithSections/Search/TransactionListItem.tsx @@ -155,19 +155,7 @@ function TransactionListItem({ policy?.type === CONST.POLICY.TYPE.CORPORATE, ); return attendeeOnyxViolations; - }, [ - onyxPolicy, - snapshotPolicy, - policyCategories, - snapshotReport, - transactionItem, - violations, - currentUserDetails.email, - currentUserDetails.accountID, - currentUserDetails, - transaction?.category, - transaction?.comment?.attendees, - ]); + }, [onyxPolicy, snapshotPolicy, policyCategories, snapshotReport, transactionItem, violations, currentUserDetails, transaction?.category, transaction?.comment?.attendees]); const {isDelegateAccessRestricted, showDelegateNoAccessModal} = useContext(DelegateNoAccessContext); From d5c78e7a45a3a68630ce87d3f6c59b56f3a4c45a Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Tue, 13 Jan 2026 15:47:00 -0800 Subject: [PATCH 14/16] chore: submodule sync --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 24ad08565f129..000e746c5f0cb 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 24ad08565f1298eaa08ce29e6e7bc44b30a98def +Subproject commit 000e746c5f0cb68ed7598d85ab563ad227301883 From 29da2831099016dc452e8df9ab4a7c2fc63baab9 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Wed, 14 Jan 2026 14:17:50 -0800 Subject: [PATCH 15/16] chore: submodule sync --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 000e746c5f0cb..187983a613fcb 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 000e746c5f0cb68ed7598d85ab563ad227301883 +Subproject commit 187983a613fcb256535b7737b4cbf529418a336b From f8f7196b2afa85554d3b751d33ecddbfeb1d212f Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Wed, 14 Jan 2026 14:34:05 -0800 Subject: [PATCH 16/16] fix: added OnyxData argument and fixed spellcheck - ready to merge --- .../SelectionListWithSections/Search/TransactionListItem.tsx | 2 +- src/libs/actions/Policy/Category.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/SelectionListWithSections/Search/TransactionListItem.tsx b/src/components/SelectionListWithSections/Search/TransactionListItem.tsx index acdbc690ef1ee..7ed0619ac1502 100644 --- a/src/components/SelectionListWithSections/Search/TransactionListItem.tsx +++ b/src/components/SelectionListWithSections/Search/TransactionListItem.tsx @@ -137,7 +137,7 @@ function TransactionListItem({ // Prefer live Onyx policy data over snapshot to ensure fresh policy settings // like isAttendeeTrackingEnabled is not missing - // Use snapshotReport/snapshotPolic as fallbacks to fix offline issues where + // 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; diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts index f60a660181d90..0b2f8b64dccff 100644 --- a/src/libs/actions/Policy/Category.ts +++ b/src/libs/actions/Policy/Category.ts @@ -1527,7 +1527,7 @@ function setPolicyCategoryAttendeesRequired(policyID: string, categoryName: stri const policyCategoryToUpdate = policyCategories?.[categoryName]; const originalAreAttendeesRequired = policyCategoryToUpdate?.areAttendeesRequired; - const onyxData: OnyxData = { + const onyxData: OnyxData = { optimisticData: [ { onyxMethod: Onyx.METHOD.MERGE,