From bbcd3a9eee3abce89920d696346be0620ebdc1b7 Mon Sep 17 00:00:00 2001 From: KJ21-ENG <140263938+KJ21-ENG@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:04:30 +0530 Subject: [PATCH 1/3] Remove unsafe type assertions --- .../SearchPageHeader/SearchFilterBar.tsx | 6 +- .../SelectionListWithSections/types.ts | 1 - .../hooks/useFlattenedSections.ts | 47 ++++------ .../BaseTextInput/implementations.ts | 10 +- src/hooks/useBulkPayOptions.ts | 15 ++- .../steps/PlaidConnectionStep.tsx | 8 +- tests/unit/Search/SearchFilterBarTest.tsx | 93 +++++++++++++++++++ 7 files changed, 132 insertions(+), 48 deletions(-) create mode 100644 tests/unit/Search/SearchFilterBarTest.tsx diff --git a/src/components/Search/SearchPageHeader/SearchFilterBar.tsx b/src/components/Search/SearchPageHeader/SearchFilterBar.tsx index 66ec8af37bc8..116eef032b6b 100644 --- a/src/components/Search/SearchPageHeader/SearchFilterBar.tsx +++ b/src/components/Search/SearchPageHeader/SearchFilterBar.tsx @@ -47,7 +47,7 @@ function WorkspaceDropdown({label, value, PopoverComponent, sentryLabel, onClose } function FeedDropdown({label, value, PopoverComponent, sentryLabel, onClosePress}: DropdownProps) { - const feedValue = useFilterFeedValue(value as string[]); + const feedValue = useFilterFeedValue(Array.isArray(value) ? value : [value]); return ( = TItem & { - flatIndex: number; type: typeof CONST.SECTION_LIST_ITEM_TYPE.ROW; /** Unique key for FlashList rendering, containing section info */ flatListKey: string; diff --git a/src/components/SelectionList/hooks/useFlattenedSections.ts b/src/components/SelectionList/hooks/useFlattenedSections.ts index 100b81b746cd..50e0a5d9bb42 100644 --- a/src/components/SelectionList/hooks/useFlattenedSections.ts +++ b/src/components/SelectionList/hooks/useFlattenedSections.ts @@ -3,6 +3,8 @@ import type {FlattenedItem, Section, SectionListItem} from '@components/Selectio import CONST from '@src/CONST'; +import type {TupleToUnion} from 'type-fest'; + import {useMemo} from 'react'; function isItemSelected(item: TItem): boolean { @@ -15,26 +17,32 @@ function isItemSelected(item: TItem): boolean { * Selected items remain interactive even when marked as disabled. */ function shouldTreatItemAsDisabled(item: TItem | FlattenedItem): boolean { - return !!item?.isDisabled && !isItemSelected(item as TItem); + return !!item?.isDisabled && !('isSelected' in item && isItemSelected(item)); } -type UseFlattenedSectionsResult = { - flattenedData: Array>; +type UseFlattenedSectionsResultGeneric = { + flattenedData: Array>; disabledIndexes: number[]; itemsCount: number; - selectedItems: ListItem[]; + selectedItems: TItem[]; initialFocusedIndex: number; firstFocusableIndex: number; }; +type UseFlattenedSections = (sections: Array>, initiallyFocusedItemKey?: string | null) => UseFlattenedSectionsResultGeneric; + /** - * Non-generic implementation so OXC's React Compiler can memoize the hook. - * OXC bails on type params inside hooks ("Unsupported declaration type for hoisting"). + * Hook that flattens sections with headers and items into a single array for FlashList. + * Also computes disabled indexes, selected items, and initial focus index. + * The contextual generic keeps item provenance without declaring type params inside the hook, + * which OXC's React Compiler cannot hoist. */ -function useFlattenedSectionsImpl(sections: Array>, initiallyFocusedItemKey?: string | null): UseFlattenedSectionsResult { +const useFlattenedSections: UseFlattenedSections = (sections, initiallyFocusedItemKey) => { return useMemo(() => { - const data: Array> = []; - const selectedOptions: ListItem[] = []; + type Item = TupleToUnion['data'][number]; + + const data: Array> = []; + const selectedOptions: Item[] = []; const disabledIndices: number[] = []; let focusedIndex = -1; let firstNonHeaderIndex = -1; @@ -58,12 +66,12 @@ function useFlattenedSectionsImpl(sections: Array>, initiallyF for (const item of section.data ?? []) { const currentIndex = data.length; - const itemData = { + const itemData: SectionListItem = { ...item, type: CONST.SECTION_LIST_ITEM_TYPE.ROW, isDisabled: section.isDisabled === true || item.isDisabled === true, flatListKey: `${section.sectionIndex}-${item.keyForList}`, - } as SectionListItem; + }; data.push(itemData); if (firstNonHeaderIndex === -1) { @@ -94,24 +102,7 @@ function useFlattenedSectionsImpl(sections: Array>, initiallyF firstFocusableIndex: firstNonHeaderIndex === -1 ? 0 : firstNonHeaderIndex, }; }, [initiallyFocusedItemKey, sections]); -} - -type UseFlattenedSectionsResultGeneric = { - flattenedData: Array>; - disabledIndexes: number[]; - itemsCount: number; - selectedItems: TItem[]; - initialFocusedIndex: number; - firstFocusableIndex: number; }; -/** - * Hook that flattens sections with headers and items into a single array for FlashList. - * Also computes disabled indexes, selected items, and initial focus index. - */ -function useFlattenedSections(sections: Array>, initiallyFocusedItemKey?: string | null): UseFlattenedSectionsResultGeneric { - return useFlattenedSectionsImpl(sections as Array>, initiallyFocusedItemKey) as UseFlattenedSectionsResultGeneric; -} - export default useFlattenedSections; export {isItemSelected, shouldTreatItemAsDisabled}; diff --git a/src/components/TextInput/BaseTextInput/implementations.ts b/src/components/TextInput/BaseTextInput/implementations.ts index ccb8096335b9..e5ec2af1e1d2 100644 --- a/src/components/TextInput/BaseTextInput/implementations.ts +++ b/src/components/TextInput/BaseTextInput/implementations.ts @@ -2,14 +2,14 @@ import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; import RNMaskedTextInput from '@components/RNMaskedTextInput'; import RNTextInput from '@components/RNTextInput'; -import type {BaseTextInputProps, InputType} from './types'; +import type {InputType} from './types'; -type InputComponentType = React.ComponentType; +type InputComponentType = typeof RNTextInput | typeof RNMaskedTextInput | typeof RNMarkdownTextInput; const InputComponentMap = new Map([ - ['default', RNTextInput as InputComponentType], - ['mask', RNMaskedTextInput as InputComponentType], - ['markdown', RNMarkdownTextInput as InputComponentType], + ['default', RNTextInput], + ['mask', RNMaskedTextInput], + ['markdown', RNMarkdownTextInput], ]); export default InputComponentMap; diff --git a/src/hooks/useBulkPayOptions.ts b/src/hooks/useBulkPayOptions.ts index ca4f51130408..7f28baab3b9b 100644 --- a/src/hooks/useBulkPayOptions.ts +++ b/src/hooks/useBulkPayOptions.ts @@ -1,7 +1,6 @@ import type {PopoverMenuItem} from '@components/PopoverMenu'; import type {BankAccountMenuItem} from '@components/Search/types'; -import {isCurrencySupportedForGlobalReimbursement} from '@libs/actions/Policy/Policy'; import {isBankAccountPartiallySetup} from '@libs/BankAccountUtils'; import Navigation from '@libs/Navigation/Navigation'; import {formatPaymentMethods, getBusinessBankAccountOptions, matchesCurrency} from '@libs/PaymentUtils'; @@ -19,9 +18,6 @@ import useSettlementButtonPaymentMethods from '@libs/SettlementButtonUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {AccountData} from '@src/types/onyx'; - -import type {TupleToUnion} from 'type-fest'; import {areInvoicesEnabledSelector} from '@selectors/Policy'; import truncate from 'lodash/truncate'; @@ -34,8 +30,6 @@ import useOnyx from './useOnyx'; import usePermissions from './usePermissions'; import useThemeStyles from './useThemeStyles'; -type CurrencyType = TupleToUnion; - type UseBulkPayOptionProps = { selectedPolicyID: string | undefined; selectedReportID: string | undefined; @@ -96,7 +90,10 @@ function useBulkPayOptions({ const requiredAccountType = payAsBusiness ? CONST.BANK_ACCOUNT.TYPE.BUSINESS : CONST.BANK_ACCOUNT.TYPE.PERSONAL; return formattedPaymentMethods .filter((method) => { - const accountData = method?.accountData as AccountData; + if (!('bankCurrency' in method)) { + return false; + } + const accountData = method.accountData; const isPartiallySetup = isBankAccountPartiallySetup(accountData?.state); return accountData?.type === requiredAccountType && !isPartiallySetup && matchesCurrency(method, currency); }) @@ -128,7 +125,7 @@ function useBulkPayOptions({ value: CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT, })) : undefined; - const personalBankAccountList = formattedPaymentMethods.filter((ba) => (ba.accountData as AccountData)?.type === CONST.BANK_ACCOUNT.TYPE.PERSONAL); + const personalBankAccountList = formattedPaymentMethods.filter((method) => 'bankCurrency' in method && method.accountData?.type === CONST.BANK_ACCOUNT.TYPE.PERSONAL); let bulkPayButtonOptions; if (!selectedReportID || !selectedPolicyID) { @@ -196,7 +193,7 @@ function useBulkPayOptions({ } if (isInvoiceReport) { - const showPayViaExpensifyOptions = isPayInvoiceViaExpensifyBetaEnabled && isCurrencySupportedForGlobalReimbursement(currency as CurrencyType); + const showPayViaExpensifyOptions = isPayInvoiceViaExpensifyBetaEnabled && CONST.DIRECT_REIMBURSEMENT_CURRENCIES.some((supportedCurrency) => supportedCurrency === currency); const getInvoicesOptions = (payAsBusiness: boolean) => { const addBankAccountItem = { text: translate('bankAccount.addBankAccount'), diff --git a/src/pages/settings/Wallet/PersonalCards/steps/PlaidConnectionStep.tsx b/src/pages/settings/Wallet/PersonalCards/steps/PlaidConnectionStep.tsx index fdefea8b7b02..c74431c76273 100644 --- a/src/pages/settings/Wallet/PersonalCards/steps/PlaidConnectionStep.tsx +++ b/src/pages/settings/Wallet/PersonalCards/steps/PlaidConnectionStep.tsx @@ -154,8 +154,12 @@ function PlaidConnectionStep({feed, onExit}: {feed?: CompanyCardFeedWithDomainID // on success we need to move to bank connection screen with token, bank name = plaid Log.info('[PlaidLink] Success!'); - const plaidConnectedFeed = (metadata?.institution as PlaidLinkOnSuccessMetadata['institution'])?.institution_id ?? (metadata?.institution as LinkSuccessMetadata['institution'])?.id; - const plaidConnectedFeedName = (metadata?.institution as PlaidLinkOnSuccessMetadata['institution'])?.name ?? (metadata?.institution as LinkSuccessMetadata['institution'])?.name; + const institution = metadata.institution; + let plaidConnectedFeed: string | undefined; + if (institution) { + plaidConnectedFeed = 'institution_id' in institution ? institution.institution_id : institution.id; + } + const plaidConnectedFeedName = institution?.name; setAddNewPersonalCardStepAndData({ step: CONST.PERSONAL_CARDS.STEP.BANK_CONNECTION, diff --git a/tests/unit/Search/SearchFilterBarTest.tsx b/tests/unit/Search/SearchFilterBarTest.tsx new file mode 100644 index 000000000000..f2e893aa25a6 --- /dev/null +++ b/tests/unit/Search/SearchFilterBarTest.tsx @@ -0,0 +1,93 @@ +import {render, screen} from '@testing-library/react-native'; + +import DropdownButton from '@components/Search/FilterDropdowns/DropdownButton'; +import SearchFilterBar from '@components/Search/SearchPageHeader/SearchFilterBar'; +import type {FilterItem} from '@components/Search/SearchPageHeader/useSearchFiltersBar'; +import Text from '@components/Text'; + +import type {SearchFilter} from '@libs/SearchUIUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Card, CardList} from '@src/types/onyx'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import createMock from '../../utils/createMock'; + +const FIRST_CARD: Card = { + bank: CONST.COMPANY_CARD.FEED_BANK_NAME.UPLOAD, + cardID: 123, + domainName: 'first-card.example', + fraud: CONST.EXPENSIFY_CARD.FRAUD_TYPES.NONE, + lastUpdated: '', + nameValuePairs: createMock>({cardTitle: 'First card'}), + state: CONST.EXPENSIFY_CARD.STATE.OPEN, +}; +const SECOND_CARD: Card = { + bank: CONST.COMPANY_CARD.FEED_BANK_NAME.UPLOAD, + cardID: 456, + domainName: 'second-card.example', + fraud: CONST.EXPENSIFY_CARD.FRAUD_TYPES.NONE, + lastUpdated: '', + nameValuePairs: createMock>({cardTitle: 'Second card'}), + state: CONST.EXPENSIFY_CARD.STATE.OPEN, +}; + +const CARD_LIST: CardList = { + [FIRST_CARD.cardID]: FIRST_CARD, + [SECOND_CARD.cardID]: SECOND_CARD, +}; + +jest.mock('@components/Search/FilterDropdowns/DropdownButton', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock('@hooks/useLocalize', () => ({ + __esModule: true, + default: () => ({translate: (key: string) => key}), +})); + +const mockDropdownButton = jest.mocked(DropdownButton); + +function createCardFilter(value: string): SearchFilter & FilterItem { + return { + key: CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID, + label: 'Cards', + value, + PopoverComponent: () => null, + sentryLabel: 'Search-Filter-cardID', + onClosePress: jest.fn(), + }; +} + +describe('SearchFilterBar card descriptions', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + mockDropdownButton.mockImplementation(({label, value}) => { + const selectedItems = Array.isArray(value) ? value.join(', ') : value; + return {selectedItems ? `${label}: ${selectedItems}` : label}; + }); + + await Onyx.clear(); + await Onyx.set(ONYXKEYS.DERIVED.PERSONAL_AND_WORKSPACE_CARD_LIST, CARD_LIST); + }); + + it('renders the description for one scalar card ID', () => { + render(); + + expect(screen.getByText('Cards: First card')).toBeOnTheScreen(); + }); + + it('renders both descriptions for joined scalar card IDs in card-list order', () => { + render(); + + expect(screen.getByText('Cards: First card, Second card')).toBeOnTheScreen(); + }); +}); From 1c43df2cf0023a0cab0119225bd9447c52260d7d Mon Sep 17 00:00:00 2001 From: KJ21-ENG <140263938+KJ21-ENG@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:53:40 +0530 Subject: [PATCH 2/3] Remove partitioned BaseTextInput cleanup --- .../TextInput/BaseTextInput/implementations.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/TextInput/BaseTextInput/implementations.ts b/src/components/TextInput/BaseTextInput/implementations.ts index e5ec2af1e1d2..ccb8096335b9 100644 --- a/src/components/TextInput/BaseTextInput/implementations.ts +++ b/src/components/TextInput/BaseTextInput/implementations.ts @@ -2,14 +2,14 @@ import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; import RNMaskedTextInput from '@components/RNMaskedTextInput'; import RNTextInput from '@components/RNTextInput'; -import type {InputType} from './types'; +import type {BaseTextInputProps, InputType} from './types'; -type InputComponentType = typeof RNTextInput | typeof RNMaskedTextInput | typeof RNMarkdownTextInput; +type InputComponentType = React.ComponentType; const InputComponentMap = new Map([ - ['default', RNTextInput], - ['mask', RNMaskedTextInput], - ['markdown', RNMarkdownTextInput], + ['default', RNTextInput as InputComponentType], + ['mask', RNMaskedTextInput as InputComponentType], + ['markdown', RNMarkdownTextInput as InputComponentType], ]); export default InputComponentMap; From a5db7366f999119e2ca1de6070be58220842c1f6 Mon Sep 17 00:00:00 2001 From: KJ21-ENG <140263938+KJ21-ENG@users.noreply.github.com> Date: Fri, 21 Aug 2026 01:28:42 +0530 Subject: [PATCH 3/3] Add cleanup regression coverage --- tests/unit/Search/SearchFilterBarTest.tsx | 81 +++++++++++++++- tests/unit/hooks/useBulkPayOptions.test.ts | 103 +++++++++++++++++++++ 2 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 tests/unit/hooks/useBulkPayOptions.test.ts diff --git a/tests/unit/Search/SearchFilterBarTest.tsx b/tests/unit/Search/SearchFilterBarTest.tsx index f2e893aa25a6..a82867c6f05a 100644 --- a/tests/unit/Search/SearchFilterBarTest.tsx +++ b/tests/unit/Search/SearchFilterBarTest.tsx @@ -1,6 +1,8 @@ import {render, screen} from '@testing-library/react-native'; import DropdownButton from '@components/Search/FilterDropdowns/DropdownButton'; +import useFilterFeedValue from '@components/Search/hooks/useFilterFeedValue'; +import useFilterTaxRateValue from '@components/Search/hooks/useFilterTaxRateValue'; import SearchFilterBar from '@components/Search/SearchPageHeader/SearchFilterBar'; import type {FilterItem} from '@components/Search/SearchPageHeader/useSearchFiltersBar'; import Text from '@components/Text'; @@ -16,6 +18,8 @@ import Onyx from 'react-native-onyx'; import createMock from '../../utils/createMock'; +jest.mock('@expensify/react-native-hybrid-app', () => ({__esModule: true, default: {isHybridApp: () => false}})); + const FIRST_CARD: Card = { bank: CONST.COMPANY_CARD.FEED_BANK_NAME.UPLOAD, cardID: 123, @@ -45,12 +49,29 @@ jest.mock('@components/Search/FilterDropdowns/DropdownButton', () => ({ default: jest.fn(), })); +jest.mock('@components/Search/hooks/useFilterFeedValue', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock('@components/Search/hooks/useFilterTaxRateValue', () => ({ + __esModule: true, + default: jest.fn(), +})); + jest.mock('@hooks/useLocalize', () => ({ __esModule: true, default: () => ({translate: (key: string) => key}), })); const mockDropdownButton = jest.mocked(DropdownButton); +const mockUseFilterFeedValue = jest.mocked(useFilterFeedValue); +const mockUseFilterTaxRateValue = jest.mocked(useFilterTaxRateValue); + +const FIRST_FEED = `${CONST.COMPANY_CARD.FEED_BANK_NAME.CHASE}#first-domain`; +const SECOND_FEED = `${CONST.COMPANY_CARD.FEED_BANK_NAME.VISA}#second-domain`; +const FIRST_TAX_RATE = 'id_TAX_RATE_1'; +const SECOND_TAX_RATE = 'id_TAX_RATE_2'; function createCardFilter(value: string): SearchFilter & FilterItem { return { @@ -63,7 +84,29 @@ function createCardFilter(value: string): SearchFilter & FilterItem { }; } -describe('SearchFilterBar card descriptions', () => { +function createFeedFilter(value: string | string[]): SearchFilter & FilterItem { + return { + key: CONST.SEARCH.SYNTAX_FILTER_KEYS.FEED, + label: 'Feeds', + value, + PopoverComponent: () => null, + sentryLabel: 'Search-Filter-feed', + onClosePress: jest.fn(), + }; +} + +function createTaxRateFilter(value: string | string[]): SearchFilter & FilterItem { + return { + key: CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE, + label: 'Tax rates', + value, + PopoverComponent: () => null, + sentryLabel: 'Search-Filter-taxRate', + onClosePress: jest.fn(), + }; +} + +describe('SearchFilterBar descriptions', () => { beforeAll(() => { Onyx.init({keys: ONYXKEYS}); }); @@ -90,4 +133,40 @@ describe('SearchFilterBar card descriptions', () => { expect(screen.getByText('Cards: First card, Second card')).toBeOnTheScreen(); }); + + it('normalizes a scalar feed before rendering its display label', () => { + mockUseFilterFeedValue.mockReturnValue('Chase'); + + render(); + + expect(mockUseFilterFeedValue).toHaveBeenCalledWith([FIRST_FEED]); + expect(mockDropdownButton.mock.calls.at(-1)?.[0].value).toBe('Chase'); + }); + + it('preserves ordered feed identifiers before rendering their display labels', () => { + mockUseFilterFeedValue.mockReturnValue('Chase, Visa'); + + render(); + + expect(mockUseFilterFeedValue).toHaveBeenCalledWith([FIRST_FEED, SECOND_FEED]); + expect(mockDropdownButton.mock.calls.at(-1)?.[0].value).toBe('Chase, Visa'); + }); + + it('normalizes a scalar tax rate before rendering its display label', () => { + mockUseFilterTaxRateValue.mockReturnValue('5%'); + + render(); + + expect(mockUseFilterTaxRateValue).toHaveBeenCalledWith([FIRST_TAX_RATE]); + expect(mockDropdownButton.mock.calls.at(-1)?.[0].value).toBe('5%'); + }); + + it('preserves ordered tax-rate identifiers before rendering their display labels', () => { + mockUseFilterTaxRateValue.mockReturnValue('5%, 10%'); + + render(); + + expect(mockUseFilterTaxRateValue).toHaveBeenCalledWith([FIRST_TAX_RATE, SECOND_TAX_RATE]); + expect(mockDropdownButton.mock.calls.at(-1)?.[0].value).toBe('5%, 10%'); + }); }); diff --git a/tests/unit/hooks/useBulkPayOptions.test.ts b/tests/unit/hooks/useBulkPayOptions.test.ts new file mode 100644 index 000000000000..6a77cb6fce3b --- /dev/null +++ b/tests/unit/hooks/useBulkPayOptions.test.ts @@ -0,0 +1,103 @@ +import {renderHook} from '@testing-library/react-native'; + +import mockPlaceholderIcon from '@components/Icon/PlaceholderIcon'; + +import useBulkPayOptions from '@hooks/useBulkPayOptions'; + +import * as PaymentUtils from '@libs/PaymentUtils'; + +import CONST from '@src/CONST'; +import type PaymentMethod from '@src/types/onyx/PaymentMethod'; + +jest.mock('@hooks/useActiveAdminPolicies', () => ({__esModule: true, default: () => []})); +jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({__esModule: true, default: () => ({accountID: 1})})); +jest.mock('@hooks/useLazyAsset', () => ({ + useMemoizedLazyExpensifyIcons: () => ({Bank: mockPlaceholderIcon, Building: mockPlaceholderIcon, Cash: mockPlaceholderIcon, User: mockPlaceholderIcon, Wallet: mockPlaceholderIcon}), +})); +jest.mock('@hooks/useLocalize', () => ({__esModule: true, default: () => ({translate: (key: string) => key, localeCompare: (left: string, right: string) => left.localeCompare(right)})})); +jest.mock('@hooks/useOnyx', () => ({__esModule: true, default: jest.fn(() => [undefined])})); +jest.mock('@hooks/usePermissions', () => ({__esModule: true, default: () => ({isBetaEnabled: () => true})})); +jest.mock('@hooks/useThemeStyles', () => ({__esModule: true, default: () => ({})})); +jest.mock('@expensify/react-native-hybrid-app', () => ({__esModule: true, default: {isHybridApp: () => false}})); + +jest.mock('@libs/ReportUtils', () => ({ + getInvoiceReceiverPolicyID: jest.fn(), + isExpenseReport: () => false, + isIndividualInvoiceRoom: () => false, + isInvoiceReport: () => true, + isIOUReport: () => false, + parseReportRouteParams: () => ({}), +})); + +const BUSINESS_BANK_METHOD = { + accountData: {type: CONST.BANK_ACCOUNT.TYPE.BUSINESS, state: CONST.BANK_ACCOUNT.STATE.OPEN}, + bankCountry: CONST.COUNTRY.US, + bankCurrency: CONST.CURRENCY.USD, + description: 'USD business account', + icon: mockPlaceholderIcon, + methodID: 101, + title: 'Business bank', +} satisfies PaymentMethod; + +const DEBIT_CARD_METHOD = { + accountType: CONST.PAYMENT_METHODS.DEBIT_CARD, + description: 'Debit card ending in 0001', + icon: mockPlaceholderIcon, + methodID: 202, + title: 'Debit card', +} satisfies PaymentMethod; + +const INVOICE_PAYMENT_PROPS = { + currency: CONST.CURRENCY.USD, + formattedAmount: '$10.00', + onlyShowPayElsewhere: false, + selectedPolicyID: 'policy-1', + selectedReportID: 'invoice-1', +} satisfies Parameters[0]; + +const mockFormatPaymentMethods = jest.spyOn(PaymentUtils, 'formatPaymentMethods'); + +describe('useBulkPayOptions invoice payment methods', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockFormatPaymentMethods.mockReturnValue([BUSINESS_BANK_METHOD, DEBIT_CARD_METHOD]); + }); + + it('includes an open business bank and add-bank-account for supported USD while rejecting a debit-card union member', () => { + const {result} = renderHook(() => useBulkPayOptions(INVOICE_PAYMENT_PROPS)); + const options = result.current.bulkPayButtonOptions; + + if (!options) { + throw new Error('Expected supported-currency invoice payment options'); + } + const bankOption = options.find((option) => option.additionalData?.bankAccountID === BUSINESS_BANK_METHOD.methodID); + if (!bankOption) { + throw new Error('Expected the business-bank payment option'); + } + expect(bankOption.text).toBe(BUSINESS_BANK_METHOD.title); + expect(options.some((option) => option.text === DEBIT_CARD_METHOD.title)).toBe(false); + + const addBankOption = options.find((option) => option.text === 'bankAccount.addBankAccount'); + if (!addBankOption) { + throw new Error('Expected the add-bank-account option'); + } + expect(addBankOption.onSelected).toEqual(expect.any(Function)); + }); + + it('excludes bank payment and add-bank-account for unsupported JPY while retaining pay-elsewhere', () => { + const {result} = renderHook(() => useBulkPayOptions({...INVOICE_PAYMENT_PROPS, currency: 'JPY'})); + const options = result.current.bulkPayButtonOptions; + + if (!options) { + throw new Error('Expected unsupported-currency invoice payment options'); + } + expect(options.some((option) => option.text === BUSINESS_BANK_METHOD.title)).toBe(false); + expect(options.some((option) => option.text === 'bankAccount.addBankAccount')).toBe(false); + + const payElsewhereOption = options.find((option) => option.key === CONST.IOU.PAYMENT_TYPE.ELSEWHERE); + if (!payElsewhereOption) { + throw new Error('Expected the pay-elsewhere option'); + } + expect(payElsewhereOption.text).toBe('iou.payElsewhere'); + }); +});