diff --git a/components/BuyCrypto/OnramperWidget/OnramperWidget.native.tsx b/components/BuyCrypto/OnramperWidget/OnramperWidget.native.tsx index c86290832..8f9cd1850 100644 --- a/components/BuyCrypto/OnramperWidget/OnramperWidget.native.tsx +++ b/components/BuyCrypto/OnramperWidget/OnramperWidget.native.tsx @@ -29,8 +29,8 @@ const APP_SCHEME = 'solid://'; * `mediaCapturePermissionGrantType` answers the WebView's own camera prompt, * which is asked separately from the OS permission. */ -export const OnramperWidget = ({ onOutcome }: OnramperWidgetProps) => { - const { data: session, isPending, isError, refetch } = useOnramperWidget(); +export const OnramperWidget = ({ destination = 'wallet', onOutcome }: OnramperWidgetProps) => { + const { data: session, isPending, isError, refetch } = useOnramperWidget(destination); const [isOpeningBrowser, setIsOpeningBrowser] = useState(false); /** diff --git a/components/BuyCrypto/OnramperWidget/OnramperWidget.web.tsx b/components/BuyCrypto/OnramperWidget/OnramperWidget.web.tsx index e4560c5d4..0f7fbdcc6 100644 --- a/components/BuyCrypto/OnramperWidget/OnramperWidget.web.tsx +++ b/components/BuyCrypto/OnramperWidget/OnramperWidget.web.tsx @@ -16,8 +16,8 @@ import { * missing entry raises no error — the step just quietly does nothing, deep * inside a provider's flow where we have no visibility at all. */ -export const OnramperWidget = (_props: OnramperWidgetProps) => { - const { data: session, isPending, isError, refetch } = useOnramperWidget(); +export const OnramperWidget = ({ destination = 'wallet' }: OnramperWidgetProps) => { + const { data: session, isPending, isError, refetch } = useOnramperWidget(destination); if (isPending) return ; if (isError || !session) { diff --git a/components/BuyCrypto/OnramperWidget/OnramperWidgetStates.tsx b/components/BuyCrypto/OnramperWidget/OnramperWidgetStates.tsx index 114974e55..dc74f610b 100644 --- a/components/BuyCrypto/OnramperWidget/OnramperWidgetStates.tsx +++ b/components/BuyCrypto/OnramperWidget/OnramperWidgetStates.tsx @@ -3,7 +3,15 @@ import { ActivityIndicator, View } from 'react-native'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; +import type { OnramperDestination } from '@/lib/api'; + export interface OnramperWidgetProps { + /** + * What the purchase funds. The card funding screens pass `card`, which the + * backend resolves to the user's card deposit address; everything else funds + * the wallet. + */ + destination?: OnramperDestination; /** * Called when a provider redirects back to us, on the platforms that redirect * at all. Advisory only — not every provider honours a redirect, so the diff --git a/components/BuyCrypto/Transfi/BuyCryptoFlow.tsx b/components/BuyCrypto/Transfi/BuyCryptoFlow.tsx index 334f7ebeb..ad797a377 100644 --- a/components/BuyCrypto/Transfi/BuyCryptoFlow.tsx +++ b/components/BuyCrypto/Transfi/BuyCryptoFlow.tsx @@ -44,9 +44,10 @@ export const BuyCryptoFlowContent = ({ // Onramper's hosted widget. It lives here rather than in a switch of its // own because this is the generic embed point: the card funding modals // render whatever DepositModal they are handed, so one case reaches all - // three of them. + // three of them. Being opened from a card funding modal is also why it + // delivers to the card deposit address rather than the wallet. case DEPOSIT_MODAL.OPEN_ONRAMPER_WIDGET.name: - return ; + return ; default: return null; } diff --git a/components/BuyCrypto/Transfi/__tests__/buyCryptoFlowContent.test.tsx b/components/BuyCrypto/Transfi/__tests__/buyCryptoFlowContent.test.tsx new file mode 100644 index 000000000..2dd47540e --- /dev/null +++ b/components/BuyCrypto/Transfi/__tests__/buyCryptoFlowContent.test.tsx @@ -0,0 +1,53 @@ +import React from 'react'; + +import { BuyCryptoFlowContent } from '@/components/BuyCrypto/Transfi/BuyCryptoFlow'; +import { DEPOSIT_MODAL } from '@/constants/modals'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { act, create } = require('react-test-renderer'); + +jest.mock('@/components/BuyCrypto/OnramperWidget/OnramperWidget', () => ({ + OnramperWidget: 'OnramperWidget', +})); +jest.mock('@/components/BuyCrypto/Transfi/BuyCryptoNavigation', () => ({ + BuyCryptoNavigationProvider: ({ children }: { children: React.ReactNode }) => children, +})); +jest.mock('@/components/BuyCrypto/Transfi/TransfiAmount', () => ({ TransfiAmount: 'Screen' })); +jest.mock('@/components/BuyCrypto/Transfi/TransfiCurrencySelector', () => ({ + TransfiCurrencySelector: 'Screen', +})); +jest.mock('@/components/BuyCrypto/Transfi/TransfiError', () => ({ TransfiError: 'Screen' })); +jest.mock('@/components/BuyCrypto/Transfi/TransfiKycConsent', () => ({ + TransfiKycConsent: 'Screen', +})); +jest.mock('@/components/BuyCrypto/Transfi/TransfiKycPending', () => ({ + TransfiKycPending: 'Screen', +})); +jest.mock('@/components/BuyCrypto/Transfi/TransfiOrderStatus', () => ({ + TransfiOrderStatus: 'Screen', +})); +jest.mock('@/components/BuyCrypto/Transfi/TransfiPayment', () => ({ TransfiPayment: 'Screen' })); +jest.mock('@/components/BuyCrypto/Transfi/TransfiPaymentMethodSelector', () => ({ + TransfiPaymentMethodSelector: 'Screen', +})); +jest.mock('@/components/BuyCrypto/Transfi/TransfiProfileForm', () => ({ + TransfiProfileForm: 'Screen', +})); + +beforeEach(() => { + (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +}); + +// Only the card funding modals embed this content, so the widget it opens must +// fund the card — a wallet URL here would deliver the purchase to the Safe. +it('opens the Onramper widget delivering to the card', () => { + let root: any; + act(() => { + root = create( + , + ); + }); + + expect(root.root.findByType('OnramperWidget').props.destination).toBe('card'); + act(() => root.unmount()); +}); diff --git a/components/Card/CardDirectDepositModalDesktop.tsx b/components/Card/CardDirectDepositModalDesktop.tsx index 0439177ec..61cadb843 100644 --- a/components/Card/CardDirectDepositModalDesktop.tsx +++ b/components/Card/CardDirectDepositModalDesktop.tsx @@ -16,6 +16,7 @@ import { } from '@/components/Card/CardFund/constants'; import DepositNetwork from '@/components/DepositNetwork/DepositNetwork'; import AddFundsToWalletForm from '@/components/DepositOption/AddFundsToWalletForm'; +import { UsdMethodList } from '@/components/DepositOption/DepositUsdOptions'; import VirtualAccountApplyDialog from '@/components/DepositOption/VirtualAccountDetails/VirtualAccountApplyDialog'; import { OrchestraFlowContent } from '@/components/Orchestra/OrchestraFlow'; import ResponsiveModal, { ModalState } from '@/components/ResponsiveModal'; @@ -44,12 +45,13 @@ import { useCardDepositStore } from '@/store/useCardDepositStore'; import { useDepositStore } from '@/store/useDepositStore'; import { useTransfiStore } from '@/store/useTransfiStore'; -type Step = 'options' | 'networks' | 'address' | 'walletNetworks' | 'form'; +type Step = 'options' | 'usdMethods' | 'networks' | 'address' | 'walletNetworks' | 'form'; const CLOSE_STATE: ModalState = { name: 'close', number: -1 }; const MODAL_STATES: Record = { options: { name: 'options', number: 0 }, + usdMethods: { name: 'usd-methods', number: 1 }, networks: { name: 'networks', number: 1 }, walletNetworks: { name: 'wallet-networks', number: 1 }, address: { name: 'address', number: 2 }, @@ -198,9 +200,13 @@ export default function CardDirectDepositModal({ [], ); - // First-time setup stacks above this funding dialog so closing it returns to - // "Fund your card". Existing accounts still hand off to the global details flow. - const handleUsdPress = useCallback(() => { + // USD lists its methods — the bank rail and Apple Pay — as the wallet's cash + // flow does, rather than going straight to the bank rail. + const handleUsdPress = useCallback(() => goToStep('usdMethods'), [goToStep]); + + // First-time setup stacks above this funding dialog so closing it returns here. + // Existing accounts still hand off to the global details flow. + const handleBankTransferPress = useCallback(() => { track(TRACKING_EVENTS.DEPOSIT_METHOD_SELECTED, { deposit_method: 'bank_transfer', provider: virtualAccountProvider, @@ -248,8 +254,17 @@ export default function CardDirectDepositModal({ ); // Onramper's hosted widget, rendered inside this modal by the same embedded - // navigator the TransFi screens use — so back and the title come from + // navigator the TransFi screens use — so its title comes from // lib/buyCryptoFlow, not from a step of our own. + const handleApplePayPress = useCallback(() => { + track(TRACKING_EVENTS.DEPOSIT_METHOD_SELECTED, { + deposit_method: 'buy_crypto', + provider: 'onramper', + currency: 'USD', + }); + navigateBuyCrypto(DEPOSIT_MODAL.OPEN_ONRAMPER_WIDGET); + }, [navigateBuyCrypto]); + // "Deposit from an external wallet" — connect a crypto wallet, then send. const handleConnectWallet = useCallback(async () => { try { @@ -347,7 +362,10 @@ export default function CardDirectDepositModal({ if (buyCryptoModal) { const target = getEmbeddedBackTarget(buyCryptoModal); if (target === 'entry') { - goToStep('options'); + // The widget is opened from the USD methods; the TransFi and Cash App + // screens from the options themselves. + const isWidget = buyCryptoModal.name === DEPOSIT_MODAL.OPEN_ONRAMPER_WIDGET.name; + goToStep(isWidget ? 'usdMethods' : 'options'); } else if (target) { navigateBuyCrypto(target); } @@ -370,6 +388,7 @@ export default function CardDirectDepositModal({ const title = (() => { if (buyCryptoModal) return getEmbeddedTitle(buyCryptoModal); + if (step === 'usdMethods') return 'Deposit US Dollars'; if (step === 'networks') return selectedToken; if (step === 'address') return `Deposit ${selectedToken}`; return 'Fund your card'; @@ -406,6 +425,15 @@ export default function CardDirectDepositModal({ ); } + if (step === 'usdMethods') { + return ( + + ); + } + if (step === 'networks') { return ; } diff --git a/components/Card/CardDirectDepositModalMobile.tsx b/components/Card/CardDirectDepositModalMobile.tsx index 3f321a491..33e4149ed 100644 --- a/components/Card/CardDirectDepositModalMobile.tsx +++ b/components/Card/CardDirectDepositModalMobile.tsx @@ -13,6 +13,7 @@ import { getCardFundTokenIcon, } from '@/components/Card/CardFund/constants'; import DepositPublicAddress from '@/components/DepositOption/DepositPublicAddress'; +import { UsdMethodList } from '@/components/DepositOption/DepositUsdOptions'; import VirtualAccountApplyDialog from '@/components/DepositOption/VirtualAccountDetails/VirtualAccountApplyDialog'; import { OrchestraFlowContent } from '@/components/Orchestra/OrchestraFlow'; import ResponsiveModal, { ModalState } from '@/components/ResponsiveModal'; @@ -37,12 +38,13 @@ import { useCardDepositStore } from '@/store/useCardDepositStore'; import { useDepositStore } from '@/store/useDepositStore'; import { useTransfiStore } from '@/store/useTransfiStore'; -type Step = 'options' | 'networks' | 'address' | 'externalAddress'; +type Step = 'options' | 'usdMethods' | 'networks' | 'address' | 'externalAddress'; const CLOSE_STATE: ModalState = { name: 'close', number: -1 }; const MODAL_STATES: Record = { options: { name: 'options', number: 0 }, + usdMethods: { name: 'usd-methods', number: 1 }, networks: { name: 'networks', number: 1 }, externalAddress: { name: 'external-address', number: 1 }, address: { name: 'address', number: 2 }, @@ -176,9 +178,13 @@ export default function CardDirectDepositModalMobile({ [], ); - // First-time setup stacks above this funding dialog so closing it returns to - // "Fund your card". Existing accounts still hand off to the global details flow. - const handleUsdPress = useCallback(() => { + // USD lists its methods — the bank rail and Apple Pay — as the wallet's cash + // flow does, rather than going straight to the bank rail. + const handleUsdPress = useCallback(() => goToStep('usdMethods'), [goToStep]); + + // First-time setup stacks above this funding dialog so closing it returns here. + // Existing accounts still hand off to the global details flow. + const handleBankTransferPress = useCallback(() => { track(TRACKING_EVENTS.DEPOSIT_METHOD_SELECTED, { deposit_method: 'bank_transfer', provider: virtualAccountProvider, @@ -226,8 +232,17 @@ export default function CardDirectDepositModalMobile({ ); // Onramper's hosted widget, rendered inside this modal by the same embedded - // navigator the TransFi screens use — so back and the title come from + // navigator the TransFi screens use — so its title comes from // lib/buyCryptoFlow, not from a step of our own. + const handleApplePayPress = useCallback(() => { + track(TRACKING_EVENTS.DEPOSIT_METHOD_SELECTED, { + deposit_method: 'buy_crypto', + provider: 'onramper', + currency: 'USD', + }); + navigateBuyCrypto(DEPOSIT_MODAL.OPEN_ONRAMPER_WIDGET); + }, [navigateBuyCrypto]); + const handleTransferFromWallet = useCallback(() => { handleOpenChange(false); setDepositModal(CARD_DEPOSIT_MODAL.OPEN_INTERNAL_FORM); @@ -279,7 +294,10 @@ export default function CardDirectDepositModalMobile({ if (buyCryptoModal) { const target = getEmbeddedBackTarget(buyCryptoModal); if (target === 'entry') { - goToStep('options'); + // The widget is opened from the USD methods; the TransFi and Cash App + // screens from the options themselves. + const isWidget = buyCryptoModal.name === DEPOSIT_MODAL.OPEN_ONRAMPER_WIDGET.name; + goToStep(isWidget ? 'usdMethods' : 'options'); } else if (target) { navigateBuyCrypto(target); } @@ -298,6 +316,7 @@ export default function CardDirectDepositModalMobile({ const title = (() => { if (buyCryptoModal) return getEmbeddedTitle(buyCryptoModal); + if (step === 'usdMethods') return 'Deposit US Dollars'; if (step === 'networks') return selectedToken; if (step === 'address') return `Deposit ${selectedToken}`; return 'Fund your card'; @@ -333,6 +352,15 @@ export default function CardDirectDepositModalMobile({ ); } + if (step === 'usdMethods') { + return ( + + ); + } + if (step === 'networks') { return ; } diff --git a/components/Card/CardFund/CardFundOptions.tsx b/components/Card/CardFund/CardFundOptions.tsx index 863b5fe74..443985175 100644 --- a/components/Card/CardFund/CardFundOptions.tsx +++ b/components/Card/CardFund/CardFundOptions.tsx @@ -30,7 +30,7 @@ type CardFundOptionsProps = { onTokenPress: (symbol: string) => void; onMoveFromSavingsPress?: () => void; onExternalWalletPress?: () => void; - /** USD (ACH / Wire) — opens the virtual-account flow. */ + /** USD — opens its methods: the virtual account (ACH / Wire) or Apple Pay. */ onUsdPress?: () => void; /** * A local currency (BRL, BDT…) — opens the buy-crypto onramp for it. Omit to @@ -109,7 +109,7 @@ const CardFundOptions = ({ } title="USD" - chips={['ACH', 'Wire']} + chips={['ACH', 'Wire', 'Apple Pay']} onPress={onUsdPress} /> ) : null} diff --git a/components/DepositOption/DepositCashOptions.tsx b/components/DepositOption/DepositCashOptions.tsx index 418f37a3f..3ba56a0e6 100644 --- a/components/DepositOption/DepositCashOptions.tsx +++ b/components/DepositOption/DepositCashOptions.tsx @@ -14,27 +14,24 @@ 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 { useCashAppDepositAvailability } from '@/hooks/useCashAppDepositAvailability'; import useGeoCompliance from '@/hooks/useGeoCompliance'; -import { useOrchestraConfig } from '@/hooks/useOrchestra'; -import { useVirtualAccountEntry } from '@/hooks/useVirtualAccountEntry'; +import { useIsCashAppAvailable } from '@/hooks/useOrchestra'; import { track } from '@/lib/analytics'; import { getAsset } from '@/lib/assets'; import { useDepositStore } from '@/store/useDepositStore'; import { useTransfiStore } from '@/store/useTransfiStore'; -import VirtualAccountApplyDialog from './VirtualAccountDetails/VirtualAccountApplyDialog'; - const ICON_SIZE = 36; /** Matches the muted row text the "Show more" footer sits beside. */ const SHOW_MORE_ICON_COLOR = 'rgba(255,255,255,0.7)'; /** - * USD is funded by the virtual account, which only takes ACH and wire. The - * local currencies show their committed corridor list (localCurrencies.tsx). + * USD's methods, as the screen it opens lists them: the virtual account's ACH + * and wire, and Apple Pay. The local currencies show their committed corridor + * list (localCurrencies.tsx). */ -const USD_PAYMENT_METHOD_CHIPS = ['ACH', 'Wire']; -/** In the US the same row also leads to Cash App, so the chips say so. */ -const USD_PAYMENT_METHOD_CHIPS_US = ['ACH', 'Wire', 'Cash App']; +const USD_PAYMENT_METHOD_CHIPS = ['ACH', 'Wire', 'Apple Pay']; +/** In the US the same screen also offers Cash App, so the chips say so. */ +const USD_PAYMENT_METHOD_CHIPS_US = ['ACH', 'Wire', 'Cash App', 'Apple Pay']; const FEATURED_LOCAL_CURRENCY_CODES = ['EUR', 'BRL', 'BDT', 'PHP'] as const; const ADDITIONAL_LOCAL_CURRENCY_CODES = ['MXN'] as const; @@ -50,9 +47,9 @@ export const DEPOSIT_CASH_CURRENCY_COUNT = export const DEPOSIT_CASH_CLUSTER_ICONS = [CARD_FUND_USD_ICON, getAsset('images/flag-eur.png')]; /** - * "Deposit with cash" — the cash branch of the deposit chooser. USD opens the - * virtual account (its own bank details, funded by ACH/wire); every other - * currency opens the TransFi onramp preseeded with it. + * "Deposit with cash" — the cash branch of the deposit chooser. USD opens its + * methods (the virtual account's ACH/wire, Apple Pay, and Cash App in the US); + * every other currency opens the TransFi onramp preseeded with it. */ const DepositCashOptions = () => { const setModal = useDepositStore(state => state.setModal); @@ -60,13 +57,7 @@ const DepositCashOptions = () => { const setTransfiCurrency = useTransfiStore(state => state.setFiatCurrency); const [showAllCurrencies, setShowAllCurrencies] = useState(false); - const { open: openVirtualAccount, isApplyOpen, closeApply } = useVirtualAccountEntry(); - // One rule, decided server-side: supported region **or** allowlisted. The - // country is resolved here only because the backend has no geoip — the verdict - // is still theirs, and they enforce it again on order creation. - const { countryCode, isResolving: isResolvingCountry } = useCashAppDepositAvailability(); - const { data: orchestraConfig } = useOrchestraConfig(countryCode, !isResolvingCountry); - const isCashAppAvailable = orchestraConfig?.isAvailable === true; + const isCashAppAvailable = useIsCashAppAvailable(); const { isBuyCryptoAvailable } = useGeoCompliance(); const { handleBuyCryptoPress } = useBuyCryptoEntry(); @@ -80,16 +71,10 @@ const DepositCashOptions = () => { .filter((currency): currency is (typeof CARD_FUND_LOCAL_CURRENCIES)[number] => !!currency); }, [showAllCurrencies]); + // The bank rail and Apple Pay are offered everywhere, so USD always has more + // than one method and always opens the chooser, with Cash App added in the US. const handleUsdPress = () => { - // Two USD rails in the US, one everywhere else. Showing a chooser outside - // the US would ask a question with a single answer, so USD goes straight to - // the bank rail there — which is exactly what it did before Cash App - // existed. - if (isCashAppAvailable) { - setModal(DEPOSIT_MODAL.OPEN_DEPOSIT_USD_METHOD); - return; - } - openVirtualAccount(); + setModal(DEPOSIT_MODAL.OPEN_DEPOSIT_USD_METHOD); }; const handleLocalCurrencyPress = (code: string) => { @@ -109,51 +94,47 @@ const DepositCashOptions = () => { }; return ( - <> - + + + } + title="USD" + chips={isCashAppAvailable ? USD_PAYMENT_METHOD_CHIPS_US : USD_PAYMENT_METHOD_CHIPS} + onPress={handleUsdPress} + /> + {localCurrencies.map(currency => ( - } - title="USD" - chips={isCashAppAvailable ? USD_PAYMENT_METHOD_CHIPS_US : USD_PAYMENT_METHOD_CHIPS} - onPress={handleUsdPress} + icon={currency.icon} + title={currency.code} + chips={getCardFundLocalPaymentMethods(currency.code)} + onPress={() => handleLocalCurrencyPress(currency.code)} /> - {localCurrencies.map(currency => ( - handleLocalCurrencyPress(currency.code)} - /> - ))} - {/* Owned here rather than by CardFundGroup's own footer: the toggle also - decides which corridors are in the list at all, and so whether their - payment methods are fetched. */} - setShowAllCurrencies(current => !current)} - > - {showAllCurrencies ? ( - - ) : ( - - )} - - {showAllCurrencies ? 'Show less' : 'Show more'} - - - - - - + ))} + {/* Owned here rather than by CardFundGroup's own footer: the toggle also + decides which corridors are in the list at all, and so whether their + payment methods are fetched. */} + setShowAllCurrencies(current => !current)} + > + {showAllCurrencies ? ( + + ) : ( + + )} + + {showAllCurrencies ? 'Show less' : 'Show more'} + + + ); }; diff --git a/components/DepositOption/DepositUsdOptions.tsx b/components/DepositOption/DepositUsdOptions.tsx index 068632a64..6a3f6552f 100644 --- a/components/DepositOption/DepositUsdOptions.tsx +++ b/components/DepositOption/DepositUsdOptions.tsx @@ -2,10 +2,12 @@ import { useEffect } from 'react'; import { View } from 'react-native'; import { Building2, Zap } from 'lucide-react-native'; +import ApplePayCircle from '@/assets/images/apple-pay-circle'; 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 { useIsCashAppAvailable } from '@/hooks/useOrchestra'; import { useVirtualAccountEntry } from '@/hooks/useVirtualAccountEntry'; import { track } from '@/lib/analytics'; import { useDepositStore } from '@/store/useDepositStore'; @@ -19,17 +21,81 @@ const BANK_CHIPS = ['Wire', 'ACH']; // The row is already titled Cash App; the chip says how fast, not how. const CASH_APP_CHIPS = ['Instant']; +type UsdMethodListProps = { + onBankTransferPress: () => void; + /** Omit to hide the row — Cash App is only offered where the server allows it. */ + onCashAppPress?: () => void; + onApplePayPress: () => void; +}; + +/** + * The USD methods as rows. Shared by this screen and the card funding modals' + * USD step, so the two lists cannot drift apart; each caller decides what a + * press does, because each owns a different navigator. + */ +export const UsdMethodList = ({ + onBankTransferPress, + onCashAppPress, + onApplePayPress, +}: UsdMethodListProps) => ( + + + + + } + title="Wire transfer, ACH" + subtitle="Your own US account details" + onPress={onBankTransferPress} + chips={BANK_CHIPS} + /> + {onCashAppPress ? ( + + + + } + title="Cash App" + subtitle="Pay from your Cash App balance" + onPress={onCashAppPress} + chips={CASH_APP_CHIPS} + /> + ) : null} + {/* Onramper's hosted widget. Apple Pay is the name the row goes by, but the + widget also takes cards, so the subtitle says so. */} + } + title="Apple Pay" + subtitle="Pay with Apple Pay or a card" + onPress={onApplePayPress} + /> + +); + /** - * How to fund in USD: the bank rail, or Cash App over Lightning. + * How to fund in USD: the bank rail, Apple Pay through Onramper's widget, or + * Cash App over Lightning. * - * Only reached when Cash App is available — outside the US the cash list sends - * USD straight to the virtual account, because a chooser with one option is a - * tap that asks a question with one answer. + * 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. */ const DepositUsdOptions = () => { const setModal = useDepositStore(state => state.setModal); const resetOrchestra = useOrchestraStore(state => state.reset); const { open: openVirtualAccount, isApplyOpen, closeApply } = useVirtualAccountEntry(); + const isCashAppAvailable = useIsCashAppAvailable(); useEffect(() => { track(TRACKING_EVENTS.DEPOSIT_USD_METHOD_VIEWED); @@ -47,40 +113,22 @@ const DepositUsdOptions = () => { setModal(DEPOSIT_MODAL.OPEN_ORCHESTRA_AMOUNT); }; + const handleApplePayPress = () => { + track(TRACKING_EVENTS.DEPOSIT_METHOD_SELECTED, { + deposit_method: 'buy_crypto', + provider: 'onramper', + currency: 'USD', + }); + setModal(DEPOSIT_MODAL.OPEN_ONRAMPER_WIDGET); + }; + return ( <> - - - - - } - title="Wire transfer, ACH" - subtitle="Your own US account details" - onPress={openVirtualAccount} - chips={BANK_CHIPS} - /> - - - - } - title="Cash App" - subtitle="Pay from your Cash App balance" - onPress={handleCashAppPress} - chips={CASH_APP_CHIPS} - /> - + diff --git a/components/DepositOption/__tests__/depositUsdOptions.test.tsx b/components/DepositOption/__tests__/depositUsdOptions.test.tsx new file mode 100644 index 000000000..64bf4abc7 --- /dev/null +++ b/components/DepositOption/__tests__/depositUsdOptions.test.tsx @@ -0,0 +1,83 @@ +import React from 'react'; + +import DepositUsdOptions from '@/components/DepositOption/DepositUsdOptions'; +import { DEPOSIT_MODAL } from '@/constants/modals'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { act, create } = require('react-test-renderer'); + +jest.mock('lucide-react-native', () => ({ Building2: 'Building2', Zap: 'Zap' })); +jest.mock('@/assets/images/apple-pay-circle', () => 'ApplePayCircle'); +jest.mock('@/components/Card/CardFund/CardFundGroup', () => 'CardFundGroup'); +jest.mock('@/components/Card/CardFund/CardFundRow', () => 'CardFundRow'); +jest.mock( + '@/components/DepositOption/VirtualAccountDetails/VirtualAccountApplyDialog', + () => 'VirtualAccountApplyDialog', +); +jest.mock('@/hooks/useOrchestra', () => ({ + useIsCashAppAvailable: () => mockCashApp.isAvailable, +})); +jest.mock('@/hooks/useVirtualAccountEntry', () => ({ + useVirtualAccountEntry: () => ({ + open: mockOpenVirtualAccount, + isApplyOpen: false, + closeApply: jest.fn(), + }), +})); +jest.mock('@/lib/analytics', () => ({ track: jest.fn() })); +jest.mock('@/store/useDepositStore', () => ({ + useDepositStore: (selector: (state: any) => unknown) => selector(mockDeposit), +})); +jest.mock('@/store/useOrchestraStore', () => ({ + useOrchestraStore: (selector: (state: any) => unknown) => selector({ reset: jest.fn() }), +})); + +const mockCashApp = { isAvailable: false }; +const mockOpenVirtualAccount = jest.fn(); +const mockDeposit = { setModal: jest.fn() }; + +const render = () => { + let root: any; + act(() => { + root = create(); + }); + return root; +}; + +const rowsOf = (root: any) => root.root.findAllByType('CardFundRow'); +const titlesOf = (root: any) => rowsOf(root).map((row: any) => row.props.title); + +beforeEach(() => { + (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + jest.clearAllMocks(); + mockCashApp.isAvailable = false; +}); + +it('offers Apple Pay beside the bank rail where Cash App is not available', () => { + const root = render(); + expect(titlesOf(root)).toEqual(['Wire transfer, ACH', 'Apple Pay']); + act(() => root.unmount()); +}); + +it('adds Cash App between them where it is available', () => { + mockCashApp.isAvailable = true; + const root = render(); + expect(titlesOf(root)).toEqual(['Wire transfer, ACH', 'Cash App', 'Apple Pay']); + act(() => root.unmount()); +}); + +it('opens the Onramper widget from Apple Pay', () => { + const root = render(); + const applePay = rowsOf(root).find((row: any) => row.props.title === 'Apple Pay'); + act(() => applePay.props.onPress()); + expect(mockDeposit.setModal).toHaveBeenCalledWith(DEPOSIT_MODAL.OPEN_ONRAMPER_WIDGET); + act(() => root.unmount()); +}); + +it('still opens the virtual account from the bank rail', () => { + const root = render(); + act(() => rowsOf(root)[0].props.onPress()); + expect(mockOpenVirtualAccount).toHaveBeenCalledTimes(1); + expect(mockDeposit.setModal).not.toHaveBeenCalled(); + act(() => root.unmount()); +}); diff --git a/constants/modals.ts b/constants/modals.ts index c6ec6f91a..2468aa6bf 100644 --- a/constants/modals.ts +++ b/constants/modals.ts @@ -29,9 +29,9 @@ export const DEPOSIT_MODAL = { }, /** * "Deposit US Dollars" — how to fund in USD: the bank rail (the virtual - * account's own wire/ACH details) or Cash App. Only reached where Cash App is - * available; elsewhere the cash list opens the virtual account directly, - * since a chooser with one option asks a question with one answer. + * account's own wire/ACH details), Apple Pay through Onramper's widget, or + * Cash App where it is available. Always reached from the cash list's USD + * row, since the first two are offered everywhere. */ OPEN_DEPOSIT_USD_METHOD: { name: 'open_deposit_usd_method', diff --git a/hooks/useDepositOption.tsx b/hooks/useDepositOption.tsx index cdc7f93fd..6eb2dadae 100644 --- a/hooks/useDepositOption.tsx +++ b/hooks/useDepositOption.tsx @@ -343,7 +343,7 @@ const useDepositOption = ({ } if (isOnramperWidget) { - return setModal(DEPOSIT_MODAL.OPEN_DEPOSIT_CASH)} />; + return setModal(DEPOSIT_MODAL.OPEN_DEPOSIT_USD_METHOD)} />; } if (isDepositUsdMethod) { @@ -853,8 +853,8 @@ const useDepositOption = ({ // The onramp is only ever entered by picking a currency on the cash screen. setModal(DEPOSIT_MODAL.OPEN_DEPOSIT_CASH); } else if (isOnramperWidget) { - // Entered from the cash screen's "Buy crypto" row. - setModal(DEPOSIT_MODAL.OPEN_DEPOSIT_CASH); + // Entered from the USD methods' "Apple Pay" row. + setModal(DEPOSIT_MODAL.OPEN_DEPOSIT_USD_METHOD); } else if (isBuyCryptoCurrency || isBuyCryptoPaymentMethod) { setModal(DEPOSIT_MODAL.OPEN_BUY_CRYPTO_AMOUNT); } else if (isBuyCryptoPayment) { diff --git a/hooks/useOnramperWidget.ts b/hooks/useOnramperWidget.ts index b045e97e3..15b99e307 100644 --- a/hooks/useOnramperWidget.ts +++ b/hooks/useOnramperWidget.ts @@ -1,13 +1,13 @@ import { Platform } from 'react-native'; import { useQuery } from '@tanstack/react-query'; -import { fetchOnramperWidgetSession } from '@/lib/api'; +import { fetchOnramperWidgetSession, OnramperDestination } from '@/lib/api'; import { withRefreshToken } from '@/lib/utils'; const ONRAMPER_WIDGET_SESSION_KEY = 'onramperWidgetSession'; /** - * A signed widget URL for this platform. + * A signed widget URL for this platform, delivering to `destination`. * * Nothing is cached between mounts. Onramper's signature is single-use and * expires after 15 minutes, so a URL kept from a previous visit loads a widget @@ -15,11 +15,15 @@ const ONRAMPER_WIDGET_SESSION_KEY = 'onramperWidgetSession'; * is trying to pay. Minting one per open costs a request and removes the whole * class of problem. */ -export default function useOnramperWidget() { +export default function useOnramperWidget(destination: OnramperDestination) { return useQuery({ - queryKey: [ONRAMPER_WIDGET_SESSION_KEY, Platform.OS], + // The destination is part of the key: a wallet URL and a card URL deliver + // to different addresses, so one must never stand in for the other. + queryKey: [ONRAMPER_WIDGET_SESSION_KEY, Platform.OS, destination], queryFn: () => - withRefreshToken(() => fetchOnramperWidgetSession(Platform.OS === 'web' ? 'web' : 'native')), + withRefreshToken(() => + fetchOnramperWidgetSession(Platform.OS === 'web' ? 'web' : 'native', destination), + ), // A fresh URL every time this mounts, and none left behind afterwards. staleTime: 0, gcTime: 0, diff --git a/hooks/useOrchestra.ts b/hooks/useOrchestra.ts index 394c42a40..bf4bb0062 100644 --- a/hooks/useOrchestra.ts +++ b/hooks/useOrchestra.ts @@ -1,5 +1,6 @@ import { useMutation, useQuery } from '@tanstack/react-query'; +import { useCashAppDepositAvailability } from '@/hooks/useCashAppDepositAvailability'; import { createOrchestraOnramp, getOrchestraConfig, getOrchestraStatus } from '@/lib/api/orchestra'; import { OrchestraError } from '@/lib/orchestraErrors'; import { ORCHESTRA_SETTLED_STATUSES } from '@/lib/types/orchestra'; @@ -48,6 +49,23 @@ export function useOrchestraConfig(countryCode?: string, enabled = true) { }); } +/** + * Whether Cash App can be offered here. One rule, decided server-side: + * supported region **or** allowlisted. The country is resolved on the client + * only because the backend has no geoip — the verdict is still theirs, and they + * enforce it again on order creation. + * + * Shared by the cash list's USD chips, the USD methods screen and the card + * funding modals' Cash App row, which ask the same question and share the one + * cached config call. + */ +export function useIsCashAppAvailable() { + const { countryCode, isResolving } = useCashAppDepositAvailability(); + const { data: config } = useOrchestraConfig(countryCode, !isResolving); + + return config?.isAvailable === true; +} + /** * Create the order and its invoice. * diff --git a/hooks/useOrchestraCardEntry.ts b/hooks/useOrchestraCardEntry.ts index ec83a0135..dfecc43ac 100644 --- a/hooks/useOrchestraCardEntry.ts +++ b/hooks/useOrchestraCardEntry.ts @@ -3,8 +3,7 @@ import { useCallback } from 'react'; import { OrchestraNavigate } from '@/components/Orchestra/OrchestraNavigation'; import { DEPOSIT_MODAL } from '@/constants/modals'; import { TRACKING_EVENTS } from '@/constants/tracking-events'; -import { useCashAppDepositAvailability } from '@/hooks/useCashAppDepositAvailability'; -import { useOrchestraConfig } from '@/hooks/useOrchestra'; +import { useIsCashAppAvailable } from '@/hooks/useOrchestra'; import { track } from '@/lib/analytics'; import { useOrchestraStore } from '@/store/useOrchestraStore'; @@ -22,9 +21,7 @@ import { useOrchestraStore } from '@/store/useOrchestraStore'; export const useOrchestraCardEntry = (navigate: OrchestraNavigate) => { const reset = useOrchestraStore(state => state.reset); const setDestination = useOrchestraStore(state => state.setDestination); - - const { countryCode, isResolving } = useCashAppDepositAvailability(); - const { data: config } = useOrchestraConfig(countryCode, !isResolving); + const isAvailable = useIsCashAppAvailable(); const openCashApp = useCallback(() => { track(TRACKING_EVENTS.DEPOSIT_METHOD_SELECTED, { @@ -40,7 +37,7 @@ export const useOrchestraCardEntry = (navigate: OrchestraNavigate) => { navigate(DEPOSIT_MODAL.OPEN_ORCHESTRA_AMOUNT); }, [navigate, reset, setDestination]); - return { openCashApp, isAvailable: config?.isAvailable === true }; + return { openCashApp, isAvailable }; }; export default useOrchestraCardEntry; diff --git a/lib/api.ts b/lib/api.ts index 95d61e677..5b31de02a 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -2310,14 +2310,23 @@ export interface OnramperWidgetSession { expiresAt: string; } +/** + * What an Onramper purchase funds: the wallet (the Safe), or the card by way of + * the card deposit address. + */ +export type OnramperDestination = 'wallet' | 'card'; + /** * Mints a signed widget URL for the signed-in user. * * The destination address is not sent — the backend reads it from the * authenticated user, so nothing the client says can redirect the delivery. + * `destination` picks only the route, which the backend resolves to that user's + * own Safe or card deposit address. */ export const fetchOnramperWidgetSession = async ( platform: 'web' | 'native', + destination: OnramperDestination, ): Promise => { const jwt = getJWTToken(); @@ -2331,7 +2340,7 @@ export const fetchOnramperWidgetSession = async ( ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}), }, credentials: 'include', - body: JSON.stringify({ platform }), + body: JSON.stringify({ platform, destination }), }, ); diff --git a/lib/buyCryptoFlow.ts b/lib/buyCryptoFlow.ts index 29654c50a..43820d52c 100644 --- a/lib/buyCryptoFlow.ts +++ b/lib/buyCryptoFlow.ts @@ -43,7 +43,8 @@ export const getBuyCryptoBackTarget = (modal: DepositModal): BuyCryptoBackTarget case DEPOSIT_MODAL.OPEN_BUY_CRYPTO_KYC_PENDING.name: case DEPOSIT_MODAL.OPEN_BUY_CRYPTO_AMOUNT.name: // Onramper's widget is a single screen: everything inside it is the - // provider's own, so the only way back is out to the funding options. + // provider's own, so the only way back is out of it — to the USD methods it + // is opened from, which the funding modals resolve 'entry' to. case DEPOSIT_MODAL.OPEN_ONRAMPER_WIDGET.name: return 'entry'; case DEPOSIT_MODAL.OPEN_BUY_CRYPTO_CURRENCY.name: