diff --git a/components/DepositOption/DepositBuyCryptoOptions/DepositBuyCryptoOptions.native.tsx b/components/DepositOption/DepositBuyCryptoOptions/DepositBuyCryptoOptions.native.tsx index e87bc6e2..255a0024 100644 --- a/components/DepositOption/DepositBuyCryptoOptions/DepositBuyCryptoOptions.native.tsx +++ b/components/DepositOption/DepositBuyCryptoOptions/DepositBuyCryptoOptions.native.tsx @@ -1,65 +1,16 @@ -import { useCallback, useMemo } from 'react'; import { View } from 'react-native'; -import { Image } from 'expo-image'; import DepositOption from '@/components/DepositOption/DepositOption'; -import { DEPOSIT_MODAL } from '@/constants/modals'; -import { getAsset } from '@/lib/assets'; -import { DepositMethod } from '@/lib/types'; +import useDepositBuyCryptoOptions from '@/hooks/useDepositBuyCryptoOptions'; import { getVaultDepositConfig } from '@/lib/vaults'; -import { useDepositStore } from '@/store/useDepositStore'; const DepositBuyCryptoOptions = () => { - const setModal = useDepositStore(state => state.setModal); + const { buyCryptoOptions } = useDepositBuyCryptoOptions(); const depositConfig = getVaultDepositConfig(); - // const handleBankDepositPress = useCallback(() => { - // setModal(DEPOSIT_MODAL.OPEN_BANK_TRANSFER_AMOUNT); - // }, [setModal]); - - // const handleCreditCardPress = useCallback(() => { - // setModal(DEPOSIT_MODAL.OPEN_BUY_CRYPTO); - // }, [setModal]); - - const buyCryptoOptions = useMemo( - () => [ - // { - // text: 'Debit/Credit Card', - // subtitle: 'Google Pay, card or bank account', - // icon: ( - // - // ), - // onPress: handleCreditCardPress, - // method: 'credit_card' as DepositMethod, - // }, - // { - // text: 'Bank Deposit', - // subtitle: 'Make a transfer from your bank.', - // icon: ( - // - // ), - // onPress: handleBankDepositPress, - // isComingSoon: false, - // method: 'bank_transfer' as DepositMethod, - // }, - ], - [ - // handleCreditCardPress, - // handleBankDepositPress - ], - ); - return ( - {/* {buyCryptoOptions + {buyCryptoOptions .filter(option => !option.method || depositConfig.methods.includes(option.method)) .map(option => ( { subtitle={option.subtitle} icon={option.icon} onPress={option.onPress} - // isComingSoon={option.isComingSoon} + chipText={option.chipText} /> - ))} */} + ))} ); }; diff --git a/components/DepositOption/VirtualAccountDetails/VirtualAccountDetailsModal.tsx b/components/DepositOption/VirtualAccountDetails/VirtualAccountDetailsModal.tsx new file mode 100644 index 00000000..deaec6f2 --- /dev/null +++ b/components/DepositOption/VirtualAccountDetails/VirtualAccountDetailsModal.tsx @@ -0,0 +1,132 @@ +import { useState } from 'react'; +import { ActivityIndicator, View } from 'react-native'; +import { Info } from 'lucide-react-native'; + +import CopyToClipboard from '@/components/CopyToClipboard'; +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { DEPOSIT_MODAL } from '@/constants/modals'; +import { useOnrampAutomation } from '@/hooks/useOnrampAutomation'; +import type { OnrampAutomationRail } from '@/lib/types'; +import { useDepositStore } from '@/store/useDepositStore'; + +const TAB_CONFIG: { key: OnrampAutomationRail; label: string }[] = [ + { key: 'ach', label: 'ACH' }, + { key: 'wire', label: 'Wire' }, +]; + +const RAIL_FOOTER: Record = { + ach: 'ACH cutoff is 4:00 PM ET. Funds typically settle in 1–3 business days.', + wire: 'Wire cutoff is 5:45 PM ET. Funds typically settle the same business day.', +}; + +const Row = ({ + label, + value, + withDivider = false, +}: { + label: string; + value: string; + withDivider?: boolean; +}) => ( + + + + {label} + + + + {value} + + {value ? : null} + + + {withDivider && } + +); + +export const VirtualAccountDetailsModal = () => { + const setModal = useDepositStore(state => state.setModal); + const { data: automation, isLoading } = useOnrampAutomation(); + const [rail, setRail] = useState('ach'); + + if (isLoading) { + return ( + + + + ); + } + + if (!automation) { + return ( + + Could not load your bank details. + + + ); + } + + const { depositAddress } = automation; + + return ( + + + Deposit USD + + Send a transfer from your bank — funds arrive as soUSD in your Solid balance. + + + + + {TAB_CONFIG.map(tab => { + const isActive = rail === tab.key; + return ( + + ); + })} + + + + + + + + + + + + + + {RAIL_FOOTER[rail]} + + + + + ); +}; diff --git a/components/DepositOption/VirtualAccountDetails/VirtualAccountTosModal.tsx b/components/DepositOption/VirtualAccountDetails/VirtualAccountTosModal.tsx new file mode 100644 index 00000000..328d9cde --- /dev/null +++ b/components/DepositOption/VirtualAccountDetails/VirtualAccountTosModal.tsx @@ -0,0 +1,100 @@ +import { useCallback, useEffect, useState } from 'react'; +import { ActivityIndicator, Pressable, View } from 'react-native'; +import { Check } from 'lucide-react-native'; + +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { DEPOSIT_MODAL } from '@/constants/modals'; +import { + useCreateOnrampAutomation, + useOnrampAutomation, +} from '@/hooks/useOnrampAutomation'; +import { useDepositStore } from '@/store/useDepositStore'; + +const TOS_POINTS = [ + 'A persistent virtual bank account will be issued in your name for ACH and Wire deposits.', + 'Incoming USD is converted to USDC by Rain and automatically deposited into the soUSD vault on your behalf.', + 'You agree to Rain Payments’ Terms of Service and Privacy Policy and confirm you are the account holder.', + 'Deposits are subject to ACH (cutoff 4:00 PM ET) and Wire (cutoff 5:45 PM ET) banking hours. Settlement may take 1–3 business days.', +]; + +export const VirtualAccountTosModal = () => { + const setModal = useDepositStore(state => state.setModal); + const { data: existingAutomation } = useOnrampAutomation(); + const createMutation = useCreateOnrampAutomation(); + const [agreed, setAgreed] = useState(false); + + // Defensive: if an automation already exists, skip ToS straight to details. + useEffect(() => { + if (existingAutomation) { + setModal(DEPOSIT_MODAL.OPEN_VIRTUAL_ACCOUNT_DETAILS); + } + }, [existingAutomation, setModal]); + + const handleAccept = useCallback(() => { + createMutation.mutate('ach', { + onSuccess: () => { + setModal(DEPOSIT_MODAL.OPEN_VIRTUAL_ACCOUNT_DETAILS); + }, + }); + }, [createMutation, setModal]); + + return ( + + + Before you continue + + Review the terms of the Rain virtual bank account. + + + + + {TOS_POINTS.map(point => ( + + + {point} + + ))} + + + setAgreed(prev => !prev)} + accessibilityRole="checkbox" + accessibilityState={{ checked: agreed }} + > + + {agreed && } + + + I agree to the terms above and authorize Rain to issue a virtual bank account on my behalf. + + + + {createMutation.isError && ( + + Something went wrong creating your bank account. Please try again. + + )} + + + + ); +}; diff --git a/constants/modals.ts b/constants/modals.ts index 8bfaded0..c0332e75 100644 --- a/constants/modals.ts +++ b/constants/modals.ts @@ -75,6 +75,14 @@ export const DEPOSIT_MODAL = { name: 'open_token_selector', number: 17, }, + OPEN_VIRTUAL_ACCOUNT_DETAILS: { + name: 'open_virtual_account_details', + number: 18, + }, + OPEN_VIRTUAL_ACCOUNT_TOS: { + name: 'open_virtual_account_tos', + number: 19, + }, }; export const SEND_MODAL = { diff --git a/hooks/useDepositBuyCryptoOptions.tsx b/hooks/useDepositBuyCryptoOptions.tsx index acfd7f29..9fd715f8 100644 --- a/hooks/useDepositBuyCryptoOptions.tsx +++ b/hooks/useDepositBuyCryptoOptions.tsx @@ -1,76 +1,70 @@ -import { useCallback, useMemo } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; import { Platform } from 'react-native'; import { Image } from 'expo-image'; +import { useRouter } from 'expo-router'; import { DEPOSIT_MODAL } from '@/constants/modals'; +import { path } from '@/constants/path'; import { TRACKING_EVENTS } from '@/constants/tracking-events'; +import { useCardStatus } from '@/hooks/useCardStatus'; +import { useOnrampAutomation } from '@/hooks/useOnrampAutomation'; import { track } from '@/lib/analytics'; import { getAsset } from '@/lib/assets'; -import { DepositMethod } from '@/lib/types'; +import { DepositMethod, RainApplicationStatus } from '@/lib/types'; import { useDepositStore } from '@/store/useDepositStore'; -import { useDimension } from './useDimension'; - const useDepositBuyCryptoOptions = () => { + const router = useRouter(); const setModal = useDepositStore(state => state.setModal); - const { isScreenMedium } = useDimension(); + const { data: cardStatus } = useCardStatus(); + useEffect(() => { + console.warn(cardStatus); + }, [cardStatus]); + const isRainApproved = cardStatus?.rainApplicationStatus === RainApplicationStatus.APPROVED; + const { data: existingAutomation } = useOnrampAutomation(isRainApproved); + + const handleBankDepositPress = useCallback(() => { + track(TRACKING_EVENTS.DEPOSIT_METHOD_SELECTED, { + deposit_method: 'bank_transfer', + }); - // const handleBankDepositPress = useCallback(() => { - // track(TRACKING_EVENTS.DEPOSIT_METHOD_SELECTED, { - // deposit_method: 'bank_transfer', - // }); - // setModal(DEPOSIT_MODAL.OPEN_BANK_TRANSFER_AMOUNT); - // }, [setModal]); + if (!isRainApproved) { + setModal(DEPOSIT_MODAL.CLOSE); + router.push(path.KYC); + return; + } - // const handleCreditCardPress = useCallback(() => { - // track(TRACKING_EVENTS.DEPOSIT_METHOD_SELECTED, { - // deposit_method: 'credit_card', - // }); - // setModal(DEPOSIT_MODAL.OPEN_BUY_CRYPTO); - // }, [setModal]); + if (existingAutomation) { + setModal(DEPOSIT_MODAL.OPEN_VIRTUAL_ACCOUNT_DETAILS); + return; + } + + setModal(DEPOSIT_MODAL.OPEN_VIRTUAL_ACCOUNT_TOS); + }, [existingAutomation, isRainApproved, router, setModal]); const buyCryptoOptions = useMemo( () => [ - // { - // text: 'Debit/Credit Card', - // subtitle: isScreenMedium - // ? 'Apple pay, Google Pay, or your\ncredit card' - // : 'Apple pay, Google Pay, or your credit card', - // icon: ( - // - // ), - // onPress: handleCreditCardPress, - // method: 'credit_card' as DepositMethod, - // }, - // { - // text: 'Bank Deposit', - // subtitle: 'Make a transfer from your bank', - // chipText: 'Cheapest', - // icon: ( - // - // ), - // onPress: handleBankDepositPress, - // method: 'bank_transfer' as DepositMethod, - // }, - ], - [ - // handleCreditCardPress, - // // handleBankDepositPress, - isScreenMedium, + { + text: 'Bank Deposit', + subtitle: 'Wire or ACH from your bank.', + chipText: 'Cheapest', + icon: ( + + ), + onPress: handleBankDepositPress, + method: 'bank_transfer' as DepositMethod, + }, ], + [handleBankDepositPress], ); const filteredOptions = Platform.OS === 'ios' - ? buyCryptoOptions.filter((option: any) => option.method !== 'credit_card') + ? buyCryptoOptions.filter(option => option.method !== 'credit_card') : buyCryptoOptions; return { buyCryptoOptions: filteredOptions }; diff --git a/hooks/useDepositOption.tsx b/hooks/useDepositOption.tsx index 6038d96d..20f908bc 100644 --- a/hooks/useDepositOption.tsx +++ b/hooks/useDepositOption.tsx @@ -19,6 +19,8 @@ import DepositDirectlyTokens from '@/components/DepositOption/DepositDirectlyTok import DepositExternalWalletOptions from '@/components/DepositOption/DepositExternalWalletOptions'; import DepositOptions from '@/components/DepositOption/DepositOptions'; import DepositPublicAddress from '@/components/DepositOption/DepositPublicAddress'; +import { VirtualAccountDetailsModal } from '@/components/DepositOption/VirtualAccountDetails/VirtualAccountDetailsModal'; +import { VirtualAccountTosModal } from '@/components/DepositOption/VirtualAccountDetails/VirtualAccountTosModal'; import { DepositTokenSelector, DepositToVaultForm } from '@/components/DepositToVault'; import SavingsDepositTokenSelector from '@/components/DepositToVault/SavingsDepositTokenSelector'; import TransactionStatus from '@/components/TransactionStatus'; @@ -128,6 +130,9 @@ const useDepositOption = ({ const isDepositDirectlyTokens = currentModal.name === DEPOSIT_MODAL.OPEN_DEPOSIT_DIRECTLY_TOKENS.name; const isTokenSelector = currentModal.name === DEPOSIT_MODAL.OPEN_TOKEN_SELECTOR.name; + const isVirtualAccountDetails = + currentModal.name === DEPOSIT_MODAL.OPEN_VIRTUAL_ACCOUNT_DETAILS.name; + const isVirtualAccountTos = currentModal.name === DEPOSIT_MODAL.OPEN_VIRTUAL_ACCOUNT_TOS.name; const isClose = currentModal.name === DEPOSIT_MODAL.CLOSE.name; const shouldAnimate = previousModal.name !== DEPOSIT_MODAL.CLOSE.name; const isForward = currentModal.number > previousModal.number; @@ -252,6 +257,14 @@ const useDepositOption = ({ return ; } + if (isVirtualAccountTos) { + return ; + } + + if (isVirtualAccountDetails) { + return ; + } + if (isTokenSelector) { if (depositFromSolid) { return ; @@ -280,6 +293,8 @@ const useDepositOption = ({ if (isDepositDirectlyAddress) return 'deposit-directly-address'; if (isDepositDirectlyTokens) return 'deposit-directly-tokens'; if (isTokenSelector) return 'token-selector'; + if (isVirtualAccountDetails) return 'virtual-account-details'; + if (isVirtualAccountTos) return 'virtual-account-tos'; return 'deposit-options'; }; @@ -297,6 +312,8 @@ const useDepositOption = ({ if (isDepositDirectlyTokens) return 'Choose token'; if (isTokenSelector && depositFromSolid) return 'Deposit'; if (isTokenSelector) return 'Select a token'; + if (isVirtualAccountDetails) return 'Bank Deposit'; + if (isVirtualAccountTos) return 'Bank Deposit'; if ((isNetworks || isFormAndAddress) && depositFromSolid) return 'Deposit'; if (isFormAndAddress && !depositFromSolid) return 'Add funds'; return 'Add funds'; diff --git a/hooks/useOnrampAutomation.ts b/hooks/useOnrampAutomation.ts new file mode 100644 index 00000000..864a4ff5 --- /dev/null +++ b/hooks/useOnrampAutomation.ts @@ -0,0 +1,30 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { createOnrampAutomation, getOnrampAutomation } from '@/lib/api'; +import { OnrampAutomationRail } from '@/lib/types'; +import { withRefreshToken } from '@/lib/utils'; + +const ONRAMP_AUTOMATION_KEY = 'onrampAutomation'; + +export function useOnrampAutomation(enabled = true) { + return useQuery({ + queryKey: [ONRAMP_AUTOMATION_KEY], + queryFn: () => withRefreshToken(() => getOnrampAutomation()), + enabled, + retry: 1, + }); +} + +export function useCreateOnrampAutomation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (rail: OnrampAutomationRail = 'ach') => { + const data = await withRefreshToken(() => createOnrampAutomation(rail)); + if (!data) throw new Error('Failed to create onramp automation'); + return data; + }, + onSuccess: data => { + queryClient.setQueryData([ONRAMP_AUTOMATION_KEY], data); + }, + }); +} diff --git a/lib/api.ts b/lib/api.ts index b78bba57..3307225d 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -71,6 +71,8 @@ import { LifiQuoteResponse, LifiStatusResponse, MppCredentialsResponse, + OnrampAutomationRail, + OnrampAutomationResponseDto, Points, PromotionsBannerResponse, ProvisioningSessionRequest, @@ -877,6 +879,53 @@ export const getCardContracts = async (): Promise => return response.json(); }; +/** + * Rain onramp automation: persistent virtual bank account (ACH + Wire). + * Returns 404 when the user has not yet created an automation. + */ +export const getOnrampAutomation = async (): Promise => { + const jwt = getJWTToken(); + + const response = await fetch(`${EXPO_PUBLIC_FLASH_API_BASE_URL}/accounts/v1/onramp-automations`, { + credentials: 'include', + headers: { + ...getPlatformHeaders(), + ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}), + }, + }); + + if (response.status === 404) return null; + if (!response.ok) throw response; + + return response.json(); +}; + +/** + * Creates an onramp automation for the current user. Idempotent — the backend + * returns the existing automation if one is already active. Throws a Response + * with status 412 if Rain KYC is incomplete. + */ +export const createOnrampAutomation = async ( + rail: OnrampAutomationRail = 'ach', +): Promise => { + const jwt = getJWTToken(); + + const response = await fetch(`${EXPO_PUBLIC_FLASH_API_BASE_URL}/accounts/v1/onramp-automations`, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...getPlatformHeaders(), + ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}), + }, + body: JSON.stringify({ rail }), + }); + + if (!response.ok) throw response; + + return response.json(); +}; + /** Rain MPP: GET wallet eligibility for push provisioning. Throw Response on non-OK. */ export const getWalletEligibility = async (): Promise => { const jwt = getJWTToken(); diff --git a/lib/types.ts b/lib/types.ts index e1bdef81..6cd996f5 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -637,6 +637,40 @@ export interface RainContractResponseDto { onramp?: RainContractOnrampDto; } +export type OnrampAutomationRail = 'ach' | 'wire'; + +export interface OnrampAutomationDepositAddressDto { + type: 'fiat'; + beneficiaryName: string; + beneficiaryAddress: string; + beneficiaryBankName: string; + beneficiaryBankAddress: string; + accountNumber: string; + routingNumber: string; +} + +export interface OnrampAutomationSourceDto { + currency: 'usd'; + rail: OnrampAutomationRail; +} + +export interface OnrampAutomationDestinationDto { + currency: string; + rail: string; + address: { type: 'onchain'; address: string }; +} + +export interface OnrampAutomationResponseDto { + id: string; + rainAutomationId: string; + status: 'active' | 'deleted' | 'failed'; + source: OnrampAutomationSourceDto; + destination: OnrampAutomationDestinationDto; + depositAddress: OnrampAutomationDepositAddressDto; + createdAt: string; + updatedAt: string; +} + export enum LayerZeroTransactionStatus { INFLIGHT = 'INFLIGHT', CONFIRMING = 'CONFIRMING',