Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions components/Card/__tests__/CardFundOptions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({}));
Expand Down
5 changes: 4 additions & 1 deletion components/DepositOption/DepositCashOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -51,6 +53,7 @@ const DepositCashOptions = () => {

const [showAllCurrencies, setShowAllCurrencies] = useState(false);
const isCashAppAvailable = useIsCashAppAvailable();
const { provider: cardProvider } = useCardProvider();
const { isBuyCryptoAvailable } = useGeoCompliance();
const { handleBuyCryptoPress } = useBuyCryptoEntry();

Expand Down Expand Up @@ -98,7 +101,7 @@ const DepositCashOptions = () => {
/>
}
title="USD"
chips={getUsdMethodChips(isCashAppAvailable)}
chips={getUsdMethodChips(isCashAppAvailable, canFundByUsdBankTransfer(cardProvider))}
onPress={handleUsdPress}
/>
{localCurrencies.map(currency => (
Expand Down
66 changes: 38 additions & 28 deletions components/DepositOption/DepositUsdOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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;
Expand All @@ -42,21 +42,23 @@ export const UsdMethodList = ({
onApplePayPress,
}: UsdMethodListProps) => (
<CardFundGroup>
<CardFundRow
className="min-h-[93px]"
icon={
<View
className="items-center justify-center rounded-full bg-[#333333]"
style={{ width: ICON_SIZE, height: ICON_SIZE }}
>
<Building2 size={18} color="#FFFFFF" />
</View>
}
title="Wire transfer, ACH"
subtitle="Your own US account details"
onPress={onBankTransferPress}
chips={BANK_CHIPS}
/>
{onBankTransferPress ? (
<CardFundRow
className="min-h-[93px]"
icon={
<View
className="items-center justify-center rounded-full bg-[#333333]"
style={{ width: ICON_SIZE, height: ICON_SIZE }}
>
<Building2 size={18} color="#FFFFFF" />
</View>
}
title="Wire transfer, ACH"
subtitle="Your own US account details"
onPress={onBankTransferPress}
chips={BANK_CHIPS}
/>
) : null}
{onCashAppPress ? (
<CardFundRow
className="min-h-[93px]"
Expand Down Expand Up @@ -88,26 +90,32 @@ export const UsdMethodList = ({

/**
* The chips for a USD row that opens {@link UsdMethodList}, naming the methods
* it lists — Cash App only where it is offered, since only there does the list
* show it. Shared by the wallet's cash list and the card funding options, so
* the two USD rows cannot drift apart.
* it lists — Cash App only where it is offered, and ACH / Wire only where the
* bank rail is, since only there does the list show them. Shared by the
* wallet's cash list and the card funding options, so the two USD rows cannot
* drift apart.
*/
export const getUsdMethodChips = (isCashAppAvailable: boolean) =>
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);
Expand Down Expand Up @@ -137,7 +145,9 @@ const DepositUsdOptions = () => {
return (
<>
<UsdMethodList
onBankTransferPress={openVirtualAccount}
onBankTransferPress={
canFundByUsdBankTransfer(cardProvider) ? openVirtualAccount : undefined
}
onCashAppPress={isCashAppAvailable ? handleCashAppPress : undefined}
onApplePayPress={handleApplePayPress}
/>
Expand Down
31 changes: 30 additions & 1 deletion components/DepositOption/__tests__/depositUsdOptions.test.tsx
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -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,
}));
Expand All @@ -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() };

Expand All @@ -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', () => {
Expand Down Expand Up @@ -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']);
});
12 changes: 12 additions & 0 deletions lib/utils/cardHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading