diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index f3d9b9843d50..17849830b8dd 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -16,7 +16,6 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy'; import useDeleteTransactions from '@hooks/useDeleteTransactions'; import useDuplicateTransactionsAndViolations from '@hooks/useDuplicateTransactionsAndViolations'; -import useEnvironment from '@hooks/useEnvironment'; import useExportAgainModal from '@hooks/useExportAgainModal'; import useGetIOUReportFromReportAction from '@hooks/useGetIOUReportFromReportAction'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; @@ -266,7 +265,6 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt const draftTransactionIDs = Object.keys(transactionDrafts ?? {}); const {translate, localeCompare} = useLocalize(); - const {isProduction} = useEnvironment(); const exportTemplates = useMemo( () => getExportTemplates(integrationsExportTemplates ?? [], csvExportLayouts ?? {}, translate, policy), [integrationsExportTemplates, csvExportLayouts, policy, translate], @@ -1200,9 +1198,6 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt ); const selectionModeReportLevelActions = useMemo(() => { - if (isProduction) { - return []; - } const actions: Array & Pick> = []; if (hasSubmitAction && !shouldBlockSubmit) { actions.push({ @@ -1244,7 +1239,6 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt } return actions; }, [ - isProduction, hasSubmitAction, shouldBlockSubmit, hasApproveAction, @@ -1912,7 +1906,8 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt const shouldShowSelectedTransactionsButton = !!selectedTransactionsOptions.length && !transactionThreadReportID; const popoverUseScrollView = shouldPopoverUseScrollView(selectedTransactionsOptions); - const hasPayInSelectionMode = allExpensesSelected && hasPayAction; + const hasActualPaymentOptions = paymentButtonOptions.some((opt) => Object.values(CONST.IOU.PAYMENT_TYPE).some((type) => type === opt.value)); + const hasPayInSelectionMode = allExpensesSelected && hasPayAction && hasActualPaymentOptions; const makePaymentSelectHandler = useCallback( (fromSelectionMode: boolean) => (event: KYCFlowEvent, iouPaymentType: PaymentMethodType, triggerKYCFlow: TriggerKYCFlow) => { diff --git a/src/components/MoneyReportHeaderKYCDropdown.tsx b/src/components/MoneyReportHeaderKYCDropdown.tsx index c104bd759351..b88541b6bad3 100644 --- a/src/components/MoneyReportHeaderKYCDropdown.tsx +++ b/src/components/MoneyReportHeaderKYCDropdown.tsx @@ -6,6 +6,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import {isSecondaryActionAPaymentOption} from '@libs/PaymentUtils'; import type {KYCFlowEvent, TriggerKYCFlow} from '@libs/PaymentUtils'; +import shouldPopoverUseScrollView from '@libs/shouldPopoverUseScrollView'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; @@ -82,7 +83,7 @@ function MoneyReportHeaderKYCDropdown({ }} buttonRef={buttonRef} shouldAlwaysShowDropdownMenu - shouldPopoverUseScrollView={applicableSecondaryActions.length >= CONST.DROPDOWN_SCROLL_THRESHOLD} + shouldPopoverUseScrollView={shouldPopoverUseScrollView(applicableSecondaryActions)} customText={customText ?? translate('common.more')} options={applicableSecondaryActions} isSplitButton={false} diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 2b2d08b6bc53..d477a372e2e2 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -14,9 +14,11 @@ import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/ import FlatListWithScrollKey from '@components/FlatList/FlatListWithScrollKey'; import HoldOrRejectEducationalModal from '@components/HoldOrRejectEducationalModal'; import {ModalActions} from '@components/Modal/Global/ModalContext'; +import MoneyReportHeaderKYCDropdown from '@components/MoneyReportHeaderKYCDropdown'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import {PressableWithFeedback} from '@components/Pressable'; +import ProcessMoneyReportHoldMenu from '@components/ProcessMoneyReportHoldMenu'; import ScrollView from '@components/ScrollView'; import BulkDuplicateHandler from '@components/Search/BulkDuplicateHandler'; import {useSearchActionsContext, useSearchStateContext} from '@components/Search/SearchContext'; @@ -39,6 +41,7 @@ import useReportTransactionsCollection from '@hooks/useReportTransactionsCollect import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; import useScrollToEndOnNewMessageReceived from '@hooks/useScrollToEndOnNewMessageReceived'; import useSelectedTransactionsActions from '@hooks/useSelectedTransactionsActions'; +import useSelectionModeReportActions from '@hooks/useSelectionModeReportActions'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import {dismissRejectUseExplanation} from '@libs/actions/IOU'; @@ -219,6 +222,36 @@ function MoneyRequestReportActionsList({reportID: reportIDProp, onLayout}: Money const isMobileSelectionModeEnabled = useMobileSelectionMode(); const {showConfirmModal} = useConfirmModal(); + + const { + selectionModeReportLevelActions, + allExpensesSelected, + hasPayInSelectionMode, + onSelectionModePaymentSelect, + selectionModeKYCSuccess, + primaryAction, + kycWallRef, + isHoldMenuVisible, + requestType, + paymentType, + selectedVBBAToPayFromHoldMenu, + handleHoldMenuClose, + handleHoldMenuConfirm, + hasOnlyHeldExpenses, + nonHeldAmount, + fullAmount, + hasValidNonHeldAmount, + } = useSelectionModeReportActions({ + report, + chatReport, + policy, + reportActions, + reportNameValuePairs, + reportMetadata, + transactions: transactionsWithoutPendingDelete, + selectedTransactionIDs, + }); + const beginExportWithTemplate = useCallback( (templateName: string, templateType: string, transactionIDList: string[]) => { if (isOffline) { @@ -305,7 +338,7 @@ function MoneyRequestReportActionsList({reportID: reportIDProp, onLayout}: Money const [rejectModalAction, setRejectModalAction] = useState | null>(null); const selectedTransactionsOptions = useMemo(() => { - return originalSelectedTransactionsOptions.map((option) => { + const mappedOptions = originalSelectedTransactionsOptions.map((option) => { if (option.value === CONST.REPORT.SECONDARY_ACTIONS.REJECT) { return { ...option, @@ -325,7 +358,12 @@ function MoneyRequestReportActionsList({reportID: reportIDProp, onLayout}: Money } return option; }); - }, [originalSelectedTransactionsOptions, dismissedRejectUseExplanation, isDelegateAccessRestricted, showDelegateNoAccessModal]); + + if (allExpensesSelected && selectionModeReportLevelActions.length) { + return [...selectionModeReportLevelActions, ...mappedOptions]; + } + return mappedOptions; + }, [originalSelectedTransactionsOptions, dismissedRejectUseExplanation, isDelegateAccessRestricted, showDelegateNoAccessModal, allExpensesSelected, selectionModeReportLevelActions]); const popoverUseScrollView = shouldPopoverUseScrollView(selectedTransactionsOptions); @@ -864,17 +902,33 @@ function MoneyRequestReportActionsList({reportID: reportIDProp, onLayout}: Money > {shouldUseNarrowLayout && isMobileSelectionModeEnabled && ( - null} - options={selectedTransactionsOptions} - customText={translate('workspace.common.selected', { - count: selectedTransactionIDs.length, - })} - isSplitButton={false} - shouldAlwaysShowDropdownMenu - shouldPopoverUseScrollView={popoverUseScrollView} - wrapperStyle={[styles.w100, styles.ph5]} - /> + {hasPayInSelectionMode ? ( + + + + ) : ( + null} + options={selectedTransactionsOptions} + customText={translate('workspace.common.selected', { + count: selectedTransactionIDs.length, + })} + isSplitButton={false} + shouldAlwaysShowDropdownMenu + shouldPopoverUseScrollView={popoverUseScrollView} + wrapperStyle={[styles.w100, styles.ph5]} + /> + )} )} + {isHoldMenuVisible && requestType !== undefined && ( + + )} ); } diff --git a/src/hooks/useSelectionModeReportActions.ts b/src/hooks/useSelectionModeReportActions.ts new file mode 100644 index 000000000000..a85b2270bd47 --- /dev/null +++ b/src/hooks/useSelectionModeReportActions.ts @@ -0,0 +1,575 @@ +import {delegateEmailSelector, isUserValidatedSelector} from '@selectors/Account'; +import {hasSeenTourSelector} from '@selectors/Onboarding'; +import truncate from 'lodash/truncate'; +import {useContext, useEffect, useRef, useState} from 'react'; +import {InteractionManager} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; +import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; +import {KYCWallContext} from '@components/KYCWall/KYCWallContext'; +import {useLockedAccountActions, useLockedAccountState} from '@components/LockedAccountModalProvider'; +import type {PopoverMenuItem} from '@components/PopoverMenu'; +import type {ActionHandledType} from '@components/ProcessMoneyReportHoldMenu'; +import {useSearchActionsContext, useSearchStateContext} from '@components/Search/SearchContext'; +import type {PaymentActionParams} from '@components/SettlementButton/types'; +import {approveMoneyRequest, canApproveIOU, canIOUBePaid as canIOUBePaidAction, payInvoice, payMoneyRequest, submitReport} from '@libs/actions/IOU'; +import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; +import {search} from '@libs/actions/Search'; +import getPlatform from '@libs/getPlatform'; +import {getTotalAmountForIOUReportPreviewButton} from '@libs/MoneyRequestReportUtils'; +import type {KYCFlowEvent, TriggerKYCFlow} from '@libs/PaymentUtils'; +import {handleUnvalidatedAccount, selectPaymentType} from '@libs/PaymentUtils'; +import {sortPoliciesByName} from '@libs/PolicyUtils'; +import {hasRequestFromCurrentAccount} from '@libs/ReportActionsUtils'; +import {getReportPrimaryAction} from '@libs/ReportPrimaryActionUtils'; +import {getSecondaryReportActions} from '@libs/ReportSecondaryActionUtils'; +import { + getNextApproverAccountID, + getNonHeldAndFullAmount, + hasHeldExpenses as hasHeldExpensesReportUtils, + hasOnlyHeldExpenses as hasOnlyHeldExpensesReportUtils, + hasOnlyNonReimbursableTransactions, + hasUpdatedTotal, + hasViolations as hasViolationsReportUtils, + isAllowedToApproveExpenseReport, + isInvoiceReport as isInvoiceReportUtil, + isIOUReport as isIOUReportUtil, + isReportOwner, + shouldBlockSubmitDueToStrictPolicyRules, +} from '@libs/ReportUtils'; +import {hasAnyPendingRTERViolation as hasAnyPendingRTERViolationTransactionUtils, isExpensifyCardTransaction, isPending} from '@libs/TransactionUtils'; +import {markPendingRTERTransactionsAsCash} from '@userActions/Transaction'; +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'; +import useActiveAdminPolicies from './useActiveAdminPolicies'; +import useConfirmPendingRTERAndProceed from './useConfirmPendingRTERAndProceed'; +import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import {useMemoizedLazyExpensifyIcons} from './useLazyAsset'; +import useLocalize from './useLocalize'; +import useNonReimbursablePaymentModal from './useNonReimbursablePaymentModal'; +import useOnyx from './useOnyx'; +import useParticipantsInvoiceReport from './useParticipantsInvoiceReport'; +import usePaymentOptions from './usePaymentOptions'; +import usePermissions from './usePermissions'; +import usePolicy from './usePolicy'; +import useReportIsArchived from './useReportIsArchived'; +import useSearchShouldCalculateTotals from './useSearchShouldCalculateTotals'; +import useStrictPolicyRules from './useStrictPolicyRules'; + +type UseSelectionModeReportActionsParams = { + report: OnyxEntry; + chatReport: OnyxEntry; + policy: OnyxEntry; + reportActions: OnyxTypes.ReportAction[]; + reportNameValuePairs: OnyxEntry; + reportMetadata: OnyxEntry; + transactions: OnyxTypes.Transaction[]; + selectedTransactionIDs: string[]; +}; + +function useSelectionModeReportActions({ + report, + chatReport, + policy, + reportActions, + reportNameValuePairs, + reportMetadata, + transactions, + selectedTransactionIDs, +}: UseSelectionModeReportActionsParams) { + const {translate, localeCompare} = useLocalize(); + const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); + const {isBetaEnabled} = usePermissions(); + const {areStrictPolicyRulesEnabled} = useStrictPolicyRules(); + const {isDelegateAccessRestricted} = useDelegateNoAccessState(); + const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); + const {isAccountLocked} = useLockedAccountState(); + const {showLockedAccountModal} = useLockedAccountActions(); + const kycWallRef = useContext(KYCWallContext); + + const {currentSearchQueryJSON, currentSearchKey, currentSearchResults} = useSearchStateContext(); + const {clearSelectedTransactions} = useSearchActionsContext(); + const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); + + const [session] = useOnyx(ONYXKEYS.SESSION); + const [isUserValidated] = useOnyx(ONYXKEYS.ACCOUNT, {selector: isUserValidatedSelector}); + const [delegateEmail] = useOnyx(ONYXKEYS.ACCOUNT, {selector: delegateEmailSelector}); + const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); + const [nextStep] = useOnyx(`${ONYXKEYS.COLLECTION.NEXT_STEP}${report?.reportID}`); + const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); + const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); + const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); + const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); + const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [outstandingReportsByPolicyID] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); + const [betas] = useOnyx(ONYXKEYS.BETAS); + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const [isSelfTourViewed = false] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [networkStatus] = useOnyx(ONYXKEYS.NETWORK); + const isOffline = networkStatus?.isOffline ?? false; + + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); + const activePolicy = usePolicy(activePolicyID); + const [invoiceReceiverPolicy] = useOnyx( + `${ONYXKEYS.COLLECTION.POLICY}${chatReport?.invoiceReceiver && 'policyID' in chatReport.invoiceReceiver ? chatReport.invoiceReceiver.policyID : undefined}`, + ); + const existingB2BInvoiceReport = useParticipantsInvoiceReport(activePolicyID, CONST.REPORT.INVOICE_RECEIVER_TYPE.BUSINESS, chatReport?.policyID); + const activeAdminPolicies = useActiveAdminPolicies(); + + const isChatReportArchived = useReportIsArchived(chatReport?.reportID); + const {showNonReimbursablePaymentErrorModal, shouldBlockDirectPayment} = useNonReimbursablePaymentModal(report, transactions); + + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Send', 'ThumbsUp', 'Cash', 'ArrowRight', 'Building'] as const); + + const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); + + const currentUserEmail = session?.email; + const hasViolations = hasViolationsReportUtils(report?.reportID, allTransactionViolations, currentUserAccountID, currentUserEmail ?? ''); + + const hasAnyPendingRTERViolation = hasAnyPendingRTERViolationTransactionUtils(transactions, allTransactionViolations, currentUserEmail ?? '', currentUserAccountID, report, policy); + + const handleMarkPendingRTERTransactionsAsCash = () => { + markPendingRTERTransactionsAsCash(transactions, allTransactionViolations, reportActions); + }; + + const confirmPendingRTERAndProceed = useConfirmPendingRTERAndProceed(hasAnyPendingRTERViolation, handleMarkPendingRTERTransactionsAsCash); + + const nextApproverAccountID = getNextApproverAccountID(report); + const isSubmitterSameAsNextApprover = isReportOwner(report) && (nextApproverAccountID === report?.ownerAccountID || report?.managerID === report?.ownerAccountID); + const isBlockSubmitDueToPreventSelfApproval = isSubmitterSameAsNextApprover && policy?.preventSelfApproval; + const isBlockSubmitDueToStrictPolicyRules = shouldBlockSubmitDueToStrictPolicyRules( + report?.reportID, + allTransactionViolations, + areStrictPolicyRulesEnabled, + currentUserAccountID, + currentUserEmail ?? '', + transactions, + ); + const shouldBlockSubmit = isBlockSubmitDueToStrictPolicyRules || isBlockSubmitDueToPreventSelfApproval; + + const canAllowSettlement = hasUpdatedTotal(report, policy); + const isAnyTransactionOnHold = hasHeldExpensesReportUtils(report?.reportID); + const isInvoiceReport = isInvoiceReportUtil(report); + + const hasOnlyPendingTransactions = !!transactions && transactions.length > 0 && transactions.every((t) => isExpensifyCardTransaction(t) && isPending(t)); + + const getCanIOUBePaid = (onlyShowPayElsewhere = false) => + canIOUBePaidAction(report, chatReport, policy, bankAccountList, transactions, onlyShowPayElsewhere, undefined, invoiceReceiverPolicy); + + const canIOUBePaid = getCanIOUBePaid(); + const reportHasOnlyNonReimbursableTransactions = hasOnlyNonReimbursableTransactions(report?.reportID, transactions); + const onlyShowPayElsewhere = reportHasOnlyNonReimbursableTransactions ? false : !canIOUBePaid && getCanIOUBePaid(true); + + const shouldShowPayButton = canIOUBePaid || onlyShowPayElsewhere || (reportHasOnlyNonReimbursableTransactions && (report?.total ?? 0) !== 0); + + const {nonHeldAmount, fullAmount, hasValidNonHeldAmount} = getNonHeldAndFullAmount(report, shouldShowPayButton); + + const shouldShowApproveButton = canApproveIOU(report, policy, reportMetadata, transactions) && !hasOnlyPendingTransactions; + + const shouldDisableApproveButton = shouldShowApproveButton && !isAllowedToApproveExpenseReport(report); + + const totalAmount = getTotalAmountForIOUReportPreviewButton(report, policy, CONST.REPORT.PRIMARY_ACTIONS.PAY); + + // confirmPayment is declared below but used by usePaymentOptions; we use a ref to avoid a circular dependency. + const confirmPaymentRef = useRef<(params: PaymentActionParams) => void>(() => {}); + + const paymentButtonOptions = usePaymentOptions({ + currency: report?.currency, + iouReport: report, + chatReportID: chatReport?.reportID, + formattedAmount: totalAmount, + policyID: report?.policyID, + onPress: (params: PaymentActionParams) => confirmPaymentRef.current(params), + shouldHidePaymentOptions: !shouldShowPayButton, + shouldShowApproveButton, + shouldDisableApproveButton, + onlyShowPayElsewhere, + }); + + const workspacePolicyOptions = (() => { + if (!isIOUReportUtil(report)) { + return []; + } + + const hasPersonalPaymentOption = paymentButtonOptions.some((opt) => opt.value === CONST.IOU.PAYMENT_TYPE.EXPENSIFY); + if (!hasPersonalPaymentOption || !activeAdminPolicies.length) { + return []; + } + + const canUseBusinessBankAccount = report?.reportID && !hasRequestFromCurrentAccount(report.reportID, currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID); + if (!canUseBusinessBankAccount) { + return []; + } + + return sortPoliciesByName(activeAdminPolicies, localeCompare); + })(); + + const primaryAction = getReportPrimaryAction({ + currentUserLogin: currentUserEmail ?? '', + currentUserAccountID, + report, + chatReport, + reportTransactions: transactions, + violations: allTransactionViolations, + bankAccountList, + policy, + reportNameValuePairs, + reportActions, + reportMetadata, + isChatReportArchived, + invoiceReceiverPolicy, + }); + + const secondaryActions = (() => { + if (!report) { + return []; + } + return getSecondaryReportActions({ + currentUserLogin: currentUserEmail ?? '', + currentUserAccountID, + report, + chatReport, + reportTransactions: transactions, + originalTransaction: undefined, + violations: allTransactionViolations, + bankAccountList, + policy, + reportNameValuePairs, + reportActions, + reportMetadata, + policies, + outstandingReportsByPolicyID, + isChatReportArchived, + }); + })(); + + const hasSubmitAction = primaryAction === CONST.REPORT.PRIMARY_ACTIONS.SUBMIT || secondaryActions.includes(CONST.REPORT.SECONDARY_ACTIONS.SUBMIT); + const hasApproveAction = primaryAction === CONST.REPORT.PRIMARY_ACTIONS.APPROVE || secondaryActions.includes(CONST.REPORT.SECONDARY_ACTIONS.APPROVE); + const hasPayAction = primaryAction === CONST.REPORT.PRIMARY_ACTIONS.PAY || secondaryActions.includes(CONST.REPORT.SECONDARY_ACTIONS.PAY); + + const allExpensesSelected = selectedTransactionIDs.length > 0 && selectedTransactionIDs.length === transactions.length; + + // Hold menu state + const [isHoldMenuVisible, setIsHoldMenuVisible] = useState(false); + const [paymentType, setPaymentType] = useState(); + const [requestType, setRequestType] = useState(); + const [selectedVBBAToPayFromHoldMenu, setSelectedVBBAToPayFromHoldMenu] = useState(undefined); + + const shouldBlockAction = () => { + if (isDelegateAccessRestricted) { + showDelegateNoAccessModal(); + return true; + } + if (isAccountLocked) { + showLockedAccountModal(); + return true; + } + if (!isUserValidated) { + handleUnvalidatedAccount(report); + return true; + } + return false; + }; + + const handleSubmitReport = () => { + if (!report || shouldBlockSubmit) { + return; + } + const doSubmit = () => { + submitReport({ + expenseReport: report, + policy, + currentUserAccountIDParam: currentUserAccountID, + currentUserEmailParam: currentUserEmail ?? '', + hasViolations, + isASAPSubmitBetaEnabled, + expenseReportCurrentNextStepDeprecated: nextStep, + userBillingGracePeriodEnds, + amountOwed, + ownerBillingGracePeriodEnd, + delegateEmail, + }); + if (currentSearchQueryJSON && !isOffline) { + search({ + searchKey: currentSearchKey, + shouldCalculateTotals, + offset: 0, + queryJSON: currentSearchQueryJSON, + isOffline, + isLoading: !!currentSearchResults?.search?.isLoading, + }); + } + clearSelectedTransactions(true); + turnOffMobileSelectionMode(); + }; + confirmPendingRTERAndProceed(doSubmit); + }; + + const confirmApproval = () => { + setRequestType(CONST.IOU.REPORT_ACTION_TYPE.APPROVE); + if (isDelegateAccessRestricted) { + showDelegateNoAccessModal(); + } else if (isAnyTransactionOnHold) { + setIsHoldMenuVisible(true); + } else { + approveMoneyRequest({ + expenseReport: report, + policy, + currentUserAccountIDParam: currentUserAccountID, + currentUserEmailParam: currentUserEmail ?? '', + hasViolations, + isASAPSubmitBetaEnabled, + expenseReportCurrentNextStepDeprecated: nextStep, + betas, + userBillingGracePeriodEnds, + amountOwed, + ownerBillingGracePeriodEnd, + delegateEmail, + full: true, + }); + clearSelectedTransactions(true); + turnOffMobileSelectionMode(); + } + }; + + const confirmPayment = ({paymentType: type, payAsBusiness, methodID, paymentMethod}: PaymentActionParams) => { + if (!type || !chatReport) { + return; + } + if (shouldBlockDirectPayment(type)) { + showNonReimbursablePaymentErrorModal(); + return; + } + setPaymentType(type); + setRequestType(CONST.IOU.REPORT_ACTION_TYPE.PAY); + if (isDelegateAccessRestricted) { + showDelegateNoAccessModal(); + } else if (isAnyTransactionOnHold) { + setSelectedVBBAToPayFromHoldMenu(type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined); + if (getPlatform() === CONST.PLATFORM.IOS) { + // On iOS, opening the hold menu immediately can conflict with the popover dismiss animation, so we defer it. + // eslint-disable-next-line @typescript-eslint/no-deprecated + InteractionManager.runAfterInteractions(() => setIsHoldMenuVisible(true)); + } else { + setIsHoldMenuVisible(true); + } + } else if (isInvoiceReport) { + payInvoice({ + paymentMethodType: type, + chatReport, + invoiceReport: report, + invoiceReportCurrentNextStepDeprecated: nextStep, + introSelected, + currentUserAccountIDParam: currentUserAccountID, + currentUserEmailParam: currentUserEmail ?? '', + payAsBusiness, + existingB2BInvoiceReport, + methodID, + paymentMethod, + activePolicy, + betas, + isSelfTourViewed, + }); + clearSelectedTransactions(true); + turnOffMobileSelectionMode(); + } else { + payMoneyRequest({ + paymentType: type, + chatReport, + iouReport: report, + introSelected, + iouReportCurrentNextStepDeprecated: nextStep, + currentUserAccountID, + activePolicy, + policy, + betas, + isSelfTourViewed, + userBillingGracePeriodEnds, + amountOwed, + ownerBillingGracePeriodEnd, + methodID: type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, + }); + if (currentSearchQueryJSON && !isOffline) { + search({ + searchKey: currentSearchKey, + shouldCalculateTotals, + offset: 0, + queryJSON: currentSearchQueryJSON, + isOffline, + isLoading: !!currentSearchResults?.search?.isLoading, + }); + } + clearSelectedTransactions(true); + turnOffMobileSelectionMode(); + } + }; + + // Keep confirmPaymentRef in sync so usePaymentOptions always calls the latest version. + useEffect(() => { + confirmPaymentRef.current = confirmPayment; + }); + + const handleApproveSelected = () => { + confirmApproval(); + }; + + // No-op: the Pay action has subMenuItems, so PopoverMenu navigates into the submenu + // without calling onSelected. This handler exists only to satisfy the DropdownOption type. + const handlePaySelected = () => {}; + + const onSelectionModePaymentSelect = (event: KYCFlowEvent, iouPaymentType: PaymentMethodType, triggerKYCFlow: TriggerKYCFlow) => { + if (shouldBlockAction()) { + return; + } + // This callback fires via onSubItemSelected before the popover closes. Defer heavy payment + // work so the dropdown dismiss animation completes first, avoiding perceived UI lag. + // eslint-disable-next-line @typescript-eslint/no-deprecated + InteractionManager.runAfterInteractions(() => { + selectPaymentType({ + event, + iouPaymentType, + triggerKYCFlow, + policy, + onPress: confirmPayment, + currentAccountID: currentUserAccountID, + currentEmail: currentUserEmail ?? '', + hasViolations, + isASAPSubmitBetaEnabled, + isUserValidated, + confirmApproval: () => confirmApproval(), + iouReport: report, + iouReportNextStep: nextStep, + betas, + userBillingGracePeriodEnds, + amountOwed, + ownerBillingGracePeriodEnd, + delegateEmail, + }); + }); + }; + + const selectionModeKYCSuccess = (type?: PaymentMethodType) => { + confirmPayment({paymentType: type}); + }; + + const hasActualPaymentOptions = paymentButtonOptions.some((opt) => Object.values(CONST.IOU.PAYMENT_TYPE).some((type) => type === opt.value)); + const hasPayInSelectionMode = allExpensesSelected && hasPayAction && hasActualPaymentOptions; + + const handleWorkspaceSelected = (wp: OnyxTypes.Policy) => { + if (shouldBlockAction()) { + return; + } + kycWallRef.current?.continueAction?.({policy: wp}); + }; + + const paymentSubMenuItems = ((): PopoverMenuItem[] => { + if (!workspacePolicyOptions.length) { + return Object.values(paymentButtonOptions); + } + + const result: PopoverMenuItem[] = []; + let idx = 0; + for (const opt of Object.values(paymentButtonOptions)) { + result[idx++] = opt; + if (opt.value === CONST.IOU.PAYMENT_TYPE.EXPENSIFY) { + for (const wp of workspacePolicyOptions) { + result[idx++] = { + text: translate('iou.payWithPolicy', truncate(wp.name, {length: CONST.ADDITIONAL_ALLOWED_CHARACTERS}), ''), + icon: expensifyIcons.Building, + onSelected: () => handleWorkspaceSelected(wp), + }; + } + } + } + + return result; + })(); + + const selectionModeReportLevelActions = (() => { + const actions: Array & Pick> = []; + let idx = 0; + if (hasSubmitAction && !shouldBlockSubmit) { + actions[idx++] = { + text: translate('common.submit'), + icon: expensifyIcons.Send, + value: CONST.REPORT.PRIMARY_ACTIONS.SUBMIT, + onSelected: handleSubmitReport, + }; + } + if (hasApproveAction && !isBlockSubmitDueToPreventSelfApproval) { + actions[idx++] = { + text: translate('iou.approve'), + icon: expensifyIcons.ThumbsUp, + value: CONST.REPORT.PRIMARY_ACTIONS.APPROVE, + onSelected: handleApproveSelected, + }; + } + if (hasPayAction && !(isOffline && !canAllowSettlement)) { + actions[idx++] = { + text: translate('iou.settlePayment', totalAmount), + icon: expensifyIcons.Cash, + value: CONST.REPORT.PRIMARY_ACTIONS.PAY, + rightIcon: expensifyIcons.ArrowRight, + backButtonText: translate('iou.settlePayment', totalAmount), + subMenuItems: paymentSubMenuItems, + onSelected: handlePaySelected, + }; + } + return actions; + })(); + + const handleHoldMenuClose = () => { + setSelectedVBBAToPayFromHoldMenu(undefined); + setIsHoldMenuVisible(false); + }; + + const handleHoldMenuConfirm = () => { + clearSelectedTransactions(true); + turnOffMobileSelectionMode(); + }; + + return { + selectionModeReportLevelActions, + allExpensesSelected, + shouldBlockSubmit, + isBlockSubmitDueToPreventSelfApproval, + + // Hold menu state + isHoldMenuVisible, + requestType, + paymentType, + selectedVBBAToPayFromHoldMenu, + handleHoldMenuClose, + handleHoldMenuConfirm, + confirmPayment, + confirmApproval, + shouldBlockAction, + + // Pay-related + hasPayAction, + hasPayInSelectionMode, + hasSubmitAction, + hasApproveAction, + totalAmount, + canAllowSettlement, + isAnyTransactionOnHold, + isInvoiceReport, + hasOnlyHeldExpenses: hasOnlyHeldExpensesReportUtils(report?.reportID), + nonHeldAmount, + fullAmount, + hasValidNonHeldAmount, + + // KYC dropdown integration + onSelectionModePaymentSelect, + selectionModeKYCSuccess, + + // Data for external use + primaryAction, + kycWallRef, + }; +} + +export default useSelectionModeReportActions; +export type {UseSelectionModeReportActionsParams}; diff --git a/tests/unit/hooks/useSelectionModeReportActions.test.ts b/tests/unit/hooks/useSelectionModeReportActions.test.ts new file mode 100644 index 000000000000..17431f57f62b --- /dev/null +++ b/tests/unit/hooks/useSelectionModeReportActions.test.ts @@ -0,0 +1,763 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import {act, renderHook, waitFor} from '@testing-library/react-native'; +import Onyx from 'react-native-onyx'; +import useSelectionModeReportActions from '@hooks/useSelectionModeReportActions'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, Report, Transaction} from '@src/types/onyx'; +import createRandomPolicy from '../../utils/collections/policies'; +import createRandomTransaction from '../../utils/collections/transaction'; +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; + +const TEST_ACCOUNT_ID = 12345; +const TEST_EMAIL = 'test@expensify.com'; +const TEST_REPORT_ID = '1'; +const TEST_CHAT_REPORT_ID = '2'; +const TEST_POLICY_ID = '3'; + +const mockClearSelectedTransactions = jest.fn(); + +jest.mock('@hooks/useLocalize', () => ({ + __esModule: true, + default: jest.fn(() => ({ + translate: jest.fn((key: string, ...args: string[]) => { + if (key === 'common.submit') { + return 'Submit'; + } + if (key === 'iou.approve') { + return 'Approve'; + } + if (key === 'iou.settlePayment') { + return `Pay ${args.at(0) ?? ''}`.trim(); + } + return key; + }), + localeCompare: jest.fn((a: string, b: string) => a.localeCompare(b)), + })), +})); + +jest.mock('@components/Search/SearchScopeProvider', () => ({ + __esModule: true, + useIsOnSearch: jest.fn(() => false), + SearchScopeProvider: ({children}: {children: React.ReactNode}) => children, +})); + +jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({ + __esModule: true, + default: jest.fn(() => ({accountID: TEST_ACCOUNT_ID, login: TEST_EMAIL, email: TEST_EMAIL})), +})); + +jest.mock('@hooks/useEnvironment', () => ({ + __esModule: true, + default: jest.fn(() => ({environment: 'dev'})), +})); + +jest.mock('@hooks/usePermissions', () => ({ + __esModule: true, + default: jest.fn(() => ({ + isBetaEnabled: (beta: string) => beta === 'selectionModeReportActions', + })), +})); + +jest.mock('@hooks/useStrictPolicyRules', () => ({ + __esModule: true, + default: jest.fn(() => ({areStrictPolicyRulesEnabled: false})), +})); + +jest.mock('@hooks/useConfirmModal', () => ({ + __esModule: true, + default: jest.fn(() => ({showConfirmModal: jest.fn()})), +})); + +jest.mock('@hooks/useConfirmPendingRTERAndProceed', () => ({ + __esModule: true, + default: jest.fn(() => (onProceed: () => void) => onProceed()), +})); + +jest.mock('@hooks/useReportIsArchived', () => ({ + __esModule: true, + default: jest.fn(() => false), +})); + +jest.mock('@hooks/useSearchShouldCalculateTotals', () => ({ + __esModule: true, + default: jest.fn(() => false), +})); + +jest.mock('@hooks/useActiveAdminPolicies', () => ({ + __esModule: true, + default: jest.fn(() => []), +})); + +jest.mock('@hooks/useParticipantsInvoiceReport', () => ({ + __esModule: true, + default: jest.fn(() => undefined), +})); + +jest.mock('@hooks/usePolicy', () => ({ + __esModule: true, + default: jest.fn(() => null), +})); + +jest.mock('@hooks/usePaymentOptions', () => ({ + __esModule: true, + default: jest.fn(() => []), +})); + +jest.mock('@hooks/useNonReimbursablePaymentModal', () => ({ + __esModule: true, + default: jest.fn(() => ({ + showNonReimbursablePaymentErrorModal: jest.fn(), + shouldBlockDirectPayment: jest.fn(() => false), + nonReimbursablePaymentErrorDecisionModal: null, + })), +})); + +jest.mock('@hooks/useLazyAsset', () => ({ + __esModule: true, + useMemoizedLazyExpensifyIcons: jest.fn(() => ({ + Send: 'Send', + ThumbsUp: 'ThumbsUp', + Cash: 'Cash', + ArrowRight: 'ArrowRight', + Building: 'Building', + })), +})); + +jest.mock('@components/DelegateNoAccessModalProvider', () => ({ + __esModule: true, + useDelegateNoAccessState: jest.fn(() => ({isDelegateAccessRestricted: false})), + useDelegateNoAccessActions: jest.fn(() => ({showDelegateNoAccessModal: jest.fn()})), +})); + +jest.mock('@components/LockedAccountModalProvider', () => ({ + __esModule: true, + useLockedAccountState: jest.fn(() => ({isAccountLocked: false})), + useLockedAccountActions: jest.fn(() => ({showLockedAccountModal: jest.fn()})), +})); + +jest.mock('@components/KYCWall/KYCWallContext', () => ({ + __esModule: true, + KYCWallContext: { + _currentValue: {current: null}, + Provider: ({children}: {children: React.ReactNode}) => children, + Consumer: ({children}: {children: (value: unknown) => React.ReactNode}) => children({current: null}), + }, +})); + +jest.mock('@components/Search/SearchContext', () => ({ + __esModule: true, + useSearchStateContext: jest.fn(() => ({ + currentSearchQueryJSON: null, + currentSearchKey: '', + currentSearchResults: null, + selectedTransactionIDs: [], + })), + useSearchActionsContext: jest.fn(() => ({ + clearSelectedTransactions: mockClearSelectedTransactions, + setSelectedTransactions: jest.fn(), + })), +})); + +let mockPrimaryAction = ''; +jest.mock('@libs/ReportPrimaryActionUtils', () => ({ + __esModule: true, + getReportPrimaryAction: jest.fn(() => mockPrimaryAction), +})); + +let mockSecondaryActions: string[] = []; +jest.mock('@libs/ReportSecondaryActionUtils', () => ({ + __esModule: true, + getSecondaryReportActions: jest.fn(() => mockSecondaryActions), +})); + +jest.mock('@libs/ReportUtils', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = jest.requireActual('@libs/ReportUtils'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + hasHeldExpenses: jest.fn(() => false), + hasOnlyHeldExpenses: jest.fn(() => false), + hasUpdatedTotal: jest.fn(() => true), + hasViolations: jest.fn(() => false), + getNextApproverAccountID: jest.fn(() => 0), + isReportOwner: jest.fn(() => false), + isAllowedToApproveExpenseReport: jest.fn(() => true), + shouldBlockSubmitDueToStrictPolicyRules: jest.fn(() => false), + getNonHeldAndFullAmount: jest.fn(() => ({nonHeldAmount: '$100.00', fullAmount: '$100.00', hasValidNonHeldAmount: true})), + isInvoiceReport: jest.fn(() => false), + }; +}); + +jest.mock('@libs/actions/IOU', () => ({ + __esModule: true, + submitReport: jest.fn(), + approveMoneyRequest: jest.fn(), + payMoneyRequest: jest.fn(), + payInvoice: jest.fn(), + canApproveIOU: jest.fn(() => false), + canIOUBePaid: jest.fn(() => false), +})); + +jest.mock('@libs/actions/Link', () => ({ + __esModule: true, + openOldDotLink: jest.fn(), +})); + +jest.mock('@libs/actions/Search', () => ({ + __esModule: true, + search: jest.fn(), +})); + +jest.mock('@libs/PolicyUtils', () => ({ + __esModule: true, + hasDynamicExternalWorkflow: jest.fn(() => false), + sortPoliciesByName: jest.fn(() => []), +})); + +jest.mock('@libs/ReportActionsUtils', () => ({ + __esModule: true, + hasRequestFromCurrentAccount: jest.fn(() => false), +})); + +jest.mock('@libs/MoneyRequestReportUtils', () => ({ + __esModule: true, + getTotalAmountForIOUReportPreviewButton: jest.fn(() => '$100.00'), +})); + +jest.mock('@libs/PaymentUtils', () => ({ + __esModule: true, + handleUnvalidatedAccount: jest.fn(), + selectPaymentType: jest.fn(), +})); + +jest.mock('@libs/TransactionUtils', () => ({ + __esModule: true, + hasAnyPendingRTERViolation: jest.fn(() => false), + isExpensifyCardTransaction: jest.fn(() => false), + isPending: jest.fn(() => false), + getReimbursable: jest.fn(() => true), +})); + +jest.mock('@userActions/Transaction', () => ({ + __esModule: true, + markPendingRTERTransactionsAsCash: jest.fn(), +})); + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const ReportUtils = require('@libs/ReportUtils') as Record; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const DelegateProvider = require('@components/DelegateNoAccessModalProvider') as Record; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const LockedProvider = require('@components/LockedAccountModalProvider') as Record; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const IOUActions = require('@libs/actions/IOU') as Record; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const usePaymentOptionsMock = require('@hooks/usePaymentOptions') as {default: jest.Mock}; + +function resetMocksToDefaults() { + ReportUtils.hasHeldExpenses.mockReturnValue(false); + ReportUtils.hasOnlyHeldExpenses.mockReturnValue(false); + ReportUtils.isReportOwner.mockReturnValue(false); + ReportUtils.getNextApproverAccountID.mockReturnValue(0); + ReportUtils.isInvoiceReport.mockReturnValue(false); + ReportUtils.hasUpdatedTotal.mockReturnValue(true); + ReportUtils.isAllowedToApproveExpenseReport.mockReturnValue(true); + + DelegateProvider.useDelegateNoAccessState.mockReturnValue({isDelegateAccessRestricted: false}); + DelegateProvider.useDelegateNoAccessActions.mockReturnValue({showDelegateNoAccessModal: jest.fn()}); + + LockedProvider.useLockedAccountState.mockReturnValue({isAccountLocked: false}); + LockedProvider.useLockedAccountActions.mockReturnValue({showLockedAccountModal: jest.fn()}); +} + +function buildReport(overrides: Partial = {}): Report { + return { + reportID: TEST_REPORT_ID, + chatReportID: TEST_CHAT_REPORT_ID, + policyID: TEST_POLICY_ID, + ownerAccountID: TEST_ACCOUNT_ID, + currency: 'USD', + type: CONST.REPORT.TYPE.EXPENSE, + ...overrides, + } as Report; +} + +function buildChatReport(overrides: Partial = {}): Report { + return { + reportID: TEST_CHAT_REPORT_ID, + type: CONST.REPORT.TYPE.CHAT, + ...overrides, + } as Report; +} + +function buildPolicy(overrides: Partial = {}): Policy { + return { + ...createRandomPolicy(Number(TEST_POLICY_ID)), + id: TEST_POLICY_ID, + preventSelfApproval: false, + pendingAction: undefined, + ...overrides, + }; +} + +function buildTransaction(id: number, overrides: Partial = {}): Transaction { + return { + ...createRandomTransaction(id), + transactionID: id.toString(), + reportID: TEST_REPORT_ID, + ...overrides, + }; +} + +function renderSelectionModeHook(overrides: Partial[0]> = {}) { + const defaultParams = { + report: buildReport(), + chatReport: buildChatReport(), + policy: buildPolicy(), + reportActions: [], + reportNameValuePairs: undefined, + reportMetadata: undefined, + transactions: [buildTransaction(1), buildTransaction(2)], + selectedTransactionIDs: ['1', '2'], + }; + return renderHook(() => useSelectionModeReportActions({...defaultParams, ...overrides})); +} + +describe('useSelectionModeReportActions', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + jest.clearAllMocks(); + resetMocksToDefaults(); + mockPrimaryAction = ''; + mockSecondaryActions = []; + await Onyx.merge(ONYXKEYS.ACCOUNT, {validated: true}); + await Onyx.merge(ONYXKEYS.SESSION, {email: TEST_EMAIL, accountID: TEST_ACCOUNT_ID}); + }); + + afterEach(async () => { + await Onyx.clear(); + }); + + describe('allExpensesSelected', () => { + it('returns true when all transactions are selected', () => { + const transactions = [buildTransaction(1), buildTransaction(2)]; + const {result} = renderSelectionModeHook({ + transactions, + selectedTransactionIDs: ['1', '2'], + }); + + expect(result.current.allExpensesSelected).toBe(true); + }); + + it('returns false when only some transactions are selected', () => { + const transactions = [buildTransaction(1), buildTransaction(2), buildTransaction(3)]; + const {result} = renderSelectionModeHook({ + transactions, + selectedTransactionIDs: ['1', '2'], + }); + + expect(result.current.allExpensesSelected).toBe(false); + }); + + it('returns false when no transactions are selected', () => { + const {result} = renderSelectionModeHook({ + selectedTransactionIDs: [], + }); + + expect(result.current.allExpensesSelected).toBe(false); + }); + }); + + describe('selectionModeReportLevelActions', () => { + it('includes Submit when primaryAction is SUBMIT', () => { + mockPrimaryAction = CONST.REPORT.PRIMARY_ACTIONS.SUBMIT; + + const {result} = renderSelectionModeHook(); + + const submitAction = result.current.selectionModeReportLevelActions.find((a) => a.value === CONST.REPORT.PRIMARY_ACTIONS.SUBMIT); + expect(submitAction).toBeDefined(); + expect(submitAction?.text).toBe('Submit'); + }); + + it('includes Submit when secondaryActions includes SUBMIT', () => { + mockSecondaryActions = [CONST.REPORT.SECONDARY_ACTIONS.SUBMIT]; + + const {result} = renderSelectionModeHook(); + + const submitAction = result.current.selectionModeReportLevelActions.find((a) => a.value === CONST.REPORT.PRIMARY_ACTIONS.SUBMIT); + expect(submitAction).toBeDefined(); + }); + + it('includes Approve when primaryAction is APPROVE', () => { + mockPrimaryAction = CONST.REPORT.PRIMARY_ACTIONS.APPROVE; + + const {result} = renderSelectionModeHook(); + + const approveAction = result.current.selectionModeReportLevelActions.find((a) => a.value === CONST.REPORT.PRIMARY_ACTIONS.APPROVE); + expect(approveAction).toBeDefined(); + expect(approveAction?.text).toBe('Approve'); + }); + + it('includes Pay when primaryAction is PAY', () => { + mockPrimaryAction = CONST.REPORT.PRIMARY_ACTIONS.PAY; + + const {result} = renderSelectionModeHook(); + + const payAction = result.current.selectionModeReportLevelActions.find((a) => a.value === CONST.REPORT.PRIMARY_ACTIONS.PAY); + expect(payAction).toBeDefined(); + }); + + it('returns empty array when no Submit/Approve/Pay actions exist', () => { + mockPrimaryAction = CONST.REPORT.PRIMARY_ACTIONS.MARK_AS_CASH; + + const {result} = renderSelectionModeHook(); + + expect(result.current.selectionModeReportLevelActions).toHaveLength(0); + }); + + it('can include multiple actions simultaneously', () => { + mockPrimaryAction = CONST.REPORT.PRIMARY_ACTIONS.SUBMIT; + mockSecondaryActions = [CONST.REPORT.SECONDARY_ACTIONS.APPROVE, CONST.REPORT.SECONDARY_ACTIONS.PAY]; + + const {result} = renderSelectionModeHook(); + + expect(result.current.selectionModeReportLevelActions.length).toBeGreaterThanOrEqual(2); + expect(result.current.selectionModeReportLevelActions.find((a) => a.value === CONST.REPORT.PRIMARY_ACTIONS.SUBMIT)).toBeDefined(); + expect(result.current.selectionModeReportLevelActions.find((a) => a.value === CONST.REPORT.PRIMARY_ACTIONS.APPROVE)).toBeDefined(); + }); + }); + + describe('hasPayInSelectionMode', () => { + it('returns true when all expenses selected and pay action exists', () => { + mockPrimaryAction = CONST.REPORT.PRIMARY_ACTIONS.PAY; + usePaymentOptionsMock.default.mockReturnValue([{value: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, text: 'Pay elsewhere'}]); + const transactions = [buildTransaction(1), buildTransaction(2)]; + + const {result} = renderSelectionModeHook({ + transactions, + selectedTransactionIDs: ['1', '2'], + }); + + expect(result.current.hasPayInSelectionMode).toBe(true); + usePaymentOptionsMock.default.mockReturnValue([]); + }); + + it('returns false when not all expenses are selected', () => { + mockPrimaryAction = CONST.REPORT.PRIMARY_ACTIONS.PAY; + const transactions = [buildTransaction(1), buildTransaction(2), buildTransaction(3)]; + + const {result} = renderSelectionModeHook({ + transactions, + selectedTransactionIDs: ['1', '2'], + }); + + expect(result.current.hasPayInSelectionMode).toBe(false); + }); + + it('returns false when no pay action exists', () => { + mockPrimaryAction = CONST.REPORT.PRIMARY_ACTIONS.SUBMIT; + const transactions = [buildTransaction(1)]; + + const {result} = renderSelectionModeHook({ + transactions, + selectedTransactionIDs: ['1'], + }); + + expect(result.current.hasPayInSelectionMode).toBe(false); + }); + }); + + describe('hold menu state', () => { + it('initializes hold menu as not visible', () => { + const {result} = renderSelectionModeHook(); + + expect(result.current.isHoldMenuVisible).toBe(false); + }); + + it('returns hold menu amounts from getNonHeldAndFullAmount', () => { + const {result} = renderSelectionModeHook(); + + expect(result.current.nonHeldAmount).toBe('$100.00'); + expect(result.current.fullAmount).toBe('$100.00'); + expect(result.current.hasValidNonHeldAmount).toBe(true); + }); + }); + + describe('action flags', () => { + it('correctly identifies hasSubmitAction from primaryAction', () => { + mockPrimaryAction = CONST.REPORT.PRIMARY_ACTIONS.SUBMIT; + + const {result} = renderSelectionModeHook(); + + expect(result.current.hasSubmitAction).toBe(true); + expect(result.current.hasApproveAction).toBe(false); + expect(result.current.hasPayAction).toBe(false); + }); + + it('correctly identifies hasApproveAction from secondaryActions', () => { + mockSecondaryActions = [CONST.REPORT.SECONDARY_ACTIONS.APPROVE]; + + const {result} = renderSelectionModeHook(); + + expect(result.current.hasSubmitAction).toBe(false); + expect(result.current.hasApproveAction).toBe(true); + expect(result.current.hasPayAction).toBe(false); + }); + + it('correctly identifies hasPayAction from primaryAction', () => { + mockPrimaryAction = CONST.REPORT.PRIMARY_ACTIONS.PAY; + + const {result} = renderSelectionModeHook(); + + expect(result.current.hasPayAction).toBe(true); + }); + }); + + describe('Submit action callback', () => { + it('calls submitReport and clears selections when submitted', async () => { + mockPrimaryAction = CONST.REPORT.PRIMARY_ACTIONS.SUBMIT; + + const {result} = renderSelectionModeHook(); + + const submitAction = result.current.selectionModeReportLevelActions.find((a) => a.value === CONST.REPORT.PRIMARY_ACTIONS.SUBMIT); + submitAction?.onSelected?.(); + + await waitFor(() => { + expect(IOUActions.submitReport).toHaveBeenCalled(); + expect(mockClearSelectedTransactions).toHaveBeenCalledWith(true); + }); + }); + }); + + describe('Approve action callback', () => { + it('calls approveMoneyRequest and clears selections when approved', async () => { + mockPrimaryAction = CONST.REPORT.PRIMARY_ACTIONS.APPROVE; + + const {result} = renderSelectionModeHook(); + + const approveAction = result.current.selectionModeReportLevelActions.find((a) => a.value === CONST.REPORT.PRIMARY_ACTIONS.APPROVE); + approveAction?.onSelected?.(); + + await waitFor(() => { + expect(IOUActions.approveMoneyRequest).toHaveBeenCalled(); + expect(mockClearSelectedTransactions).toHaveBeenCalledWith(true); + }); + }); + }); + + describe('primaryAction output', () => { + it('returns the computed primaryAction', () => { + mockPrimaryAction = CONST.REPORT.PRIMARY_ACTIONS.APPROVE; + + const {result} = renderSelectionModeHook(); + + expect(result.current.primaryAction).toBe(CONST.REPORT.PRIMARY_ACTIONS.APPROVE); + }); + }); + + describe('shouldBlockAction guards', () => { + it('returns true and shows delegate modal when delegate access is restricted', () => { + const mockShowDelegateModal = jest.fn(); + DelegateProvider.useDelegateNoAccessState.mockReturnValue({isDelegateAccessRestricted: true}); + DelegateProvider.useDelegateNoAccessActions.mockReturnValue({showDelegateNoAccessModal: mockShowDelegateModal}); + + const {result} = renderSelectionModeHook(); + const blocked = result.current.shouldBlockAction(); + + expect(blocked).toBe(true); + expect(mockShowDelegateModal).toHaveBeenCalled(); + }); + + it('returns true and shows locked modal when account is locked', () => { + const mockShowLockedModal = jest.fn(); + LockedProvider.useLockedAccountState.mockReturnValue({isAccountLocked: true}); + LockedProvider.useLockedAccountActions.mockReturnValue({showLockedAccountModal: mockShowLockedModal}); + + const {result} = renderSelectionModeHook(); + const blocked = result.current.shouldBlockAction(); + + expect(blocked).toBe(true); + expect(mockShowLockedModal).toHaveBeenCalled(); + }); + + it('returns false when no restrictions apply', () => { + const {result} = renderSelectionModeHook(); + const blocked = result.current.shouldBlockAction(); + + expect(blocked).toBe(false); + }); + }); + + describe('handleSubmitReport guards', () => { + it('does not submit when shouldBlockSubmit is true (preventSelfApproval)', () => { + mockPrimaryAction = CONST.REPORT.PRIMARY_ACTIONS.SUBMIT; + ReportUtils.isReportOwner.mockReturnValue(true); + ReportUtils.getNextApproverAccountID.mockReturnValue(TEST_ACCOUNT_ID); + + const {result} = renderSelectionModeHook({ + report: buildReport({ownerAccountID: TEST_ACCOUNT_ID, managerID: TEST_ACCOUNT_ID}), + policy: buildPolicy({preventSelfApproval: true}), + }); + + expect(result.current.shouldBlockSubmit).toBe(true); + const submitAction = result.current.selectionModeReportLevelActions.find((a) => a.value === CONST.REPORT.PRIMARY_ACTIONS.SUBMIT); + expect(submitAction).toBeUndefined(); + expect(IOUActions.submitReport).not.toHaveBeenCalled(); + }); + }); + + describe('confirmPayment branches', () => { + it('does not proceed when chatReport is undefined', () => { + const {result} = renderSelectionModeHook({chatReport: undefined}); + + act(() => { + result.current.confirmPayment({paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE}); + }); + + expect(IOUActions.payMoneyRequest).not.toHaveBeenCalled(); + }); + + it('shows delegate modal when delegate restricted during payment', () => { + const mockShowDelegateModal = jest.fn(); + DelegateProvider.useDelegateNoAccessState.mockReturnValue({isDelegateAccessRestricted: true}); + DelegateProvider.useDelegateNoAccessActions.mockReturnValue({showDelegateNoAccessModal: mockShowDelegateModal}); + + const {result} = renderSelectionModeHook(); + act(() => { + result.current.confirmPayment({paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE}); + }); + + expect(mockShowDelegateModal).toHaveBeenCalled(); + }); + + it('opens hold menu when there are held expenses during payment', () => { + ReportUtils.hasHeldExpenses.mockReturnValue(true); + + const {result} = renderSelectionModeHook(); + act(() => { + result.current.confirmPayment({paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE}); + }); + + expect(result.current.isHoldMenuVisible).toBe(true); + expect(result.current.requestType).toBe(CONST.IOU.REPORT_ACTION_TYPE.PAY); + expect(result.current.paymentType).toBe(CONST.IOU.PAYMENT_TYPE.ELSEWHERE); + }); + + it('calls payMoneyRequest for normal (non-invoice, non-hold) payment', () => { + const {result} = renderSelectionModeHook(); + act(() => { + result.current.confirmPayment({paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE}); + }); + + expect(IOUActions.payMoneyRequest).toHaveBeenCalled(); + expect(mockClearSelectedTransactions).toHaveBeenCalledWith(true); + }); + + it('calls payInvoice for invoice reports', () => { + ReportUtils.isInvoiceReport.mockReturnValue(true); + + const {result} = renderSelectionModeHook({ + report: buildReport({type: CONST.REPORT.TYPE.INVOICE}), + }); + act(() => { + result.current.confirmPayment({paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE}); + }); + + expect(IOUActions.payInvoice).toHaveBeenCalled(); + expect(mockClearSelectedTransactions).toHaveBeenCalledWith(true); + }); + }); + + describe('confirmApproval branches', () => { + it('opens hold menu when there are held expenses during approval', () => { + ReportUtils.hasHeldExpenses.mockReturnValue(true); + + const {result} = renderSelectionModeHook(); + act(() => { + result.current.confirmApproval(); + }); + + expect(result.current.isHoldMenuVisible).toBe(true); + expect(result.current.requestType).toBe(CONST.IOU.REPORT_ACTION_TYPE.APPROVE); + }); + + it('calls approveMoneyRequest directly when no held expenses', () => { + const {result} = renderSelectionModeHook(); + act(() => { + result.current.confirmApproval(); + }); + + expect(IOUActions.approveMoneyRequest).toHaveBeenCalled(); + expect(mockClearSelectedTransactions).toHaveBeenCalledWith(true); + }); + }); + + describe('handleHoldMenuClose', () => { + it('resets hold menu state', () => { + ReportUtils.hasHeldExpenses.mockReturnValue(true); + + const {result} = renderSelectionModeHook(); + + act(() => { + result.current.confirmPayment({paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE}); + }); + expect(result.current.isHoldMenuVisible).toBe(true); + + act(() => { + result.current.handleHoldMenuClose(); + }); + expect(result.current.isHoldMenuVisible).toBe(false); + }); + }); + + describe('handleHoldMenuConfirm', () => { + it('clears selected transactions', () => { + const {result} = renderSelectionModeHook(); + + result.current.handleHoldMenuConfirm(); + + expect(mockClearSelectedTransactions).toHaveBeenCalledWith(true); + }); + }); + + describe('selectionModeKYCSuccess', () => { + it('calls confirmPayment with the given payment type', () => { + const {result} = renderSelectionModeHook(); + act(() => { + result.current.selectionModeKYCSuccess(CONST.IOU.PAYMENT_TYPE.ELSEWHERE); + }); + + expect(IOUActions.payMoneyRequest).toHaveBeenCalled(); + }); + }); + + describe('shouldBlockSubmit', () => { + it('returns false by default', () => { + const {result} = renderSelectionModeHook(); + expect(result.current.shouldBlockSubmit).toBe(false); + }); + }); + + describe('isInvoiceReport', () => { + it('returns false for expense reports', () => { + const {result} = renderSelectionModeHook(); + expect(result.current.isInvoiceReport).toBe(false); + }); + + it('returns true for invoice reports', () => { + ReportUtils.isInvoiceReport.mockReturnValue(true); + + const {result} = renderSelectionModeHook({ + report: buildReport({type: CONST.REPORT.TYPE.INVOICE}), + }); + expect(result.current.isInvoiceReport).toBe(true); + }); + }); +});