From 02e71a0239cc6734955558caa2bd6cb52b110ebf Mon Sep 17 00:00:00 2001 From: "DylanDylann (via MelvinBot)" Date: Thu, 6 Aug 2026 07:35:53 +0000 Subject: [PATCH 1/3] Show Pay option in bulk actions dropdown while offline Co-authored-by: DylanDylann --- src/hooks/useSearchBulkActions.ts | 4 +- .../unit/hooks/useSearchBulkActionsPayTest.ts | 297 ++++++++++++++++++ 2 files changed, 300 insertions(+), 1 deletion(-) create mode 100644 tests/unit/hooks/useSearchBulkActionsPayTest.ts diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index adc28422bbf8..f752eee4817a 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -2108,7 +2108,9 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { } const {shouldEnableBulkPayOption} = getPayOption(selectedReports, selectedTransactions, lastPaymentMethods, selectedReportIDs, personalPolicyID); - const shouldShowPayOption = !isOffline && !isAnyTransactionOnHold && shouldEnableBulkPayOption && !!bulkPayButtonOptions?.length; + // Keep Pay visible while offline: selecting it is handled by onBulkPaySelected, which shows the offline modal + // rather than attempting a payment. Gating on !isOffline here would hide Pay entirely offline, which is wrong. + const shouldShowPayOption = !isAnyTransactionOnHold && shouldEnableBulkPayOption && !!bulkPayButtonOptions?.length; if (shouldShowPayOption) { const shouldShowPaySubmenu = !!bulkPayButtonOptions?.length; diff --git a/tests/unit/hooks/useSearchBulkActionsPayTest.ts b/tests/unit/hooks/useSearchBulkActionsPayTest.ts new file mode 100644 index 000000000000..0faacd606d83 --- /dev/null +++ b/tests/unit/hooks/useSearchBulkActionsPayTest.ts @@ -0,0 +1,297 @@ +import {act, renderHook, waitFor} from '@testing-library/react-native'; + +import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; +import type {SearchQueryJSON, SelectedReports, SelectedTransactions} from '@components/Search/types'; + +import useSearchBulkActions from '@hooks/useSearchBulkActions'; +import type {SearchHeaderOptionValue} from '@hooks/useSearchBulkActions'; + +import {payMoneyRequest} from '@libs/actions/IOU/PayMoneyRequest'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import Onyx from 'react-native-onyx'; + +// The Pay option is shown only when getPayOption enables it and useBulkPayOptions returns at least one payment option. +// Both are driven from these module-level flags so each test can toggle them independently. +let mockShouldEnableBulkPayOption = true; +let mockBulkPayButtonOptions: Array> = [{text: 'Pay with bank account', key: CONST.IOU.PAYMENT_TYPE.VBBA}]; + +jest.mock('@libs/actions/IOU/PayMoneyRequest', () => ({ + payMoneyRequest: jest.fn(), + payInvoice: jest.fn(), +})); + +jest.mock('@libs/actions/Search', () => ({ + getExportTemplates: jest.fn(() => ({customTemplates: [], defaultTemplates: []})), + exportSearchItemsToCSV: jest.fn(), + queueExportSearchItemsToCSV: jest.fn(), + queueExportSearchWithTemplate: jest.fn(), + getSearchApproveOnyxData: jest.fn(() => ({})), + getSearchPayOnyxData: jest.fn(() => ({})), + bulkDeleteReports: jest.fn(), + getLastPolicyBankAccountID: jest.fn(), + getLastPolicyPaymentMethod: jest.fn(), + getPayMoneyOnSearchInvoiceParams: jest.fn(), + getPayOption: jest.fn(() => ({shouldEnableBulkPayOption: mockShouldEnableBulkPayOption, isFirstTimePayment: false})), + getReportType: jest.fn(), + getTotalFormattedAmount: jest.fn(() => ''), + isCurrencySupportWalletBulkPay: jest.fn(() => false), + payMoneyRequestOnSearch: jest.fn(), + submitMoneyRequestOnSearch: jest.fn(), + unholdMoneyRequestOnSearch: jest.fn(), +})); + +jest.mock('@libs/actions/MergeTransaction', () => ({ + setupMergeTransactionDataAndNavigate: jest.fn(), +})); + +jest.mock('@libs/actions/SplitExpenses.ts', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock('@libs/actions/Report', () => ({ + deleteAppReport: jest.fn(), + exportReportToPDF: jest.fn(), + markAsManuallyExported: jest.fn(), + moveIOUReportToPolicy: jest.fn(), + moveIOUReportToPolicyAndInviteSubmitter: jest.fn(), +})); + +jest.mock('@libs/actions/User', () => ({ + setNameValuePair: jest.fn(), +})); + +jest.mock('@libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), + getActiveRoute: jest.fn(() => '/test'), +})); + +jest.mock('@hooks/useLocalize', () => ({ + __esModule: true, + default: () => ({ + translate: (key: string) => key, + localeCompare: (first: string, second: string) => first && second, + formatPhoneNumber: (phone: string) => phone, + }), +})); + +jest.mock('@hooks/useThemeStyles', () => ({ + __esModule: true, + default: () => ({colorMuted: {}, fontWeightNormal: {}, textWrap: {}}), +})); + +jest.mock('@hooks/useTheme', () => ({ + __esModule: true, + default: () => ({icon: ''}), +})); + +let mockIsOffline = false; +jest.mock('@hooks/useNetwork', () => ({ + __esModule: true, + default: () => ({isOffline: mockIsOffline}), +})); + +jest.mock('@hooks/useEnvironment', () => ({ + __esModule: true, + default: () => ({isProduction: false, isDevelopment: true, environment: 'development'}), +})); + +jest.mock('@components/DelegateNoAccessModalProvider', () => ({ + useDelegateNoAccessState: () => ({isDelegateAccessRestricted: false}), + useDelegateNoAccessActions: () => ({showDelegateNoAccessModal: jest.fn()}), +})); + +jest.mock('@hooks/useConfirmModal', () => ({ + __esModule: true, + default: () => ({showConfirmModal: jest.fn()}), +})); + +jest.mock('@hooks/usePermissions', () => ({ + __esModule: true, + default: () => ({isBetaEnabled: () => false}), +})); + +jest.mock('@hooks/useSelfDMReport', () => ({ + __esModule: true, + default: () => undefined, +})); + +jest.mock('@hooks/useBulkPayOptions', () => ({ + __esModule: true, + default: () => ({bulkPayButtonOptions: mockBulkPayButtonOptions, latestBankItems: []}), +})); + +jest.mock('@hooks/useDefaultExpensePolicy', () => ({ + __esModule: true, + default: () => undefined, +})); + +jest.mock('@hooks/usePolicyForMovingExpenses', () => ({ + __esModule: true, + default: () => ({policyForMovingExpensesID: undefined}), +})); + +jest.mock('@hooks/usePaymentContext', () => ({ + __esModule: true, + default: () => ({ + introSelected: undefined, + betas: undefined, + isSelfTourViewed: false, + activePolicyID: undefined, + activePolicy: undefined, + defaultWorkspaceName: undefined, + userBillingGracePeriodEnds: undefined, + amountOwed: undefined, + ownerBillingGracePeriodEnd: undefined, + }), + PaymentContextProvider: ({children}: {children: unknown}) => children, + useReportPaymentContext: () => ({}), +})); + +const mockClearSelectedTransactions = jest.fn(); +let mockSelectedTransactions: SelectedTransactions = {}; +let mockSelectedReports: SelectedReports[] = []; +let mockAreAllMatchingItemsSelected = false; + +jest.mock('@components/Search/SearchContext', () => ({ + useSearchSelectionContext: () => ({ + selectedTransactions: mockSelectedTransactions, + selectedReports: mockSelectedReports, + areAllMatchingItemsSelected: mockAreAllMatchingItemsSelected, + }), + useSearchResultsContext: () => ({ + currentSearchResults: undefined, + }), + useSearchQueryContext: () => ({ + currentSearchKey: undefined, + }), + useSearchSelectionActions: () => ({ + clearSelectedTransactions: mockClearSelectedTransactions, + selectAllMatchingItems: jest.fn(), + }), +})); + +const CURRENT_USER_ACCOUNT_ID = 1; + +jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({ + __esModule: true, + default: jest.fn(() => ({ + login: 'test@example.com', + accountID: CURRENT_USER_ACCOUNT_ID, + email: 'test@example.com', + })), +})); + +const expenseReportQueryJSON: SearchQueryJSON = { + inputQuery: 'type:expense-report status:all', + hash: 12345, + recentSearchHash: 12345, + similarSearchHash: 12345, + flatFilters: [], + type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, + sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE, + sortOrder: CONST.SEARCH.SORT_ORDER.DESC, + view: CONST.SEARCH.VIEW.TABLE, + filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, left: 'type', right: 'expense-report'}, +}; + +function makeSelectedTransaction(overrides: Partial = {}): SelectedTransactions[string] { + return { + isSelected: true, + canReject: false, + canHold: false, + canSplit: false, + hasBeenSplit: false, + canChangeReport: false, + isHeld: false, + canUnhold: false, + action: CONST.SEARCH.ACTION_TYPES.PAY, + reportID: '1', + policyID: 'policy1', + amount: 100, + currency: 'USD', + isFromOneTransactionReport: false, + ...overrides, + }; +} + +function getPayOptionFromResult(options: Array>): DropdownOption | undefined { + return options.find((option) => option.value === CONST.SEARCH.BULK_ACTION_TYPES.PAY); +} + +describe('useSearchBulkActions - Pay option', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + mockIsOffline = false; + mockShouldEnableBulkPayOption = true; + mockBulkPayButtonOptions = [{text: 'Pay with bank account', key: CONST.IOU.PAYMENT_TYPE.VBBA}]; + mockAreAllMatchingItemsSelected = false; + await Onyx.clear(); + mockSelectedTransactions = {tx1: makeSelectedTransaction()}; + mockSelectedReports = []; + + await Onyx.merge(ONYXKEYS.SESSION, {accountID: CURRENT_USER_ACCOUNT_ID, email: 'test@example.com'}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}policy1`, { + id: 'policy1', + role: CONST.POLICY.ROLE.ADMIN, + }); + }); + + afterEach(async () => { + await Onyx.clear(); + }); + + it('shows the Pay option when online', async () => { + const {result} = renderHook(() => useSearchBulkActions({queryJSON: expenseReportQueryJSON})); + + await waitFor(() => { + expect(getPayOptionFromResult(result.current.headerButtonsOptions)).toBeDefined(); + }); + }); + + it('still shows the Pay option when offline', async () => { + mockIsOffline = true; + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: expenseReportQueryJSON})); + + await waitFor(() => { + expect(getPayOptionFromResult(result.current.headerButtonsOptions)).toBeDefined(); + }); + }); + + it('opens the offline modal instead of paying when Pay is selected offline', async () => { + mockIsOffline = true; + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: expenseReportQueryJSON})); + + await waitFor(() => { + expect(getPayOptionFromResult(result.current.headerButtonsOptions)).toBeDefined(); + }); + + const payOption = getPayOptionFromResult(result.current.headerButtonsOptions); + await act(async () => { + await payOption?.onSelected?.(); + }); + + expect(result.current.isOfflineModalVisible).toBe(true); + expect(payMoneyRequest).not.toHaveBeenCalled(); + }); + + it('hides the Pay option when bulk pay is not enabled', async () => { + mockShouldEnableBulkPayOption = false; + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: expenseReportQueryJSON})); + + await waitFor(() => { + expect(result.current.headerButtonsOptions).toBeDefined(); + }); + expect(getPayOptionFromResult(result.current.headerButtonsOptions)).toBeUndefined(); + }); +}); From fb151a660ef40597a62ba486ca2428f431cf4ee9 Mon Sep 17 00:00:00 2001 From: "DylanDylann (via MelvinBot)" Date: Mon, 10 Aug 2026 08:32:18 +0000 Subject: [PATCH 2/3] Guard all bulk-pay payment methods behind the offline modal Co-authored-by: DylanDylann --- .../Search/SearchBulkActionsButton.tsx | 2 ++ src/libs/actions/Search.ts | 10 +++++++ .../Search/handleActionButtonPressTest.ts | 26 +++++++++++++++++++ 3 files changed, 38 insertions(+) diff --git a/src/components/Search/SearchBulkActionsButton.tsx b/src/components/Search/SearchBulkActionsButton.tsx index dd8592675339..69154d3ee4d7 100644 --- a/src/components/Search/SearchBulkActionsButton.tsx +++ b/src/components/Search/SearchBulkActionsButton.tsx @@ -211,6 +211,7 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { pendingPaymentAdditionalDataRef.current = data; }, currentUserAccountID: currentUserPersonalDetails.accountID, + isOffline, }) } variant={CONST.BUTTON_VARIANT.SUCCESS} @@ -255,6 +256,7 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { pendingPaymentAdditionalDataRef.current = data; }, currentUserAccountID: currentUserPersonalDetails.accountID, + isOffline, }) } isSplitButton={false} diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index f8f8d14b3ec8..dd1a84d43353 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -1877,6 +1877,7 @@ function handleBulkPayItemSelected(params: { confirmPayment?: (paymentType: PaymentMethodType | undefined, additionalData?: BulkPaySelectionData) => void; setPendingPaymentAdditionalData?: (data: BulkPaySelectionData | undefined) => void; currentUserAccountID: number; + isOffline: boolean; }) { const { item, @@ -1896,6 +1897,7 @@ function handleBulkPayItemSelected(params: { ownerBillingGracePeriodEnd, setPendingPaymentAdditionalData, currentUserAccountID, + isOffline, } = params; const {paymentType, policyFromPaymentMethod, policyFromContext, shouldSelectPaymentMethod} = getActivePaymentType(item.key, activeAdminPolicies, businessBankAccountOptions, policy?.id); // Early return if item is not a valid payment method and not a policy-based payment option @@ -1903,6 +1905,14 @@ function handleBulkPayItemSelected(params: { return; } + // While offline, route every payment method straight to the offline modal via confirmPayment (onBulkPaySelected), + // before any branch below can navigate the user into a KYC / account-verification / add-bank-account / restricted-action flow. + if (isOffline) { + Log.info('[BulkPay] Blocking bulk pay: offline, deferring to the offline modal'); + confirmPayment?.(paymentType as PaymentMethodType, item?.additionalData as BulkPaySelectionData | undefined); + return; + } + if (isDelegateAccessRestricted) { Log.info('[BulkPay] Blocking bulk pay: delegate access is restricted'); deferModalPresentationAfterPopoverDismiss(showDelegateNoAccessModal); diff --git a/tests/unit/Search/handleActionButtonPressTest.ts b/tests/unit/Search/handleActionButtonPressTest.ts index a33efe31aa1a..705b3479f8e5 100644 --- a/tests/unit/Search/handleActionButtonPressTest.ts +++ b/tests/unit/Search/handleActionButtonPressTest.ts @@ -432,6 +432,7 @@ describe('handleBulkPayItemSelected', () => { bankAccountList: undefined, ownerBillingGracePeriodEnd: undefined, currentUserAccountID: ownerAccountID, + isOffline: false, }; beforeEach(async () => { @@ -647,4 +648,29 @@ describe('handleBulkPayItemSelected', () => { expect(baseParams.triggerKYCFlow).toHaveBeenCalled(); expect(baseParams.confirmPayment).not.toHaveBeenCalled(); }); + + it('should defer to confirmPayment (offline modal) and never navigate to KYC/verify-account when offline, even for a bank-funded payment type', async () => { + const policy = { + ...createRandomPolicy(Number(policyID)), + id: policyID, + ownerAccountID, + } as Policy; + + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, policy); + + handleBulkPayItemSelected({ + ...baseParams, + policy, + amountOwed: 0, + // VBBA + unvalidated user would normally route to account verification / KYC; offline must short-circuit that. + isUserValidated: false, + isOffline: true, + item: {key: CONST.IOU.PAYMENT_TYPE.VBBA, text: 'Pay with bank account', icon: () => null}, + }); + + expect(baseParams.triggerKYCFlow).not.toHaveBeenCalled(); + expect(Navigation.navigate).not.toHaveBeenCalledWith(createDynamicRoute(DYNAMIC_ROUTES.VERIFY_ACCOUNT.path)); + // confirmPayment (onBulkPaySelected) is what surfaces the offline modal; the exact paymentType is not important here. + expect(baseParams.confirmPayment).toHaveBeenCalled(); + }); }); From 606bb795ee4315947f12538d86df7936071a6e5c Mon Sep 17 00:00:00 2001 From: "DylanDylann (via MelvinBot)" Date: Mon, 10 Aug 2026 09:05:46 +0000 Subject: [PATCH 3/3] Fix iOS freeze: defer offline modal until bulk Pay popover dismisses Presenting the offline DecisionModal synchronously while the payment popover is still dismissing freezes the app on iOS. Route the offline guard in onBulkPaySelected through deferModalPresentationAfterPopoverDismiss, matching the existing delegate/locked-account guards, so the modal presents only after the popover transition completes. Co-authored-by: DylanDylann --- src/hooks/useSearchBulkActions.ts | 5 ++++- tests/unit/hooks/useSearchBulkActionsPayTest.ts | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index f752eee4817a..83334ff5a243 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -36,6 +36,7 @@ import { } from '@libs/actions/Search'; import initSplitExpense from '@libs/actions/SplitExpenses'; import {setNameValuePair} from '@libs/actions/User'; +import deferModalPresentationAfterPopoverDismiss from '@libs/deferModalPresentationAfterPopoverDismiss'; import {getExpensifyCardStatementParamsFromFeed, getExpensifyCardStatementSelection} from '@libs/ExpensifyCardStatementUtils'; import type {ExpensifyCardStatementParams} from '@libs/ExpensifyCardStatementUtils'; import Log from '@libs/Log'; @@ -1165,7 +1166,9 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { return; } if (isOffline) { - setIsOfflineModalVisible(true); + // On iOS, presenting the offline modal while the payment popover is still dismissing freezes the app, + // so defer it until the popover transition completes (matching the delegate/locked-account guards). + deferModalPresentationAfterPopoverDismiss(() => setIsOfflineModalVisible(true)); return; } diff --git a/tests/unit/hooks/useSearchBulkActionsPayTest.ts b/tests/unit/hooks/useSearchBulkActionsPayTest.ts index 0faacd606d83..641cfdb15a4b 100644 --- a/tests/unit/hooks/useSearchBulkActionsPayTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsPayTest.ts @@ -23,6 +23,13 @@ jest.mock('@libs/actions/IOU/PayMoneyRequest', () => ({ payInvoice: jest.fn(), })); +// On iOS the offline modal is deferred until the payment popover finishes dismissing (via TransitionTracker), +// which never fires under Jest. Invoke the callback synchronously so we can assert the modal opens. +jest.mock('@libs/deferModalPresentationAfterPopoverDismiss', () => ({ + __esModule: true, + default: (presentModal: () => void) => presentModal(), +})); + jest.mock('@libs/actions/Search', () => ({ getExportTemplates: jest.fn(() => ({customTemplates: [], defaultTemplates: []})), exportSearchItemsToCSV: jest.fn(),