From bd9f1ae5a56118994903532348957bbfba649699 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 3 Jun 2026 19:41:16 +0530 Subject: [PATCH 1/6] refactor: extract common selection mode logic into shared hooks Signed-off-by: krishna2323 --- .../MoneyReportHeaderSelectionDropdown.tsx | 285 ++--------- src/hooks/useLifecycleActions.tsx | 24 +- src/hooks/useSelectionModePayment.ts | 318 ++++++++++++ src/hooks/useSelectionModeReportActions.ts | 456 ++++-------------- src/libs/SearchRefreshUtils.ts | 28 ++ .../useSelectionModeReportActions.test.ts | 267 +++------- 6 files changed, 553 insertions(+), 825 deletions(-) create mode 100644 src/hooks/useSelectionModePayment.ts create mode 100644 src/libs/SearchRefreshUtils.ts diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx index 1473624a2728..cbb9c8555b75 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown.tsx @@ -1,72 +1,46 @@ import {useRoute} from '@react-navigation/native'; -import {delegateEmailSelector, isUserValidatedSelector} from '@selectors/Account'; -import {hasSeenTourSelector} from '@selectors/Onboarding'; -import truncate from 'lodash/truncate'; -import React, {useContext} from 'react'; +import React from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import type {ValueOf} from 'type-fest'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; 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 {ModalActions} from '@components/Modal/Global/ModalContext'; import MoneyReportHeaderKYCDropdown from '@components/MoneyReportHeaderKYCDropdown'; import {useMoneyReportHeaderModals} from '@components/MoneyReportHeaderModalsContext'; import {usePaymentAnimationsContext} from '@components/PaymentAnimationsContext'; import type {PopoverMenuItem} from '@components/PopoverMenu'; import BulkDuplicateHandler from '@components/Search/BulkDuplicateHandler'; -import {useSearchQueryContext, useSearchResultsContext, useSearchSelectionActions, useSearchSelectionContext} from '@components/Search/SearchContext'; -import type {PaymentActionParams} from '@components/SettlementButton/types'; -import useActiveAdminPolicies from '@hooks/useActiveAdminPolicies'; +import {useSearchSelectionActions, useSearchSelectionContext} from '@components/Search/SearchContext'; import useConfirmModal from '@hooks/useConfirmModal'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useEnvironment from '@hooks/useEnvironment'; import useExportActions from '@hooks/useExportActions'; -import useLastWorkspaceNumber from '@hooks/useLastWorkspaceNumber'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLifecycleActions from '@hooks/useLifecycleActions'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; -import useParticipantsInvoiceReport from '@hooks/useParticipantsInvoiceReport'; -import usePaymentOptions from '@hooks/usePaymentOptions'; -import usePermissions from '@hooks/usePermissions'; -import usePolicy from '@hooks/usePolicy'; import useReportIsArchived from '@hooks/useReportIsArchived'; -import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; import useSelectedTransactionsActions from '@hooks/useSelectedTransactionsActions'; +import useSelectionModePayment from '@hooks/useSelectionModePayment'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; import useTransactionThreadReport from '@hooks/useTransactionThreadReport'; -import {generateDefaultWorkspaceName} from '@libs/actions/Policy/Policy'; -import {search} from '@libs/actions/Search'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getTotalAmountForIOUReportPreviewButton} from '@libs/MoneyRequestReportUtils'; -import type {KYCFlowEvent, TriggerKYCFlow, WorkspacePolicyPaymentOption} from '@libs/PaymentUtils'; -import {handleUnvalidatedAccount, selectPaymentType} from '@libs/PaymentUtils'; -import {sortPoliciesByName} from '@libs/PolicyUtils'; -import {hasRequestFromCurrentAccount} from '@libs/ReportActionsUtils'; import {getSecondaryReportActions} from '@libs/ReportSecondaryActionUtils'; -import { - hasHeldExpensesFromTransactions, - hasUpdatedTotal, - hasViolations as hasViolationsReportUtils, - isInvoiceReport as isInvoiceReportUtil, - isIOUReport as isIOUReportUtil, -} from '@libs/ReportUtils'; +import {hasUpdatedTotal} from '@libs/ReportUtils'; import shouldPopoverUseScrollView from '@libs/shouldPopoverUseScrollView'; import {isTransactionPendingDelete} from '@libs/TransactionUtils'; -import {payInvoice, payMoneyRequest} from '@userActions/IOU/PayMoneyRequest'; import {canIOUBePaid as canIOUBePaidAction} from '@userActions/IOU/ReportWorkflow'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import {personalDetailsLoginSelector} from '@src/selectors/PersonalDetails'; -import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; -const PAYMENT_ICONS = ['Send', 'ThumbsUp', 'Cash', 'ArrowRight', 'Building'] as const; +const PAYMENT_ICONS = ['Send', 'ThumbsUp', 'Cash', 'ArrowRight'] as const; type MoneyReportHeaderSelectionDropdownProps = { reportID: string | undefined; @@ -82,35 +56,18 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn const openHoldMenu = (params: Parameters[0]) => { openHoldMenuAsync(params); }; - const {translate, localeCompare} = useLocalize(); + const {translate} = useLocalize(); const {isOffline} = useNetwork(); - const {isBetaEnabled} = usePermissions(); - const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); - const activeAdminPolicies = useActiveAdminPolicies(); - const lastWorkspaceNumber = useLastWorkspaceNumber(); - const {convertToDisplayString} = useCurrencyListActions(); const {selectedTransactionIDs} = useSearchSelectionContext(); - const {currentSearchQueryJSON, currentSearchKey} = useSearchQueryContext(); - const {currentSearchResults} = useSearchResultsContext(); const {clearSelectedTransactions} = useSearchSelectionActions(); - const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportID)}`); const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(moneyRequestReport?.chatReportID)}`); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(moneyRequestReport?.policyID)}`); const [submitterLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(moneyRequestReport?.ownerAccountID)}, [moneyRequestReport?.ownerAccountID]); const [session] = useOnyx(ONYXKEYS.SESSION); - const [isUserValidated] = useOnyx(ONYXKEYS.ACCOUNT, {selector: isUserValidatedSelector}); - const [delegateEmail] = useOnyx(ONYXKEYS.ACCOUNT, {selector: delegateEmailSelector}); const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); - const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); - const [nextStep] = useOnyx(`${ONYXKEYS.COLLECTION.NEXT_STEP}${getNonEmptyStringOnyxID(moneyRequestReport?.reportID)}`); - const [betas] = useOnyx(ONYXKEYS.BETAS); - const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); - const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); - const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); - const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${getNonEmptyStringOnyxID(moneyRequestReport?.reportID)}`); const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${getNonEmptyStringOnyxID(moneyRequestReport?.reportID)}`); @@ -120,37 +77,15 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn `${ONYXKEYS.COLLECTION.POLICY}${chatReport?.invoiceReceiver && 'policyID' in chatReport.invoiceReceiver ? chatReport.invoiceReceiver.policyID : undefined}`, ); - const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); - const [isSelfTourViewed = false] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); - const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); - const activePolicy = usePolicy(activePolicyID); - const chatReportPolicy = usePolicy(chatReport?.policyID); - const existingB2BInvoiceReport = useParticipantsInvoiceReport(activePolicyID, CONST.REPORT.INVOICE_RECEIVER_TYPE.BUSINESS, chatReport?.policyID); - const isInvoiceReport = isInvoiceReportUtil(moneyRequestReport); - - const {transactionThreadReportID, reportActions} = useTransactionThreadReport(reportID); - - const {transactions: reportTransactions, violations} = useTransactionsAndViolationsForReport(moneyRequestReport?.reportID); - - const allTransactionValues = Object.values(reportTransactions); - const transactions = allTransactionValues; - const nonPendingDeleteTransactions = allTransactionValues.filter((t) => !isTransactionPendingDelete(t)); - const singleTransaction = nonPendingDeleteTransactions.length === 1 ? nonPendingDeleteTransactions.at(0) : undefined; - const [originalTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(singleTransaction?.comment?.originalTransactionID)}`); - - const {accountID, email, login: currentUserLogin, localCurrencyCode} = useCurrentUserPersonalDetails(); - const hasViolations = hasViolationsReportUtils(moneyRequestReport?.reportID, allTransactionViolations, accountID, email ?? ''); + const {accountID, login: currentUserLogin} = useCurrentUserPersonalDetails(); + const {convertToDisplayString} = useCurrencyListActions(); const isChatReportArchived = useReportIsArchived(chatReport?.reportID); - const isAnyTransactionOnHold = hasHeldExpensesFromTransactions(transactions); - const {isDelegateAccessRestricted} = useDelegateNoAccessState(); const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); - const {isAccountLocked} = useLockedAccountState(); - const {showLockedAccountModal} = useLockedAccountActions(); - const kycWallRef = useContext(KYCWallContext); const {showConfirmModal} = useConfirmModal(); + const {isProduction} = useEnvironment(); const expensifyIcons = useMemoizedLazyExpensifyIcons(PAYMENT_ICONS); @@ -159,6 +94,16 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn policy, }); + const {transactionThreadReportID, reportActions} = useTransactionThreadReport(reportID); + const {transactions: reportTransactions, violations} = useTransactionsAndViolationsForReport(moneyRequestReport?.reportID); + + const allTransactionValues = Object.values(reportTransactions); + const transactions = allTransactionValues; + const nonPendingDeleteTransactions = allTransactionValues.filter((t) => !isTransactionPendingDelete(t)); + const singleTransaction = nonPendingDeleteTransactions.length === 1 ? nonPendingDeleteTransactions.at(0) : undefined; + const [originalTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(singleTransaction?.comment?.originalTransactionID)}`); + + // Submit/approve via shared lifecycle actions const {confirmApproval, handleSubmitReport, shouldBlockSubmit, isBlockSubmitDueToPreventSelfApproval} = useLifecycleActions({ reportID, startApprovedAnimation, @@ -195,8 +140,6 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn isOnSearch: !!isReportInSearch, }); - const {isProduction} = useEnvironment(); - const computedSecondaryActions = moneyRequestReport ? getSecondaryReportActions({ currentUserLogin: currentUserLogin ?? '', @@ -223,147 +166,41 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn const hasApproveAction = primaryAction === CONST.REPORT.PRIMARY_ACTIONS.APPROVE || computedSecondaryActions.includes(CONST.REPORT.SECONDARY_ACTIONS.APPROVE); const hasPayAction = primaryAction === CONST.REPORT.PRIMARY_ACTIONS.PAY || computedSecondaryActions.includes(CONST.REPORT.SECONDARY_ACTIONS.PAY); - const checkForNecessaryAction = (paymentMethodType?: PaymentMethodType) => { - if (isDelegateAccessRestricted) { - showDelegateNoAccessModal(); - return true; - } - if (isAccountLocked) { - showLockedAccountModal(); - return true; - } - if (!isUserValidated && paymentMethodType !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE) { - handleUnvalidatedAccount(moneyRequestReport); - return true; - } - return false; - }; - const canAllowSettlement = hasUpdatedTotal(moneyRequestReport, policy); - const totalAmount = getTotalAmountForIOUReportPreviewButton(moneyRequestReport, policy, CONST.REPORT.PRIMARY_ACTIONS.PAY, nonPendingDeleteTransactions, convertToDisplayString); const canIOUBePaid = canIOUBePaidAction(moneyRequestReport, chatReport, policy, bankAccountList, currentUserLogin ?? '', accountID, undefined, false, undefined, invoiceReceiverPolicy); const onlyShowPayElsewhere = !canIOUBePaid && canIOUBePaidAction(moneyRequestReport, chatReport, policy, bankAccountList, currentUserLogin ?? '', accountID, undefined, true, undefined, invoiceReceiverPolicy); const isPayable = hasPayAction && canIOUBePaid; - const confirmPayment = ({paymentType: type, payAsBusiness, methodID, paymentMethod}: PaymentActionParams) => { - if (!type || !chatReport) { - return; - } - - if (isDelegateAccessRestricted) { - showDelegateNoAccessModal(); - return; - } - - if (isAnyTransactionOnHold) { - openHoldMenu({ - requestType: CONST.IOU.REPORT_ACTION_TYPE.PAY, - paymentType: type, - methodID: type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, - onConfirm: () => clearSelectedTransactions(true), - }); - return; - } - - if (isInvoiceReport) { - payInvoice({ - paymentMethodType: type, - chatReport, - invoiceReport: moneyRequestReport, - invoiceReportCurrentNextStepDeprecated: nextStep, - introSelected, - currentUserAccountIDParam: accountID, - currentUserEmailParam: email ?? '', - currentUserLocalCurrency: localCurrencyCode ?? CONST.CURRENCY.USD, - payAsBusiness, - existingB2BInvoiceReport, - methodID, - paymentMethod, - activePolicy, - betas, - isSelfTourViewed, - defaultWorkspaceName: generateDefaultWorkspaceName(email ?? '', lastWorkspaceNumber, translate), - }); - } else { - payMoneyRequest({ - paymentType: type, - chatReport, - iouReport: moneyRequestReport, - introSelected, - iouReportCurrentNextStepDeprecated: nextStep, - currentUserAccountID: accountID, - currentUserLogin: currentUserLogin ?? '', - activePolicy, - policy, - chatReportPolicy, - betas, - isSelfTourViewed, - userBillingGracePeriodEnds, - amountOwed, - ownerBillingGracePeriodEnd, - methodID: type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, - conciergeReportID, - onPaid: () => { - startAnimation(); - }, - }); - if (currentSearchQueryJSON && !isOffline) { - search({ - searchKey: currentSearchKey, - shouldCalculateTotals, - offset: 0, - queryJSON: currentSearchQueryJSON, - isOffline, - isLoading: !!currentSearchResults?.search?.isLoading, - }); - } - } - - clearSelectedTransactions(true); - }; + const totalAmount = getTotalAmountForIOUReportPreviewButton(moneyRequestReport, policy, CONST.REPORT.PRIMARY_ACTIONS.PAY, nonPendingDeleteTransactions, convertToDisplayString); + const allExpensesSelected = selectedTransactionIDs.length > 0 && selectedTransactionIDs.length === nonPendingDeleteTransactions.length; - const paymentButtonOptions = usePaymentOptions({ - currency: moneyRequestReport?.currency, - iouReport: moneyRequestReport, - chatReportID: chatReport?.reportID, + // Shared payment hook + const {shouldBlockAction, onSelectionModePaymentSelect, selectionModeKYCSuccess, paymentSubMenuItems, hasPayInSelectionMode, kycWallRef} = useSelectionModePayment({ + reportID, + transactions, formattedAmount: totalAmount, - policyID: moneyRequestReport?.policyID, - onPress: confirmPayment, shouldHidePaymentOptions: !isPayable, - shouldShowApproveButton: false, - shouldDisableApproveButton: false, onlyShowPayElsewhere, + hasPayAction, + allExpensesSelected, + onHoldMenuOpen: ({requestType, paymentType, methodID}) => { + openHoldMenu({ + requestType, + paymentType, + methodID, + onConfirm: () => clearSelectedTransactions(true), + }); + }, + onPaymentComplete: () => { + clearSelectedTransactions(true); + }, + onPaid: () => { + startAnimation(); + }, + confirmApproval: () => confirmApproval(), }); - const hasPersonalPaymentOption = paymentButtonOptions.some((opt) => opt.value === CONST.IOU.PAYMENT_TYPE.EXPENSIFY); - const canUseBusinessBankAccount = !!moneyRequestReport?.reportID && !hasRequestFromCurrentAccount(moneyRequestReport, accountID ?? CONST.DEFAULT_NUMBER_ID); - const workspacePolicyOptions = - isIOUReportUtil(moneyRequestReport) && hasPersonalPaymentOption && activeAdminPolicies.length && canUseBusinessBankAccount - ? sortPoliciesByName(activeAdminPolicies, localeCompare) - : []; - - // Workspace-policy entries carry the policy as data with no onSelected. - // MoneyReportHeaderKYCDropdown picks them up via onSubItemSelected where triggerKYCFlow is in scope - const paymentSubMenuItems: PopoverMenuItem[] = []; - if (!workspacePolicyOptions.length) { - paymentSubMenuItems.push(...Object.values(paymentButtonOptions)); - } else { - for (const opt of Object.values(paymentButtonOptions)) { - paymentSubMenuItems.push(opt); - if (opt.value === CONST.IOU.PAYMENT_TYPE.EXPENSIFY) { - for (const wp of workspacePolicyOptions) { - const workspacePolicyItem: WorkspacePolicyPaymentOption = { - text: translate('iou.payWithPolicy', truncate(wp.name, {length: CONST.ADDITIONAL_ALLOWED_CHARACTERS}), ''), - icon: expensifyIcons.Building, - workspacePolicy: wp, - }; - paymentSubMenuItems.push(workspacePolicyItem); - } - } - } - } - const showDeleteModal = () => { showConfirmModal({ title: translate('iou.deleteExpense', {count: selectedTransactionIDs.length}), @@ -387,10 +224,6 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn }); }; - const allExpensesSelected = selectedTransactionIDs.length > 0 && selectedTransactionIDs.length === nonPendingDeleteTransactions.length; - - // Ref writes below are inside onSelected callbacks that only fire on user interaction, never during render. - const selectionModeReportLevelActions: Array & Pick> = [ ...(hasSubmitAction && !shouldBlockSubmit ? [ @@ -456,38 +289,6 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn const popoverUseScrollView = shouldPopoverUseScrollView(selectedTransactionsOptions); - const hasActualPaymentOptions = paymentButtonOptions.some((opt) => Object.values(CONST.IOU.PAYMENT_TYPE).some((type) => type === opt.value)); - const hasPayInSelectionMode = allExpensesSelected && hasPayAction && hasActualPaymentOptions; - - const onSelectionModePaymentSelect = (event: KYCFlowEvent, iouPaymentType: PaymentMethodType, triggerKYCFlow: TriggerKYCFlow) => { - if (checkForNecessaryAction(iouPaymentType)) { - return; - } - selectPaymentType({ - event, - iouPaymentType, - triggerKYCFlow, - expenseReportPolicy: policy, - policy, - onPress: confirmPayment, - currentAccountID: accountID, - currentEmail: email ?? '', - hasViolations, - isASAPSubmitBetaEnabled, - isUserValidated, - confirmApproval: () => confirmApproval(), - iouReport: moneyRequestReport, - iouReportNextStep: nextStep, - betas, - userBillingGracePeriodEnds, - amountOwed, - ownerBillingGracePeriodEnd, - delegateEmail, - }); - }; - - const selectionModeKYCSuccess = (type?: PaymentMethodType) => confirmPayment({paymentType: type}); - if (!selectedTransactionsOptions.length || transactionThreadReportID) { return null; } @@ -512,7 +313,7 @@ function MoneyReportHeaderSelectionDropdown({reportID, primaryAction, isReportIn iouReport={moneyRequestReport} onPaymentSelect={onSelectionModePaymentSelect} onWorkspacePolicySelect={(selectedPolicy, triggerKYCFlow) => { - if (checkForNecessaryAction()) { + if (shouldBlockAction()) { return; } triggerKYCFlow({policy: selectedPolicy}); diff --git a/src/hooks/useLifecycleActions.tsx b/src/hooks/useLifecycleActions.tsx index d53d292ee079..d0f99ebb545a 100644 --- a/src/hooks/useLifecycleActions.tsx +++ b/src/hooks/useLifecycleActions.tsx @@ -6,7 +6,6 @@ import {ModalActions} from '@components/Modal/Global/ModalContext'; import type {SecondaryActionEntry} from '@components/MoneyReportHeaderActions/types'; import {useSearchQueryContext, useSearchResultsContext, useSearchSelectionActions} from '@components/Search/SearchContext'; import Text from '@components/Text'; -import {search} from '@libs/actions/Search'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getValidConnectedIntegration} from '@libs/PolicyUtils'; import {getFilteredReportActionsForReportView} from '@libs/ReportActionsUtils'; @@ -19,6 +18,7 @@ import { isReportOwner, shouldBlockSubmitDueToStrictPolicyRules, } from '@libs/ReportUtils'; +import refreshSearchAfterReportAction from '@libs/SearchRefreshUtils'; import {hasAnyPendingRTERViolation as hasAnyPendingRTERViolationTransactionUtils, hasOnlyPendingCardTransactions, showPendingCardTransactionsBlockModal} from '@libs/TransactionUtils'; import {cancelPayment, markReportPaymentReceived} from '@userActions/IOU/PayMoneyRequest'; import {approveMoneyRequest, reopenReport, retractReport, submitReport, unapproveExpenseReport} from '@userActions/IOU/ReportWorkflow'; @@ -46,6 +46,7 @@ type UseLifecycleActionsParams = { startAnimation: () => void; startSubmittingAnimation: () => void; onHoldMenuOpen: (requestType: ActionHandledType, onConfirm?: () => void, paymentType?: PaymentMethodType) => void; + onCleanup?: () => void; }; type UseLifecycleActionsResult = { @@ -60,7 +61,7 @@ type UseLifecycleActionsResult = { * Provides report lifecycle transition actions (submit, approve, unapprove, cancel payment, retract, reopen) * and their associated guards (delegate access, hold, pending RTER, strict policy rules). */ -function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, startSubmittingAnimation, onHoldMenuOpen}: UseLifecycleActionsParams): UseLifecycleActionsResult { +function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, startSubmittingAnimation, onHoldMenuOpen, onCleanup}: UseLifecycleActionsParams): UseLifecycleActionsResult { const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(moneyRequestReport?.policyID)}`); const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(moneyRequestReport?.chatReportID)}`); @@ -174,6 +175,7 @@ function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, }); if (skipAnimation) { clearSelectedTransactions(true); + onCleanup?.(); } }; @@ -207,18 +209,16 @@ function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, ownerBillingGracePeriodEnd, delegateEmail, }); - if (currentSearchQueryJSON && !isOffline) { - search({ - searchKey: currentSearchKey, - shouldCalculateTotals, - offset: 0, - queryJSON: currentSearchQueryJSON, - isOffline, - isLoading: !!currentSearchResults?.search?.isLoading, - }); - } + refreshSearchAfterReportAction({ + currentSearchQueryJSON, + currentSearchKey, + shouldCalculateTotals, + isOffline, + isLoading: !!currentSearchResults?.search?.isLoading, + }); if (skipAnimation) { clearSelectedTransactions(true); + onCleanup?.(); } }; diff --git a/src/hooks/useSelectionModePayment.ts b/src/hooks/useSelectionModePayment.ts new file mode 100644 index 000000000000..030a260b6492 --- /dev/null +++ b/src/hooks/useSelectionModePayment.ts @@ -0,0 +1,318 @@ +import {delegateEmailSelector, isUserValidatedSelector} from '@selectors/Account'; +import {hasSeenTourSelector} from '@selectors/Onboarding'; +import truncate from 'lodash/truncate'; +import {useContext, useEffect, useRef} from 'react'; +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 {useSearchQueryContext, useSearchResultsContext} from '@components/Search/SearchContext'; +import type {PaymentActionParams} from '@components/SettlementButton/types'; +import {payInvoice, payMoneyRequest} from '@libs/actions/IOU/PayMoneyRequest'; +import {generateDefaultWorkspaceName} from '@libs/actions/Policy/Policy'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import type {KYCFlowEvent, TriggerKYCFlow, WorkspacePolicyPaymentOption} from '@libs/PaymentUtils'; +import {handleUnvalidatedAccount, selectPaymentType} from '@libs/PaymentUtils'; +import {sortPoliciesByName} from '@libs/PolicyUtils'; +import {hasRequestFromCurrentAccount} from '@libs/ReportActionsUtils'; +import {hasHeldExpensesFromTransactions, hasViolations as hasViolationsReportUtils, isInvoiceReport as isInvoiceReportUtil, isIOUReport as isIOUReportUtil} from '@libs/ReportUtils'; +import refreshSearchAfterReportAction from '@libs/SearchRefreshUtils'; +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 useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import useLastWorkspaceNumber from './useLastWorkspaceNumber'; +import {useMemoizedLazyExpensifyIcons} from './useLazyAsset'; +import useLocalize from './useLocalize'; +import useNetwork from './useNetwork'; +import useOnyx from './useOnyx'; +import useParticipantsInvoiceReport from './useParticipantsInvoiceReport'; +import usePaymentOptions from './usePaymentOptions'; +import usePermissions from './usePermissions'; +import usePolicy from './usePolicy'; +import useSearchShouldCalculateTotals from './useSearchShouldCalculateTotals'; + +type HoldMenuOpenParams = { + requestType: ActionHandledType; + paymentType: PaymentMethodType; + methodID?: number; +}; + +type UseSelectionModePaymentParams = { + reportID: string | undefined; + transactions: OnyxTypes.Transaction[]; + formattedAmount: string; + shouldHidePaymentOptions: boolean; + onlyShowPayElsewhere: boolean; + hasPayAction: boolean; + allExpensesSelected: boolean; + onHoldMenuOpen: (params: HoldMenuOpenParams) => void; + onPaymentComplete: () => void; + onPaid?: () => void; + confirmApproval: () => void; +}; + +function useSelectionModePayment({ + reportID, + transactions, + formattedAmount, + shouldHidePaymentOptions, + onlyShowPayElsewhere, + hasPayAction, + allExpensesSelected, + onHoldMenuOpen, + onPaymentComplete, + onPaid, + confirmApproval, +}: UseSelectionModePaymentParams) { + const {translate, localeCompare} = useLocalize(); + const {isOffline} = useNetwork(); + const {isBetaEnabled} = usePermissions(); + const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); + const activeAdminPolicies = useActiveAdminPolicies(); + const lastWorkspaceNumber = useLastWorkspaceNumber(); + + const {currentSearchQueryJSON, currentSearchKey} = useSearchQueryContext(); + const {currentSearchResults} = useSearchResultsContext(); + const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); + + const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportID)}`); + const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(moneyRequestReport?.chatReportID)}`); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(moneyRequestReport?.policyID)}`); + 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}${getNonEmptyStringOnyxID(moneyRequestReport?.reportID)}`); + const [betas] = useOnyx(ONYXKEYS.BETAS); + const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); + const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); + const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const [isSelfTourViewed = false] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + + const {accountID, login: currentUserLogin, localCurrencyCode} = useCurrentUserPersonalDetails(); + const email = session?.email; + + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); + const activePolicy = usePolicy(activePolicyID); + const chatReportPolicy = usePolicy(chatReport?.policyID); + const existingB2BInvoiceReport = useParticipantsInvoiceReport(activePolicyID, CONST.REPORT.INVOICE_RECEIVER_TYPE.BUSINESS, chatReport?.policyID); + + const {isDelegateAccessRestricted} = useDelegateNoAccessState(); + const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); + const {isAccountLocked} = useLockedAccountState(); + const {showLockedAccountModal} = useLockedAccountActions(); + const kycWallRef = useContext(KYCWallContext); + + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Cash', 'ArrowRight', 'Building'] as const); + + const hasViolations = hasViolationsReportUtils(moneyRequestReport?.reportID, allTransactionViolations, accountID, email ?? ''); + const isInvoiceReport = isInvoiceReportUtil(moneyRequestReport); + const isAnyTransactionOnHold = hasHeldExpensesFromTransactions(transactions); + + const shouldBlockAction = (paymentMethodType?: PaymentMethodType) => { + if (isDelegateAccessRestricted) { + showDelegateNoAccessModal(); + return true; + } + if (isAccountLocked) { + showLockedAccountModal(); + return true; + } + if (!isUserValidated && paymentMethodType !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE) { + handleUnvalidatedAccount(moneyRequestReport); + return true; + } + return false; + }; + + const confirmPaymentRef = useRef<(params: PaymentActionParams) => void>(() => {}); + + const confirmPayment = ({paymentType: type, payAsBusiness, methodID, paymentMethod}: PaymentActionParams) => { + if (!type || !chatReport) { + return; + } + + if (isDelegateAccessRestricted) { + showDelegateNoAccessModal(); + return; + } + + if (isAnyTransactionOnHold) { + onHoldMenuOpen({ + requestType: CONST.IOU.REPORT_ACTION_TYPE.PAY, + paymentType: type, + methodID: type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, + }); + return; + } + + if (isInvoiceReport) { + payInvoice({ + paymentMethodType: type, + chatReport, + invoiceReport: moneyRequestReport, + invoiceReportCurrentNextStepDeprecated: nextStep, + introSelected, + currentUserAccountIDParam: accountID, + currentUserEmailParam: email ?? '', + currentUserLocalCurrency: localCurrencyCode ?? CONST.CURRENCY.USD, + payAsBusiness, + existingB2BInvoiceReport, + methodID, + paymentMethod, + activePolicy, + betas, + isSelfTourViewed, + defaultWorkspaceName: generateDefaultWorkspaceName(email ?? '', lastWorkspaceNumber, translate), + }); + } else { + payMoneyRequest({ + paymentType: type, + chatReport, + iouReport: moneyRequestReport, + introSelected, + iouReportCurrentNextStepDeprecated: nextStep, + currentUserAccountID: accountID, + currentUserLogin: currentUserLogin ?? '', + activePolicy, + policy, + chatReportPolicy, + betas, + isSelfTourViewed, + userBillingGracePeriodEnds, + amountOwed, + ownerBillingGracePeriodEnd, + methodID: type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, + conciergeReportID, + onPaid, + }); + refreshSearchAfterReportAction({ + currentSearchQueryJSON, + currentSearchKey, + shouldCalculateTotals, + isOffline, + isLoading: !!currentSearchResults?.search?.isLoading, + }); + } + + onPaymentComplete(); + }; + + useEffect(() => { + confirmPaymentRef.current = confirmPayment; + }); + + const paymentButtonOptions = usePaymentOptions({ + currency: moneyRequestReport?.currency, + iouReport: moneyRequestReport, + chatReportID: chatReport?.reportID, + formattedAmount, + policyID: moneyRequestReport?.policyID, + onPress: (params: PaymentActionParams) => confirmPaymentRef.current(params), + shouldHidePaymentOptions, + shouldShowApproveButton: false, + shouldDisableApproveButton: false, + onlyShowPayElsewhere, + }); + + const workspacePolicyOptions = (() => { + if (!isIOUReportUtil(moneyRequestReport)) { + return []; + } + const hasPersonalPaymentOption = paymentButtonOptions.some((opt) => opt.value === CONST.IOU.PAYMENT_TYPE.EXPENSIFY); + if (!hasPersonalPaymentOption || !activeAdminPolicies.length) { + return []; + } + const canUseBusinessBankAccount = !!moneyRequestReport?.reportID && !hasRequestFromCurrentAccount(moneyRequestReport, accountID ?? CONST.DEFAULT_NUMBER_ID); + if (!canUseBusinessBankAccount) { + return []; + } + return sortPoliciesByName(activeAdminPolicies, localeCompare); + })(); + + const paymentSubMenuItems: PopoverMenuItem[] = (() => { + if (!workspacePolicyOptions.length) { + return [...Object.values(paymentButtonOptions)]; + } + const result: PopoverMenuItem[] = []; + for (const opt of Object.values(paymentButtonOptions)) { + result.push(opt); + if (opt.value === CONST.IOU.PAYMENT_TYPE.EXPENSIFY) { + for (const wp of workspacePolicyOptions) { + const workspacePolicyItem: WorkspacePolicyPaymentOption = { + text: translate('iou.payWithPolicy', truncate(wp.name, {length: CONST.ADDITIONAL_ALLOWED_CHARACTERS}), ''), + icon: expensifyIcons.Building, + workspacePolicy: wp, + }; + result.push(workspacePolicyItem); + } + } + } + return result; + })(); + + const handleWorkspaceSelected = (wp: OnyxTypes.Policy) => { + if (shouldBlockAction()) { + return; + } + kycWallRef.current?.continueAction?.({policy: wp}); + }; + + const onSelectionModePaymentSelect = (event: KYCFlowEvent, iouPaymentType: PaymentMethodType, triggerKYCFlow: TriggerKYCFlow) => { + if (shouldBlockAction(iouPaymentType)) { + return; + } + selectPaymentType({ + event, + iouPaymentType, + triggerKYCFlow, + policy, + onPress: confirmPayment, + currentAccountID: accountID, + currentEmail: email ?? '', + hasViolations, + isASAPSubmitBetaEnabled, + isUserValidated, + confirmApproval, + iouReport: moneyRequestReport, + iouReportNextStep: nextStep, + betas, + userBillingGracePeriodEnds, + amountOwed, + ownerBillingGracePeriodEnd, + delegateEmail, + expenseReportPolicy: policy, + }); + }; + + 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; + + return { + confirmPayment, + shouldBlockAction, + onSelectionModePaymentSelect, + selectionModeKYCSuccess, + paymentSubMenuItems, + workspacePolicyOptions, + handleWorkspaceSelected, + hasPayInSelectionMode, + hasActualPaymentOptions, + isAnyTransactionOnHold, + isInvoiceReport, + kycWallRef, + }; +} + +export default useSelectionModePayment; +export type {HoldMenuOpenParams}; diff --git a/src/hooks/useSelectionModeReportActions.ts b/src/hooks/useSelectionModeReportActions.ts index 6da23083973c..ce0a4d164344 100644 --- a/src/hooks/useSelectionModeReportActions.ts +++ b/src/hooks/useSelectionModeReportActions.ts @@ -1,69 +1,34 @@ -import {delegateEmailSelector, isUserValidatedSelector} from '@selectors/Account'; -import {hasSeenTourSelector} from '@selectors/Onboarding'; -import truncate from 'lodash/truncate'; -import {useContext, useEffect, useRef, useState} from 'react'; +import {useState} from 'react'; // eslint-disable-next-line no-restricted-imports 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 {useSearchQueryContext, useSearchResultsContext, useSearchSelectionActions} from '@components/Search/SearchContext'; -import type {PaymentActionParams} from '@components/SettlementButton/types'; -import {payInvoice, payMoneyRequest} from '@libs/actions/IOU/PayMoneyRequest'; -import {approveMoneyRequest, canApproveIOU, canIOUBePaid as canIOUBePaidAction, submitReport} from '@libs/actions/IOU/ReportWorkflow'; +import {useSearchSelectionActions} from '@components/Search/SearchContext'; +import {canIOUBePaid as canIOUBePaidAction} from '@libs/actions/IOU/ReportWorkflow'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; -import {generateDefaultWorkspaceName} from '@libs/actions/Policy/Policy'; -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, - hasHeldExpensesFromTransactions as hasHeldExpensesReportUtils, - hasOnlyHeldExpenses as hasOnlyHeldExpensesReportUtils, - hasUpdatedTotal, - hasViolations as hasViolationsReportUtils, - isAllowedToApproveExpenseReport, - isInvoiceReport as isInvoiceReportUtil, - isIOUReport as isIOUReportUtil, - isReportOwner, - shouldBlockSubmitDueToStrictPolicyRules, -} from '@libs/ReportUtils'; -import {hasAnyPendingRTERViolation as hasAnyPendingRTERViolationTransactionUtils, hasOnlyPendingCardTransactions, showPendingCardTransactionsBlockModal} from '@libs/TransactionUtils'; -import {markPendingRTERTransactionsAsCash} from '@userActions/Transaction'; +import {getNonHeldAndFullAmount, hasOnlyHeldExpenses as hasOnlyHeldExpensesReportUtils, hasUpdatedTotal} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {personalDetailsLoginSelector} from '@src/selectors/PersonalDetails'; import type * as OnyxTypes from '@src/types/onyx'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; -import useActiveAdminPolicies from './useActiveAdminPolicies'; -import useConfirmModal from './useConfirmModal'; -import useConfirmPendingRTERAndProceed from './useConfirmPendingRTERAndProceed'; import {useCurrencyListActions} from './useCurrencyList'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useEnvironment from './useEnvironment'; -import useLastWorkspaceNumber from './useLastWorkspaceNumber'; import {useMemoizedLazyExpensifyIcons} from './useLazyAsset'; +import useLifecycleActions from './useLifecycleActions'; import useLocalize from './useLocalize'; import useNetwork from './useNetwork'; 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'; +import useSelectionModePayment from './useSelectionModePayment'; type UseSelectionModeReportActionsParams = { report: OnyxEntry; @@ -86,141 +51,79 @@ function useSelectionModeReportActions({ transactions, selectedTransactionIDs, }: UseSelectionModeReportActionsParams) { - const {translate, localeCompare} = useLocalize(); - const {showConfirmModal} = useConfirmModal(); - const {accountID: currentUserAccountID, login: currentUserLogin, localCurrencyCode} = 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} = useSearchQueryContext(); - const {currentSearchResults} = useSearchResultsContext(); + const {translate} = useLocalize(); + const {isOffline} = useNetwork(); + const {convertToDisplayString} = useCurrencyListActions(); + const {accountID: currentUserAccountID, login: currentUserLogin} = useCurrentUserPersonalDetails(); const {clearSelectedTransactions} = useSearchSelectionActions(); - 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 [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); 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 [submitterLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(report?.ownerAccountID)}, [report?.ownerAccountID]); - const {isOffline} = useNetwork(); - const {isProduction} = useEnvironment(); - - const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); - const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); - const activePolicy = usePolicy(activePolicyID); - const chatReportPolicy = usePolicy(chatReport?.policyID); 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 lastWorkspaceNumber = useLastWorkspaceNumber(); - const {convertToDisplayString} = useCurrencyListActions(); + const {isProduction} = useEnvironment(); const isChatReportArchived = useReportIsArchived(chatReport?.reportID); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['Send', 'ThumbsUp', 'Cash', 'ArrowRight', 'Building'] as const); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Send', 'ThumbsUp', 'Cash', 'ArrowRight'] as const); - const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); - - const currentUserEmail = session?.email; - const hasViolations = hasViolationsReportUtils(report?.reportID, allTransactionViolations, currentUserAccountID, currentUserEmail ?? ''); + // Hold menu state (managed locally on mobile; desktop uses modals context) + const [isHoldMenuVisible, setIsHoldMenuVisible] = useState(false); + const [paymentType, setPaymentType] = useState(); + const [requestType, setRequestType] = useState(); + const [selectedVBBAToPayFromHoldMenu, setSelectedVBBAToPayFromHoldMenu] = useState(undefined); - const hasAnyPendingRTERViolation = hasAnyPendingRTERViolationTransactionUtils(transactions, allTransactionViolations, currentUserEmail ?? '', currentUserAccountID, report, policy); + // Submit/approve via shared useLifecycleActions (no animations on mobile, skipAnimation=true) + const { + confirmApproval: lifecycleConfirmApproval, + handleSubmitReport: lifecycleHandleSubmitReport, + shouldBlockSubmit, + isBlockSubmitDueToPreventSelfApproval, + } = useLifecycleActions({ + reportID: report?.reportID, + startApprovedAnimation: () => {}, + startAnimation: () => {}, + startSubmittingAnimation: () => {}, + onHoldMenuOpen: (rt, _onConfirm, pt) => { + setRequestType(rt); + if (pt) { + setPaymentType(pt); + } + setIsHoldMenuVisible(true); + }, + onCleanup: turnOffMobileSelectionMode, + }); - const handleMarkPendingRTERTransactionsAsCash = () => { - markPendingRTERTransactionsAsCash(transactions, allTransactionViolations, reportActions); + const handleSubmitReport = () => { + lifecycleHandleSubmitReport(true); }; - 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(transactions); - const isInvoiceReport = isInvoiceReportUtil(report); + const confirmApproval = () => { + lifecycleConfirmApproval(true); + }; - const hasOnlyPendingTransactions = hasOnlyPendingCardTransactions(transactions); + // Payment flow const nonPendingDeleteTransactions = transactions.filter((t): t is OnyxTypes.Transaction => !!t && (isOffline || t.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE)); const getCanIOUBePaid = (onlyShowPayElsewhere = false) => canIOUBePaidAction(report, chatReport, policy, bankAccountList, currentUserLogin ?? '', currentUserAccountID, transactions, onlyShowPayElsewhere, undefined, invoiceReceiverPolicy); - const canIOUBePaid = getCanIOUBePaid(); const onlyShowPayElsewhere = !canIOUBePaid && getCanIOUBePaid(true); - const shouldShowPayButton = canIOUBePaid || onlyShowPayElsewhere; - - const {nonHeldAmount, fullAmount, hasValidNonHeldAmount} = getNonHeldAndFullAmount(report, shouldShowPayButton, transactions); - - const shouldShowApproveButton = canApproveIOU(report, policy, reportMetadata, currentUserAccountID, transactions) && !hasOnlyPendingTransactions; - - const shouldDisableApproveButton = shouldShowApproveButton && !isAllowedToApproveExpenseReport(report); + const canAllowSettlement = hasUpdatedTotal(report, policy); const totalAmount = getTotalAmountForIOUReportPreviewButton(report, policy, CONST.REPORT.PRIMARY_ACTIONS.PAY, nonPendingDeleteTransactions, convertToDisplayString); + const {nonHeldAmount, fullAmount, hasValidNonHeldAmount} = getNonHeldAndFullAmount(report, shouldShowPayButton, transactions); - // 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, currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID); - if (!canUseBusinessBankAccount) { - return []; - } - - return sortPoliciesByName(activeAdminPolicies, localeCompare); - })(); + // Primary/secondary action detection + const currentUserEmail = currentUserLogin ?? ''; const primaryAction = getReportPrimaryAction({ - currentUserLogin: currentUserEmail ?? '', + currentUserLogin: currentUserEmail, currentUserAccountID, report, chatReport, @@ -240,7 +143,7 @@ function useSelectionModeReportActions({ return []; } return getSecondaryReportActions({ - currentUserLogin: currentUserEmail ?? '', + currentUserLogin: currentUserEmail, currentUserAccountID, submitterLogin, report, @@ -266,248 +169,53 @@ function useSelectionModeReportActions({ 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 = (paymentMethodType?: PaymentMethodType) => { - if (isDelegateAccessRestricted) { - showDelegateNoAccessModal(); - return true; - } - if (isAccountLocked) { - showLockedAccountModal(); - return true; - } - if (!isUserValidated && paymentMethodType !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE) { - handleUnvalidatedAccount(report); - return true; - } - return false; - }; - - const handleSubmitReport = () => { - if (!report || shouldBlockSubmit) { - return; - } - if (hasOnlyPendingTransactions) { - showPendingCardTransactionsBlockModal(showConfirmModal, translate); - 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, - expenseReportPolicy: policy, - }); - clearSelectedTransactions(true); - turnOffMobileSelectionMode(); - } - }; - - const confirmPayment = ({paymentType: type, payAsBusiness, methodID, paymentMethod}: PaymentActionParams) => { - if (!type || !chatReport) { - 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); + // Shared payment hook + const { + confirmPayment, + shouldBlockAction, + onSelectionModePaymentSelect: basePaymentSelect, + selectionModeKYCSuccess, + paymentSubMenuItems, + hasPayInSelectionMode, + isAnyTransactionOnHold, + isInvoiceReport, + kycWallRef, + } = useSelectionModePayment({ + reportID: report?.reportID, + transactions, + formattedAmount: totalAmount, + shouldHidePaymentOptions: !shouldShowPayButton, + onlyShowPayElsewhere, + hasPayAction, + allExpensesSelected, + onHoldMenuOpen: ({requestType: rt, paymentType: pt, methodID}) => { + setRequestType(rt); + setPaymentType(pt); + setSelectedVBBAToPayFromHoldMenu(methodID); if (getPlatform() === CONST.PLATFORM.IOS) { - // On iOS, opening the hold menu immediately can conflict with the popover dismiss animation, so we defer it. InteractionManager.runAfterInteractions(() => setIsHoldMenuVisible(true)); } else { setIsHoldMenuVisible(true); } - } else if (isInvoiceReport) { - const email = currentUserEmail ?? ''; - payInvoice({ - paymentMethodType: type, - chatReport, - invoiceReport: report, - invoiceReportCurrentNextStepDeprecated: nextStep, - introSelected, - currentUserAccountIDParam: currentUserAccountID, - currentUserEmailParam: email, - currentUserLocalCurrency: localCurrencyCode ?? CONST.CURRENCY.USD, - payAsBusiness, - existingB2BInvoiceReport, - methodID, - paymentMethod, - activePolicy, - betas, - isSelfTourViewed, - defaultWorkspaceName: generateDefaultWorkspaceName(email, lastWorkspaceNumber, translate), - }); - clearSelectedTransactions(true); - turnOffMobileSelectionMode(); - } else { - payMoneyRequest({ - paymentType: type, - chatReport, - iouReport: report, - introSelected, - iouReportCurrentNextStepDeprecated: nextStep, - currentUserAccountID, - currentUserLogin: currentUserLogin ?? '', - activePolicy, - policy, - chatReportPolicy, - betas, - isSelfTourViewed, - userBillingGracePeriodEnds, - amountOwed, - ownerBillingGracePeriodEnd, - methodID: type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, - conciergeReportID, - }); - if (currentSearchQueryJSON && !isOffline) { - search({ - searchKey: currentSearchKey, - shouldCalculateTotals, - offset: 0, - queryJSON: currentSearchQueryJSON, - isOffline, - isLoading: !!currentSearchResults?.search?.isLoading, - }); - } + }, + onPaymentComplete: () => { clearSelectedTransactions(true); turnOffMobileSelectionMode(); - } - }; - - // Keep confirmPaymentRef in sync so usePaymentOptions always calls the latest version. - useEffect(() => { - confirmPaymentRef.current = confirmPayment; + }, + confirmApproval, }); - 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 = () => {}; - + // Wrap payment select with InteractionManager for mobile performance const onSelectionModePaymentSelect = (event: KYCFlowEvent, iouPaymentType: PaymentMethodType, triggerKYCFlow: TriggerKYCFlow) => { if (shouldBlockAction(iouPaymentType)) { 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. 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, - expenseReportPolicy: policy, - }); + basePaymentSelect(event, iouPaymentType, triggerKYCFlow); }); }; - 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; - })(); - + // Build report-level action menu const selectionModeReportLevelActions = (() => { const actions: Array & Pick> = []; let idx = 0; @@ -524,7 +232,7 @@ function useSelectionModeReportActions({ text: translate('iou.approve'), icon: expensifyIcons.ThumbsUp, value: CONST.REPORT.PRIMARY_ACTIONS.APPROVE, - onSelected: handleApproveSelected, + onSelected: confirmApproval, }; } if (hasPayAction && !(isOffline && !canAllowSettlement)) { @@ -535,7 +243,7 @@ function useSelectionModeReportActions({ rightIcon: expensifyIcons.ArrowRight, backButtonText: translate('iou.settlePayment', totalAmount), subMenuItems: paymentSubMenuItems, - onSelected: handlePaySelected, + onSelected: () => {}, }; } return actions; diff --git a/src/libs/SearchRefreshUtils.ts b/src/libs/SearchRefreshUtils.ts new file mode 100644 index 000000000000..4d10a30a9461 --- /dev/null +++ b/src/libs/SearchRefreshUtils.ts @@ -0,0 +1,28 @@ +import type {SearchQueryJSON} from '@components/Search/types'; +import {search} from './actions/Search'; +import type {SearchKey} from './SearchUIUtils'; + +type RefreshSearchParams = { + currentSearchQueryJSON: Readonly | undefined; + currentSearchKey: SearchKey | undefined; + shouldCalculateTotals: boolean; + isOffline: boolean; + isLoading: boolean; +}; + +function refreshSearchAfterReportAction({currentSearchQueryJSON, currentSearchKey, shouldCalculateTotals, isOffline, isLoading}: RefreshSearchParams) { + if (!currentSearchQueryJSON || isOffline) { + return; + } + search({ + searchKey: currentSearchKey, + shouldCalculateTotals, + offset: 0, + queryJSON: currentSearchQueryJSON, + isOffline, + isLoading, + }); +} + +export default refreshSearchAfterReportAction; +export type {RefreshSearchParams}; diff --git a/tests/unit/hooks/useSelectionModeReportActions.test.ts b/tests/unit/hooks/useSelectionModeReportActions.test.ts index f229db78fb16..207edb0be0d5 100644 --- a/tests/unit/hooks/useSelectionModeReportActions.test.ts +++ b/tests/unit/hooks/useSelectionModeReportActions.test.ts @@ -104,6 +104,42 @@ jest.mock('@hooks/usePaymentOptions', () => ({ default: jest.fn(() => []), })); +const mockLifecycleHandleSubmitReport = jest.fn(); +const mockLifecycleConfirmApproval = jest.fn(); + +jest.mock('@hooks/useLifecycleActions', () => ({ + __esModule: true, + default: jest.fn(() => ({ + confirmApproval: mockLifecycleConfirmApproval, + handleSubmitReport: mockLifecycleHandleSubmitReport, + shouldBlockSubmit: false, + isBlockSubmitDueToPreventSelfApproval: false, + })), +})); + +const mockConfirmPayment = jest.fn(); +const mockShouldBlockAction = jest.fn(() => false); +const mockOnSelectionModePaymentSelect = jest.fn(); +const mockSelectionModeKYCSuccess = jest.fn(); + +jest.mock('@hooks/useSelectionModePayment', () => ({ + __esModule: true, + default: jest.fn(() => ({ + confirmPayment: mockConfirmPayment, + shouldBlockAction: mockShouldBlockAction, + onSelectionModePaymentSelect: mockOnSelectionModePaymentSelect, + selectionModeKYCSuccess: mockSelectionModeKYCSuccess, + paymentSubMenuItems: [], + workspacePolicyOptions: [], + handleWorkspaceSelected: jest.fn(), + hasPayInSelectionMode: false, + hasActualPaymentOptions: false, + isAnyTransactionOnHold: false, + isInvoiceReport: false, + kycWallRef: {current: null}, + })), +})); + jest.mock('@hooks/useLazyAsset', () => ({ __esModule: true, useMemoizedLazyExpensifyIcons: jest.fn(() => ({ @@ -220,6 +256,7 @@ jest.mock('@libs/PolicyUtils', () => ({ jest.mock('@libs/ReportActionsUtils', () => ({ __esModule: true, hasRequestFromCurrentAccount: jest.fn(() => false), + getFilteredReportActionsForReportView: jest.fn(() => []), })); jest.mock('@libs/MoneyRequestReportUtils', () => ({ @@ -254,11 +291,6 @@ const DelegateProvider = require('@components/DelegateNoAccessModalProvider') as const LockedProvider = require('@components/LockedAccountModalProvider') as Record; -const IOUActions = require('@libs/actions/IOU/ReportWorkflow') as Record; -const PayMoneyRequestActions = require('@libs/actions/IOU/PayMoneyRequest') as Record; - -const usePaymentOptionsMock = require('@hooks/usePaymentOptions') as {default: jest.Mock}; - function resetMocksToDefaults() { ReportUtils.hasHeldExpensesFromTransactions.mockReturnValue(false); ReportUtils.hasOnlyHeldExpenses.mockReturnValue(false); @@ -438,41 +470,8 @@ describe('useSelectionModeReportActions', () => { }); 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'], - }); - + it('exposes hasPayInSelectionMode from useSelectionModePayment', () => { + const {result} = renderSelectionModeHook(); expect(result.current.hasPayInSelectionMode).toBe(false); }); }); @@ -524,7 +523,7 @@ describe('useSelectionModeReportActions', () => { }); describe('Submit action callback', () => { - it('calls submitReport and clears selections when submitted', async () => { + it('delegates to useLifecycleActions.handleSubmitReport with skipAnimation=true', async () => { mockPrimaryAction = CONST.REPORT.PRIMARY_ACTIONS.SUBMIT; const {result} = renderSelectionModeHook(); @@ -533,14 +532,13 @@ describe('useSelectionModeReportActions', () => { submitAction?.onSelected?.(); await waitFor(() => { - expect(IOUActions.submitReport).toHaveBeenCalled(); - expect(mockClearSelectedTransactions).toHaveBeenCalledWith(true); + expect(mockLifecycleHandleSubmitReport).toHaveBeenCalledWith(true); }); }); }); describe('Approve action callback', () => { - it('calls approveMoneyRequest and clears selections when approved', async () => { + it('delegates to useLifecycleActions.confirmApproval with skipAnimation=true', async () => { mockPrimaryAction = CONST.REPORT.PRIMARY_ACTIONS.APPROVE; const {result} = renderSelectionModeHook(); @@ -549,8 +547,7 @@ describe('useSelectionModeReportActions', () => { approveAction?.onSelected?.(); await waitFor(() => { - expect(IOUActions.approveMoneyRequest).toHaveBeenCalled(); - expect(mockClearSelectedTransactions).toHaveBeenCalledWith(true); + expect(mockLifecycleConfirmApproval).toHaveBeenCalledWith(true); }); }); }); @@ -566,172 +563,61 @@ describe('useSelectionModeReportActions', () => { }); 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', () => { + it('exposes shouldBlockAction from useSelectionModePayment', () => { const {result} = renderSelectionModeHook(); - const blocked = result.current.shouldBlockAction(); - - expect(blocked).toBe(false); - }); - - it('returns true for unvalidated user when payment type is not Elsewhere', async () => { - await Onyx.merge(ONYXKEYS.ACCOUNT, {validated: false}); - await waitForBatchedUpdates(); - - const {result} = renderSelectionModeHook(); - const blocked = result.current.shouldBlockAction(CONST.IOU.PAYMENT_TYPE.EXPENSIFY); - - expect(blocked).toBe(true); - }); - - it('returns false for unvalidated user when payment type is Elsewhere', async () => { - await Onyx.merge(ONYXKEYS.ACCOUNT, {validated: false}); - await waitForBatchedUpdates(); - - const {result} = renderSelectionModeHook(); - const blocked = result.current.shouldBlockAction(CONST.IOU.PAYMENT_TYPE.ELSEWHERE); - - expect(blocked).toBe(false); + expect(result.current.shouldBlockAction).toBe(mockShouldBlockAction); }); }); describe('handleSubmitReport guards', () => { - it('does not submit when shouldBlockSubmit is true (preventSelfApproval)', () => { + it('hides Submit action when shouldBlockSubmit is true (from useLifecycleActions)', () => { 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}), + const useLifecycleActionsMock = require('@hooks/useLifecycleActions') as {default: jest.Mock}; + useLifecycleActionsMock.default.mockReturnValue({ + confirmApproval: mockLifecycleConfirmApproval, + handleSubmitReport: mockLifecycleHandleSubmitReport, + shouldBlockSubmit: true, + isBlockSubmitDueToPreventSelfApproval: true, }); + const {result} = renderSelectionModeHook(); + 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(PayMoneyRequestActions.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.hasHeldExpensesFromTransactions.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(PayMoneyRequestActions.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}), + useLifecycleActionsMock.default.mockReturnValue({ + confirmApproval: mockLifecycleConfirmApproval, + handleSubmitReport: mockLifecycleHandleSubmitReport, + shouldBlockSubmit: false, + isBlockSubmitDueToPreventSelfApproval: false, }); - act(() => { - result.current.confirmPayment({paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE}); - }); - - expect(PayMoneyRequestActions.payInvoice).toHaveBeenCalled(); - expect(mockClearSelectedTransactions).toHaveBeenCalledWith(true); }); }); - describe('confirmApproval branches', () => { - it('opens hold menu when there are held expenses during approval', () => { - ReportUtils.hasHeldExpensesFromTransactions.mockReturnValue(true); - + describe('confirmPayment', () => { + it('exposes confirmPayment from useSelectionModePayment', () => { const {result} = renderSelectionModeHook(); - act(() => { - result.current.confirmApproval(); - }); - - expect(result.current.isHoldMenuVisible).toBe(true); - expect(result.current.requestType).toBe(CONST.IOU.REPORT_ACTION_TYPE.APPROVE); + expect(result.current.confirmPayment).toBe(mockConfirmPayment); }); + }); - it('calls approveMoneyRequest directly when no held expenses', () => { + describe('confirmApproval', () => { + it('delegates to useLifecycleActions.confirmApproval with skipAnimation=true', () => { const {result} = renderSelectionModeHook(); act(() => { result.current.confirmApproval(); }); - expect(IOUActions.approveMoneyRequest).toHaveBeenCalled(); - expect(mockClearSelectedTransactions).toHaveBeenCalledWith(true); + expect(mockLifecycleConfirmApproval).toHaveBeenCalledWith(true); }); }); describe('handleHoldMenuClose', () => { it('resets hold menu state', () => { - ReportUtils.hasHeldExpensesFromTransactions.mockReturnValue(true); - const {result} = renderSelectionModeHook(); - act(() => { - result.current.confirmPayment({paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE}); - }); - expect(result.current.isHoldMenuVisible).toBe(true); + expect(result.current.isHoldMenuVisible).toBe(false); act(() => { result.current.handleHoldMenuClose(); @@ -751,13 +637,9 @@ describe('useSelectionModeReportActions', () => { }); describe('selectionModeKYCSuccess', () => { - it('calls confirmPayment with the given payment type', () => { + it('exposes selectionModeKYCSuccess from useSelectionModePayment', () => { const {result} = renderSelectionModeHook(); - act(() => { - result.current.selectionModeKYCSuccess(CONST.IOU.PAYMENT_TYPE.ELSEWHERE); - }); - - expect(PayMoneyRequestActions.payMoneyRequest).toHaveBeenCalled(); + expect(result.current.selectionModeKYCSuccess).toBeDefined(); }); }); @@ -769,18 +651,9 @@ describe('useSelectionModeReportActions', () => { }); describe('isInvoiceReport', () => { - it('returns false for expense reports', () => { + it('exposes isInvoiceReport from useSelectionModePayment', () => { 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); - }); }); }); From 0a496ac5ce7c6f05e6fa2e333583992543bd14a9 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 3 Jun 2026 19:49:37 +0530 Subject: [PATCH 2/6] Add clarifying comment for redundant shouldBlockAction guard in mobile payment select Signed-off-by: krishna2323 --- src/hooks/useSelectionModeReportActions.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/hooks/useSelectionModeReportActions.ts b/src/hooks/useSelectionModeReportActions.ts index ce0a4d164344..6f215fc75a5a 100644 --- a/src/hooks/useSelectionModeReportActions.ts +++ b/src/hooks/useSelectionModeReportActions.ts @@ -206,6 +206,8 @@ function useSelectionModeReportActions({ }); // Wrap payment select with InteractionManager for mobile performance + // Note: shouldBlockAction is checked synchronously for immediate modal feedback, + // and also inside basePaymentSelect (for the desktop path that uses it directly). const onSelectionModePaymentSelect = (event: KYCFlowEvent, iouPaymentType: PaymentMethodType, triggerKYCFlow: TriggerKYCFlow) => { if (shouldBlockAction(iouPaymentType)) { return; From 3460dabf33ece06c512b3281e041a71f4b5e7e99 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 3 Jun 2026 19:57:52 +0530 Subject: [PATCH 3/6] remove unused exports. Signed-off-by: krishna2323 --- src/hooks/useSelectionModePayment.ts | 1 - src/libs/SearchRefreshUtils.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/src/hooks/useSelectionModePayment.ts b/src/hooks/useSelectionModePayment.ts index 030a260b6492..b8e56797587e 100644 --- a/src/hooks/useSelectionModePayment.ts +++ b/src/hooks/useSelectionModePayment.ts @@ -315,4 +315,3 @@ function useSelectionModePayment({ } export default useSelectionModePayment; -export type {HoldMenuOpenParams}; diff --git a/src/libs/SearchRefreshUtils.ts b/src/libs/SearchRefreshUtils.ts index 4d10a30a9461..4702abc0d3e6 100644 --- a/src/libs/SearchRefreshUtils.ts +++ b/src/libs/SearchRefreshUtils.ts @@ -25,4 +25,3 @@ function refreshSearchAfterReportAction({currentSearchQueryJSON, currentSearchKe } export default refreshSearchAfterReportAction; -export type {RefreshSearchParams}; From cd0e182fb8386275421287630fb114c066cae53a Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 4 Jun 2026 14:52:11 +0530 Subject: [PATCH 4/6] Fix missing shouldBlockAction guard on workspace policy payment items Signed-off-by: krishna2323 --- src/hooks/useSelectionModePayment.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/hooks/useSelectionModePayment.ts b/src/hooks/useSelectionModePayment.ts index b8e56797587e..1ba36eaefddb 100644 --- a/src/hooks/useSelectionModePayment.ts +++ b/src/hooks/useSelectionModePayment.ts @@ -236,6 +236,13 @@ function useSelectionModePayment({ return sortPoliciesByName(activeAdminPolicies, localeCompare); })(); + const handleWorkspaceSelected = (wp: OnyxTypes.Policy) => { + if (shouldBlockAction()) { + return; + } + kycWallRef.current?.continueAction?.({policy: wp}); + }; + const paymentSubMenuItems: PopoverMenuItem[] = (() => { if (!workspacePolicyOptions.length) { return [...Object.values(paymentButtonOptions)]; @@ -249,6 +256,7 @@ function useSelectionModePayment({ text: translate('iou.payWithPolicy', truncate(wp.name, {length: CONST.ADDITIONAL_ALLOWED_CHARACTERS}), ''), icon: expensifyIcons.Building, workspacePolicy: wp, + onSelected: () => handleWorkspaceSelected(wp), }; result.push(workspacePolicyItem); } @@ -257,13 +265,6 @@ function useSelectionModePayment({ return result; })(); - const handleWorkspaceSelected = (wp: OnyxTypes.Policy) => { - if (shouldBlockAction()) { - return; - } - kycWallRef.current?.continueAction?.({policy: wp}); - }; - const onSelectionModePaymentSelect = (event: KYCFlowEvent, iouPaymentType: PaymentMethodType, triggerKYCFlow: TriggerKYCFlow) => { if (shouldBlockAction(iouPaymentType)) { return; From 3a161a35503a2bf5ae861f464434bb71844a3ad5 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 4 Jun 2026 15:00:02 +0530 Subject: [PATCH 5/6] Fix missing shouldBlockAction guard for workspace policy payment on mobile Signed-off-by: krishna2323 --- .../SelectionToolbar/SelectionDropdown.tsx | 7 ++++++- .../MoneyRequestReportView/SelectionToolbar/index.tsx | 7 +++++++ src/hooks/useSelectionModePayment.ts | 1 - 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/components/MoneyRequestReportView/SelectionToolbar/SelectionDropdown.tsx b/src/components/MoneyRequestReportView/SelectionToolbar/SelectionDropdown.tsx index 5a796d7f5820..30a27e968100 100644 --- a/src/components/MoneyRequestReportView/SelectionToolbar/SelectionDropdown.tsx +++ b/src/components/MoneyRequestReportView/SelectionToolbar/SelectionDropdown.tsx @@ -11,7 +11,7 @@ import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import type {KYCFlowEvent, TriggerKYCFlow} from '@libs/PaymentUtils'; import type CONST from '@src/CONST'; -import type {Report} from '@src/types/onyx'; +import type {Policy, Report} from '@src/types/onyx'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; type SelectionDropdownProps = { @@ -30,6 +30,9 @@ type SelectionDropdownProps = { /** Callback for the end of the onContinue trigger on option selection */ selectionModeKYCSuccess: (type?: PaymentMethodType) => void; + /** Callback when a workspace policy payment option is selected */ + onWorkspacePolicySelect: (policy: Policy, triggerKYCFlow: TriggerKYCFlow) => void; + /** Reference to the KYC wall */ kycWallRef: React.RefObject; @@ -43,6 +46,7 @@ function SelectionDropdown({ report, onSelectionModePaymentSelect, selectionModeKYCSuccess, + onWorkspacePolicySelect, primaryAction, selectedTransactionsOptions, selectedTransactionIDs, @@ -60,6 +64,7 @@ function SelectionDropdown({ chatReportID={chatReport?.reportID} iouReport={report} onPaymentSelect={onSelectionModePaymentSelect} + onWorkspacePolicySelect={onWorkspacePolicySelect} onSuccessfulKYC={selectionModeKYCSuccess} primaryAction={primaryAction} applicableSecondaryActions={selectedTransactionsOptions} diff --git a/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx b/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx index 84e20254a316..8b51c05ec0be 100644 --- a/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx +++ b/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx @@ -163,6 +163,7 @@ function SelectionToolbar({reportID, transactions, reportActions}: SelectionTool hasPayInSelectionMode, onSelectionModePaymentSelect, selectionModeKYCSuccess, + shouldBlockAction, primaryAction, kycWallRef, isHoldMenuVisible, @@ -256,6 +257,12 @@ function SelectionToolbar({reportID, transactions, reportActions}: SelectionTool report={report} onSelectionModePaymentSelect={onSelectionModePaymentSelect} selectionModeKYCSuccess={selectionModeKYCSuccess} + onWorkspacePolicySelect={(selectedPolicy, triggerKYCFlow) => { + if (shouldBlockAction()) { + return; + } + triggerKYCFlow({policy: selectedPolicy}); + }} primaryAction={primaryAction} selectedTransactionsOptions={selectedTransactionsOptions} selectedTransactionIDs={selectedTransactionIDs} diff --git a/src/hooks/useSelectionModePayment.ts b/src/hooks/useSelectionModePayment.ts index 1ba36eaefddb..7cc22bceb9f5 100644 --- a/src/hooks/useSelectionModePayment.ts +++ b/src/hooks/useSelectionModePayment.ts @@ -256,7 +256,6 @@ function useSelectionModePayment({ text: translate('iou.payWithPolicy', truncate(wp.name, {length: CONST.ADDITIONAL_ALLOWED_CHARACTERS}), ''), icon: expensifyIcons.Building, workspacePolicy: wp, - onSelected: () => handleWorkspaceSelected(wp), }; result.push(workspacePolicyItem); } From d6f26637439682e3f2075c562d86163f182be368 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 26 Jun 2026 14:42:32 +0530 Subject: [PATCH 6/6] Use dynamic verify-account route for unvalidated users in selection mode payment Signed-off-by: krishna2323 --- src/hooks/useSelectionModePayment.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/hooks/useSelectionModePayment.ts b/src/hooks/useSelectionModePayment.ts index 67e845d16fce..413caa081eb5 100644 --- a/src/hooks/useSelectionModePayment.ts +++ b/src/hooks/useSelectionModePayment.ts @@ -12,14 +12,17 @@ import type {PaymentActionParams} from '@components/SettlementButton/types'; import {payInvoice, payMoneyRequest} from '@libs/actions/IOU/PayMoneyRequest'; import {generateDefaultWorkspaceName} from '@libs/actions/Policy/Policy'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import Navigation from '@libs/Navigation/Navigation'; import type {KYCFlowEvent, TriggerKYCFlow, WorkspacePolicyPaymentOption} from '@libs/PaymentUtils'; -import {handleUnvalidatedAccount, selectPaymentType} from '@libs/PaymentUtils'; +import {selectPaymentType} from '@libs/PaymentUtils'; import {sortPoliciesByName} from '@libs/PolicyUtils'; import {hasRequestFromCurrentAccount} from '@libs/ReportActionsUtils'; import {hasHeldExpensesFromTransactions, hasViolations as hasViolationsReportUtils, isInvoiceReport as isInvoiceReportUtil, isIOUReport as isIOUReportUtil} from '@libs/ReportUtils'; import refreshSearchAfterReportAction from '@libs/SearchRefreshUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import useActiveAdminPolicies from './useActiveAdminPolicies'; @@ -126,7 +129,7 @@ function useSelectionModePayment({ return true; } if (!isUserValidated && paymentMethodType !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE) { - handleUnvalidatedAccount(moneyRequestReport); + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.VERIFY_ACCOUNT.path)); return true; } return false;