Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
5b3af80
Add non-reimbursable payment via ach DecisionModal
samranahm Feb 24, 2026
4b4f293
Add error handling for non-reimbursable payments in ProcessMoneyRepor…
samranahm Feb 24, 2026
2b09ced
add decision modal copies in all locals
samranahm Feb 24, 2026
bc3ec97
show total display spend for Mark as paid when there is only non-reim…
samranahm Feb 24, 2026
55137f3
handle non-reimbursable transactions in can pay helpers
samranahm Feb 24, 2026
6d92ab3
Add ach pay option for NonReimbursableTransactions in getActions
samranahm Feb 24, 2026
6ad7b4b
Merge remote-tracking branch 'upstream/main' into 81721/non-reimbursa…
samranahm Feb 24, 2026
aa4ed44
fix failing checks
samranahm Feb 24, 2026
3e3994d
refactor: update hasOnlyNonReimbursableTransactions to accept transac…
samranahm Feb 24, 2026
69f5c89
add unit tests for non-reimbursable expense cases
samranahm Feb 24, 2026
5a7248a
Merge remote-tracking branch 'upstream/main' into 81721/non-reimbursa…
samranahm Feb 25, 2026
6423243
update modal copy
samranahm Feb 25, 2026
5f17603
lint
samranahm Feb 25, 2026
bdd279a
Merge remote-tracking branch 'upstream/main' into 81721/non-reimbursa…
samranahm Feb 27, 2026
2343fd4
Implement non-reimbursable payment modal handling with hook
samranahm Feb 27, 2026
3136ce4
Merge remote-tracking branch 'upstream/main' into 81721/non-reimbursa…
samranahm Mar 12, 2026
3ddbc0b
Merge remote-tracking branch 'upstream/main' into 81721/non-reimbursa…
samranahm Mar 13, 2026
ac4a9eb
Merge remote-tracking branch 'upstream/main' into 81721/non-reimbursa…
samranahm Mar 18, 2026
ac58aa8
Prevent closing modal on non-reimbursable payment error for approvals
samranahm Mar 18, 2026
5fc4fe2
fix lint warnings
samranahm Mar 18, 2026
9d53c36
Merge remote-tracking branch 'upstream/main' into 81721/non-reimbursa…
samranahm Mar 24, 2026
39310da
Add non-reimbursable payment error modal handling in search bulk actions
samranahm Mar 24, 2026
458d883
Update non-reimbursable payment error modal descriptions for multiple…
samranahm Mar 25, 2026
8e9e3e8
Merge remote-tracking branch 'upstream/main' into 81721/non-reimbursa…
samranahm Mar 25, 2026
b8259d9
Refactor non-reimbursable payment modal to accept transactions as a p…
samranahm Mar 25, 2026
950f416
Add remaning tests for handling non-reimbursable transactions
samranahm Mar 25, 2026
41ff73b
Merge branch 'Expensify:main' into 81721/non-reimbursableexpenses-mar…
samranahm Mar 25, 2026
c02ade2
Merge remote-tracking branch 'upstream/main' into 81721/non-reimbursa…
samranahm Mar 27, 2026
ed5bbfd
Add transactions prop to MoneyReportHeader and ProcessMoneyReportHold…
samranahm Mar 27, 2026
d6fb246
Merge remote-tracking branch 'upstream/main' into 81721/non-reimbursa…
samranahm Mar 31, 2026
ea019ce
Refactor payment action logic to include transactions param
samranahm Mar 31, 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
24 changes: 21 additions & 3 deletions src/components/MoneyReportHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
import useMobileSelectionMode from '@hooks/useMobileSelectionMode';
import useNetwork from '@hooks/useNetwork';
import useNonReimbursablePaymentModal from '@hooks/useNonReimbursablePaymentModal';
import useOnyx from '@hooks/useOnyx';
import usePaginatedReportActions from '@hooks/usePaginatedReportActions';
import useParticipantsInvoiceReport from '@hooks/useParticipantsInvoiceReport';
Expand Down Expand Up @@ -102,6 +103,7 @@ import {
getTransactionsWithReceipts,
hasHeldExpenses as hasHeldExpensesReportUtils,
hasOnlyHeldExpenses as hasOnlyHeldExpensesReportUtils,
hasOnlyNonReimbursableTransactions,
hasUpdatedTotal,
hasViolations as hasViolationsReportUtils,
isAllowedToApproveExpenseReport,
Expand Down Expand Up @@ -567,6 +569,7 @@ function MoneyReportHeader({reportID: reportIDProp, shouldDisplayBackButton = fa
const shouldDisplayNarrowMoreButton = !shouldDisplayNarrowVersion || isWideRHPDisplayedOnWideLayout || isSuperWideRHPDisplayedOnWideLayout;

const [offlineModalVisible, setOfflineModalVisible] = useState(false);
const {showNonReimbursablePaymentErrorModal, shouldBlockDirectPayment, nonReimbursablePaymentErrorDecisionModal} = useNonReimbursablePaymentModal(moneyRequestReport, transactions);

const showExportProgressModal = useCallback(() => {
return showConfirmModal({
Expand Down Expand Up @@ -624,9 +627,15 @@ function MoneyReportHeader({reportID: reportIDProp, shouldDisplayBackButton = fa
});

const canIOUBePaid = useMemo(() => getCanIOUBePaid(), [getCanIOUBePaid]);
const onlyShowPayElsewhere = useMemo(() => !canIOUBePaid && getCanIOUBePaid(true), [canIOUBePaid, getCanIOUBePaid]);
const reportHasOnlyNonReimbursableTransactions = hasOnlyNonReimbursableTransactions(moneyRequestReport?.reportID, transactions);
const onlyShowPayElsewhere = useMemo(() => {
if (reportHasOnlyNonReimbursableTransactions) {
return false;
}
return !canIOUBePaid && getCanIOUBePaid(true);
}, [canIOUBePaid, getCanIOUBePaid, reportHasOnlyNonReimbursableTransactions]);

const shouldShowPayButton = isPaidAnimationRunning || canIOUBePaid || onlyShowPayElsewhere;
const shouldShowPayButton = isPaidAnimationRunning || canIOUBePaid || onlyShowPayElsewhere || reportHasOnlyNonReimbursableTransactions;

const shouldShowApproveButton = useMemo(
() => (canApproveIOU(moneyRequestReport, policy, reportMetadata, transactions) && !hasOnlyPendingTransactions) || isApprovedAnimationRunning,
Expand Down Expand Up @@ -706,6 +715,10 @@ function MoneyReportHeader({reportID: reportIDProp, shouldDisplayBackButton = fa
if (!type || !chatReport) {
return;
}
if (shouldBlockDirectPayment(type)) {
showNonReimbursablePaymentErrorModal();
return;
}
setPaymentType(type);
setRequestType(CONST.IOU.REPORT_ACTION_TYPE.PAY);
const isFromSelectionMode = isSelectionModePaymentRef.current;
Expand Down Expand Up @@ -789,6 +802,8 @@ function MoneyReportHeader({reportID: reportIDProp, shouldDisplayBackButton = fa
isAnyTransactionOnHold,
isInvoiceReport,
showDelegateNoAccessModal,
showNonReimbursablePaymentErrorModal,
shouldBlockDirectPayment,
startAnimation,
moneyRequestReport,
nextStep,
Expand Down Expand Up @@ -1211,7 +1226,7 @@ function MoneyReportHeader({reportID: reportIDProp, shouldDisplayBackButton = fa
}, [connectedIntegration, exportModalStatus, moneyRequestReport?.reportID]);

const getAmount = (actionType: ValueOf<typeof CONST.REPORT.REPORT_PREVIEW_ACTIONS>) => ({
formattedAmount: getTotalAmountForIOUReportPreviewButton(moneyRequestReport, policy, actionType),
formattedAmount: getTotalAmountForIOUReportPreviewButton(moneyRequestReport, policy, actionType, nonPendingDeleteTransactions),
});

const {formattedAmount: totalAmount} = getAmount(CONST.REPORT.PRIMARY_ACTIONS.PAY);
Expand Down Expand Up @@ -2572,6 +2587,8 @@ function MoneyReportHeader({reportID: reportIDProp, shouldDisplayBackButton = fa
}
}}
transactionCount={transactionIDs?.length ?? 0}
transactions={transactions}
onNonReimbursablePaymentError={showNonReimbursablePaymentErrorModal}
/>
)}
<DecisionModal
Expand Down Expand Up @@ -2640,6 +2657,7 @@ function MoneyReportHeader({reportID: reportIDProp, shouldDisplayBackButton = fa
isVisible={offlineModalVisible}
onClose={() => setOfflineModalVisible(false)}
/>
{nonReimbursablePaymentErrorDecisionModal}
<Modal
onClose={() => {
setIsPDFModalVisible(false);
Expand Down
15 changes: 14 additions & 1 deletion src/components/ProcessMoneyReportHoldMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import useOnyx from '@hooks/useOnyx';
import usePermissions from '@hooks/usePermissions';
import usePolicy from '@hooks/usePolicy';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import {hasViolations as hasViolationsReportUtils} from '@libs/ReportUtils';
import {hasOnlyNonReimbursableTransactions, hasViolations as hasViolationsReportUtils} from '@libs/ReportUtils';
import {approveMoneyRequest, payMoneyRequest} from '@userActions/IOU';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
Expand Down Expand Up @@ -55,6 +55,12 @@ type ProcessMoneyReportHoldMenuProps = {

/** Whether the report has non held expenses */
hasNonHeldExpenses?: boolean;

/** Callback when user attempts to pay via ACH but report has only non-reimbursable expenses */
onNonReimbursablePaymentError?: () => void;

/** Transactions associated with report */
transactions?: OnyxTypes.Transaction[];
};

function ProcessMoneyReportHoldMenu({
Expand All @@ -70,6 +76,8 @@ function ProcessMoneyReportHoldMenu({
transactionCount,
startAnimation,
hasNonHeldExpenses,
onNonReimbursablePaymentError,
transactions,
}: ProcessMoneyReportHoldMenuProps) {
const {translate} = useLocalize();
const isApprove = requestType === CONST.IOU.REPORT_ACTION_TYPE.APPROVE;
Expand Down Expand Up @@ -100,6 +108,11 @@ function ProcessMoneyReportHoldMenu({
return;
}

if (!isApprove && chatReport && hasOnlyNonReimbursableTransactions(moneyRequestReport?.reportID, transactions) && paymentType && paymentType !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE) {
onClose();
onNonReimbursablePaymentError?.();
return;
}
if (isApprove) {
approveMoneyRequest({
expenseReport: moneyRequestReport,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import StatusBadge from '@components/StatusBadge';
import Text from '@components/Text';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
import useNonReimbursablePaymentModal from '@hooks/useNonReimbursablePaymentModal';
import useOnyx from '@hooks/useOnyx';
import usePaymentAnimations from '@hooks/usePaymentAnimations';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
Expand Down Expand Up @@ -151,6 +152,7 @@ function MoneyRequestReportPreviewContent({

const [isHoldMenuVisible, setIsHoldMenuVisible] = useState(false);
const [requestType, setRequestType] = useState<ActionHandledType>();
const {showNonReimbursablePaymentErrorModal, nonReimbursablePaymentErrorDecisionModal} = useNonReimbursablePaymentModal(iouReport, transactions);
const [paymentType, setPaymentType] = useState<PaymentMethodType>();
const [shouldShowPayButton, setShouldShowPayButton] = useState(false);
const hasOnlyHeldExpenses = hasOnlyHeldExpensesReportUtils(iouReport?.reportID);
Expand Down Expand Up @@ -695,6 +697,7 @@ function MoneyRequestReportPreviewContent({
onPaymentOptionsHide={onPaymentOptionsHide}
openReportFromPreview={openReportFromPreview}
onHoldMenuOpen={handleHoldMenuOpen}
onNonReimbursablePaymentError={showNonReimbursablePaymentErrorModal}
transactionPreviewCarouselWidth={reportPreviewStyles.transactionPreviewCarouselStyle.width}
/>
{transactions.length > 1 && !shouldShowAccessPlaceHolder && (
Expand Down Expand Up @@ -731,6 +734,7 @@ function MoneyRequestReportPreviewContent({
chatReport={chatReport}
moneyRequestReport={iouReport}
transactionCount={numberOfRequests}
transactions={transactions}
hasNonHeldExpenses={!hasOnlyHeldExpenses}
startAnimation={() => {
if (requestType === CONST.IOU.REPORT_ACTION_TYPE.APPROVE) {
Expand All @@ -739,10 +743,12 @@ function MoneyRequestReportPreviewContent({
startAnimation();
}
}}
onNonReimbursablePaymentError={showNonReimbursablePaymentErrorModal}
/>
);
})()}
</OfflineWithFeedback>
{nonReimbursablePaymentErrorDecisionModal}
</View>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {getTotalAmountForIOUReportPreviewButton} from '@libs/MoneyRequestReportU
import {
getReportTransactions,
hasHeldExpenses as hasHeldExpensesReportUtils,
hasOnlyNonReimbursableTransactions,
hasUpdatedTotal,
hasViolations as hasViolationsReportUtils,
isInvoiceReport as isInvoiceReportUtils,
Expand All @@ -35,6 +36,7 @@ type PayActionButtonProps = {
onPaymentOptionsShow?: () => void;
onPaymentOptionsHide?: () => void;
onHoldMenuOpen: (requestType: string, paymentType?: PaymentMethodType, canPay?: boolean) => void;
onNonReimbursablePaymentError?: () => void;
buttonMaxWidth: {maxWidth?: number};
reportPreviewAction: ValueOf<typeof CONST.REPORT.REPORT_PREVIEW_ACTIONS>;
};
Expand All @@ -50,6 +52,7 @@ function PayActionButton({
onPaymentOptionsShow,
onPaymentOptionsHide,
onHoldMenuOpen,
onNonReimbursablePaymentError,
buttonMaxWidth,
reportPreviewAction,
}: PayActionButtonProps) {
Expand Down Expand Up @@ -88,12 +91,15 @@ function PayActionButton({
const hasViolations = hasViolationsReportUtils(iouReport?.reportID, transactionViolations, currentUserAccountID, currentUserEmail);

const canIOUBePaid = canIOUBePaidIOUActions(iouReport, chatReport, policy, bankAccountList, transactions, false, undefined, invoiceReceiverPolicy);
const onlyShowPayElsewhere = !canIOUBePaid && canIOUBePaidIOUActions(iouReport, chatReport, policy, bankAccountList, transactions, true, undefined, invoiceReceiverPolicy);
const shouldShowPayButton = isPaidAnimationRunning || canIOUBePaid || onlyShowPayElsewhere;
const reportHasOnlyNonReimbursableTransactions = hasOnlyNonReimbursableTransactions(iouReport?.reportID, transactions);
const onlyShowPayElsewhere = reportHasOnlyNonReimbursableTransactions
? false
: !canIOUBePaid && canIOUBePaidIOUActions(iouReport, chatReport, policy, bankAccountList, transactions, true, undefined, invoiceReceiverPolicy);
const shouldShowPayButton = isPaidAnimationRunning || canIOUBePaid || onlyShowPayElsewhere || reportHasOnlyNonReimbursableTransactions;
const shouldShowOnlyPayElsewhere = !canIOUBePaid && onlyShowPayElsewhere;
const canIOUBePaidAndApproved = canIOUBePaid;

const formattedAmount = getTotalAmountForIOUReportPreviewButton(iouReport, policy, reportPreviewAction);
const formattedAmount = getTotalAmountForIOUReportPreviewButton(iouReport, policy, reportPreviewAction, transactions);

const confirmApproval = () => {
if (isDelegateAccessRestricted) {
Expand Down Expand Up @@ -123,6 +129,10 @@ function PayActionButton({
if (!type) {
return;
}
if (!isInvoiceReportUtils(iouReport) && reportHasOnlyNonReimbursableTransactions && type !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE) {
onNonReimbursablePaymentError?.();
return;
}
if (isDelegateAccessRestricted) {
showDelegateNoAccessModal();
} else if (hasHeldExpensesReportUtils(iouReport?.reportID)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type ReportPreviewActionButtonProps = {
startSubmittingAnimation: () => void;
onPaymentOptionsShow?: () => void;
onPaymentOptionsHide?: () => void;
onNonReimbursablePaymentError?: () => void;
openReportFromPreview: () => void;
onHoldMenuOpen: (requestType: string, paymentType?: PaymentMethodType, canPay?: boolean) => void;
transactionPreviewCarouselWidth: number;
Expand All @@ -54,6 +55,7 @@ function ReportPreviewActionButton({
startSubmittingAnimation,
onPaymentOptionsShow,
onPaymentOptionsHide,
onNonReimbursablePaymentError,
openReportFromPreview,
onHoldMenuOpen,
transactionPreviewCarouselWidth,
Expand Down Expand Up @@ -149,6 +151,7 @@ function ReportPreviewActionButton({
onPaymentOptionsShow={onPaymentOptionsShow}
onPaymentOptionsHide={onPaymentOptionsHide}
onHoldMenuOpen={onHoldMenuOpen}
onNonReimbursablePaymentError={onNonReimbursablePaymentError}
buttonMaxWidth={buttonMaxWidth}
reportPreviewAction={reportPreviewAction}
/>
Expand Down
13 changes: 11 additions & 2 deletions src/components/Search/SearchBulkActionsButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,15 +59,16 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) {
confirmPayment,
isOfflineModalVisible,
isDownloadErrorModalVisible,
isNonReimbursablePaymentErrorModalVisible,
isHoldEducationalModalVisible,
rejectModalAction,
emptyReportsCount,
handleOfflineModalClose,
handleDownloadErrorModalClose,
handleNonReimbursablePaymentErrorModalClose,
dismissModalAndUpdateUseHold,
dismissRejectModalBasedOnAction,
} = useSearchBulkActions({queryJSON});

const currentSelectedPolicyID = selectedPolicyIDs?.at(0);
const currentSelectedReportID = selectedTransactionReportIDs?.at(0) ?? selectedReportIDs?.at(0);
const currentPolicy = usePolicy(currentSelectedPolicyID);
Expand All @@ -79,7 +80,6 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) {
const isExpenseReportType = queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT;

const popoverUseScrollView = shouldPopoverUseScrollView(headerButtonsOptions);

const selectedItemsCount = useMemo(() => {
if (!selectedTransactions) {
return 0;
Expand Down Expand Up @@ -228,6 +228,15 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) {
isVisible={isDownloadErrorModalVisible}
onClose={handleDownloadErrorModalClose}
/>
<DecisionModal
title={translate('iou.error.nonReimbursablePayment')}
prompt={translate('iou.error.nonReimbursablePaymentDescription', selectedItemsCount > 1)}
isSmallScreenWidth={isSmallScreenWidth}
onSecondOptionSubmit={handleNonReimbursablePaymentErrorModalClose}
secondOptionText={translate('common.buttonConfirm')}
isVisible={isNonReimbursablePaymentErrorModalVisible}
onClose={handleNonReimbursablePaymentErrorModalClose}
/>
{!!rejectModalAction && (
<HoldOrRejectEducationalModal
onClose={dismissRejectModalBasedOnAction}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ import {SearchScopeProvider} from '@components/Search/SearchScopeProvider';
import SettlementButton from '@components/SettlementButton';
import type {PaymentActionParams} from '@components/SettlementButton/types';
import useNetwork from '@hooks/useNetwork';
import useNonReimbursablePaymentModal from '@hooks/useNonReimbursablePaymentModal';
import useOnyx from '@hooks/useOnyx';
import usePolicy from '@hooks/usePolicy';
import useReportWithTransactionsAndViolations from '@hooks/useReportWithTransactionsAndViolations';
import useThemeStyles from '@hooks/useThemeStyles';
import {canIOUBePaid} from '@libs/actions/IOU';
import {getPayMoneyOnSearchInvoiceParams, payMoneyRequestOnSearch} from '@libs/actions/Search';
import {convertToDisplayString} from '@libs/CurrencyUtils';
import {isInvoiceReport} from '@libs/ReportUtils';
import {hasOnlyNonReimbursableTransactions, isInvoiceReport} from '@libs/ReportUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
Expand All @@ -33,13 +34,17 @@ function PayActionCell({isLoading, policyID, reportID, hash, amount, extraSmall,
const {isDelegateAccessRestricted} = useDelegateNoAccessState();
const {showDelegateNoAccessModal} = useDelegateNoAccessActions();
const [iouReport, transactions] = useReportWithTransactionsAndViolations(reportID);
const {showNonReimbursablePaymentErrorModal, shouldBlockDirectPayment, nonReimbursablePaymentErrorDecisionModal} = useNonReimbursablePaymentModal(iouReport, transactions);
const policy = usePolicy(policyID);
const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST);
const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${iouReport?.chatReportID}`);
const invoiceReceiverPolicyID = chatReport?.invoiceReceiver && 'policyID' in chatReport.invoiceReceiver ? chatReport.invoiceReceiver.policyID : undefined;
const invoiceReceiverPolicy = usePolicy(invoiceReceiverPolicyID);
const canBePaid = canIOUBePaid(iouReport, chatReport, policy, bankAccountList, transactions, false, undefined, invoiceReceiverPolicy);
const shouldOnlyShowElsewhere = !canBePaid && canIOUBePaid(iouReport, chatReport, policy, bankAccountList, transactions, true, undefined, invoiceReceiverPolicy);
const shouldOnlyShowElsewhere =
!canBePaid &&
canIOUBePaid(iouReport, chatReport, policy, bankAccountList, transactions, true, undefined, invoiceReceiverPolicy) &&
!hasOnlyNonReimbursableTransactions(iouReport?.reportID, transactions);

const {currency} = iouReport ?? {};

Expand All @@ -53,6 +58,11 @@ function PayActionCell({isLoading, policyID, reportID, hash, amount, extraSmall,
return;
}

if (shouldBlockDirectPayment(type)) {
showNonReimbursablePaymentErrorModal();
return;
}

const invoiceParams = getPayMoneyOnSearchInvoiceParams(policyID, payAsBusiness, methodID, paymentMethod);
payMoneyRequestOnSearch(hash, [
{
Expand Down Expand Up @@ -87,6 +97,7 @@ function PayActionCell({isLoading, policyID, reportID, hash, amount, extraSmall,
onlyShowPayElsewhere={shouldOnlyShowElsewhere}
sentryLabel={CONST.SENTRY_LABEL.SEARCH.ACTION_CELL_PAY}
/>
{nonReimbursablePaymentErrorDecisionModal}
</SearchScopeProvider>
);
}
Expand Down
Loading
Loading