From ee2f523daa3da3fe2d18ae06e5c5fbb6f63b4bd9 Mon Sep 17 00:00:00 2001 From: mul53 Date: Tue, 29 Sep 2026 16:00:16 +0200 Subject: [PATCH] fix(deposit): hide the USD bank rail from Wirex cardholders A Wirex cardholder's "Add funds" opens the wallet deposit flow, whose USD step offered "Wire transfer, ACH". Nothing sent that way reaches their card: the Wirex virtual account has no wire rail, and what it receives settles into the Wirex balance rather than the Safe the card spends from. "Fund your card" already leaves the rail off for Wirex (WIREX_CARD_FUND_SECTIONS.cashDeposit). - canFundByUsdBankTransfer(provider) is false for a Wirex card. - DepositUsdOptions drops the bank row for them; Apple Pay and Cash App stay. - getUsdMethodChips takes a hasBankTransfer flag, so the cash list's USD row stops naming ACH and Wire for them too. Co-Authored-By: Claude Opus 5.5 --- .../Card/__tests__/CardFundOptions.test.tsx | 1 + .../DepositOption/DepositCashOptions.tsx | 5 +- .../DepositOption/DepositUsdOptions.tsx | 66 +++++++++++-------- .../__tests__/depositUsdOptions.test.tsx | 31 ++++++++- lib/utils/cardHelpers.ts | 12 ++++ 5 files changed, 85 insertions(+), 30 deletions(-) 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