From 5b3af80caa00191f6998e915ad88a840aebc006e Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Tue, 24 Feb 2026 15:46:00 +0500 Subject: [PATCH 01/20] Add non-reimbursable payment via ach DecisionModal --- src/components/MoneyReportHeader.tsx | 26 ++++++++++++++-- .../MoneyRequestReportPreviewContent.tsx | 31 ++++++++++++++++--- .../Search/ActionCell/PayActionCell.tsx | 27 ++++++++++++++-- 3 files changed, 75 insertions(+), 9 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index d5bf29943fb4d..c04cb1e5ce416 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -76,6 +76,7 @@ import { getTransactionsWithReceipts, hasHeldExpenses as hasHeldExpensesReportUtils, hasOnlyHeldExpenses as hasOnlyHeldExpensesReportUtils, + hasOnlyNonReimbursableTransactions, hasUpdatedTotal, hasViolations as hasViolationsReportUtils, isAllowedToApproveExpenseReport, @@ -437,6 +438,7 @@ function MoneyReportHeader({ const shouldDisplayNarrowMoreButton = !shouldDisplayNarrowVersion || isWideRHPDisplayedOnWideLayout || isSuperWideRHPDisplayedOnWideLayout; const [offlineModalVisible, setOfflineModalVisible] = useState(false); + const [nonReimbursablePaymentErrorModalVisible, setNonReimbursablePaymentErrorModalVisible] = useState(false); const showExportProgressModal = useCallback(() => { return showConfirmModal({ @@ -494,9 +496,15 @@ function MoneyReportHeader({ }); const canIOUBePaid = useMemo(() => getCanIOUBePaid(), [getCanIOUBePaid]); - const onlyShowPayElsewhere = useMemo(() => !canIOUBePaid && getCanIOUBePaid(true), [canIOUBePaid, getCanIOUBePaid]); + const reportHasOnlyNonReimbursableTransactions = hasOnlyNonReimbursableTransactions(moneyRequestReport?.reportID); + 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, @@ -574,6 +582,10 @@ function MoneyReportHeader({ if (!type || !chatReport) { return; } + if (!isInvoiceReportUtil(moneyRequestReport) && hasOnlyNonReimbursableTransactions(moneyRequestReport?.reportID) && type !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE) { + setNonReimbursablePaymentErrorModalVisible(true); + return; + } setPaymentType(type); if (isDelegateAccessRestricted) { showDelegateNoAccessModal(); @@ -1934,6 +1946,7 @@ function MoneyReportHeader({ hasNonHeldExpenses={!hasOnlyHeldExpenses} startAnimation={startAnimation} transactionCount={transactionIDs?.length ?? 0} + onNonReimbursablePaymentError={() => setNonReimbursablePaymentErrorModalVisible(true)} /> )} setOfflineModalVisible(false)} /> + setNonReimbursablePaymentErrorModalVisible(false)} + secondOptionText={translate('common.buttonConfirm')} + isVisible={nonReimbursablePaymentErrorModalVisible} + onClose={() => setNonReimbursablePaymentErrorModalVisible(false)} + /> { setIsPDFModalVisible(false); diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx index 301fe84e98e1b..83d9f7251e1cc 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx @@ -10,6 +10,7 @@ import AnimatedSubmitButton from '@components/AnimatedSubmitButton'; import Button from '@components/Button'; import {getButtonRole} from '@components/Button/utils'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; +import DecisionModal from '@components/DecisionModal'; import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; import ExpenseHeaderApprovalButton from '@components/ExpenseHeaderApprovalButton'; import Icon from '@components/Icon'; @@ -63,6 +64,7 @@ import { hasHeldExpenses as hasHeldExpensesReportUtils, hasNonReimbursableTransactions as hasNonReimbursableTransactionsReportUtils, hasOnlyHeldExpenses as hasOnlyHeldExpensesReportUtils, + hasOnlyNonReimbursableTransactions, hasOnlyTransactionsWithPendingRoutes as hasOnlyTransactionsWithPendingRoutesReportUtils, hasReportBeenReopened as hasReportBeenReopenedUtils, hasReportBeenRetracted as hasReportBeenRetractedUtils, @@ -174,6 +176,7 @@ function MoneyRequestReportPreviewContent({ usePaymentAnimations(); const {showConfirmModal} = useConfirmModal(); const [isHoldMenuVisible, setIsHoldMenuVisible] = useState(false); + const [nonReimbursablePaymentErrorModalVisible, setNonReimbursablePaymentErrorModalVisible] = useState(false); const [paymentType, setPaymentType] = useState(); const isIouReportArchived = useReportIsArchived(iouReportID); const isChatReportArchived = useReportIsArchived(chatReport?.reportID); @@ -192,8 +195,15 @@ function MoneyRequestReportPreviewContent({ ); const canIOUBePaid = useMemo(() => getCanIOUBePaid(), [getCanIOUBePaid]); - const onlyShowPayElsewhere = useMemo(() => !canIOUBePaid && getCanIOUBePaid(true), [canIOUBePaid, getCanIOUBePaid]); - const shouldShowPayButton = isPaidAnimationRunning || canIOUBePaid || onlyShowPayElsewhere; + const reportHasOnlyNonReimbursableTransactions = hasOnlyNonReimbursableTransactions(iouReport?.reportID); + const onlyShowPayElsewhere = useMemo(() => { + if (reportHasOnlyNonReimbursableTransactions) { + return false; + } + return !canIOUBePaid && getCanIOUBePaid(true); + }, [canIOUBePaid, getCanIOUBePaid, reportHasOnlyNonReimbursableTransactions]); + + const shouldShowPayButton = isPaidAnimationRunning || canIOUBePaid || onlyShowPayElsewhere || reportHasOnlyNonReimbursableTransactions; const {nonHeldAmount, fullAmount, hasValidNonHeldAmount} = getNonHeldAndFullAmount(iouReport, shouldShowPayButton); const canIOUBePaidAndApproved = useMemo(() => getCanIOUBePaid(false), [getCanIOUBePaid]); @@ -223,7 +233,6 @@ function MoneyRequestReportPreviewContent({ const numberOfPendingRequests = transactionsWithReceipts.filter((transaction) => isPending(transaction) && isManagedCardTransaction(transaction)).length; const shouldShowRTERViolationMessage = numberOfRequests === 1 && hasPendingUI(lastTransaction, lastTransactionViolations); - const shouldShowOnlyPayElsewhere = useMemo(() => !canIOUBePaid && getCanIOUBePaid(true), [canIOUBePaid, getCanIOUBePaid]); const [reportAttributes] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, {selector: reportAttributesSelector}); @@ -249,6 +258,10 @@ function MoneyRequestReportPreviewContent({ if (!type) { return; } + if (!isInvoiceReportUtils(iouReport) && hasOnlyNonReimbursableTransactions(iouReport?.reportID) && type !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE) { + setNonReimbursablePaymentErrorModalVisible(true); + return; + } setPaymentType(type); if (isDelegateAccessRestricted) { showDelegateNoAccessModal(); @@ -665,7 +678,7 @@ function MoneyRequestReportPreviewContent({ ), [CONST.REPORT.REPORT_PREVIEW_ACTIONS.PAY]: ( setNonReimbursablePaymentErrorModalVisible(true)} /> )} + setNonReimbursablePaymentErrorModalVisible(false)} + secondOptionText={translate('common.buttonConfirm')} + isVisible={nonReimbursablePaymentErrorModalVisible} + onClose={() => setNonReimbursablePaymentErrorModalVisible(false)} + /> ); } diff --git a/src/components/SelectionListWithSections/Search/ActionCell/PayActionCell.tsx b/src/components/SelectionListWithSections/Search/ActionCell/PayActionCell.tsx index 270e484e359ff..2cc451cecd82b 100644 --- a/src/components/SelectionListWithSections/Search/ActionCell/PayActionCell.tsx +++ b/src/components/SelectionListWithSections/Search/ActionCell/PayActionCell.tsx @@ -1,18 +1,21 @@ -import React from 'react'; +import React, {useState} from 'react'; import type {ValueOf} from 'type-fest'; +import DecisionModal from '@components/DecisionModal'; import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; import type {PaymentMethod} from '@components/KYCWall/types'; import {SearchScopeProvider} from '@components/Search/SearchScopeProvider'; import SettlementButton from '@components/SettlementButton'; +import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; import useReportWithTransactionsAndViolations from '@hooks/useReportWithTransactionsAndViolations'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; 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'; @@ -28,16 +31,20 @@ type PayActionCellProps = { }; function PayActionCell({isLoading, policyID, reportID, hash, amount, extraSmall, shouldDisablePointerEvents}: PayActionCellProps) { + const {translate} = useLocalize(); const styles = useThemeStyles(); const {isOffline} = useNetwork(); + const {isSmallScreenWidth} = useResponsiveLayout(); const {isDelegateAccessRestricted} = useDelegateNoAccessState(); const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); + const [nonReimbursablePaymentErrorModalVisible, setNonReimbursablePaymentErrorModalVisible] = useState(false); const [iouReport, transactions] = useReportWithTransactionsAndViolations(reportID); const policy = usePolicy(policyID); const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${iouReport?.chatReportID}`); const canBePaid = canIOUBePaid(iouReport, chatReport, policy, bankAccountList, transactions, false); - const shouldOnlyShowElsewhere = !canBePaid && canIOUBePaid(iouReport, chatReport, policy, bankAccountList, transactions, true); + const shouldOnlyShowElsewhere = + !canBePaid && canIOUBePaid(iouReport, chatReport, policy, bankAccountList, transactions, true) && !hasOnlyNonReimbursableTransactions(iouReport?.reportID); const {currency} = iouReport ?? {}; @@ -51,6 +58,11 @@ function PayActionCell({isLoading, policyID, reportID, hash, amount, extraSmall, return; } + if (!isInvoiceReport(iouReport) && hasOnlyNonReimbursableTransactions(iouReport?.reportID) && type !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE) { + setNonReimbursablePaymentErrorModalVisible(true); + return; + } + const invoiceParams = getPayMoneyOnSearchInvoiceParams(policyID, payAsBusiness, methodID, paymentMethod); payMoneyRequestOnSearch(hash, [{amount, paymentType: type, reportID, ...(isInvoiceReport(iouReport) ? invoiceParams : {})}]); }; @@ -77,6 +89,15 @@ function PayActionCell({isLoading, policyID, reportID, hash, amount, extraSmall, onlyShowPayElsewhere={shouldOnlyShowElsewhere} sentryLabel={CONST.SENTRY_LABEL.SEARCH.ACTION_CELL_PAY} /> + setNonReimbursablePaymentErrorModalVisible(false)} + secondOptionText={translate('common.buttonConfirm')} + isVisible={nonReimbursablePaymentErrorModalVisible} + onClose={() => setNonReimbursablePaymentErrorModalVisible(false)} + /> ); } From 4b4f293768a5f32290f53916dc4ddb42f1aa1aac Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Tue, 24 Feb 2026 15:46:41 +0500 Subject: [PATCH 02/20] Add error handling for non-reimbursable payments in ProcessMoneyReportHoldMenu --- src/components/ProcessMoneyReportHoldMenu.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/components/ProcessMoneyReportHoldMenu.tsx b/src/components/ProcessMoneyReportHoldMenu.tsx index 4ab390e3e7a04..a06bfc86e1d94 100644 --- a/src/components/ProcessMoneyReportHoldMenu.tsx +++ b/src/components/ProcessMoneyReportHoldMenu.tsx @@ -5,8 +5,9 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import {hasOnlyNonReimbursableTransactions} from '@libs/ReportUtils'; import {payMoneyRequest} from '@userActions/IOU'; -import type CONST from '@src/CONST'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; @@ -46,6 +47,9 @@ 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; }; function ProcessMoneyReportHoldMenu({ @@ -59,6 +63,7 @@ function ProcessMoneyReportHoldMenu({ transactionCount, startAnimation, hasNonHeldExpenses, + onNonReimbursablePaymentError, }: ProcessMoneyReportHoldMenuProps) { const {translate} = useLocalize(); // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply the correct modal type @@ -79,6 +84,11 @@ function ProcessMoneyReportHoldMenu({ showDelegateNoAccessModal(); return; } + if (chatReport && hasOnlyNonReimbursableTransactions(moneyRequestReport?.reportID) && paymentType && paymentType !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE) { + onClose(); + onNonReimbursablePaymentError?.(); + return; + } if (chatReport && paymentType) { if (startAnimation) { startAnimation(); From 2b09cedc036768c80525541fafdae24dec12017a Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Tue, 24 Feb 2026 16:01:26 +0500 Subject: [PATCH 03/20] add decision modal copies in all locals --- src/languages/de.ts | 3 +++ src/languages/en.ts | 2 ++ src/languages/es.ts | 2 ++ src/languages/fr.ts | 3 +++ src/languages/it.ts | 3 +++ src/languages/ja.ts | 2 ++ src/languages/nl.ts | 3 +++ src/languages/pl.ts | 3 +++ src/languages/pt-BR.ts | 3 +++ src/languages/zh-hans.ts | 2 ++ 10 files changed, 26 insertions(+) diff --git a/src/languages/de.ts b/src/languages/de.ts index 1e1a1a947e254..67fc5728b2722 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1349,6 +1349,9 @@ const translations: TranslationDeepObject = { endDateSameAsStartDate: 'Das Enddatum darf nicht mit dem Startdatum übereinstimmen', manySplitsProvided: `Die maximale Anzahl zulässiger Aufteilungen beträgt ${CONST.IOU.SPLITS_LIMIT}.`, dateRangeExceedsMaxDays: `Der Datumsbereich darf ${CONST.IOU.SPLITS_LIMIT} Tage nicht überschreiten.`, + nonReimbursablePayment: 'Direkte Zahlung nicht möglich', + nonReimbursablePaymentDescription: + 'Dieser Bericht enthält keine erstattungsfähigen Ausgaben und kann nicht per Direktzahlung bezahlt werden. Verwenden Sie "Anderswo bezahlen", um ihn als bezahlt zu markieren.', }, dismissReceiptError: 'Fehler ausblenden', dismissReceiptErrorConfirmation: 'Achtung! Wenn du diesen Fehler schließt, wird deine hochgeladene Quittung vollständig entfernt. Bist du sicher?', diff --git a/src/languages/en.ts b/src/languages/en.ts index dc708bd10782b..2b946ee8dbecd 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1364,6 +1364,8 @@ const translations = { endDateSameAsStartDate: "The end date can't be the same as the start date", manySplitsProvided: `The maximum splits allowed is ${CONST.IOU.SPLITS_LIMIT}.`, dateRangeExceedsMaxDays: `The date range can't exceed ${CONST.IOU.SPLITS_LIMIT} days.`, + nonReimbursablePayment: 'Cannot pay via direct payment', + nonReimbursablePaymentDescription: 'This report does’t have reimbursable expenses and cannot be paid via direct payment. Use "Pay elsewhere" to mark it as paid instead.', }, dismissReceiptError: 'Dismiss error', dismissReceiptErrorConfirmation: 'Heads up! Dismissing this error will remove your uploaded receipt entirely. Are you sure?', diff --git a/src/languages/es.ts b/src/languages/es.ts index 0193f205bebc8..ff23852e0d46a 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1197,6 +1197,8 @@ const translations: TranslationDeepObject = { endDateSameAsStartDate: 'La fecha de finalización no puede ser la misma que la fecha de inicio', manySplitsProvided: `La cantidad máxima de divisiones permitidas es ${CONST.IOU.SPLITS_LIMIT}.`, dateRangeExceedsMaxDays: `El rango de fechas no puede exceder los ${CONST.IOU.SPLITS_LIMIT} días.`, + nonReimbursablePayment: 'No se puede pagar por pago directo', + nonReimbursablePaymentDescription: 'Este informe no tiene gastos reembolsables y no puede pagarse mediante pago directo. Usa "Pagar en otro lugar" para marcarlo como pagado.', }, dismissReceiptError: 'Descartar error', dismissReceiptErrorConfirmation: '¡Atención! Descartar este error eliminará completamente tu recibo cargado. ¿Estás seguro?', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 2ec90e5ca563f..6826e19d44c69 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1353,6 +1353,9 @@ const translations: TranslationDeepObject = { endDateSameAsStartDate: 'La date de fin ne peut pas être identique à la date de début', manySplitsProvided: `Le nombre maximal de répartitions autorisées est de ${CONST.IOU.SPLITS_LIMIT}.`, dateRangeExceedsMaxDays: `La plage de dates ne peut pas dépasser ${CONST.IOU.SPLITS_LIMIT} jours.`, + nonReimbursablePayment: 'Impossible de payer par virement direct', + nonReimbursablePaymentDescription: + 'Ce rapport ne contient aucune dépense remboursable et ne peut pas être payé par paiement direct. Utilisez "Payer ailleurs" pour le marquer comme payé.', }, dismissReceiptError: 'Ignorer l’erreur', dismissReceiptErrorConfirmation: 'Attention ! Ignorer cette erreur supprimera complètement votre reçu téléversé. Êtes-vous sûr ?', diff --git a/src/languages/it.ts b/src/languages/it.ts index cc8db792d5358..6238a44733192 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1346,6 +1346,9 @@ const translations: TranslationDeepObject = { endDateSameAsStartDate: 'La data di fine non può essere uguale alla data di inizio', manySplitsProvided: `Il numero massimo di suddivisioni consentite è ${CONST.IOU.SPLITS_LIMIT}.`, dateRangeExceedsMaxDays: `L’intervallo di date non può superare ${CONST.IOU.SPLITS_LIMIT} giorni.`, + nonReimbursablePayment: 'Impossibile pagare con pagamento diretto', + nonReimbursablePaymentDescription: + 'Questo report non contiene spese rimborsabili e non può essere pagato tramite pagamento diretto. Usa "Paga altrove" per contrassegnarlo come pagato.', }, dismissReceiptError: 'Ignora errore', dismissReceiptErrorConfirmation: 'Attenzione! Chiudere questo errore rimuoverà completamente la ricevuta che hai caricato. Sei sicuro?', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 150a0fbc676ae..0478054564436 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1340,6 +1340,8 @@ const translations: TranslationDeepObject = { endDateSameAsStartDate: '終了日は開始日と同じにはできません', manySplitsProvided: `分割できる最大数は${CONST.IOU.SPLITS_LIMIT}件です。`, dateRangeExceedsMaxDays: `日付範囲は${CONST.IOU.SPLITS_LIMIT}日を超えることはできません。`, + nonReimbursablePayment: '直接支払いでは支払えません', + nonReimbursablePaymentDescription: 'このレポートには精算対象の経費が含まれていないため、直接支払いはできません。「その他の方法で支払う」を使用して支払い済みにしてください。', }, dismissReceiptError: 'エラーを閉じる', dismissReceiptErrorConfirmation: 'ご注意ください!このエラーを閉じると、アップロード済みのレシートが完全に削除されます。本当に続行しますか?', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 1415e13368be3..a530825eeb1e5 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1344,6 +1344,9 @@ const translations: TranslationDeepObject = { endDateSameAsStartDate: 'De einddatum mag niet gelijk zijn aan de startdatum', manySplitsProvided: `Het maximale aantal toegestane splitsingen is ${CONST.IOU.SPLITS_LIMIT}.`, dateRangeExceedsMaxDays: `Het datumbereik mag niet meer dan ${CONST.IOU.SPLITS_LIMIT} dagen zijn.`, + nonReimbursablePayment: 'Kan niet via directe betaling betalen', + nonReimbursablePaymentDescription: + 'Dit rapport bevat geen declareerbare uitgaven en kan niet via directe betaling worden betaald. Gebruik "Elders betalen" om het als betaald te markeren.', }, dismissReceiptError: 'Foutmelding sluiten', dismissReceiptErrorConfirmation: 'Let op! Dit foutbericht negeren verwijdert je geüploade bon volledig. Weet je het zeker?', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 9dec059d3b29d..d8f04900a455e 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1343,6 +1343,9 @@ const translations: TranslationDeepObject = { endDateSameAsStartDate: 'Data zakończenia nie może być taka sama jak data rozpoczęcia', manySplitsProvided: `Maksymalna dozwolona liczba podziałów to ${CONST.IOU.SPLITS_LIMIT}.`, dateRangeExceedsMaxDays: `Zakres dat nie może przekraczać ${CONST.IOU.SPLITS_LIMIT} dni.`, + nonReimbursablePayment: 'Nie można zapłacić płatnością bezpośrednią', + nonReimbursablePaymentDescription: + 'Ten raport nie zawiera wydatków podlegających zwrotowi i nie może zostać opłacony za pomocą płatności bezpośredniej. Użyj "Zapłać gdzie indziej", aby oznaczyć go jako opłacony.', }, dismissReceiptError: 'Odrzuć błąd', dismissReceiptErrorConfirmation: 'Uwaga! Zamknięcie tego błędu spowoduje całkowite usunięcie przesłanego paragonu. Czy na pewno chcesz kontynuować?', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 96bee03e755ea..41e26a7097b93 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1341,6 +1341,9 @@ const translations: TranslationDeepObject = { endDateSameAsStartDate: 'A data de término não pode ser igual à data de início', manySplitsProvided: `O número máximo de divisões permitido é ${CONST.IOU.SPLITS_LIMIT}.`, dateRangeExceedsMaxDays: `O intervalo de datas não pode exceder ${CONST.IOU.SPLITS_LIMIT} dias.`, + nonReimbursablePayment: 'Não é possível pagar via pagamento direto', + nonReimbursablePaymentDescription: + 'Este relatório não possui despesas reembolsáveis e não pode ser pago via pagamento direto. Use "Pagar em outro lugar" para marcá-lo como pago.', }, dismissReceiptError: 'Dispensar erro', dismissReceiptErrorConfirmation: 'Atenção! Ignorar este erro removerá completamente o comprovante que você enviou. Tem certeza?', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index b45ddc73d0026..d0724bb92679c 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1318,6 +1318,8 @@ const translations: TranslationDeepObject = { endDateSameAsStartDate: '结束日期不能与开始日期相同', manySplitsProvided: `允许的最大拆分数为 ${CONST.IOU.SPLITS_LIMIT}。`, dateRangeExceedsMaxDays: `日期范围不能超过 ${CONST.IOU.SPLITS_LIMIT} 天。`, + nonReimbursablePayment: '无法通过直接付款支付', + nonReimbursablePaymentDescription: '此报告不包含可报销费用,因此无法通过直接付款进行支付。请使用“其他方式支付”将其标记为已支付。', }, dismissReceiptError: '忽略错误', dismissReceiptErrorConfirmation: '提醒:关闭此错误将彻底删除你上传的收据。确定要继续吗?', From bc3ec978f0818758e5bcac53d05871308aebb9db Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Tue, 24 Feb 2026 16:02:32 +0500 Subject: [PATCH 04/20] show total display spend for Mark as paid when there is only non-reimbursable expenses --- src/libs/MoneyRequestReportUtils.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/libs/MoneyRequestReportUtils.ts b/src/libs/MoneyRequestReportUtils.ts index bd3c60d3106c7..2b7cca131b1d5 100644 --- a/src/libs/MoneyRequestReportUtils.ts +++ b/src/libs/MoneyRequestReportUtils.ts @@ -11,6 +11,7 @@ import { getNonHeldAndFullAmount, hasHeldExpenses as hasHeldExpensesReportUtils, hasOnlyHeldExpenses as hasOnlyHeldExpensesReportUtils, + hasOnlyNonReimbursableTransactions, hasUpdatedTotal, isInvoiceReport, isMoneyRequestReport, @@ -167,6 +168,11 @@ const getTotalAmountForIOUReportPreviewButton = (report: OnyxEntry, poli return ''; } + // For reports with only non-reimbursable expenses, show total display spend for Mark as paid. + if (hasOnlyNonReimbursableTransactions(report?.reportID)) { + return convertToDisplayString(totalDisplaySpend, report?.currency); + } + // We shouldn't display the nonHeldAmount as the default option if it's not valid since we cannot pay partially in this case if (hasHeldExpensesReportUtils(report?.reportID) && canAllowSettlement && hasValidNonHeldAmount && !hasOnlyHeldExpenses) { return nonHeldAmount; From 55137f357fff022c57a3d788188eec8031903482 Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Tue, 24 Feb 2026 19:11:03 +0500 Subject: [PATCH 05/20] handle non-reimbursable transactions in can pay helpers --- src/libs/ReportPreviewActionUtils.ts | 3 ++- src/libs/ReportPrimaryActionUtils.ts | 3 ++- src/libs/actions/IOU/index.ts | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/libs/ReportPreviewActionUtils.ts b/src/libs/ReportPreviewActionUtils.ts index bc13ac1d91977..d6a6c57d527e6 100644 --- a/src/libs/ReportPreviewActionUtils.ts +++ b/src/libs/ReportPreviewActionUtils.ts @@ -9,6 +9,7 @@ import { getMoneyRequestSpendBreakdown, getParentReport, getReportTransactions, + hasOnlyNonReimbursableTransactions, isClosedReport, isCurrentUserSubmitter, isExpenseReport, @@ -121,7 +122,7 @@ function canPay( const hasExportError = report?.hasExportError ?? false; const didExportFail = !isExported && hasExportError; - if (isExpense && isReportPayer && isPaymentsEnabled && isReportFinished && reimbursableSpend !== 0) { + if (isExpense && isReportPayer && isPaymentsEnabled && isReportFinished && (reimbursableSpend !== 0 || hasOnlyNonReimbursableTransactions(report?.reportID))) { return !didExportFail; } diff --git a/src/libs/ReportPrimaryActionUtils.ts b/src/libs/ReportPrimaryActionUtils.ts index 2bea12909a348..202aa77970451 100644 --- a/src/libs/ReportPrimaryActionUtils.ts +++ b/src/libs/ReportPrimaryActionUtils.ts @@ -30,6 +30,7 @@ import { getParentReport, hasExportError as hasExportErrorUtil, hasOnlyHeldExpenses, + hasOnlyNonReimbursableTransactions, isArchivedReport, isClosedReport as isClosedReportUtils, isCurrentUserSubmitter, @@ -198,7 +199,7 @@ function isPrimaryPayAction( const isReportFinished = (isReportApproved && !report.isWaitingOnBankAccount) || isSubmittedWithoutApprovalsEnabled || isReportClosed; const {reimbursableSpend} = getMoneyRequestSpendBreakdown(report); - if (isReportPayer && isExpenseReport && arePaymentsEnabled && isReportFinished && reimbursableSpend !== 0) { + if (isReportPayer && isExpenseReport && arePaymentsEnabled && isReportFinished && (reimbursableSpend !== 0 || hasOnlyNonReimbursableTransactions(report?.reportID))) { return isSecondaryAction ?? !didExportFail; } diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 65783214b5d56..928a493697a7b 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -155,6 +155,7 @@ import { getTransactionDetails, hasHeldExpenses as hasHeldExpensesReportUtils, hasNonReimbursableTransactions as hasNonReimbursableTransactionsReportUtils, + hasOnlyNonReimbursableTransactions, hasOutstandingChildRequest, hasViolations as hasViolationsReportUtils, isArchivedReport, @@ -10448,7 +10449,7 @@ function canIOUBePaid( isPayer && isReportFinished && !iouSettled && - (reimbursableSpend > 0 || canShowMarkedAsPaidForNegativeAmount) && + (reimbursableSpend > 0 || canShowMarkedAsPaidForNegativeAmount || (onlyShowPayElsewhere && hasOnlyNonReimbursableTransactions(iouReport?.reportID))) && !isChatReportArchived && !isAutoReimbursable && !isPayAtEndExpenseReport && From 6d92ab3e550802c725ecda1f32079523e2c3812c Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Tue, 24 Feb 2026 19:12:59 +0500 Subject: [PATCH 06/20] Add ach pay option for NonReimbursableTransactions in getActions --- src/libs/SearchUIUtils.ts | 9 +++++++-- src/pages/Search/SearchPage.tsx | 4 +++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index e20fac8b4acbc..42f8ece7853a0 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -126,6 +126,7 @@ import { hasAnyViolations, hasHeldExpenses, hasInvoiceReports, + hasOnlyNonReimbursableTransactions, isAllowedToApproveExpenseReport as isAllowedToApproveExpenseReportUtils, isArchivedReport, isClosedReport, @@ -1812,10 +1813,14 @@ function getActions( const chatReport = getChatReport(data, report); const canBePaid = canIOUBePaid(report, chatReport, policy, bankAccountList, allReportTransactions, false, chatReportRNVP, invoiceReceiverPolicy); - const shouldOnlyShowElsewhere = !canBePaid && canIOUBePaid(report, chatReport, policy, bankAccountList, allReportTransactions, true, chatReportRNVP, invoiceReceiverPolicy); + const reportHasOnlyNonReimbursableTransactions = hasOnlyNonReimbursableTransactions(report?.reportID); + const shouldOnlyShowElsewhere = + !canBePaid && + canIOUBePaid(report, chatReport, policy, bankAccountList, allReportTransactions, true, chatReportRNVP, invoiceReceiverPolicy) && + !reportHasOnlyNonReimbursableTransactions; // We're not supporting pay partial amount on search page now. - if ((canBePaid || shouldOnlyShowElsewhere) && !hasHeldExpenses(report.reportID, allReportTransactions)) { + if ((canBePaid || shouldOnlyShowElsewhere || reportHasOnlyNonReimbursableTransactions) && !hasHeldExpenses(report.reportID, allReportTransactions)) { allActions.push(CONST.SEARCH.ACTION_TYPES.PAY); } diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 375b46cf1ade5..7c5f8a6c815b3 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -69,6 +69,7 @@ import {hasDynamicExternalWorkflow} from '@libs/PolicyUtils'; import {isMergeActionForSelectedTransactions} from '@libs/ReportSecondaryActionUtils'; import { getReportOrDraftReport, + hasOnlyNonReimbursableTransactions, isBusinessInvoiceRoom, isCurrentUserSubmitter, isExpenseReport as isExpenseReportUtil, @@ -205,7 +206,8 @@ function SearchPage({route}: SearchPageProps) { return ( report && !canIOUBePaid(report, chatReport, selectedPolicy, bankAccountList, undefined, false) && - canIOUBePaid(report, chatReport, selectedPolicy, bankAccountList, undefined, true) + canIOUBePaid(report, chatReport, selectedPolicy, bankAccountList, undefined, true) && + !hasOnlyNonReimbursableTransactions(report?.reportID) ); }); }, [currentSearchResults?.data, selectedPolicyIDs, selectedReportIDs, selectedTransactionReportIDs, bankAccountList]); From aa4ed4466f665e75d4d61e0ae9332079294fdc87 Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Tue, 24 Feb 2026 20:29:35 +0500 Subject: [PATCH 07/20] fix failing checks --- src/libs/SearchUIUtils.ts | 10 ++++------ tests/unit/MoneyRequestReportButtonUtils.test.ts | 1 + 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 82e9c0ea71b9e..481c3e12f2253 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -1856,14 +1856,12 @@ function getActions( const chatReport = getChatReport(data, report); const canBePaid = canIOUBePaid(report, chatReport, policy, bankAccountList, allReportTransactions, false, chatReportRNVP, invoiceReceiverPolicy); + const canOnlyBePaidElsewhere = canIOUBePaid(report, chatReport, policy, bankAccountList, allReportTransactions, true, chatReportRNVP, invoiceReceiverPolicy); const reportHasOnlyNonReimbursableTransactions = hasOnlyNonReimbursableTransactions(report?.reportID); - const shouldOnlyShowElsewhere = - !canBePaid && - canIOUBePaid(report, chatReport, policy, bankAccountList, allReportTransactions, true, chatReportRNVP, invoiceReceiverPolicy) && - !reportHasOnlyNonReimbursableTransactions; + const shouldOnlyShowElsewhere = !canBePaid && canOnlyBePaidElsewhere && !reportHasOnlyNonReimbursableTransactions; // We're not supporting pay partial amount on search page now. - if ((canBePaid || shouldOnlyShowElsewhere || reportHasOnlyNonReimbursableTransactions) && !hasHeldExpenses(report.reportID, allReportTransactions)) { + if ((canBePaid || shouldOnlyShowElsewhere || (reportHasOnlyNonReimbursableTransactions && canOnlyBePaidElsewhere)) && !hasHeldExpenses(report.reportID, allReportTransactions)) { allActions.push(CONST.SEARCH.ACTION_TYPES.PAY); } @@ -1871,7 +1869,7 @@ function getActions( allActions.push(CONST.SEARCH.ACTION_TYPES.EXPORT_TO_ACCOUNTING); } - if (isClosedReport(report) && !(canBePaid || shouldOnlyShowElsewhere)) { + if (isClosedReport(report) && !(canBePaid || shouldOnlyShowElsewhere || (reportHasOnlyNonReimbursableTransactions && canOnlyBePaidElsewhere))) { return allActions.length > 0 ? allActions : [CONST.SEARCH.ACTION_TYPES.DONE]; } diff --git a/tests/unit/MoneyRequestReportButtonUtils.test.ts b/tests/unit/MoneyRequestReportButtonUtils.test.ts index f04ba64472e1a..9abfda8bfd16e 100644 --- a/tests/unit/MoneyRequestReportButtonUtils.test.ts +++ b/tests/unit/MoneyRequestReportButtonUtils.test.ts @@ -17,6 +17,7 @@ jest.mock('@libs/ReportUtils', () => ({ reimbursableSpend: 50, totalDisplaySpend: 100, }), + hasOnlyNonReimbursableTransactions: jest.fn().mockReturnValue(false), hasHeldExpenses: jest.fn().mockReturnValue(false), parseReportRouteParams: jest.fn().mockReturnValue({ reportID: mockedReportID, From 3e3994da90b9c08dfa2163c87865e54e0beed389 Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Tue, 24 Feb 2026 21:50:30 +0500 Subject: [PATCH 08/20] refactor: update hasOnlyNonReimbursableTransactions to accept transactions as parameter --- src/libs/ReportUtils.ts | 4 ++-- src/libs/SearchUIUtils.ts | 3 ++- src/libs/actions/IOU/index.ts | 3 ++- src/pages/Search/SearchPage.tsx | 5 +++-- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 14817b3eeda37..cbe3a7cccc099 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -2625,8 +2625,8 @@ function getHelpPaneReportType(report: OnyxEntry, conciergeReportID: str /** * Checks if a report contains only Non-Reimbursable transactions */ -function hasOnlyNonReimbursableTransactions(iouReportID: string | undefined): boolean { - const transactions = getReportTransactions(iouReportID); +function hasOnlyNonReimbursableTransactions(iouReportID: string | undefined, transactionsParam?: Transaction[]): boolean { + const transactions = transactionsParam ? transactionsParam : getReportTransactions(iouReportID); if (!transactions || transactions.length === 0) { return false; } diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 481c3e12f2253..7b2c60b259493 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -1857,7 +1857,7 @@ function getActions( const chatReport = getChatReport(data, report); const canBePaid = canIOUBePaid(report, chatReport, policy, bankAccountList, allReportTransactions, false, chatReportRNVP, invoiceReceiverPolicy); const canOnlyBePaidElsewhere = canIOUBePaid(report, chatReport, policy, bankAccountList, allReportTransactions, true, chatReportRNVP, invoiceReceiverPolicy); - const reportHasOnlyNonReimbursableTransactions = hasOnlyNonReimbursableTransactions(report?.reportID); + const reportHasOnlyNonReimbursableTransactions = hasOnlyNonReimbursableTransactions(report?.reportID, allReportTransactions?.length ? allReportTransactions : undefined); const shouldOnlyShowElsewhere = !canBePaid && canOnlyBePaidElsewhere && !reportHasOnlyNonReimbursableTransactions; // We're not supporting pay partial amount on search page now. @@ -4447,6 +4447,7 @@ export { isTaskListItemType, getActions, createTypeMenuSections, + getTransactionsForReport, formatBadgeText, getItemBadgeText, createBaseSavedSearchMenuItem, diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index a4ee4ea28f70a..b12ac8097815d 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -10456,6 +10456,7 @@ function canIOUBePaid( const isReportFinished = (isApproved || isClosed) && !iouReport?.isWaitingOnBankAccount; const isIOU = isIOUReport(iouReport); const canShowMarkedAsPaidForNegativeAmount = onlyShowPayElsewhere && reimbursableSpend < 0; + const isOnlyNonReimbursablePayElsewhere = onlyShowPayElsewhere && hasOnlyNonReimbursableTransactions(iouReport?.reportID, transactions); if (isIOU && isPayer && !iouSettled && reimbursableSpend > 0) { return true; @@ -10465,7 +10466,7 @@ function canIOUBePaid( isPayer && isReportFinished && !iouSettled && - (reimbursableSpend > 0 || canShowMarkedAsPaidForNegativeAmount || (onlyShowPayElsewhere && hasOnlyNonReimbursableTransactions(iouReport?.reportID))) && + (reimbursableSpend > 0 || canShowMarkedAsPaidForNegativeAmount || isOnlyNonReimbursablePayElsewhere) && !isChatReportArchived && !isAutoReimbursable && !isPayAtEndExpenseReport && diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index bbf33c97adaac..3db1edbdacdd6 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -77,7 +77,7 @@ import { isIOUReport as isIOUReportUtil, } from '@libs/ReportUtils'; import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; -import {navigateToSearchRHP, shouldShowDeleteOption} from '@libs/SearchUIUtils'; +import {getTransactionsForReport, navigateToSearchRHP, shouldShowDeleteOption} from '@libs/SearchUIUtils'; import {hasTransactionBeenRejected} from '@libs/TransactionUtils'; import variables from '@styles/variables'; import {canIOUBePaid, dismissRejectUseExplanation} from '@userActions/IOU'; @@ -203,11 +203,12 @@ function SearchPage({route}: SearchPageProps) { const report = currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; const chatReportID = report?.chatReportID; const chatReport = chatReportID ? currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`] : undefined; + const reportTransactions = currentSearchResults?.data ? getTransactionsForReport(currentSearchResults.data, reportID) : []; return ( report && !canIOUBePaid(report, chatReport, selectedPolicy, bankAccountList, undefined, false) && canIOUBePaid(report, chatReport, selectedPolicy, bankAccountList, undefined, true) && - !hasOnlyNonReimbursableTransactions(report?.reportID) + !hasOnlyNonReimbursableTransactions(report?.reportID, reportTransactions.length ? reportTransactions : undefined) ); }); }, [currentSearchResults?.data, selectedPolicyIDs, selectedReportIDs, selectedTransactionReportIDs, bankAccountList]); From 69f5c8947004fb438af2a56bad4ae133d322f676 Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Tue, 24 Feb 2026 22:25:20 +0500 Subject: [PATCH 09/20] add unit tests for non-reimbursable expense cases --- tests/actions/IOUTest.ts | 44 +++++++++++++++++++ tests/actions/ReportPreviewActionUtilsTest.ts | 42 ++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index a60429e44a218..f7931db0f0cfa 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -10457,6 +10457,50 @@ describe('actions/IOU', () => { expect(canIOUBePaid(fakeReport, policyChat, fakePolicy, {}, [], false)).toBeFalsy(); expect(canIOUBePaid(fakeReport, policyChat, fakePolicy, {}, [], true)).toBeTruthy(); }); + + it('allows admins to mark report with only non-reimbursable expenses as paid (onlyShowPayElsewhere=true)', async () => { + const policyChat = createRandomReport(1, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); + const reportID = '999'; + + const fakePolicy: Policy = { + ...createRandomPolicy(Number('AA')), + id: 'AA', + type: CONST.POLICY.TYPE.TEAM, + approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL, + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, + role: CONST.POLICY.ROLE.ADMIN, + }; + + const fakeReport: Report = { + ...createRandomReport(Number(reportID), undefined), + reportID, + type: CONST.REPORT.TYPE.EXPENSE, + policyID: 'AA', + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + ownerAccountID: CARLOS_ACCOUNT_ID, + managerID: RORY_ACCOUNT_ID, + isWaitingOnBankAccount: false, + total: 100, + nonReimbursableTotal: 100, + }; + + const onlyNonReimbursableTransactions: Transaction[] = [ + { + ...createRandomTransaction(1), + reportID, + amount: 100, + currency: 'USD', + reimbursable: false, + }, + ]; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy); + + expect(canIOUBePaid(fakeReport, policyChat, fakePolicy, {}, [], false)).toBeFalsy(); + expect(canIOUBePaid(fakeReport, policyChat, fakePolicy, {}, onlyNonReimbursableTransactions, false)).toBeFalsy(); + expect(canIOUBePaid(fakeReport, policyChat, fakePolicy, {}, onlyNonReimbursableTransactions, true)).toBeTruthy(); + }); }); describe('calculateDiffAmount', () => { diff --git a/tests/actions/ReportPreviewActionUtilsTest.ts b/tests/actions/ReportPreviewActionUtilsTest.ts index 4c1b2621ecf74..b580c7066a1eb 100644 --- a/tests/actions/ReportPreviewActionUtilsTest.ts +++ b/tests/actions/ReportPreviewActionUtilsTest.ts @@ -6,6 +6,7 @@ import type * as PolicyUtils from '@libs/PolicyUtils'; import getReportPreviewAction from '@libs/ReportPreviewActionUtils'; // eslint-disable-next-line no-restricted-syntax import type * as ReportUtils from '@libs/ReportUtils'; +import {hasOnlyNonReimbursableTransactions} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Report, Transaction} from '@src/types/onyx'; @@ -35,6 +36,7 @@ jest.mock('@libs/ReportUtils', () => ({ ...jest.requireActual('@libs/ReportUtils'), hasAnyViolations: jest.fn().mockReturnValue(false), getReportTransactions: jest.fn().mockReturnValue(['mockValue']), + hasOnlyNonReimbursableTransactions: jest.fn().mockReturnValue(false), })); jest.mock('@libs/PolicyUtils', () => ({ ...jest.requireActual('@libs/PolicyUtils'), @@ -512,6 +514,46 @@ describe('getReportPreviewAction', () => { ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.VIEW); }); + it('canPay should return PAY for expense report with only non-reimbursable expenses when payments enabled', async () => { + const report = { + ...createRandomReport(REPORT_ID, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + statusNum: CONST.REPORT.STATUS_NUM.CLOSED, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + total: -100, + nonReimbursableTotal: -100, + isWaitingOnBankAccount: false, + }; + + const policy = createRandomPolicy(0); + policy.role = CONST.POLICY.ROLE.ADMIN; + policy.type = CONST.POLICY.TYPE.CORPORATE; + policy.reimbursementChoice = CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES; + + jest.mocked(hasOnlyNonReimbursableTransactions).mockReturnValueOnce(true); + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + const transaction = { + reportID: `${REPORT_ID}`, + } as unknown as Transaction; + + const {result: isReportArchived} = renderHook(() => useReportIsArchived(report?.parentReportID)); + await waitForBatchedUpdatesWithAct(); + expect( + getReportPreviewAction({ + isReportArchived: isReportArchived.current, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + currentUserLogin: CURRENT_USER_EMAIL, + report, + policy, + transactions: [transaction], + bankAccountList: {}, + reportMetadata: undefined, + }), + ).toBe(CONST.REPORT.REPORT_PREVIEW_ACTIONS.PAY); + }); + it('canPay should return true for submitted invoice', async () => { const report = { ...createRandomReport(REPORT_ID, undefined), From 6423243b64ed8c4a6f8aefac45316127aedf5717 Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Thu, 26 Feb 2026 03:15:36 +0500 Subject: [PATCH 10/20] update modal copy --- src/languages/de.ts | 5 ++--- src/languages/en.ts | 4 ++-- src/languages/es.ts | 4 ++-- src/languages/fr.ts | 5 ++--- src/languages/it.ts | 5 ++--- src/languages/ja.ts | 4 ++-- src/languages/nl.ts | 5 ++--- src/languages/pl.ts | 5 ++--- src/languages/pt-BR.ts | 5 ++--- src/languages/zh-hans.ts | 4 ++-- 10 files changed, 20 insertions(+), 26 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 6177ec497f6f0..2dc3018f5c62e 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1350,9 +1350,8 @@ const translations: TranslationDeepObject = { endDateSameAsStartDate: 'Das Enddatum darf nicht mit dem Startdatum übereinstimmen', manySplitsProvided: `Die maximale Anzahl zulässiger Aufteilungen beträgt ${CONST.IOU.SPLITS_LIMIT}.`, dateRangeExceedsMaxDays: `Der Datumsbereich darf ${CONST.IOU.SPLITS_LIMIT} Tage nicht überschreiten.`, - nonReimbursablePayment: 'Direkte Zahlung nicht möglich', - nonReimbursablePaymentDescription: - 'Dieser Bericht enthält keine erstattungsfähigen Ausgaben und kann nicht per Direktzahlung bezahlt werden. Verwenden Sie "Anderswo bezahlen", um ihn als bezahlt zu markieren.', + nonReimbursablePayment: 'Kann nicht über Expensify bezahlt werden', + nonReimbursablePaymentDescription: 'Der Bericht enthält keine erstattungsfähigen Ausgaben. Überprüfe die Ausgaben erneut oder markiere ihn manuell als bezahlt.', }, dismissReceiptError: 'Fehler ausblenden', dismissReceiptErrorConfirmation: 'Achtung! Wenn du diesen Fehler schließt, wird deine hochgeladene Quittung vollständig entfernt. Bist du sicher?', diff --git a/src/languages/en.ts b/src/languages/en.ts index 8af43141c81f4..e170d900da688 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1365,8 +1365,8 @@ const translations = { endDateSameAsStartDate: "The end date can't be the same as the start date", manySplitsProvided: `The maximum splits allowed is ${CONST.IOU.SPLITS_LIMIT}.`, dateRangeExceedsMaxDays: `The date range can't exceed ${CONST.IOU.SPLITS_LIMIT} days.`, - nonReimbursablePayment: 'Cannot pay via direct payment', - nonReimbursablePaymentDescription: 'This report does’t have reimbursable expenses and cannot be paid via direct payment. Use "Pay elsewhere" to mark it as paid instead.', + nonReimbursablePayment: 'Cannot pay via Expensify', + nonReimbursablePaymentDescription: "The report doesn't have reimbursable expenses. Double check the expenses, or manually mark as paid.", }, dismissReceiptError: 'Dismiss error', dismissReceiptErrorConfirmation: 'Heads up! Dismissing this error will remove your uploaded receipt entirely. Are you sure?', diff --git a/src/languages/es.ts b/src/languages/es.ts index 43804428a89d9..5befce6ddb639 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1198,8 +1198,8 @@ const translations: TranslationDeepObject = { endDateSameAsStartDate: 'La fecha de finalización no puede ser la misma que la fecha de inicio', manySplitsProvided: `La cantidad máxima de divisiones permitidas es ${CONST.IOU.SPLITS_LIMIT}.`, dateRangeExceedsMaxDays: `El rango de fechas no puede exceder los ${CONST.IOU.SPLITS_LIMIT} días.`, - nonReimbursablePayment: 'No se puede pagar por pago directo', - nonReimbursablePaymentDescription: 'Este informe no tiene gastos reembolsables y no puede pagarse mediante pago directo. Usa "Pagar en otro lugar" para marcarlo como pagado.', + nonReimbursablePayment: 'No se puede pagar a través de Expensify', + nonReimbursablePaymentDescription: 'El informe no tiene gastos reembolsables. Vuelve a revisar los gastos o márcalo manualmente como pagado.', }, dismissReceiptError: 'Descartar error', dismissReceiptErrorConfirmation: '¡Atención! Descartar este error eliminará completamente tu recibo cargado. ¿Estás seguro?', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 9c321d3efa858..72100d67e63f2 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1354,9 +1354,8 @@ const translations: TranslationDeepObject = { endDateSameAsStartDate: 'La date de fin ne peut pas être identique à la date de début', manySplitsProvided: `Le nombre maximal de répartitions autorisées est de ${CONST.IOU.SPLITS_LIMIT}.`, dateRangeExceedsMaxDays: `La plage de dates ne peut pas dépasser ${CONST.IOU.SPLITS_LIMIT} jours.`, - nonReimbursablePayment: 'Impossible de payer par virement direct', - nonReimbursablePaymentDescription: - 'Ce rapport ne contient aucune dépense remboursable et ne peut pas être payé par paiement direct. Utilisez "Payer ailleurs" pour le marquer comme payé.', + nonReimbursablePayment: 'Impossible de payer via Expensify', + nonReimbursablePaymentDescription: 'Le rapport ne contient pas de dépenses remboursables. Vérifiez les dépenses ou marquez-le manuellement comme payé.', }, dismissReceiptError: 'Ignorer l’erreur', dismissReceiptErrorConfirmation: 'Attention ! Ignorer cette erreur supprimera complètement votre reçu téléversé. Êtes-vous sûr ?', diff --git a/src/languages/it.ts b/src/languages/it.ts index d6fb01b873933..2b7907914842b 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1347,9 +1347,8 @@ const translations: TranslationDeepObject = { endDateSameAsStartDate: 'La data di fine non può essere uguale alla data di inizio', manySplitsProvided: `Il numero massimo di suddivisioni consentite è ${CONST.IOU.SPLITS_LIMIT}.`, dateRangeExceedsMaxDays: `L’intervallo di date non può superare ${CONST.IOU.SPLITS_LIMIT} giorni.`, - nonReimbursablePayment: 'Impossibile pagare con pagamento diretto', - nonReimbursablePaymentDescription: - 'Questo report non contiene spese rimborsabili e non può essere pagato tramite pagamento diretto. Usa "Paga altrove" per contrassegnarlo come pagato.', + nonReimbursablePayment: 'Impossibile pagare tramite Expensify', + nonReimbursablePaymentDescription: 'Il report non contiene spese rimborsabili. Ricontrolla le spese oppure contrassegnalo manualmente come pagato.', }, dismissReceiptError: 'Ignora errore', dismissReceiptErrorConfirmation: 'Attenzione! Chiudere questo errore rimuoverà completamente la ricevuta che hai caricato. Sei sicuro?', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index edd1104016131..c03c5d054cd29 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1338,8 +1338,8 @@ const translations: TranslationDeepObject = { endDateSameAsStartDate: '終了日は開始日と同じにはできません', manySplitsProvided: `分割できる最大数は${CONST.IOU.SPLITS_LIMIT}件です。`, dateRangeExceedsMaxDays: `日付範囲は${CONST.IOU.SPLITS_LIMIT}日を超えることはできません。`, - nonReimbursablePayment: '直接支払いでは支払えません', - nonReimbursablePaymentDescription: 'このレポートには精算対象の経費が含まれていないため、直接支払いはできません。「その他の方法で支払う」を使用して支払い済みにしてください。', + nonReimbursablePayment: 'Expensify経由では支払えません', + nonReimbursablePaymentDescription: 'このレポートには精算可能な経費がありません。経費を再確認するか、手動で支払い済みにしてください。', }, dismissReceiptError: 'エラーを閉じる', dismissReceiptErrorConfirmation: 'ご注意ください!このエラーを閉じると、アップロード済みのレシートが完全に削除されます。本当に続行しますか?', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 8ab72e6734c57..b0a816e7f399f 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1345,9 +1345,8 @@ const translations: TranslationDeepObject = { endDateSameAsStartDate: 'De einddatum mag niet gelijk zijn aan de startdatum', manySplitsProvided: `Het maximale aantal toegestane splitsingen is ${CONST.IOU.SPLITS_LIMIT}.`, dateRangeExceedsMaxDays: `Het datumbereik mag niet meer dan ${CONST.IOU.SPLITS_LIMIT} dagen zijn.`, - nonReimbursablePayment: 'Kan niet via directe betaling betalen', - nonReimbursablePaymentDescription: - 'Dit rapport bevat geen declareerbare uitgaven en kan niet via directe betaling worden betaald. Gebruik "Elders betalen" om het als betaald te markeren.', + nonReimbursablePayment: 'Kan niet via Expensify worden betaald', + nonReimbursablePaymentDescription: 'Het rapport bevat geen declareerbare kosten. Controleer de kosten opnieuw of markeer het handmatig als betaald.', }, dismissReceiptError: 'Foutmelding sluiten', dismissReceiptErrorConfirmation: 'Let op! Dit foutbericht negeren verwijdert je geüploade bon volledig. Weet je het zeker?', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 53af871dc8249..cea99264c1ad9 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1344,9 +1344,8 @@ const translations: TranslationDeepObject = { endDateSameAsStartDate: 'Data zakończenia nie może być taka sama jak data rozpoczęcia', manySplitsProvided: `Maksymalna dozwolona liczba podziałów to ${CONST.IOU.SPLITS_LIMIT}.`, dateRangeExceedsMaxDays: `Zakres dat nie może przekraczać ${CONST.IOU.SPLITS_LIMIT} dni.`, - nonReimbursablePayment: 'Nie można zapłacić płatnością bezpośrednią', - nonReimbursablePaymentDescription: - 'Ten raport nie zawiera wydatków podlegających zwrotowi i nie może zostać opłacony za pomocą płatności bezpośredniej. Użyj "Zapłać gdzie indziej", aby oznaczyć go jako opłacony.', + nonReimbursablePayment: 'Nie można zapłacić przez Expensify', + nonReimbursablePaymentDescription: 'Raport nie zawiera wydatków podlegających zwrotowi. Sprawdź wydatki ponownie lub oznacz go ręcznie jako opłacony.', }, dismissReceiptError: 'Odrzuć błąd', dismissReceiptErrorConfirmation: 'Uwaga! Zamknięcie tego błędu spowoduje całkowite usunięcie przesłanego paragonu. Czy na pewno chcesz kontynuować?', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index e3b3468cfb512..8cc78d3004ec0 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1342,9 +1342,8 @@ const translations: TranslationDeepObject = { endDateSameAsStartDate: 'A data de término não pode ser igual à data de início', manySplitsProvided: `O número máximo de divisões permitido é ${CONST.IOU.SPLITS_LIMIT}.`, dateRangeExceedsMaxDays: `O intervalo de datas não pode exceder ${CONST.IOU.SPLITS_LIMIT} dias.`, - nonReimbursablePayment: 'Não é possível pagar via pagamento direto', - nonReimbursablePaymentDescription: - 'Este relatório não possui despesas reembolsáveis e não pode ser pago via pagamento direto. Use "Pagar em outro lugar" para marcá-lo como pago.', + nonReimbursablePayment: 'Não é possível pagar via Expensify', + nonReimbursablePaymentDescription: 'O relatório não possui despesas reembolsáveis. Verifique as despesas novamente ou marque-o manualmente como pago.', }, dismissReceiptError: 'Dispensar erro', dismissReceiptErrorConfirmation: 'Atenção! Ignorar este erro removerá completamente o comprovante que você enviou. Tem certeza?', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 8346aa38c43b7..cbe8666960d60 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1319,8 +1319,8 @@ const translations: TranslationDeepObject = { endDateSameAsStartDate: '结束日期不能与开始日期相同', manySplitsProvided: `允许的最大拆分数为 ${CONST.IOU.SPLITS_LIMIT}。`, dateRangeExceedsMaxDays: `日期范围不能超过 ${CONST.IOU.SPLITS_LIMIT} 天。`, - nonReimbursablePayment: '无法通过直接付款支付', - nonReimbursablePaymentDescription: '此报告不包含可报销费用,因此无法通过直接付款进行支付。请使用“其他方式支付”将其标记为已支付。', + nonReimbursablePayment: '无法通过 Expensify 付款', + nonReimbursablePaymentDescription: '该报告没有可报销的费用。请再次检查费用,或手动将其标记为已支付。', }, dismissReceiptError: '忽略错误', dismissReceiptErrorConfirmation: '提醒:关闭此错误将彻底删除你上传的收据。确定要继续吗?', From 5f17603650343bf8c820ccba6562cb1d17aeeac6 Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Thu, 26 Feb 2026 03:16:13 +0500 Subject: [PATCH 11/20] lint --- src/libs/ReportUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 1bdea90f9ec49..979265dddad6f 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -2626,7 +2626,7 @@ function getHelpPaneReportType(report: OnyxEntry, conciergeReportID: str * Checks if a report contains only Non-Reimbursable transactions */ function hasOnlyNonReimbursableTransactions(iouReportID: string | undefined, transactionsParam?: Transaction[]): boolean { - const transactions = transactionsParam ? transactionsParam : getReportTransactions(iouReportID); + const transactions = transactionsParam ?? getReportTransactions(iouReportID); if (!transactions || transactions.length === 0) { return false; } From 2343fd49917ac0c31f78f1548032bf405bc5333b Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Fri, 27 Feb 2026 23:37:36 +0500 Subject: [PATCH 12/20] Implement non-reimbursable payment modal handling with hook --- src/components/MoneyReportHeader.tsx | 21 ++++----- .../MoneyRequestReportPreviewContent.tsx | 22 ++++----- .../Search/ActionCell/PayActionCell.tsx | 24 +++------- src/hooks/useNonReimbursablePaymentModal.tsx | 47 +++++++++++++++++++ 4 files changed, 69 insertions(+), 45 deletions(-) create mode 100644 src/hooks/useNonReimbursablePaymentModal.tsx diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index f69a85ab43002..f80a2fe3da52c 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -18,6 +18,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 useParticipantsInvoiceReport from '@hooks/useParticipantsInvoiceReport'; import usePaymentAnimations from '@hooks/usePaymentAnimations'; @@ -448,7 +449,7 @@ function MoneyReportHeader({ const shouldDisplayNarrowMoreButton = !shouldDisplayNarrowVersion || isWideRHPDisplayedOnWideLayout || isSuperWideRHPDisplayedOnWideLayout; const [offlineModalVisible, setOfflineModalVisible] = useState(false); - const [nonReimbursablePaymentErrorModalVisible, setNonReimbursablePaymentErrorModalVisible] = useState(false); + const {showNonReimbursablePaymentErrorModal, shouldBlockDirectPayment, nonReimbursablePaymentErrorDecisionModal} = useNonReimbursablePaymentModal(moneyRequestReport); const showExportProgressModal = useCallback(() => { return showConfirmModal({ @@ -591,8 +592,8 @@ function MoneyReportHeader({ if (!type || !chatReport) { return; } - if (!isInvoiceReportUtil(moneyRequestReport) && hasOnlyNonReimbursableTransactions(moneyRequestReport?.reportID) && type !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE) { - setNonReimbursablePaymentErrorModalVisible(true); + if (shouldBlockDirectPayment(type)) { + showNonReimbursablePaymentErrorModal(); return; } setPaymentType(type); @@ -654,6 +655,8 @@ function MoneyReportHeader({ isAnyTransactionOnHold, isInvoiceReport, showDelegateNoAccessModal, + showNonReimbursablePaymentErrorModal, + shouldBlockDirectPayment, startAnimation, moneyRequestReport, nextStep, @@ -1943,7 +1946,7 @@ function MoneyReportHeader({ } }} transactionCount={transactionIDs?.length ?? 0} - onNonReimbursablePaymentError={() => setNonReimbursablePaymentErrorModalVisible(true)} + onNonReimbursablePaymentError={showNonReimbursablePaymentErrorModal} /> )} setOfflineModalVisible(false)} /> - setNonReimbursablePaymentErrorModalVisible(false)} - secondOptionText={translate('common.buttonConfirm')} - isVisible={nonReimbursablePaymentErrorModalVisible} - onClose={() => setNonReimbursablePaymentErrorModalVisible(false)} - /> + {nonReimbursablePaymentErrorDecisionModal} { setIsPDFModalVisible(false); diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx index b3d55bac2debb..147c2151cc62e 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx @@ -11,7 +11,6 @@ import AnimatedSubmitButton from '@components/AnimatedSubmitButton'; import Button from '@components/Button'; import {getButtonRole} from '@components/Button/utils'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; -import DecisionModal from '@components/DecisionModal'; import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; import Icon from '@components/Icon'; import type {PaymentMethod} from '@components/KYCWall/types'; @@ -31,6 +30,7 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; +import useNonReimbursablePaymentModal from '@hooks/useNonReimbursablePaymentModal'; import useOnyx from '@hooks/useOnyx'; import useParticipantsInvoiceReport from '@hooks/useParticipantsInvoiceReport'; import usePaymentAnimations from '@hooks/usePaymentAnimations'; @@ -178,7 +178,7 @@ function MoneyRequestReportPreviewContent({ const {showConfirmModal} = useConfirmModal(); const [isHoldMenuVisible, setIsHoldMenuVisible] = useState(false); const [requestType, setRequestType] = useState(); - const [nonReimbursablePaymentErrorModalVisible, setNonReimbursablePaymentErrorModalVisible] = useState(false); + const {showNonReimbursablePaymentErrorModal, shouldBlockDirectPayment, nonReimbursablePaymentErrorDecisionModal} = useNonReimbursablePaymentModal(iouReport); const [paymentType, setPaymentType] = useState(); const isIouReportArchived = useReportIsArchived(iouReportID); const isChatReportArchived = useReportIsArchived(chatReport?.reportID); @@ -261,8 +261,8 @@ function MoneyRequestReportPreviewContent({ if (!type) { return; } - if (!isInvoiceReportUtils(iouReport) && hasOnlyNonReimbursableTransactions(iouReport?.reportID) && type !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE) { - setNonReimbursablePaymentErrorModalVisible(true); + if (shouldBlockDirectPayment(type)) { + showNonReimbursablePaymentErrorModal(); return; } setPaymentType(type); @@ -309,6 +309,8 @@ function MoneyRequestReportPreviewContent({ iouReport, chatReport, showDelegateNoAccessModal, + showNonReimbursablePaymentErrorModal, + shouldBlockDirectPayment, startAnimation, iouReportNextStep, introSelected, @@ -996,19 +998,11 @@ function MoneyRequestReportPreviewContent({ startAnimation(); } }} - onNonReimbursablePaymentError={() => setNonReimbursablePaymentErrorModalVisible(true)} + onNonReimbursablePaymentError={showNonReimbursablePaymentErrorModal} /> )} - setNonReimbursablePaymentErrorModalVisible(false)} - secondOptionText={translate('common.buttonConfirm')} - isVisible={nonReimbursablePaymentErrorModalVisible} - onClose={() => setNonReimbursablePaymentErrorModalVisible(false)} - /> + {nonReimbursablePaymentErrorDecisionModal} ); } diff --git a/src/components/SelectionListWithSections/Search/ActionCell/PayActionCell.tsx b/src/components/SelectionListWithSections/Search/ActionCell/PayActionCell.tsx index 2c27c79739963..09cdb75c1dc90 100644 --- a/src/components/SelectionListWithSections/Search/ActionCell/PayActionCell.tsx +++ b/src/components/SelectionListWithSections/Search/ActionCell/PayActionCell.tsx @@ -1,16 +1,14 @@ -import React, {useState} from 'react'; +import React from 'react'; import type {ValueOf} from 'type-fest'; -import DecisionModal from '@components/DecisionModal'; import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; import type {PaymentMethod} from '@components/KYCWall/types'; import {SearchScopeProvider} from '@components/Search/SearchScopeProvider'; import SettlementButton from '@components/SettlementButton'; -import useLocalize from '@hooks/useLocalize'; 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 useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import {canIOUBePaid} from '@libs/actions/IOU'; import {getPayMoneyOnSearchInvoiceParams, payMoneyRequestOnSearch} from '@libs/actions/Search'; @@ -31,14 +29,12 @@ type PayActionCellProps = { }; function PayActionCell({isLoading, policyID, reportID, hash, amount, extraSmall, shouldDisablePointerEvents}: PayActionCellProps) { - const {translate} = useLocalize(); const styles = useThemeStyles(); const {isOffline} = useNetwork(); - const {isSmallScreenWidth} = useResponsiveLayout(); const {isDelegateAccessRestricted} = useDelegateNoAccessState(); const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); - const [nonReimbursablePaymentErrorModalVisible, setNonReimbursablePaymentErrorModalVisible] = useState(false); const [iouReport, transactions] = useReportWithTransactionsAndViolations(reportID); + const {showNonReimbursablePaymentErrorModal, shouldBlockDirectPayment, nonReimbursablePaymentErrorDecisionModal} = useNonReimbursablePaymentModal(iouReport); const policy = usePolicy(policyID); const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${iouReport?.chatReportID}`); @@ -62,8 +58,8 @@ function PayActionCell({isLoading, policyID, reportID, hash, amount, extraSmall, return; } - if (!isInvoiceReport(iouReport) && hasOnlyNonReimbursableTransactions(iouReport?.reportID) && type !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE) { - setNonReimbursablePaymentErrorModalVisible(true); + if (shouldBlockDirectPayment(type)) { + showNonReimbursablePaymentErrorModal(); return; } @@ -93,15 +89,7 @@ function PayActionCell({isLoading, policyID, reportID, hash, amount, extraSmall, onlyShowPayElsewhere={shouldOnlyShowElsewhere} sentryLabel={CONST.SENTRY_LABEL.SEARCH.ACTION_CELL_PAY} /> - setNonReimbursablePaymentErrorModalVisible(false)} - secondOptionText={translate('common.buttonConfirm')} - isVisible={nonReimbursablePaymentErrorModalVisible} - onClose={() => setNonReimbursablePaymentErrorModalVisible(false)} - /> + {nonReimbursablePaymentErrorDecisionModal} ); } diff --git a/src/hooks/useNonReimbursablePaymentModal.tsx b/src/hooks/useNonReimbursablePaymentModal.tsx new file mode 100644 index 0000000000000..e040c1421d0af --- /dev/null +++ b/src/hooks/useNonReimbursablePaymentModal.tsx @@ -0,0 +1,47 @@ +import React, {useState} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import DecisionModal from '@components/DecisionModal'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import {hasOnlyNonReimbursableTransactions, isInvoiceReport} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import type {Report} from '@src/types/onyx'; +import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; + +type UseNonReimbursablePaymentModalReturn = { + showNonReimbursablePaymentErrorModal: () => void; + shouldBlockDirectPayment: (paymentType: PaymentMethodType) => boolean; + nonReimbursablePaymentErrorDecisionModal: React.ReactNode; +}; + +/** Blocks direct payment and shows the error modal when the report contains only non-reimbursable expenses. */ +function useNonReimbursablePaymentModal(iouReport: OnyxEntry): UseNonReimbursablePaymentModalReturn { + const [isModalVisible, setIsModalVisible] = useState(false); + const {translate} = useLocalize(); + const {isSmallScreenWidth} = useResponsiveLayout(); + + const showNonReimbursablePaymentErrorModal = () => setIsModalVisible(true); + + const shouldBlockDirectPayment = (paymentType: PaymentMethodType): boolean => + !isInvoiceReport(iouReport) && hasOnlyNonReimbursableTransactions(iouReport?.reportID) && paymentType !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE; + + const nonReimbursablePaymentErrorDecisionModal = ( + setIsModalVisible(false)} + secondOptionText={translate('common.buttonConfirm')} + isVisible={isModalVisible} + onClose={() => setIsModalVisible(false)} + /> + ); + + return { + showNonReimbursablePaymentErrorModal, + shouldBlockDirectPayment, + nonReimbursablePaymentErrorDecisionModal, + }; +} + +export default useNonReimbursablePaymentModal; From ac58aa827c09ac6637ddfa9a3ead6fd494b221f2 Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Wed, 18 Mar 2026 06:33:42 +0500 Subject: [PATCH 13/20] Prevent closing modal on non-reimbursable payment error for approvals --- src/components/ProcessMoneyReportHoldMenu.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ProcessMoneyReportHoldMenu.tsx b/src/components/ProcessMoneyReportHoldMenu.tsx index 6a4e72bdfed53..b95091900aeed 100644 --- a/src/components/ProcessMoneyReportHoldMenu.tsx +++ b/src/components/ProcessMoneyReportHoldMenu.tsx @@ -103,7 +103,7 @@ function ProcessMoneyReportHoldMenu({ return; } - if (chatReport && hasOnlyNonReimbursableTransactions(moneyRequestReport?.reportID) && paymentType && paymentType !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE) { + if (!isApprove && chatReport && hasOnlyNonReimbursableTransactions(moneyRequestReport?.reportID) && paymentType && paymentType !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE) { onClose(); onNonReimbursablePaymentError?.(); return; From 5fc4fe2753ffb4ae00198c9d2e639c9f6c030ee6 Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Wed, 18 Mar 2026 06:49:35 +0500 Subject: [PATCH 14/20] fix lint warnings --- src/hooks/useNonReimbursablePaymentModal.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/hooks/useNonReimbursablePaymentModal.tsx b/src/hooks/useNonReimbursablePaymentModal.tsx index e040c1421d0af..0dbf879b95f8d 100644 --- a/src/hooks/useNonReimbursablePaymentModal.tsx +++ b/src/hooks/useNonReimbursablePaymentModal.tsx @@ -1,12 +1,12 @@ import React, {useState} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import DecisionModal from '@components/DecisionModal'; -import useLocalize from '@hooks/useLocalize'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; import {hasOnlyNonReimbursableTransactions, isInvoiceReport} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import type {Report} from '@src/types/onyx'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; +import useLocalize from './useLocalize'; +import useResponsiveLayout from './useResponsiveLayout'; type UseNonReimbursablePaymentModalReturn = { showNonReimbursablePaymentErrorModal: () => void; @@ -18,7 +18,7 @@ type UseNonReimbursablePaymentModalReturn = { function useNonReimbursablePaymentModal(iouReport: OnyxEntry): UseNonReimbursablePaymentModalReturn { const [isModalVisible, setIsModalVisible] = useState(false); const {translate} = useLocalize(); - const {isSmallScreenWidth} = useResponsiveLayout(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); const showNonReimbursablePaymentErrorModal = () => setIsModalVisible(true); @@ -29,7 +29,7 @@ function useNonReimbursablePaymentModal(iouReport: OnyxEntry): UseNonRei setIsModalVisible(false)} secondOptionText={translate('common.buttonConfirm')} isVisible={isModalVisible} From 39310dad1c808abfbb84a06ff8c58e042917818d Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Tue, 24 Mar 2026 18:08:05 +0500 Subject: [PATCH 15/20] Add non-reimbursable payment error modal handling in search bulk actions --- .../Search/SearchBulkActionsButton.tsx | 13 ++++++++-- src/hooks/useNonReimbursablePaymentModal.tsx | 6 +++-- src/hooks/useSearchBulkActions.ts | 26 ++++++++++++++++--- 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/src/components/Search/SearchBulkActionsButton.tsx b/src/components/Search/SearchBulkActionsButton.tsx index f418270be5e68..ac5bfb74ddd96 100644 --- a/src/components/Search/SearchBulkActionsButton.tsx +++ b/src/components/Search/SearchBulkActionsButton.tsx @@ -58,15 +58,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); @@ -78,7 +79,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; @@ -225,6 +225,15 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { isVisible={isDownloadErrorModalVisible} onClose={handleDownloadErrorModalClose} /> + {!!rejectModalAction && ( ): UseNonReimbursablePaymentModalReturn { const [isModalVisible, setIsModalVisible] = useState(false); const {translate} = useLocalize(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); + // We need to use isSmallScreenWidth here because the DecisionModal breaks in RHP with shouldUseNarrowLayout. + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isSmallScreenWidth} = useResponsiveLayout(); const showNonReimbursablePaymentErrorModal = () => setIsModalVisible(true); @@ -29,7 +31,7 @@ function useNonReimbursablePaymentModal(iouReport: OnyxEntry): UseNonRei setIsModalVisible(false)} secondOptionText={translate('common.buttonConfirm')} isVisible={isModalVisible} diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 1b236e2835bc7..c703494ec0f5e 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -40,6 +40,7 @@ import {getSecondaryExportReportActions, isMergeActionForSelectedTransactions} f import { getIntegrationIcon, getReportOrDraftReport, + hasOnlyNonReimbursableTransactions, isBusinessInvoiceRoom, isCurrentUserSubmitter, isExpenseReport as isExpenseReportUtil, @@ -117,6 +118,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const [isOfflineModalVisible, setIsOfflineModalVisible] = useState(false); const [isDownloadErrorModalVisible, setIsDownloadErrorModalVisible] = useState(false); + const [isNonReimbursablePaymentErrorModalVisible, setIsNonReimbursablePaymentErrorModalVisible] = useState(false); const {showConfirmModal} = useConfirmModal(); const {isBetaEnabled} = usePermissions(); const isDEWBetaEnabled = isBetaEnabled(CONST.BETAS.NEW_DOT_DEW); @@ -589,6 +591,20 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { return; } + const reportTransactions = Object.values(allTransactions ?? {}).filter( + (transaction): transaction is NonNullable => !!transaction && transaction.reportID === itemReportID, + ); + + if ( + isExpenseReport && + !isInvoiceReport(itemReportID) && + hasOnlyNonReimbursableTransactions(itemReportID, reportTransactions) && + lastPolicyPaymentMethod !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE + ) { + setIsNonReimbursablePaymentErrorModalVisible(true); + return; + } + const hasPolicyVBBA = itemPolicyID ? policyIDsWithVBBA.includes(itemPolicyID) : false; // Allow bulk pay when user selected a business bank account, even if that account is not linked to the report's policy const hasSelectedBusinessBankAccount = expenseReportBankAccountID != null; @@ -614,9 +630,6 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { ); return; } - const reportTransactions = Object.values(allTransactions ?? {}).filter( - (transaction): transaction is NonNullable => !!transaction && transaction.reportID === itemReportID, - ); const invite = moveIOUReportToPolicyAndInviteSubmitter(itemReport, adminPolicy, formatPhoneNumber, reportTransactions); if (!invite?.policyExpenseChatReportID) { moveIOUReportToPolicy(itemReport, adminPolicy, false, reportTransactions); @@ -1213,6 +1226,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { showDelegateNoAccessModal, bulkPayButtonOptions, businessBankAccountOptions?.length, + shouldShowBusinessBankAccountOptions, onBulkPaySelected, areAllTransactionsFromSubmitter, dismissedHoldUseExplanation, @@ -1236,6 +1250,10 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { setIsDownloadErrorModalVisible(false); }, [setIsDownloadErrorModalVisible]); + const handleNonReimbursablePaymentErrorModalClose = useCallback(() => { + setIsNonReimbursablePaymentErrorModalVisible(false); + }, [setIsNonReimbursablePaymentErrorModalVisible]); + const dismissModalAndUpdateUseHold = useCallback(() => { setIsHoldEducationalModalVisible(false); setNameValuePair(ONYXKEYS.NVP_DISMISSED_HOLD_USE_EXPLANATION, true, false, !isOffline); @@ -1266,11 +1284,13 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { confirmPayment: stableOnBulkPaySelected, isOfflineModalVisible, isDownloadErrorModalVisible, + isNonReimbursablePaymentErrorModalVisible, isHoldEducationalModalVisible, rejectModalAction, emptyReportsCount, handleOfflineModalClose, handleDownloadErrorModalClose, + handleNonReimbursablePaymentErrorModalClose, dismissModalAndUpdateUseHold, dismissRejectModalBasedOnAction, }; From 458d883866734c9b433cda0f2b3a6950920c3b1e Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Wed, 25 Mar 2026 20:57:40 +0500 Subject: [PATCH 16/20] Update non-reimbursable payment error modal descriptions for multiple selections --- src/components/Search/SearchBulkActionsButton.tsx | 2 +- src/hooks/useNonReimbursablePaymentModal.tsx | 2 +- 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 | 3 ++- 12 files changed, 40 insertions(+), 12 deletions(-) diff --git a/src/components/Search/SearchBulkActionsButton.tsx b/src/components/Search/SearchBulkActionsButton.tsx index ac5bfb74ddd96..0fff421c8ef11 100644 --- a/src/components/Search/SearchBulkActionsButton.tsx +++ b/src/components/Search/SearchBulkActionsButton.tsx @@ -227,7 +227,7 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { /> 1)} isSmallScreenWidth={isSmallScreenWidth} onSecondOptionSubmit={handleNonReimbursablePaymentErrorModalClose} secondOptionText={translate('common.buttonConfirm')} diff --git a/src/hooks/useNonReimbursablePaymentModal.tsx b/src/hooks/useNonReimbursablePaymentModal.tsx index b06d11466fe78..2e92881508c02 100644 --- a/src/hooks/useNonReimbursablePaymentModal.tsx +++ b/src/hooks/useNonReimbursablePaymentModal.tsx @@ -30,7 +30,7 @@ function useNonReimbursablePaymentModal(iouReport: OnyxEntry): UseNonRei const nonReimbursablePaymentErrorDecisionModal = ( setIsModalVisible(false)} secondOptionText={translate('common.buttonConfirm')} diff --git a/src/languages/de.ts b/src/languages/de.ts index 429ca5a5722c1..8d703002d6f52 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1426,7 +1426,10 @@ const translations: TranslationDeepObject = { dateRangeExceedsMaxDays: `Der Datumsbereich darf ${CONST.IOU.SPLITS_LIMIT} Tage nicht überschreiten.`, stitchOdometerImagesFailed: 'Kilometerzählerbilder konnten nicht zusammengeführt werden. Bitte versuchen Sie es später noch einmal.', nonReimbursablePayment: 'Kann nicht über Expensify bezahlt werden', - nonReimbursablePaymentDescription: 'Der Bericht enthält keine erstattungsfähigen Ausgaben. Überprüfe die Ausgaben erneut oder markiere ihn manuell als bezahlt.', + nonReimbursablePaymentDescription: (isMultiple?: boolean) => + isMultiple + ? 'Einer oder mehrere ausgewählte Berichte enthalten keine erstattungsfähigen Ausgaben. Überprüfe die Ausgaben erneut oder markiere sie manuell als bezahlt.' + : 'Der Bericht enthält keine erstattungsfähigen Ausgaben. Überprüfe die Ausgaben erneut oder markiere ihn manuell als bezahlt.', }, dismissReceiptError: 'Fehler ausblenden', dismissReceiptErrorConfirmation: 'Achtung! Wenn du diesen Fehler schließt, wird deine hochgeladene Quittung vollständig entfernt. Bist du sicher?', diff --git a/src/languages/en.ts b/src/languages/en.ts index 0949b9909b3bc..331335b65b3d2 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1475,7 +1475,10 @@ const translations = { manySplitsProvided: `The maximum splits allowed is ${CONST.IOU.SPLITS_LIMIT}.`, dateRangeExceedsMaxDays: `The date range can't exceed ${CONST.IOU.SPLITS_LIMIT} days.`, nonReimbursablePayment: 'Cannot pay via Expensify', - nonReimbursablePaymentDescription: "The report doesn't have reimbursable expenses. Double check the expenses, or manually mark as paid.", + nonReimbursablePaymentDescription: (isMultiple?: boolean) => + isMultiple + ? "One or more selected reports don't have reimbursable expenses. Double check the expenses, or manually mark as paid." + : "The report doesn't have reimbursable expenses. Double check the expenses, or manually mark as paid.", }, dismissReceiptError: 'Dismiss error', dismissReceiptErrorConfirmation: 'Heads up! Dismissing this error will remove your uploaded receipt entirely. Are you sure?', diff --git a/src/languages/es.ts b/src/languages/es.ts index 626cefb28d55c..a00841b1e2c5b 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1347,7 +1347,10 @@ const translations: TranslationDeepObject = { manySplitsProvided: `La cantidad máxima de divisiones permitidas es ${CONST.IOU.SPLITS_LIMIT}.`, dateRangeExceedsMaxDays: `El rango de fechas no puede exceder los ${CONST.IOU.SPLITS_LIMIT} días.`, nonReimbursablePayment: 'No se puede pagar a través de Expensify', - nonReimbursablePaymentDescription: 'El informe no tiene gastos reembolsables. Vuelve a revisar los gastos o márcalo manualmente como pagado.', + nonReimbursablePaymentDescription: (isMultiple?: boolean) => + isMultiple + ? 'Uno o más informes seleccionados no tienen gastos reembolsables. Vuelve a revisar los gastos o márcalos manualmente como pagados.' + : 'El informe no tiene gastos reembolsables. Vuelve a revisar los gastos o márcalo manualmente como pagado.', }, dismissReceiptError: 'Descartar error', dismissReceiptErrorConfirmation: '¡Atención! Descartar este error eliminará completamente tu recibo cargado. ¿Estás seguro?', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 75f2e266a6c61..d952c586c5b4c 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1430,7 +1430,10 @@ const translations: TranslationDeepObject = { dateRangeExceedsMaxDays: `La plage de dates ne peut pas dépasser ${CONST.IOU.SPLITS_LIMIT} jours.`, stitchOdometerImagesFailed: 'Échec de la combinaison des images de l’odomètre. Veuillez réessayer plus tard.', nonReimbursablePayment: 'Impossible de payer via Expensify', - nonReimbursablePaymentDescription: 'Le rapport ne contient pas de dépenses remboursables. Vérifiez les dépenses ou marquez-le manuellement comme payé.', + nonReimbursablePaymentDescription: (isMultiple?: boolean) => + isMultiple + ? 'Un ou plusieurs rapports sélectionnés ne contiennent pas de dépenses remboursables. Vérifiez les dépenses ou marquez-les manuellement comme payés.' + : 'Le rapport ne contient pas de dépenses remboursables. Vérifiez les dépenses ou marquez-le manuellement comme payé.', }, dismissReceiptError: 'Ignorer l’erreur', dismissReceiptErrorConfirmation: 'Attention ! Ignorer cette erreur supprimera complètement votre reçu téléversé. Êtes-vous sûr ?', diff --git a/src/languages/it.ts b/src/languages/it.ts index 7cdfd318c03db..04c3cfe25c166 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1424,7 +1424,10 @@ const translations: TranslationDeepObject = { dateRangeExceedsMaxDays: `L’intervallo di date non può superare ${CONST.IOU.SPLITS_LIMIT} giorni.`, stitchOdometerImagesFailed: 'Impossibile combinare le immagini del contachilometri. Riprova più tardi.', nonReimbursablePayment: 'Impossibile pagare tramite Expensify', - nonReimbursablePaymentDescription: 'Il report non contiene spese rimborsabili. Ricontrolla le spese oppure contrassegnalo manualmente come pagato.', + nonReimbursablePaymentDescription: (isMultiple?: boolean) => + isMultiple + ? 'Uno o più report selezionati non contengono spese rimborsabili. Ricontrolla le spese oppure contrassegnali manualmente come pagati.' + : 'Il report non contiene spese rimborsabili. Ricontrolla le spese oppure contrassegnalo manualmente come pagato.', }, dismissReceiptError: 'Ignora errore', dismissReceiptErrorConfirmation: 'Attenzione! Chiudere questo errore rimuoverà completamente la ricevuta che hai caricato. Sei sicuro?', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 23393ab789fd5..4f2c1d7215654 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1406,7 +1406,10 @@ const translations: TranslationDeepObject = { dateRangeExceedsMaxDays: `日付範囲は${CONST.IOU.SPLITS_LIMIT}日を超えることはできません。`, stitchOdometerImagesFailed: '走行距離計の画像を結合できませんでした。後でもう一度お試しください。', nonReimbursablePayment: 'Expensify経由では支払えません', - nonReimbursablePaymentDescription: 'このレポートには精算可能な経費がありません。経費を再確認するか、手動で支払い済みにしてください。', + nonReimbursablePaymentDescription: (isMultiple?: boolean) => + isMultiple + ? '1つ以上の選択したレポートには精算可能な経費がありません。経費を再確認するか、手動で支払い済みにしてください。' + : 'このレポートには精算可能な経費がありません。経費を再確認するか、手動で支払い済みにしてください。', }, dismissReceiptError: 'エラーを閉じる', dismissReceiptErrorConfirmation: 'ご注意ください!このエラーを閉じると、アップロード済みのレシートが完全に削除されます。本当に続行しますか?', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index fe2f3f8606d2f..3f8e41e768c31 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1422,7 +1422,10 @@ const translations: TranslationDeepObject = { dateRangeExceedsMaxDays: `Het datumbereik mag niet meer dan ${CONST.IOU.SPLITS_LIMIT} dagen zijn.`, stitchOdometerImagesFailed: 'Odometerafbeeldingen combineren mislukt. Probeer het later opnieuw.', nonReimbursablePayment: 'Kan niet via Expensify worden betaald', - nonReimbursablePaymentDescription: 'Het rapport bevat geen declareerbare kosten. Controleer de kosten opnieuw of markeer het handmatig als betaald.', + nonReimbursablePaymentDescription: (isMultiple?: boolean) => + isMultiple + ? 'Een of meer geselecteerde rapporten bevatten geen declareerbare kosten. Controleer de kosten opnieuw of markeer ze handmatig als betaald.' + : 'Het rapport bevat geen declareerbare kosten. Controleer de kosten opnieuw of markeer het handmatig als betaald.', }, dismissReceiptError: 'Foutmelding sluiten', dismissReceiptErrorConfirmation: 'Let op! Dit foutbericht negeren verwijdert je geüploade bon volledig. Weet je het zeker?', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 56ad7d7ec868f..02f00b36b7937 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1421,7 +1421,10 @@ const translations: TranslationDeepObject = { dateRangeExceedsMaxDays: `Zakres dat nie może przekraczać ${CONST.IOU.SPLITS_LIMIT} dni.`, stitchOdometerImagesFailed: 'Nie udało się połączyć zdjęć licznika kilometrów. Spróbuj ponownie później.', nonReimbursablePayment: 'Nie można zapłacić przez Expensify', - nonReimbursablePaymentDescription: 'Raport nie zawiera wydatków podlegających zwrotowi. Sprawdź wydatki ponownie lub oznacz go ręcznie jako opłacony.', + nonReimbursablePaymentDescription: (isMultiple?: boolean) => + isMultiple + ? 'Co najmniej jeden z wybranych raportów nie zawiera wydatków podlegających zwrotowi. Sprawdź wydatki ponownie lub oznacz je ręcznie jako opłacone.' + : 'Raport nie zawiera wydatków podlegających zwrotowi. Sprawdź wydatki ponownie lub oznacz go ręcznie jako opłacony.', }, dismissReceiptError: 'Odrzuć błąd', dismissReceiptErrorConfirmation: 'Uwaga! Zamknięcie tego błędu spowoduje całkowite usunięcie przesłanego paragonu. Czy na pewno chcesz kontynuować?', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 73ed78fcfe1ae..746c1ca9ea4a1 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1419,7 +1419,10 @@ const translations: TranslationDeepObject = { dateRangeExceedsMaxDays: `O intervalo de datas não pode exceder ${CONST.IOU.SPLITS_LIMIT} dias.`, stitchOdometerImagesFailed: 'Falha ao combinar imagens do hodômetro. Tente novamente mais tarde.', nonReimbursablePayment: 'Não é possível pagar via Expensify', - nonReimbursablePaymentDescription: 'O relatório não possui despesas reembolsáveis. Verifique as despesas novamente ou marque-o manualmente como pago.', + nonReimbursablePaymentDescription: (isMultiple?: boolean) => + isMultiple + ? 'Um ou mais relatórios selecionados não possuem despesas reembolsáveis. Verifique as despesas novamente ou marque-os manualmente como pagos.' + : 'O relatório não possui despesas reembolsáveis. Verifique as despesas novamente ou marque-o manualmente como pago.', }, dismissReceiptError: 'Dispensar erro', dismissReceiptErrorConfirmation: 'Atenção! Ignorar este erro removerá completamente o comprovante que você enviou. Tem certeza?', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index c20448d0e9af8..28f4d4c962715 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1380,7 +1380,8 @@ const translations: TranslationDeepObject = { dateRangeExceedsMaxDays: `日期范围不能超过 ${CONST.IOU.SPLITS_LIMIT} 天。`, stitchOdometerImagesFailed: '合并里程表图片失败。请稍后重试。', nonReimbursablePayment: '无法通过 Expensify 付款', - nonReimbursablePaymentDescription: '该报告没有可报销的费用。请再次检查费用,或手动将其标记为已支付。', + nonReimbursablePaymentDescription: (isMultiple?: boolean) => + isMultiple ? '一个或多个所选报告没有可报销的费用。请再次检查费用,或手动将其标记为已支付。' : '该报告没有可报销的费用。请再次检查费用,或手动将其标记为已支付。', }, dismissReceiptError: '忽略错误', dismissReceiptErrorConfirmation: '提醒:关闭此错误将彻底删除你上传的收据。确定要继续吗?', From b8259d98d699eab303b09e13ef870b2f067a4356 Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Wed, 25 Mar 2026 22:44:00 +0500 Subject: [PATCH 17/20] Refactor non-reimbursable payment modal to accept transactions as a parameter --- src/components/MoneyReportHeader.tsx | 4 ++-- .../MoneyRequestReportPreviewContent.tsx | 4 ++-- .../Search/SearchList/ListItem/ActionCell/PayActionCell.tsx | 4 ++-- src/hooks/useNonReimbursablePaymentModal.tsx | 6 +++--- src/libs/SearchUIUtils.ts | 1 - 5 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 0aeaa34e9b431..ee383b9f9a405 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -566,7 +566,7 @@ function MoneyReportHeader({reportID: reportIDProp, shouldDisplayBackButton = fa const shouldDisplayNarrowMoreButton = !shouldDisplayNarrowVersion || isWideRHPDisplayedOnWideLayout || isSuperWideRHPDisplayedOnWideLayout; const [offlineModalVisible, setOfflineModalVisible] = useState(false); - const {showNonReimbursablePaymentErrorModal, shouldBlockDirectPayment, nonReimbursablePaymentErrorDecisionModal} = useNonReimbursablePaymentModal(moneyRequestReport); + const {showNonReimbursablePaymentErrorModal, shouldBlockDirectPayment, nonReimbursablePaymentErrorDecisionModal} = useNonReimbursablePaymentModal(moneyRequestReport, transactions); const showExportProgressModal = useCallback(() => { return showConfirmModal({ @@ -624,7 +624,7 @@ function MoneyReportHeader({reportID: reportIDProp, shouldDisplayBackButton = fa }); const canIOUBePaid = useMemo(() => getCanIOUBePaid(), [getCanIOUBePaid]); - const reportHasOnlyNonReimbursableTransactions = hasOnlyNonReimbursableTransactions(moneyRequestReport?.reportID); + const reportHasOnlyNonReimbursableTransactions = hasOnlyNonReimbursableTransactions(moneyRequestReport?.reportID, transactions); const onlyShowPayElsewhere = useMemo(() => { if (reportHasOnlyNonReimbursableTransactions) { return false; diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx index 7093a42614a14..18287abd312d9 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx @@ -191,7 +191,7 @@ function MoneyRequestReportPreviewContent({ usePaymentAnimations(); const [isHoldMenuVisible, setIsHoldMenuVisible] = useState(false); const [requestType, setRequestType] = useState(); - const {showNonReimbursablePaymentErrorModal, shouldBlockDirectPayment, nonReimbursablePaymentErrorDecisionModal} = useNonReimbursablePaymentModal(iouReport); + const {showNonReimbursablePaymentErrorModal, shouldBlockDirectPayment, nonReimbursablePaymentErrorDecisionModal} = useNonReimbursablePaymentModal(iouReport, transactions); const [paymentType, setPaymentType] = useState(); const isIouReportArchived = useReportIsArchived(iouReportID); const isChatReportArchived = useReportIsArchived(chatReport?.reportID); @@ -217,7 +217,7 @@ function MoneyRequestReportPreviewContent({ ); const canIOUBePaid = useMemo(() => getCanIOUBePaid(), [getCanIOUBePaid]); - const reportHasOnlyNonReimbursableTransactions = hasOnlyNonReimbursableTransactions(iouReport?.reportID); + const reportHasOnlyNonReimbursableTransactions = hasOnlyNonReimbursableTransactions(iouReport?.reportID, transactions); const onlyShowPayElsewhere = useMemo(() => { if (reportHasOnlyNonReimbursableTransactions) { return false; diff --git a/src/components/Search/SearchList/ListItem/ActionCell/PayActionCell.tsx b/src/components/Search/SearchList/ListItem/ActionCell/PayActionCell.tsx index ee41a76b49c28..a62048c9b78b2 100644 --- a/src/components/Search/SearchList/ListItem/ActionCell/PayActionCell.tsx +++ b/src/components/Search/SearchList/ListItem/ActionCell/PayActionCell.tsx @@ -34,7 +34,7 @@ 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); + 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}`); @@ -44,7 +44,7 @@ function PayActionCell({isLoading, policyID, reportID, hash, amount, extraSmall, const shouldOnlyShowElsewhere = !canBePaid && canIOUBePaid(iouReport, chatReport, policy, bankAccountList, transactions, true, undefined, invoiceReceiverPolicy) && - !hasOnlyNonReimbursableTransactions(iouReport?.reportID); + !hasOnlyNonReimbursableTransactions(iouReport?.reportID, transactions); const {currency} = iouReport ?? {}; diff --git a/src/hooks/useNonReimbursablePaymentModal.tsx b/src/hooks/useNonReimbursablePaymentModal.tsx index 2e92881508c02..121a55b60ffb5 100644 --- a/src/hooks/useNonReimbursablePaymentModal.tsx +++ b/src/hooks/useNonReimbursablePaymentModal.tsx @@ -3,7 +3,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import DecisionModal from '@components/DecisionModal'; import {hasOnlyNonReimbursableTransactions, isInvoiceReport} from '@libs/ReportUtils'; import CONST from '@src/CONST'; -import type {Report} from '@src/types/onyx'; +import type {Report, Transaction} from '@src/types/onyx'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import useLocalize from './useLocalize'; import useResponsiveLayout from './useResponsiveLayout'; @@ -15,7 +15,7 @@ type UseNonReimbursablePaymentModalReturn = { }; /** Blocks direct payment and shows the error modal when the report contains only non-reimbursable expenses. */ -function useNonReimbursablePaymentModal(iouReport: OnyxEntry): UseNonReimbursablePaymentModalReturn { +function useNonReimbursablePaymentModal(iouReport: OnyxEntry, transactions?: Transaction[]): UseNonReimbursablePaymentModalReturn { const [isModalVisible, setIsModalVisible] = useState(false); const {translate} = useLocalize(); // We need to use isSmallScreenWidth here because the DecisionModal breaks in RHP with shouldUseNarrowLayout. @@ -25,7 +25,7 @@ function useNonReimbursablePaymentModal(iouReport: OnyxEntry): UseNonRei const showNonReimbursablePaymentErrorModal = () => setIsModalVisible(true); const shouldBlockDirectPayment = (paymentType: PaymentMethodType): boolean => - !isInvoiceReport(iouReport) && hasOnlyNonReimbursableTransactions(iouReport?.reportID) && paymentType !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE; + !isInvoiceReport(iouReport) && hasOnlyNonReimbursableTransactions(iouReport?.reportID, transactions) && paymentType !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE; const nonReimbursablePaymentErrorDecisionModal = ( Date: Wed, 25 Mar 2026 22:44:42 +0500 Subject: [PATCH 18/20] Add remaning tests for handling non-reimbursable transactions --- tests/actions/IOUTest.ts | 4 ++-- tests/unit/MoneyRequestReportButtonUtils.test.ts | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index 3e4cddde30df7..31efd2edc2891 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -11576,7 +11576,7 @@ describe('actions/IOU', () => { it('should return false if the report has negative total and onlyShowPayElsewhere is false', async () => { const policyChat = createRandomReport(1, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); const fakePolicy: Policy = { - ...createRandomPolicy(Number('AA')), + ...createRandomPolicy(1), id: 'AA', type: CONST.POLICY.TYPE.TEAM, approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL, @@ -11607,7 +11607,7 @@ describe('actions/IOU', () => { const reportID = '999'; const fakePolicy: Policy = { - ...createRandomPolicy(Number('AA')), + ...createRandomPolicy(1), id: 'AA', type: CONST.POLICY.TYPE.TEAM, approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL, diff --git a/tests/unit/MoneyRequestReportButtonUtils.test.ts b/tests/unit/MoneyRequestReportButtonUtils.test.ts index 9abfda8bfd16e..f83ac7b300122 100644 --- a/tests/unit/MoneyRequestReportButtonUtils.test.ts +++ b/tests/unit/MoneyRequestReportButtonUtils.test.ts @@ -1,5 +1,6 @@ import Onyx from 'react-native-onyx'; import {getTotalAmountForIOUReportPreviewButton} from '@libs/MoneyRequestReportUtils'; +import {hasOnlyNonReimbursableTransactions} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {policy420A as mockPolicy} from '../../__mocks__/reportData/policies'; @@ -48,5 +49,11 @@ describe('ReportButtonUtils', () => { expect(getTotalAmountForIOUReportPreviewButton(mockReport, mockPolicy, CONST.REPORT.REPORT_PREVIEW_ACTIONS.APPROVE)).toBe(`$100.00`); expect(getTotalAmountForIOUReportPreviewButton(mockReport, mockPolicy, CONST.REPORT.REPORT_PREVIEW_ACTIONS.SUBMIT)).toBe(`$100.00`); }); + + it('returns total display spend for PAY when report has only non-reimbursable transactions', () => { + jest.mocked(hasOnlyNonReimbursableTransactions).mockReturnValueOnce(true); + + expect(getTotalAmountForIOUReportPreviewButton(mockReport, mockPolicy, CONST.REPORT.REPORT_PREVIEW_ACTIONS.PAY)).toBe(`$100.00`); + }); }); }); From ed5bbfdf9b290e11b9a658df9c4f50b518abba12 Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Sat, 28 Mar 2026 02:23:43 +0500 Subject: [PATCH 19/20] Add transactions prop to MoneyReportHeader and ProcessMoneyReportHoldMenu components --- src/components/MoneyReportHeader.tsx | 1 + src/components/ProcessMoneyReportHoldMenu.tsx | 6 +++++- .../MoneyRequestReportPreviewContent.tsx | 1 + src/hooks/useSearchBulkActions.ts | 2 +- 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index ee383b9f9a405..874b1c603f92f 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -2589,6 +2589,7 @@ function MoneyReportHeader({reportID: reportIDProp, shouldDisplayBackButton = fa } }} transactionCount={transactionIDs?.length ?? 0} + transactions={transactions} onNonReimbursablePaymentError={showNonReimbursablePaymentErrorModal} /> )} diff --git a/src/components/ProcessMoneyReportHoldMenu.tsx b/src/components/ProcessMoneyReportHoldMenu.tsx index c8b2b86e780fe..d1e30c41e6559 100644 --- a/src/components/ProcessMoneyReportHoldMenu.tsx +++ b/src/components/ProcessMoneyReportHoldMenu.tsx @@ -58,6 +58,9 @@ type ProcessMoneyReportHoldMenuProps = { /** 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({ @@ -74,6 +77,7 @@ function ProcessMoneyReportHoldMenu({ startAnimation, hasNonHeldExpenses, onNonReimbursablePaymentError, + transactions, }: ProcessMoneyReportHoldMenuProps) { const {translate} = useLocalize(); const isApprove = requestType === CONST.IOU.REPORT_ACTION_TYPE.APPROVE; @@ -104,7 +108,7 @@ function ProcessMoneyReportHoldMenu({ return; } - if (!isApprove && chatReport && hasOnlyNonReimbursableTransactions(moneyRequestReport?.reportID) && paymentType && paymentType !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE) { + if (!isApprove && chatReport && hasOnlyNonReimbursableTransactions(moneyRequestReport?.reportID, transactions) && paymentType && paymentType !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE) { onClose(); onNonReimbursablePaymentError?.(); return; diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx index bb66675fa1c7e..f078afcc21b4a 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx @@ -734,6 +734,7 @@ function MoneyRequestReportPreviewContent({ chatReport={chatReport} moneyRequestReport={iouReport} transactionCount={numberOfRequests} + transactions={transactions} hasNonHeldExpenses={!hasOnlyHeldExpenses} startAnimation={() => { if (requestType === CONST.IOU.REPORT_ACTION_TYPE.APPROVE) { diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index e75d5779f26ee..febb14e48db73 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -563,7 +563,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { if ( isExpenseReport && !isInvoiceReport(itemReportID) && - hasOnlyNonReimbursableTransactions(itemReportID, reportTransactions) && + hasOnlyNonReimbursableTransactions(itemReportID, reportTransactions.length > 0 ? reportTransactions : undefined) && lastPolicyPaymentMethod !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE ) { setIsNonReimbursablePaymentErrorModalVisible(true); From ea019ce61807fb9d6da50dfabe7cb6c2b1fbf2fa Mon Sep 17 00:00:00 2001 From: Samran Ahmed Date: Wed, 1 Apr 2026 01:06:58 +0500 Subject: [PATCH 20/20] Refactor payment action logic to include transactions param --- src/components/MoneyReportHeader.tsx | 2 +- .../PayActionButton.tsx | 2 +- src/libs/MoneyRequestReportUtils.ts | 9 ++- src/libs/ReportPreviewActionUtils.ts | 5 +- src/libs/ReportPrimaryActionUtils.ts | 70 ++++++++++++++----- src/libs/ReportSecondaryActionUtils.ts | 13 +++- src/libs/actions/OnyxDerived/configs/todos.ts | 12 +++- 7 files changed, 89 insertions(+), 24 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 6f61213ee340f..8f14fc1b69940 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -1226,7 +1226,7 @@ function MoneyReportHeader({reportID: reportIDProp, shouldDisplayBackButton = fa }, [connectedIntegration, exportModalStatus, moneyRequestReport?.reportID]); const getAmount = (actionType: ValueOf) => ({ - formattedAmount: getTotalAmountForIOUReportPreviewButton(moneyRequestReport, policy, actionType), + formattedAmount: getTotalAmountForIOUReportPreviewButton(moneyRequestReport, policy, actionType, nonPendingDeleteTransactions), }); const {formattedAmount: totalAmount} = getAmount(CONST.REPORT.PRIMARY_ACTIONS.PAY); diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/PayActionButton.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/PayActionButton.tsx index 73e6f1c6ad619..333aa028e5c57 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/PayActionButton.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/PayActionButton.tsx @@ -99,7 +99,7 @@ function PayActionButton({ const shouldShowOnlyPayElsewhere = !canIOUBePaid && onlyShowPayElsewhere; const canIOUBePaidAndApproved = canIOUBePaid; - const formattedAmount = getTotalAmountForIOUReportPreviewButton(iouReport, policy, reportPreviewAction); + const formattedAmount = getTotalAmountForIOUReportPreviewButton(iouReport, policy, reportPreviewAction, transactions); const confirmApproval = () => { if (isDelegateAccessRestricted) { diff --git a/src/libs/MoneyRequestReportUtils.ts b/src/libs/MoneyRequestReportUtils.ts index 7e93d729430fc..f5c542e3bbc6d 100644 --- a/src/libs/MoneyRequestReportUtils.ts +++ b/src/libs/MoneyRequestReportUtils.ts @@ -153,7 +153,12 @@ function shouldWaitForTransactions(report: OnyxEntry, transactions: Tran * @param reportPreviewAction - The action that will take place when button is clicked which determines how amounts are calculated and displayed. * @returns - The total amount to be formatted as a string. Returns an empty string if no amount is applicable. */ -const getTotalAmountForIOUReportPreviewButton = (report: OnyxEntry, policy: OnyxEntry, reportPreviewAction: ValueOf) => { +const getTotalAmountForIOUReportPreviewButton = ( + report: OnyxEntry, + policy: OnyxEntry, + reportPreviewAction: ValueOf, + transactions?: Transaction[], +) => { // Determine whether the non-held amount is appropriate to display for the PAY button. const {nonHeldAmount, hasValidNonHeldAmount} = getNonHeldAndFullAmount(report, reportPreviewAction === CONST.REPORT.REPORT_PREVIEW_ACTIONS.PAY); const hasOnlyHeldExpenses = hasOnlyHeldExpensesReportUtils(report?.reportID); @@ -169,7 +174,7 @@ const getTotalAmountForIOUReportPreviewButton = (report: OnyxEntry, poli } // For reports with only non-reimbursable expenses, show total display spend for Mark as paid. - if (hasOnlyNonReimbursableTransactions(report?.reportID)) { + if (hasOnlyNonReimbursableTransactions(report?.reportID, transactions)) { return convertToDisplayString(totalDisplaySpend, report?.currency); } diff --git a/src/libs/ReportPreviewActionUtils.ts b/src/libs/ReportPreviewActionUtils.ts index d19a2fb907128..a08cd25e2ffad 100644 --- a/src/libs/ReportPreviewActionUtils.ts +++ b/src/libs/ReportPreviewActionUtils.ts @@ -103,6 +103,7 @@ function canPay( currentUserAccountID: number, currentUserLogin: string, bankAccountList: OnyxEntry, + transactions: Transaction[], policy?: Policy, invoiceReceiverPolicy?: Policy, ) { @@ -126,7 +127,7 @@ function canPay( const hasExportError = report?.hasExportError ?? false; const didExportFail = !isExported && hasExportError; - if (isExpense && isReportPayer && isPaymentsEnabled && isReportFinished && (reimbursableSpend !== 0 || hasOnlyNonReimbursableTransactions(report?.reportID))) { + if (isExpense && isReportPayer && isPaymentsEnabled && isReportFinished && (reimbursableSpend !== 0 || hasOnlyNonReimbursableTransactions(report?.reportID, transactions))) { return !didExportFail; } @@ -242,7 +243,7 @@ function getReportPreviewAction({ if (canApprove(report, currentUserAccountID, reportMetadata, policy, transactions)) { return CONST.REPORT.REPORT_PREVIEW_ACTIONS.APPROVE; } - if (canPay(report, isReportArchived, currentUserAccountID, currentUserLogin, bankAccountList, policy, invoiceReceiverPolicy)) { + if (canPay(report, isReportArchived, currentUserAccountID, currentUserLogin, bankAccountList, transactions, policy, invoiceReceiverPolicy)) { return CONST.REPORT.REPORT_PREVIEW_ACTIONS.PAY; } if (canExport(report, currentUserLogin, policy)) { diff --git a/src/libs/ReportPrimaryActionUtils.ts b/src/libs/ReportPrimaryActionUtils.ts index ed3617c149fd8..dc9588661945f 100644 --- a/src/libs/ReportPrimaryActionUtils.ts +++ b/src/libs/ReportPrimaryActionUtils.ts @@ -79,6 +79,20 @@ type GetReportPrimaryActionParams = { isSubmittingAnimationRunning?: boolean; }; +type IsPrimaryPayActionParams = { + report: Report; + reportTransactions: Transaction[]; + currentUserAccountID: number; + currentUserLogin: string; + bankAccountList: OnyxEntry; + policy?: Policy; + reportNameValuePairs?: ReportNameValuePairs; + isChatReportArchived?: boolean; + invoiceReceiverPolicy?: Policy; + reportActions?: ReportAction[]; + isSecondaryAction?: boolean; +}; + function isAddExpenseAction(report: Report, reportTransactions: Transaction[], isChatReportArchived: boolean) { if (isChatReportArchived) { return false; @@ -172,18 +186,19 @@ function isApproveAction(report: Report, reportTransactions: Transaction[], curr return isProcessingReportUtils(report); } -function isPrimaryPayAction( - report: Report, - currentUserAccountID: number, - currentUserLogin: string, - bankAccountList: OnyxEntry, - policy?: Policy, - reportNameValuePairs?: ReportNameValuePairs, - isChatReportArchived?: boolean, - invoiceReceiverPolicy?: Policy, - reportActions?: ReportAction[], - isSecondaryAction?: boolean, -) { +function isPrimaryPayAction({ + report, + reportTransactions, + currentUserAccountID, + currentUserLogin, + bankAccountList, + policy, + reportNameValuePairs, + isChatReportArchived, + invoiceReceiverPolicy, + reportActions, + isSecondaryAction, +}: IsPrimaryPayActionParams) { if (isArchivedReport(reportNameValuePairs) || isChatReportArchived) { return false; } @@ -203,7 +218,7 @@ function isPrimaryPayAction( const isReportFinished = (isReportApproved && !report.isWaitingOnBankAccount) || isSubmittedWithoutApprovalsEnabled || isReportClosed; const {reimbursableSpend} = getMoneyRequestSpendBreakdown(report); - if (isReportPayer && isExpenseReport && arePaymentsEnabled && isReportFinished && (reimbursableSpend !== 0 || hasOnlyNonReimbursableTransactions(report?.reportID))) { + if (isReportPayer && isExpenseReport && arePaymentsEnabled && isReportFinished && (reimbursableSpend !== 0 || hasOnlyNonReimbursableTransactions(report?.reportID, reportTransactions))) { return isSecondaryAction ?? !didExportFail; } @@ -453,8 +468,18 @@ function getReportPrimaryAction(params: GetReportPrimaryActionParams): ValueOf