diff --git a/components/Card/__tests__/CardFundOptions.test.tsx b/components/Card/__tests__/CardFundOptions.test.tsx index b7c65e3e6..e0d1d4d56 100644 --- a/components/Card/__tests__/CardFundOptions.test.tsx +++ b/components/Card/__tests__/CardFundOptions.test.tsx @@ -52,6 +52,7 @@ jest.mock( '@/components/DepositOption/VirtualAccountDetails/VirtualAccountApplyDialog', () => 'VirtualAccountApplyDialog', ); +jest.mock('@/hooks/useCardProvider', () => ({})); jest.mock('@/hooks/useOrchestra', () => ({})); jest.mock('@/hooks/useVirtualAccountEntry', () => ({})); jest.mock('@/lib/analytics', () => ({})); diff --git a/components/DepositOption/DepositCashOptions.tsx b/components/DepositOption/DepositCashOptions.tsx index d7d6b3190..24096c61c 100644 --- a/components/DepositOption/DepositCashOptions.tsx +++ b/components/DepositOption/DepositCashOptions.tsx @@ -15,10 +15,12 @@ import { Text } from '@/components/ui/text'; import { DEPOSIT_MODAL } from '@/constants/modals'; import { TRACKING_EVENTS } from '@/constants/tracking-events'; import { useBuyCryptoEntry } from '@/hooks/useBuyCryptoEntry'; +import { useCardProvider } from '@/hooks/useCardProvider'; import useGeoCompliance from '@/hooks/useGeoCompliance'; import { useIsCashAppAvailable } from '@/hooks/useOrchestra'; import { track } from '@/lib/analytics'; import { getAsset } from '@/lib/assets'; +import { canFundByUsdBankTransfer } from '@/lib/utils/cardHelpers'; import { useDepositStore } from '@/store/useDepositStore'; import { useTransfiStore } from '@/store/useTransfiStore'; @@ -51,6 +53,7 @@ const DepositCashOptions = () => { const [showAllCurrencies, setShowAllCurrencies] = useState(false); const isCashAppAvailable = useIsCashAppAvailable(); + const { provider: cardProvider } = useCardProvider(); const { isBuyCryptoAvailable } = useGeoCompliance(); const { handleBuyCryptoPress } = useBuyCryptoEntry(); @@ -98,7 +101,7 @@ const DepositCashOptions = () => { /> } title="USD" - chips={getUsdMethodChips(isCashAppAvailable)} + chips={getUsdMethodChips(isCashAppAvailable, canFundByUsdBankTransfer(cardProvider))} onPress={handleUsdPress} /> {localCurrencies.map(currency => ( diff --git a/components/DepositOption/DepositUsdOptions.tsx b/components/DepositOption/DepositUsdOptions.tsx index d7984da1e..46ee495c0 100644 --- a/components/DepositOption/DepositUsdOptions.tsx +++ b/components/DepositOption/DepositUsdOptions.tsx @@ -7,9 +7,11 @@ import CardFundGroup from '@/components/Card/CardFund/CardFundGroup'; import CardFundRow from '@/components/Card/CardFund/CardFundRow'; import { DEPOSIT_MODAL } from '@/constants/modals'; import { TRACKING_EVENTS } from '@/constants/tracking-events'; +import { useCardProvider } from '@/hooks/useCardProvider'; import { useIsCashAppAvailable } from '@/hooks/useOrchestra'; import { useVirtualAccountEntry } from '@/hooks/useVirtualAccountEntry'; import { track } from '@/lib/analytics'; +import { canFundByUsdBankTransfer } from '@/lib/utils/cardHelpers'; import { useDepositStore } from '@/store/useDepositStore'; import { useOrchestraStore } from '@/store/useOrchestraStore'; @@ -21,11 +23,9 @@ const BANK_CHIPS = ['Wire', 'ACH']; // The row is already titled Cash App; the chip says how fast, not how. const CASH_APP_CHIPS = ['Instant']; -const USD_METHOD_CHIPS = ['ACH', 'Wire', 'Apple Pay']; -const USD_METHOD_CHIPS_WITH_CASH_APP = ['ACH', 'Wire', 'Cash App', 'Apple Pay']; - type UsdMethodListProps = { - onBankTransferPress: () => void; + /** Omit to hide the row — a Wirex cardholder has no bank rail to their card. */ + onBankTransferPress?: () => void; /** Omit to hide the row — Cash App is only offered where the server allows it. */ onCashAppPress?: () => void; onApplePayPress: () => void; @@ -42,21 +42,23 @@ export const UsdMethodList = ({ onApplePayPress, }: UsdMethodListProps) => ( - - - - } - title="Wire transfer, ACH" - subtitle="Your own US account details" - onPress={onBankTransferPress} - chips={BANK_CHIPS} - /> + {onBankTransferPress ? ( + + + + } + title="Wire transfer, ACH" + subtitle="Your own US account details" + onPress={onBankTransferPress} + chips={BANK_CHIPS} + /> + ) : null} {onCashAppPress ? ( - isCashAppAvailable ? USD_METHOD_CHIPS_WITH_CASH_APP : USD_METHOD_CHIPS; +export const getUsdMethodChips = (isCashAppAvailable: boolean, hasBankTransfer = true) => [ + ...(hasBankTransfer ? ['ACH', 'Wire'] : []), + ...(isCashAppAvailable ? ['Cash App'] : []), + 'Apple Pay', +]; /** * How to fund in USD: the bank rail, Apple Pay through Onramper's widget, or * Cash App over Lightning. * - * The bank rail and Apple Pay are offered everywhere, so USD always opens this - * list. Cash App is US-only, and its row appears only where the server says it - * is available. + * Apple Pay is offered everywhere, so USD always opens this list. The bank rail + * is too, except to a Wirex cardholder, who has no wire and no ACH leg to their + * card (`canFundByUsdBankTransfer`). Cash App is US-only, and its row appears + * only where the server says it is available. */ const DepositUsdOptions = () => { const setModal = useDepositStore(state => state.setModal); const resetOrchestra = useOrchestraStore(state => state.reset); const { open: openVirtualAccount, isApplyOpen, closeApply } = useVirtualAccountEntry(); const isCashAppAvailable = useIsCashAppAvailable(); + const { provider: cardProvider } = useCardProvider(); useEffect(() => { track(TRACKING_EVENTS.DEPOSIT_USD_METHOD_VIEWED); @@ -137,7 +145,9 @@ const DepositUsdOptions = () => { return ( <> diff --git a/components/DepositOption/__tests__/depositUsdOptions.test.tsx b/components/DepositOption/__tests__/depositUsdOptions.test.tsx index 64bf4abc7..03b72668b 100644 --- a/components/DepositOption/__tests__/depositUsdOptions.test.tsx +++ b/components/DepositOption/__tests__/depositUsdOptions.test.tsx @@ -1,7 +1,8 @@ import React from 'react'; -import DepositUsdOptions from '@/components/DepositOption/DepositUsdOptions'; +import DepositUsdOptions, { getUsdMethodChips } from '@/components/DepositOption/DepositUsdOptions'; import { DEPOSIT_MODAL } from '@/constants/modals'; +import { CardProvider } from '@/lib/types'; // eslint-disable-next-line @typescript-eslint/no-require-imports const { act, create } = require('react-test-renderer'); @@ -14,6 +15,9 @@ jest.mock( '@/components/DepositOption/VirtualAccountDetails/VirtualAccountApplyDialog', () => 'VirtualAccountApplyDialog', ); +jest.mock('@/hooks/useCardProvider', () => ({ + useCardProvider: () => ({ provider: mockCard.provider, isLoading: false }), +})); jest.mock('@/hooks/useOrchestra', () => ({ useIsCashAppAvailable: () => mockCashApp.isAvailable, })); @@ -33,6 +37,7 @@ jest.mock('@/store/useOrchestraStore', () => ({ })); const mockCashApp = { isAvailable: false }; +const mockCard: { provider: CardProvider | null } = { provider: null }; const mockOpenVirtualAccount = jest.fn(); const mockDeposit = { setModal: jest.fn() }; @@ -51,6 +56,7 @@ beforeEach(() => { (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; jest.clearAllMocks(); mockCashApp.isAvailable = false; + mockCard.provider = null; }); it('offers Apple Pay beside the bank rail where Cash App is not available', () => { @@ -81,3 +87,26 @@ it('still opens the virtual account from the bank rail', () => { expect(mockDeposit.setModal).not.toHaveBeenCalled(); act(() => root.unmount()); }); + +it('keeps the bank rail for a Rain cardholder', () => { + mockCard.provider = CardProvider.RAIN; + const root = render(); + expect(titlesOf(root)).toEqual(['Wire transfer, ACH', 'Apple Pay']); + act(() => root.unmount()); +}); + +it('hides the bank rail from a Wirex cardholder, who has no wire leg to their card', () => { + mockCard.provider = CardProvider.WIREX; + mockCashApp.isAvailable = true; + const root = render(); + expect(titlesOf(root)).toEqual(['Cash App', 'Apple Pay']); + expect(mockOpenVirtualAccount).not.toHaveBeenCalled(); + act(() => root.unmount()); +}); + +it('drops the ACH and Wire chips where the bank rail is hidden', () => { + expect(getUsdMethodChips(true)).toEqual(['ACH', 'Wire', 'Cash App', 'Apple Pay']); + expect(getUsdMethodChips(false)).toEqual(['ACH', 'Wire', 'Apple Pay']); + expect(getUsdMethodChips(true, false)).toEqual(['Cash App', 'Apple Pay']); + expect(getUsdMethodChips(false, false)).toEqual(['Apple Pay']); +}); diff --git a/lib/utils/cardHelpers.ts b/lib/utils/cardHelpers.ts index 2f0fa9a84..79dcb0a9a 100644 --- a/lib/utils/cardHelpers.ts +++ b/lib/utils/cardHelpers.ts @@ -138,6 +138,18 @@ export const canWithdrawFromCard = ({ isCustomerRestricted, provider }: CardFund export const canDepositToCard = (provider: CardProvider | null | undefined): boolean => provider !== CardProvider.WIREX; +/** + * Whether the USD bank rail (Wire transfer / ACH) is offered as a way to fund. + * + * A Wirex cardholder funds their card through the wallet deposit flow, and that + * flow's USD rail has no leg to their card: the Wirex virtual account supports + * no wire, and what it receives settles into the Wirex balance, not the Safe + * the card spends from. "Fund your card" leaves it off for the same reason + * (`WIREX_CARD_FUND_SECTIONS.cashDeposit`), and the wallet flow now agrees. + */ +export const canFundByUsdBankTransfer = (provider: CardProvider | null | undefined): boolean => + provider !== CardProvider.WIREX; + /** * Whether the savings Deposit button opens the direct-deposit flow — currency, * then chain, then an address to send to — rather than the amount form that