diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 3a1cc56a74cec..8f14fc1b69940 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -24,6 +24,7 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useNetwork from '@hooks/useNetwork'; +import useNonReimbursablePaymentModal from '@hooks/useNonReimbursablePaymentModal'; import useOnyx from '@hooks/useOnyx'; import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; import useParticipantsInvoiceReport from '@hooks/useParticipantsInvoiceReport'; @@ -102,6 +103,7 @@ import { getTransactionsWithReceipts, hasHeldExpenses as hasHeldExpensesReportUtils, hasOnlyHeldExpenses as hasOnlyHeldExpensesReportUtils, + hasOnlyNonReimbursableTransactions, hasUpdatedTotal, hasViolations as hasViolationsReportUtils, isAllowedToApproveExpenseReport, @@ -567,6 +569,7 @@ function MoneyReportHeader({reportID: reportIDProp, shouldDisplayBackButton = fa const shouldDisplayNarrowMoreButton = !shouldDisplayNarrowVersion || isWideRHPDisplayedOnWideLayout || isSuperWideRHPDisplayedOnWideLayout; const [offlineModalVisible, setOfflineModalVisible] = useState(false); + const {showNonReimbursablePaymentErrorModal, shouldBlockDirectPayment, nonReimbursablePaymentErrorDecisionModal} = useNonReimbursablePaymentModal(moneyRequestReport, transactions); const showExportProgressModal = useCallback(() => { return showConfirmModal({ @@ -624,9 +627,15 @@ function MoneyReportHeader({reportID: reportIDProp, shouldDisplayBackButton = fa }); const canIOUBePaid = useMemo(() => getCanIOUBePaid(), [getCanIOUBePaid]); - const onlyShowPayElsewhere = useMemo(() => !canIOUBePaid && getCanIOUBePaid(true), [canIOUBePaid, getCanIOUBePaid]); + const reportHasOnlyNonReimbursableTransactions = hasOnlyNonReimbursableTransactions(moneyRequestReport?.reportID, transactions); + const onlyShowPayElsewhere = useMemo(() => { + if (reportHasOnlyNonReimbursableTransactions) { + return false; + } + return !canIOUBePaid && getCanIOUBePaid(true); + }, [canIOUBePaid, getCanIOUBePaid, reportHasOnlyNonReimbursableTransactions]); - const shouldShowPayButton = isPaidAnimationRunning || canIOUBePaid || onlyShowPayElsewhere; + const shouldShowPayButton = isPaidAnimationRunning || canIOUBePaid || onlyShowPayElsewhere || reportHasOnlyNonReimbursableTransactions; const shouldShowApproveButton = useMemo( () => (canApproveIOU(moneyRequestReport, policy, reportMetadata, transactions) && !hasOnlyPendingTransactions) || isApprovedAnimationRunning, @@ -706,6 +715,10 @@ function MoneyReportHeader({reportID: reportIDProp, shouldDisplayBackButton = fa if (!type || !chatReport) { return; } + if (shouldBlockDirectPayment(type)) { + showNonReimbursablePaymentErrorModal(); + return; + } setPaymentType(type); setRequestType(CONST.IOU.REPORT_ACTION_TYPE.PAY); const isFromSelectionMode = isSelectionModePaymentRef.current; @@ -789,6 +802,8 @@ function MoneyReportHeader({reportID: reportIDProp, shouldDisplayBackButton = fa isAnyTransactionOnHold, isInvoiceReport, showDelegateNoAccessModal, + showNonReimbursablePaymentErrorModal, + shouldBlockDirectPayment, startAnimation, moneyRequestReport, nextStep, @@ -1211,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); @@ -2572,6 +2587,8 @@ function MoneyReportHeader({reportID: reportIDProp, shouldDisplayBackButton = fa } }} transactionCount={transactionIDs?.length ?? 0} + transactions={transactions} + onNonReimbursablePaymentError={showNonReimbursablePaymentErrorModal} /> )} setOfflineModalVisible(false)} /> + {nonReimbursablePaymentErrorDecisionModal} { setIsPDFModalVisible(false); diff --git a/src/components/ProcessMoneyReportHoldMenu.tsx b/src/components/ProcessMoneyReportHoldMenu.tsx index a1d1ab28bcca1..7fbc2aff5c88d 100644 --- a/src/components/ProcessMoneyReportHoldMenu.tsx +++ b/src/components/ProcessMoneyReportHoldMenu.tsx @@ -7,7 +7,7 @@ import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import {hasViolations as hasViolationsReportUtils} from '@libs/ReportUtils'; +import {hasOnlyNonReimbursableTransactions, hasViolations as hasViolationsReportUtils} from '@libs/ReportUtils'; import {approveMoneyRequest, payMoneyRequest} from '@userActions/IOU'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -55,6 +55,12 @@ type ProcessMoneyReportHoldMenuProps = { /** Whether the report has non held expenses */ hasNonHeldExpenses?: boolean; + + /** Callback when user attempts to pay via ACH but report has only non-reimbursable expenses */ + onNonReimbursablePaymentError?: () => void; + + /** Transactions associated with report */ + transactions?: OnyxTypes.Transaction[]; }; function ProcessMoneyReportHoldMenu({ @@ -70,6 +76,8 @@ function ProcessMoneyReportHoldMenu({ transactionCount, startAnimation, hasNonHeldExpenses, + onNonReimbursablePaymentError, + transactions, }: ProcessMoneyReportHoldMenuProps) { const {translate} = useLocalize(); const isApprove = requestType === CONST.IOU.REPORT_ACTION_TYPE.APPROVE; @@ -100,6 +108,11 @@ function ProcessMoneyReportHoldMenu({ return; } + if (!isApprove && chatReport && hasOnlyNonReimbursableTransactions(moneyRequestReport?.reportID, transactions) && paymentType && paymentType !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE) { + onClose(); + onNonReimbursablePaymentError?.(); + return; + } if (isApprove) { approveMoneyRequest({ expenseReport: moneyRequestReport, diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx index 29c06e57274ab..86535e539fd63 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewContent.tsx @@ -20,6 +20,7 @@ import StatusBadge from '@components/StatusBadge'; import Text from '@components/Text'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useNonReimbursablePaymentModal from '@hooks/useNonReimbursablePaymentModal'; import useOnyx from '@hooks/useOnyx'; import usePaymentAnimations from '@hooks/usePaymentAnimations'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -151,6 +152,7 @@ function MoneyRequestReportPreviewContent({ const [isHoldMenuVisible, setIsHoldMenuVisible] = useState(false); const [requestType, setRequestType] = useState(); + const {showNonReimbursablePaymentErrorModal, nonReimbursablePaymentErrorDecisionModal} = useNonReimbursablePaymentModal(iouReport, transactions); const [paymentType, setPaymentType] = useState(); const [shouldShowPayButton, setShouldShowPayButton] = useState(false); const hasOnlyHeldExpenses = hasOnlyHeldExpensesReportUtils(iouReport?.reportID); @@ -695,6 +697,7 @@ function MoneyRequestReportPreviewContent({ onPaymentOptionsHide={onPaymentOptionsHide} openReportFromPreview={openReportFromPreview} onHoldMenuOpen={handleHoldMenuOpen} + onNonReimbursablePaymentError={showNonReimbursablePaymentErrorModal} transactionPreviewCarouselWidth={reportPreviewStyles.transactionPreviewCarouselStyle.width} /> {transactions.length > 1 && !shouldShowAccessPlaceHolder && ( @@ -731,6 +734,7 @@ function MoneyRequestReportPreviewContent({ chatReport={chatReport} moneyRequestReport={iouReport} transactionCount={numberOfRequests} + transactions={transactions} hasNonHeldExpenses={!hasOnlyHeldExpenses} startAnimation={() => { if (requestType === CONST.IOU.REPORT_ACTION_TYPE.APPROVE) { @@ -739,10 +743,12 @@ function MoneyRequestReportPreviewContent({ startAnimation(); } }} + onNonReimbursablePaymentError={showNonReimbursablePaymentErrorModal} /> ); })()} + {nonReimbursablePaymentErrorDecisionModal} ); } diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/PayActionButton.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/PayActionButton.tsx index 52ac3b4dcaace..333aa028e5c57 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/PayActionButton.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/PayActionButton.tsx @@ -14,6 +14,7 @@ import {getTotalAmountForIOUReportPreviewButton} from '@libs/MoneyRequestReportU import { getReportTransactions, hasHeldExpenses as hasHeldExpensesReportUtils, + hasOnlyNonReimbursableTransactions, hasUpdatedTotal, hasViolations as hasViolationsReportUtils, isInvoiceReport as isInvoiceReportUtils, @@ -35,6 +36,7 @@ type PayActionButtonProps = { onPaymentOptionsShow?: () => void; onPaymentOptionsHide?: () => void; onHoldMenuOpen: (requestType: string, paymentType?: PaymentMethodType, canPay?: boolean) => void; + onNonReimbursablePaymentError?: () => void; buttonMaxWidth: {maxWidth?: number}; reportPreviewAction: ValueOf; }; @@ -50,6 +52,7 @@ function PayActionButton({ onPaymentOptionsShow, onPaymentOptionsHide, onHoldMenuOpen, + onNonReimbursablePaymentError, buttonMaxWidth, reportPreviewAction, }: PayActionButtonProps) { @@ -88,12 +91,15 @@ function PayActionButton({ const hasViolations = hasViolationsReportUtils(iouReport?.reportID, transactionViolations, currentUserAccountID, currentUserEmail); const canIOUBePaid = canIOUBePaidIOUActions(iouReport, chatReport, policy, bankAccountList, transactions, false, undefined, invoiceReceiverPolicy); - const onlyShowPayElsewhere = !canIOUBePaid && canIOUBePaidIOUActions(iouReport, chatReport, policy, bankAccountList, transactions, true, undefined, invoiceReceiverPolicy); - const shouldShowPayButton = isPaidAnimationRunning || canIOUBePaid || onlyShowPayElsewhere; + const reportHasOnlyNonReimbursableTransactions = hasOnlyNonReimbursableTransactions(iouReport?.reportID, transactions); + const onlyShowPayElsewhere = reportHasOnlyNonReimbursableTransactions + ? false + : !canIOUBePaid && canIOUBePaidIOUActions(iouReport, chatReport, policy, bankAccountList, transactions, true, undefined, invoiceReceiverPolicy); + const shouldShowPayButton = isPaidAnimationRunning || canIOUBePaid || onlyShowPayElsewhere || reportHasOnlyNonReimbursableTransactions; const shouldShowOnlyPayElsewhere = !canIOUBePaid && onlyShowPayElsewhere; const canIOUBePaidAndApproved = canIOUBePaid; - const formattedAmount = getTotalAmountForIOUReportPreviewButton(iouReport, policy, reportPreviewAction); + const formattedAmount = getTotalAmountForIOUReportPreviewButton(iouReport, policy, reportPreviewAction, transactions); const confirmApproval = () => { if (isDelegateAccessRestricted) { @@ -123,6 +129,10 @@ function PayActionButton({ if (!type) { return; } + if (!isInvoiceReportUtils(iouReport) && reportHasOnlyNonReimbursableTransactions && type !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE) { + onNonReimbursablePaymentError?.(); + return; + } if (isDelegateAccessRestricted) { showDelegateNoAccessModal(); } else if (hasHeldExpensesReportUtils(iouReport?.reportID)) { diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/ReportPreviewActionButton.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/ReportPreviewActionButton.tsx index e48e409f03ca1..65abb86d82dde 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/ReportPreviewActionButton.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/ReportPreviewActionButton.tsx @@ -37,6 +37,7 @@ type ReportPreviewActionButtonProps = { startSubmittingAnimation: () => void; onPaymentOptionsShow?: () => void; onPaymentOptionsHide?: () => void; + onNonReimbursablePaymentError?: () => void; openReportFromPreview: () => void; onHoldMenuOpen: (requestType: string, paymentType?: PaymentMethodType, canPay?: boolean) => void; transactionPreviewCarouselWidth: number; @@ -54,6 +55,7 @@ function ReportPreviewActionButton({ startSubmittingAnimation, onPaymentOptionsShow, onPaymentOptionsHide, + onNonReimbursablePaymentError, openReportFromPreview, onHoldMenuOpen, transactionPreviewCarouselWidth, @@ -149,6 +151,7 @@ function ReportPreviewActionButton({ onPaymentOptionsShow={onPaymentOptionsShow} onPaymentOptionsHide={onPaymentOptionsHide} onHoldMenuOpen={onHoldMenuOpen} + onNonReimbursablePaymentError={onNonReimbursablePaymentError} buttonMaxWidth={buttonMaxWidth} reportPreviewAction={reportPreviewAction} /> diff --git a/src/components/Search/SearchBulkActionsButton.tsx b/src/components/Search/SearchBulkActionsButton.tsx index b157f994ebb49..5d6b110722531 100644 --- a/src/components/Search/SearchBulkActionsButton.tsx +++ b/src/components/Search/SearchBulkActionsButton.tsx @@ -59,15 +59,16 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { confirmPayment, isOfflineModalVisible, isDownloadErrorModalVisible, + isNonReimbursablePaymentErrorModalVisible, isHoldEducationalModalVisible, rejectModalAction, emptyReportsCount, handleOfflineModalClose, handleDownloadErrorModalClose, + handleNonReimbursablePaymentErrorModalClose, dismissModalAndUpdateUseHold, dismissRejectModalBasedOnAction, } = useSearchBulkActions({queryJSON}); - const currentSelectedPolicyID = selectedPolicyIDs?.at(0); const currentSelectedReportID = selectedTransactionReportIDs?.at(0) ?? selectedReportIDs?.at(0); const currentPolicy = usePolicy(currentSelectedPolicyID); @@ -79,7 +80,6 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { const isExpenseReportType = queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; const popoverUseScrollView = shouldPopoverUseScrollView(headerButtonsOptions); - const selectedItemsCount = useMemo(() => { if (!selectedTransactions) { return 0; @@ -228,6 +228,15 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { isVisible={isDownloadErrorModalVisible} onClose={handleDownloadErrorModalClose} /> + 1)} + isSmallScreenWidth={isSmallScreenWidth} + onSecondOptionSubmit={handleNonReimbursablePaymentErrorModalClose} + secondOptionText={translate('common.buttonConfirm')} + isVisible={isNonReimbursablePaymentErrorModalVisible} + onClose={handleNonReimbursablePaymentErrorModalClose} + /> {!!rejectModalAction && ( + {nonReimbursablePaymentErrorDecisionModal} ); } diff --git a/src/hooks/useNonReimbursablePaymentModal.tsx b/src/hooks/useNonReimbursablePaymentModal.tsx new file mode 100644 index 0000000000000..121a55b60ffb5 --- /dev/null +++ b/src/hooks/useNonReimbursablePaymentModal.tsx @@ -0,0 +1,49 @@ +import React, {useState} from 'react'; +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, Transaction} from '@src/types/onyx'; +import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; +import useLocalize from './useLocalize'; +import useResponsiveLayout from './useResponsiveLayout'; + +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, 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. + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isSmallScreenWidth} = useResponsiveLayout(); + + const showNonReimbursablePaymentErrorModal = () => setIsModalVisible(true); + + const shouldBlockDirectPayment = (paymentType: PaymentMethodType): boolean => + !isInvoiceReport(iouReport) && hasOnlyNonReimbursableTransactions(iouReport?.reportID, transactions) && 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; diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 51e9cae9de8a2..76918008007d5 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -40,6 +40,7 @@ import { canEditMultipleTransactions, getIntegrationIcon, getReportOrDraftReport, + hasOnlyNonReimbursableTransactions, isBusinessInvoiceRoom, isCurrentUserSubmitter, isExpenseReport as isExpenseReportUtil, @@ -134,6 +135,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const [isOfflineModalVisible, setIsOfflineModalVisible] = useState(false); const [isDownloadErrorModalVisible, setIsDownloadErrorModalVisible] = useState(false); + const [isNonReimbursablePaymentErrorModalVisible, setIsNonReimbursablePaymentErrorModalVisible] = useState(false); const {showConfirmModal} = useConfirmModal(); const [isHoldEducationalModalVisible, setIsHoldEducationalModalVisible] = useState(false); const [rejectModalAction, setRejectModalAction] = useState => !!transaction && transaction.reportID === itemReportID, + ); + + if ( + isExpenseReport && + !isInvoiceReport(itemReportID) && + hasOnlyNonReimbursableTransactions(itemReportID, reportTransactions.length > 0 ? reportTransactions : undefined) && + 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; @@ -610,9 +626,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); @@ -1267,6 +1280,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { showDelegateNoAccessModal, bulkPayButtonOptions, businessBankAccountOptions?.length, + shouldShowBusinessBankAccountOptions, onBulkPaySelected, areAllTransactionsFromSubmitter, dismissedHoldUseExplanation, @@ -1298,6 +1312,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); @@ -1328,11 +1346,13 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { confirmPayment: stableOnBulkPaySelected, isOfflineModalVisible, isDownloadErrorModalVisible, + isNonReimbursablePaymentErrorModalVisible, isHoldEducationalModalVisible, rejectModalAction, emptyReportsCount, handleOfflineModalClose, handleDownloadErrorModalClose, + handleNonReimbursablePaymentErrorModalClose, dismissModalAndUpdateUseHold, dismissRejectModalBasedOnAction, }; diff --git a/src/languages/de.ts b/src/languages/de.ts index 19af3b65f4a12..d88ed049bd07f 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1432,6 +1432,11 @@ const translations: TranslationDeepObject = { manySplitsProvided: `Die maximale Anzahl zulässiger Aufteilungen beträgt ${CONST.IOU.SPLITS_LIMIT}.`, 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: (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 dd449f3de55cc..736b20fa975f9 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1482,6 +1482,11 @@ 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 Expensify', + 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 31005af87c4cf..d38d20886a334 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1354,6 +1354,11 @@ 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 a través de Expensify', + 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 bd2d04bba985a..ed25fdcc66328 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1436,6 +1436,11 @@ const translations: TranslationDeepObject = { 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.`, stitchOdometerImagesFailed: 'Échec de la combinaison des images de l’odomètre. Veuillez réessayer plus tard.', + nonReimbursablePayment: 'Impossible de payer via Expensify', + 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 67c09794b7b70..c18b5537e1490 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1430,6 +1430,11 @@ const translations: TranslationDeepObject = { manySplitsProvided: `Il numero massimo di suddivisioni consentite è ${CONST.IOU.SPLITS_LIMIT}.`, 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: (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 e148476ebdf95..62cf1b4d6b8f3 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1412,6 +1412,11 @@ const translations: TranslationDeepObject = { manySplitsProvided: `分割できる最大数は${CONST.IOU.SPLITS_LIMIT}件です。`, dateRangeExceedsMaxDays: `日付範囲は${CONST.IOU.SPLITS_LIMIT}日を超えることはできません。`, stitchOdometerImagesFailed: '走行距離計の画像を結合できませんでした。後でもう一度お試しください。', + nonReimbursablePayment: 'Expensify経由では支払えません', + nonReimbursablePaymentDescription: (isMultiple?: boolean) => + isMultiple + ? '1つ以上の選択したレポートには精算可能な経費がありません。経費を再確認するか、手動で支払い済みにしてください。' + : 'このレポートには精算可能な経費がありません。経費を再確認するか、手動で支払い済みにしてください。', }, dismissReceiptError: 'エラーを閉じる', dismissReceiptErrorConfirmation: 'ご注意ください!このエラーを閉じると、アップロード済みのレシートが完全に削除されます。本当に続行しますか?', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index f59c5361e5689..c48049e070e8f 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1428,6 +1428,11 @@ const translations: TranslationDeepObject = { manySplitsProvided: `Het maximale aantal toegestane splitsingen is ${CONST.IOU.SPLITS_LIMIT}.`, 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: (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 dca93fb9f9b34..6a2c34a40784b 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1426,6 +1426,11 @@ const translations: TranslationDeepObject = { manySplitsProvided: `Maksymalna dozwolona liczba podziałów to ${CONST.IOU.SPLITS_LIMIT}.`, 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: (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 5519fbc97cf29..201bce0c51730 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1425,6 +1425,11 @@ const translations: TranslationDeepObject = { 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.`, stitchOdometerImagesFailed: 'Falha ao combinar imagens do hodômetro. Tente novamente mais tarde.', + nonReimbursablePayment: 'Não é possível pagar via Expensify', + 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 c9f9c81a75282..b216f47ffa1fd 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1385,6 +1385,9 @@ const translations: TranslationDeepObject = { manySplitsProvided: `允许的最大拆分数为 ${CONST.IOU.SPLITS_LIMIT}。`, dateRangeExceedsMaxDays: `日期范围不能超过 ${CONST.IOU.SPLITS_LIMIT} 天。`, stitchOdometerImagesFailed: '合并里程表图片失败。请稍后重试。', + nonReimbursablePayment: '无法通过 Expensify 付款', + nonReimbursablePaymentDescription: (isMultiple?: boolean) => + isMultiple ? '一个或多个所选报告没有可报销的费用。请再次检查费用,或手动将其标记为已支付。' : '该报告没有可报销的费用。请再次检查费用,或手动将其标记为已支付。', }, dismissReceiptError: '忽略错误', dismissReceiptErrorConfirmation: '提醒:关闭此错误将彻底删除你上传的收据。确定要继续吗?', diff --git a/src/libs/MoneyRequestReportUtils.ts b/src/libs/MoneyRequestReportUtils.ts index 69413600449a8..f5c542e3bbc6d 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, @@ -152,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); @@ -167,6 +173,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, transactions)) { + 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; diff --git a/src/libs/ReportPreviewActionUtils.ts b/src/libs/ReportPreviewActionUtils.ts index 38ade57e848e2..a08cd25e2ffad 100644 --- a/src/libs/ReportPreviewActionUtils.ts +++ b/src/libs/ReportPreviewActionUtils.ts @@ -9,6 +9,7 @@ import { getMoneyRequestSpendBreakdown, getParentReport, getReportTransactions, + hasOnlyNonReimbursableTransactions, isClosedReport, isCurrentUserSubmitter, isExpenseReport, @@ -102,6 +103,7 @@ function canPay( currentUserAccountID: number, currentUserLogin: string, bankAccountList: OnyxEntry, + transactions: Transaction[], policy?: Policy, invoiceReceiverPolicy?: Policy, ) { @@ -125,7 +127,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, transactions))) { return !didExportFail; } @@ -241,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 f34b04661e729..dc9588661945f 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, @@ -78,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; @@ -171,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; } @@ -202,7 +218,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, reportTransactions))) { return isSecondaryAction ?? !didExportFail; } @@ -452,8 +468,18 @@ function getReportPrimaryAction(params: GetReportPrimaryActionParams): ValueOf | string, rep /** * 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 ?? getReportTransactions(iouReportID); if (!transactions || transactions.length === 0) { return false; } diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index fee25679711f0..c7e6cddd31c89 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -130,6 +130,7 @@ import { hasAnyViolations, hasHeldExpenses, hasInvoiceReports, + hasOnlyNonReimbursableTransactions, isAllowedToApproveExpenseReport as isAllowedToApproveExpenseReportUtils, isArchivedReport, isClosedReport, @@ -1999,10 +2000,12 @@ 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 canOnlyBePaidElsewhere = canIOUBePaid(report, chatReport, policy, bankAccountList, allReportTransactions, true, chatReportRNVP, invoiceReceiverPolicy); + 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. - if ((canBePaid || shouldOnlyShowElsewhere) && !hasHeldExpenses(report.reportID, allReportTransactions)) { + if ((canBePaid || shouldOnlyShowElsewhere || (reportHasOnlyNonReimbursableTransactions && canOnlyBePaidElsewhere)) && !hasHeldExpenses(report.reportID, allReportTransactions)) { allActions.push(CONST.SEARCH.ACTION_TYPES.PAY); } @@ -2010,7 +2013,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/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 83b0c187bab65..84e382e435799 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -156,6 +156,7 @@ import { getTransactionDetails, hasHeldExpenses as hasHeldExpensesReportUtils, hasNonReimbursableTransactions as hasNonReimbursableTransactionsReportUtils, + hasOnlyNonReimbursableTransactions, hasOutstandingChildRequest, hasViolations as hasViolationsReportUtils, isArchivedReport, @@ -9845,6 +9846,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; @@ -9854,7 +9856,7 @@ function canIOUBePaid( isPayer && isReportFinished && !iouSettled && - (reimbursableSpend > 0 || canShowMarkedAsPaidForNegativeAmount) && + (reimbursableSpend > 0 || canShowMarkedAsPaidForNegativeAmount || isOnlyNonReimbursablePayElsewhere) && !isChatReportArchived && !isAutoReimbursable && !isPayAtEndExpenseReport && diff --git a/src/libs/actions/OnyxDerived/configs/todos.ts b/src/libs/actions/OnyxDerived/configs/todos.ts index 9b8976c2b80fc..c104e25485dd3 100644 --- a/src/libs/actions/OnyxDerived/configs/todos.ts +++ b/src/libs/actions/OnyxDerived/configs/todos.ts @@ -64,7 +64,17 @@ const createTodosReportsAndTransactions = ({ if (isApproveAction(report, reportTransactions, currentUserAccountID, reportMetadata, policy)) { reportsToApprove.push(report); } - if (isPrimaryPayAction(report, currentUserAccountID, login, bankAccountList, policy, reportNameValuePair)) { + if ( + isPrimaryPayAction({ + report, + reportTransactions, + currentUserAccountID, + currentUserLogin: login, + bankAccountList, + policy, + reportNameValuePairs: reportNameValuePair, + }) + ) { reportsToPay.push(report); } if (isExportAction(report, login, policy, reportActions) && policy?.exporter === login) { diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index 16d0b9aa54e90..45b83962da0a7 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -11604,7 +11604,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, @@ -11629,6 +11629,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(1), + 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 8adb35182477a..67180b8e92750 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'), @@ -557,6 +559,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), diff --git a/tests/unit/MoneyRequestReportButtonUtils.test.ts b/tests/unit/MoneyRequestReportButtonUtils.test.ts index f04ba64472e1a..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'; @@ -17,6 +18,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, @@ -47,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`); + }); }); });