From 49d59d8a25a8eeedf516f00063383447a4b4af09 Mon Sep 17 00:00:00 2001 From: mul53 Date: Mon, 28 Sep 2026 08:14:14 +0200 Subject: [PATCH] feat(deposit): Apple Pay under USD, funding the card from card screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Onramper's hosted widget is back, as an "Apple Pay" method under USD in both deposit flows. - Wallet: Cash → USD → "Deposit US Dollars" lists Wire transfer/ACH, Cash App where the server allows it, and Apple Pay. The bank rail and Apple Pay are offered everywhere, so USD now always opens the chooser instead of going straight to the bank rail outside the US. - Card (Rain mobile and desktop): USD opens the same list — Wire transfer/ACH and Apple Pay — as a step of the funding modal, and back from the widget returns to it. Wirex's funding screen has no USD section and is unchanged. - The rows live in one UsdMethodList shared by both flows, and Cash App availability in one useIsCashAppAvailable hook shared by the cash list's chips and the USD list. - The widget session now names its destination: the card funding modals ask for `card`, so the purchase lands on the card deposit address and arrives as card balance, while the wallet flow keeps `wallet`. Until the matching backend change deploys, the field is ignored and card-screen purchases keep landing in the wallet. Co-Authored-By: Claude Opus 5.5 --- .../OnramperWidget/OnramperWidget.native.tsx | 4 +- .../OnramperWidget/OnramperWidget.web.tsx | 4 +- .../OnramperWidget/OnramperWidgetStates.tsx | 8 ++ .../BuyCrypto/Transfi/BuyCryptoFlow.tsx | 5 +- .../__tests__/buyCryptoFlowContent.test.tsx | 53 ++++++++ .../Card/CardDirectDepositModalDesktop.tsx | 40 +++++- .../Card/CardDirectDepositModalMobile.tsx | 40 +++++- components/Card/CardFund/CardFundOptions.tsx | 4 +- .../DepositOption/DepositCashOptions.tsx | 123 ++++++++---------- .../DepositOption/DepositUsdOptions.tsx | 120 ++++++++++++----- .../__tests__/depositUsdOptions.test.tsx | 83 ++++++++++++ constants/modals.ts | 6 +- hooks/useDepositOption.tsx | 6 +- hooks/useOnramperWidget.ts | 14 +- hooks/useOrchestra.ts | 17 +++ lib/api.ts | 11 +- lib/buyCryptoFlow.ts | 3 +- 17 files changed, 401 insertions(+), 140 deletions(-) create mode 100644 components/BuyCrypto/Transfi/__tests__/buyCryptoFlowContent.test.tsx create mode 100644 components/DepositOption/__tests__/depositUsdOptions.test.tsx 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 405dac48c..5d8ff9a02 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 ResponsiveModal, { ModalState } from '@/components/ResponsiveModal'; import { Text } from '@/components/ui/text'; @@ -41,12 +42,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 }, @@ -181,9 +183,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, @@ -231,8 +237,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 { @@ -330,7 +345,10 @@ export default function CardDirectDepositModal({ if (buyCryptoModal) { const target = getBuyCryptoBackTarget(buyCryptoModal); if (target === 'entry') { - goToStep('options'); + // The widget is opened from the USD methods; the TransFi screens from + // the options themselves. + const isWidget = buyCryptoModal.name === DEPOSIT_MODAL.OPEN_ONRAMPER_WIDGET.name; + goToStep(isWidget ? 'usdMethods' : 'options'); } else if (target) { navigateBuyCrypto(target); } @@ -353,6 +371,7 @@ export default function CardDirectDepositModal({ const title = (() => { if (buyCryptoModal) return getBuyCryptoTitle(buyCryptoModal); + if (step === 'usdMethods') return 'Deposit US Dollars'; if (step === 'networks') return selectedToken; if (step === 'address') return `Deposit ${selectedToken}`; return 'Fund your card'; @@ -384,6 +403,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 9ccd0a20f..5d13b87db 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 ResponsiveModal, { ModalState } from '@/components/ResponsiveModal'; import { CARD_DEPOSIT_MODAL, DEPOSIT_MODAL } from '@/constants/modals'; @@ -34,12 +35,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 }, @@ -159,9 +161,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, @@ -209,8 +215,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); @@ -262,7 +277,10 @@ export default function CardDirectDepositModalMobile({ if (buyCryptoModal) { const target = getBuyCryptoBackTarget(buyCryptoModal); if (target === 'entry') { - goToStep('options'); + // The widget is opened from the USD methods; the TransFi screens from + // the options themselves. + const isWidget = buyCryptoModal.name === DEPOSIT_MODAL.OPEN_ONRAMPER_WIDGET.name; + goToStep(isWidget ? 'usdMethods' : 'options'); } else if (target) { navigateBuyCrypto(target); } @@ -281,6 +299,7 @@ export default function CardDirectDepositModalMobile({ const title = (() => { if (buyCryptoModal) return getBuyCryptoTitle(buyCryptoModal); + if (step === 'usdMethods') return 'Deposit US Dollars'; if (step === 'networks') return selectedToken; if (step === 'address') return `Deposit ${selectedToken}`; return 'Fund your card'; @@ -311,6 +330,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 007ed9d74..19770abc5 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 @@ -102,7 +102,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 83e97e131..7749b3eb7 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,22 @@ 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 and the USD methods screen, 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/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: