Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
62314b5
feat: add ability to require attendees based on category selection
ikevin127 Jan 7, 2026
9509674
fix: 79003 - app crashes after removing expense from report
ikevin127 Jan 7, 2026
97d5baf
fix: 79007 - violation appears twice
ikevin127 Jan 7, 2026
8de058d
fix: 79008 - invoice cannot be sent when user selects a category that…
ikevin127 Jan 8, 2026
7c8a863
fix: 79011 - no attendee require violation on confirm page when there…
ikevin127 Jan 8, 2026
5e6c0a5
fix: 79011 - additional follow-up
ikevin127 Jan 8, 2026
4e8a274
fix: 79013 - violation for required attendee disappears after opening…
ikevin127 Jan 8, 2026
452b3bb
fix: 79017 - violation does not show up on expense row when additiona…
ikevin127 Jan 8, 2026
f67c42e
Merge branch 'main' of https://github.com/Expensify/App into ikevin12…
ikevin127 Jan 8, 2026
1bba24d
resolve submodule - ready for review
ikevin127 Jan 8, 2026
cf2e0fb
fix: typecheck
ikevin127 Jan 8, 2026
c869695
Merge branch 'main' of https://github.com/Expensify/App into ikevin12…
ikevin127 Jan 12, 2026
586e011
chore: resolve submodule
ikevin127 Jan 12, 2026
8f17f9e
fix: corrected getCurrentUserEmail import and eslint
ikevin127 Jan 12, 2026
30dce16
Merge branch 'main' of https://github.com/Expensify/App into ikevin12…
ikevin127 Jan 13, 2026
3c44ad3
fix: adjusted missingAttendees violation clearing logic
ikevin127 Jan 13, 2026
d5c78e7
chore: submodule sync
ikevin127 Jan 13, 2026
c6c68e0
Merge branch 'main' of https://github.com/Expensify/App into ikevin12…
ikevin127 Jan 14, 2026
29da283
chore: submodule sync
ikevin127 Jan 14, 2026
f8f7196
fix: added OnyxData argument and fixed spellcheck - ready to merge
ikevin127 Jan 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5732,6 +5732,11 @@ const CONST = {
WARNING: 'warning',
},

/**
* Constant for prefix of violations.
*/
VIOLATIONS_PREFIX: 'violations.',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NAB: Is the trailing period in VIOLATIONS_PREFIX intentional?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it was either adding it in component like '${CONST. VIOLATIONS_PREFIX}.' or having it include the dot as part of the constant - this made more sense at the time 😃


/**
* Constants with different types for the modifiedAmount violation
*/
Expand Down Expand Up @@ -5786,6 +5791,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',
Expand Down
4 changes: 4 additions & 0 deletions src/ROUTES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2007,6 +2007,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,
Expand Down
1 change: 1 addition & 0 deletions src/SCREENS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -690,6 +690,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',
Expand Down
33 changes: 29 additions & 4 deletions src/components/MoneyRequestConfirmationList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -433,11 +434,20 @@ function MoneyRequestConfirmationList({
setFormError('iou.receiptScanningFailed');
return;
}
// reset the form error whenever the screen gains or loses focus
setFormError('');

// 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('');
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('');
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reverting the above change fixed the problem with the persistent error #79902

// 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]);
}, [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
Expand Down Expand Up @@ -928,6 +938,15 @@ function MoneyRequestConfirmationList({
return;
}

// 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;
}

if (isPerDiemRequest && (transaction.comment?.customUnit?.subRates ?? []).length === 0) {
setFormError('iou.error.invalidSubrateLength');
return;
Expand Down Expand Up @@ -1001,6 +1020,8 @@ function MoneyRequestConfirmationList({
showDelegateNoAccessModal,
iouCategory,
policyCategories,
iouAttendees,
currentUserPersonalDetails,
],
);

Expand All @@ -1024,6 +1045,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]);

Expand Down
7 changes: 5 additions & 2 deletions src/components/MoneyRequestConfirmationListFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,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
Expand Down Expand Up @@ -736,7 +737,7 @@ function MoneyRequestConfirmationListFooter({
item: (
<MenuItemWithTopDescription
key="attendees"
shouldShowRightIcon
shouldShowRightIcon={!isReadOnly}
title={iouAttendees?.map((item) => item?.displayName ?? item?.login).join(', ')}
description={`${translate('iou.attendees')} ${
iouAttendees?.length && iouAttendees.length > 1 && formattedAmountPerAttendee ? `\u00B7 ${formattedAmountPerAttendee} ${translate('common.perPerson')}` : ''
Expand All @@ -750,8 +751,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,
Expand Down
25 changes: 20 additions & 5 deletions src/components/ReportActionItem/MoneyRequestView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, mergeCardListWithWorkspaceFeeds} from '@libs/CardUtils';
import {getDecodedCategoryName, isCategoryMissing} from '@libs/CategoryUtils';
import {convertToDisplayString} from '@libs/CurrencyUtils';
Expand All @@ -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,
Expand Down Expand Up @@ -99,7 +100,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';
Expand Down Expand Up @@ -169,6 +169,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();
Expand Down Expand Up @@ -489,6 +490,13 @@ function MoneyRequestView({
}

const hasErrors = hasMissingSmartscanFields(transaction);
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
Expand Down Expand Up @@ -529,6 +537,10 @@ function MoneyRequestView({
return `${violations.map((violation) => ViolationsUtils.getViolationTranslation(violation, translate, canEdit, undefined, companyCardPageURL)).join('. ')}.`;
}

if (field === 'attendees' && isMissingAttendeesViolation) {
return translate('violations.missingAttendees');
}

return '';
};

Expand Down Expand Up @@ -744,8 +756,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);
Expand Down Expand Up @@ -1036,6 +1049,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
Expand Down
3 changes: 3 additions & 0 deletions src/components/Search/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,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, login} = useCurrentUserPersonalDetails();
const [isActionLoadingSet = new Set<string>()] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}`, {canBeMissing: true, selector: isActionLoadingSetSelector});
const [visibleColumns] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {canBeMissing: true, selector: columnsSelector});
Expand Down Expand Up @@ -384,6 +385,7 @@ function Search({
const [filteredData1, allLength] = getSections({
type,
data: searchResults.data,
policies,
currentAccountID: accountID,
currentUserEmail: email ?? '',
translate,
Expand Down Expand Up @@ -414,6 +416,7 @@ function Search({
email,
isActionLoadingSet,
cardFeeds,
policies,
bankAccountList,
violations,
]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, mergeProhibitedViolations, shouldShowViolation} from '@libs/TransactionUtils';
import variables from '@styles/variables';
Expand All @@ -29,7 +30,6 @@ import ONYXKEYS from '@src/ONYXKEYS';
import {isActionLoadingSelector} from '@src/selectors/ReportMetaData';
import type {Policy, Report, ReportAction, ReportActions} from '@src/types/onyx';
import type {TransactionViolation} from '@src/types/onyx/TransactionViolation';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import UserInfoAndActionButtonRow from './UserInfoAndActionButtonRow';

function TransactionListItem<TItem extends ListItem>({
Expand Down Expand Up @@ -63,14 +63,27 @@ function TransactionListItem<TItem extends ListItem>({

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 [parentPolicy] = 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(policyID)}`, {canBeMissing: true});
const [lastPaymentMethod] = useOnyx(`${ONYXKEYS.NVP_LAST_PAYMENT_METHOD}`, {canBeMissing: true});
const [personalPolicyID] = useOnyx(ONYXKEYS.PERSONAL_POLICY_ID, {canBeMissing: true});

const [parentReport] = originalUseOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(transactionItem.reportID)}`, {canBeMissing: true});
const [parentPolicy] = originalUseOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(transactionItem.policyID)}`, {canBeMissing: true});
const [transactionThreadReport] = originalUseOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionItem?.reportAction?.childReportID}`, {canBeMissing: true});
const [transaction] = originalUseOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transactionItem.transactionID)}`, {canBeMissing: true});
const parentReportActionSelector = useCallback(
Expand Down Expand Up @@ -122,20 +135,34 @@ function TransactionListItem<TItem extends ListItem>({
transactionItem.shouldShowYearExported,
]);

// Use parentReport/parentPolicy as fallbacks when snapshotReport/snapshotPolicy are empty
// to fix offline issues where newly created reports aren't in the search snapshot yet
const reportForViolations = isEmptyObject(snapshotReport) ? parentReport : snapshotReport;
const policyForViolations = isEmptyObject(snapshotPolicy) ? parentPolicy : snapshotPolicy;
// Prefer live Onyx policy data over snapshot to ensure fresh policy settings
// like isAttendeeTrackingEnabled is not missing
// Use snapshotReport/snapshotPolicy as fallbacks to fix offline issues where
// newly created reports aren't in the search snapshot yet
const policyForViolations = parentPolicy ?? snapshotPolicy;
const reportForViolations = parentReport ?? snapshotReport;

const transactionViolations = useMemo(() => {
return mergeProhibitedViolations(
(violations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionItem.transactionID}`] ?? []).filter(
(violation: TransactionViolation) =>
!isViolationDismissed(transactionItem, violation, currentUserDetails.email ?? '', currentUserDetails.accountID, reportForViolations, policyForViolations) &&
shouldShowViolation(reportForViolations, policyForViolations, violation.name, currentUserDetails.email ?? '', false, transactionItem),
),
const onyxViolations = (violations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionItem.transactionID}`] ?? []).filter(
(violation: TransactionViolation) =>
!isViolationDismissed(transactionItem, violation, currentUserDetails.email ?? '', currentUserDetails.accountID, reportForViolations, policyForViolations) &&
shouldShowViolation(reportForViolations, policyForViolations, 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,
transaction?.category ?? transactionItem.category ?? '',
transaction?.comment?.attendees ?? transactionItem.attendees,
currentUserDetails,
policyForViolations?.isAttendeeTrackingEnabled ?? false,
policyForViolations?.type === CONST.POLICY.TYPE.CORPORATE,
);
}, [policyForViolations, reportForViolations, transactionItem, violations, currentUserDetails.email, currentUserDetails.accountID]);

return mergeProhibitedViolations(attendeeOnyxViolations);
}, [policyForViolations, reportForViolations, policyCategories, transactionItem, currentUserDetails, violations]);

const {isDelegateAccessRestricted, showDelegateNoAccessModal} = useContext(DelegateNoAccessContext);

Expand Down
Loading
Loading