diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b9afcd122e..592ebcb7679 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,10 @@ ## 4.42.0 (staging) - added: Zcash buy/sell support with Banxa +- changed: ramps: Infinite buy support according to new API - changed: Optimize login performance. - changed: Update Monero LWS server name to "Edge LWS" +- fixed: ramps: Various Infinite UI/UX issues ## 4.41.1 (2025-12-29) diff --git a/src/__tests__/components/__snapshots__/Row.test.tsx.snap b/src/__tests__/components/__snapshots__/Row.test.tsx.snap index 0cbdda861d3..b74cf1f3745 100644 --- a/src/__tests__/components/__snapshots__/Row.test.tsx.snap +++ b/src/__tests__/components/__snapshots__/Row.test.tsx.snap @@ -427,19 +427,26 @@ exports[`RowUi4 renders correctly with flex: 1 children 1`] = ` } > = props => { const { error } = props + const isDevMode = useSelector(state => state.ui.settings.developerModeOn) const [reportSent, setReportSent] = React.useState(false) const handleReportError = useHandler((): void => { @@ -47,19 +52,34 @@ export const ErrorCard: React.FC = props => { ) } + const buttonProps = + isDevMode || __DEV__ + ? { + label: 'Show Error', + onPress: async () => { + await Airship.show(bridge => ( + + )) + } + } + : { + label: reportSent + ? lstrings.string_report_sent + : lstrings.string_report_error, + disabled: reportSent, + onPress: handleReportError + } + // Unhappy path return ( ) } diff --git a/src/components/icons/ThemedIcons.tsx b/src/components/icons/ThemedIcons.tsx index 9f5466c4b9d..1fe22b0c21a 100644 --- a/src/components/icons/ThemedIcons.tsx +++ b/src/components/icons/ThemedIcons.tsx @@ -9,6 +9,7 @@ import Feather from 'react-native-vector-icons/Feather' import FontAwesome from 'react-native-vector-icons/FontAwesome' import type { Icon } from 'react-native-vector-icons/Icon' import Ionicons from 'react-native-vector-icons/Ionicons' +import SimpleLineIcons from 'react-native-vector-icons/SimpleLineIcons' import { Fontello } from '../../assets/vector' import { useTheme } from '../services/ThemeContext' @@ -168,3 +169,7 @@ export const CopyIcon = makeFontIcon(FontAwesome, 'copy') export const CheckIcon = makeFontIcon(AntDesignIcon, 'check') export const ArrowRightIcon = makeFontIcon(AntDesignIcon, 'arrowright') + +export const EditIcon = makeFontIcon(FontAwesome, 'edit') +export const DeleteIcon = makeFontIcon(FontAwesome, 'times') +export const QuestionIcon = makeFontIcon(SimpleLineIcons, 'question') diff --git a/src/components/modals/OtpVerificationModal.tsx b/src/components/modals/OtpVerificationModal.tsx new file mode 100644 index 00000000000..92123fb98bd --- /dev/null +++ b/src/components/modals/OtpVerificationModal.tsx @@ -0,0 +1,113 @@ +import * as React from 'react' +import type { AirshipBridge } from 'react-native-airship' + +import { useHandler } from '../../hooks/useHandler' +import { lstrings } from '../../locales/strings' +import { ModalButtons } from '../buttons/ModalButtons' +import { ErrorCard } from '../cards/ErrorCard' +import { Airship } from '../services/AirshipInstance' +import { Paragraph } from '../themed/EdgeText' +import { ModalFilledTextInput } from '../themed/FilledTextInput' +import { EdgeModal } from './EdgeModal' + +export interface OtpVerificationModalParams { + title?: string + message: string + inputLabel?: string + submitLabel?: string + onVerify: (code: string) => Promise +} + +interface Props { + bridge: AirshipBridge + title?: string + message: string + inputLabel?: string + submitLabel?: string + onVerify: (code: string) => Promise +} + +export const OtpVerificationModal: React.FC = props => { + const { bridge, inputLabel, message, onVerify, submitLabel, title } = props + + const [code, setCode] = React.useState('') + const [error, setError] = React.useState() + const [verifying, setVerifying] = React.useState(false) + + const handleCancel = useHandler(() => { + bridge.resolve(undefined) + }) + + const handleChangeText = useHandler((nextCode: string) => { + setCode(nextCode) + setError(undefined) + }) + + const isValid = code.trim().length === 6 + + const handleSubmit = useHandler(() => { + if (verifying || !isValid) return + + const trimmedCode = code.trim() + setVerifying(true) + setError(undefined) + + onVerify(trimmedCode).then( + (result: unknown) => { + bridge.resolve(result) + }, + (err: unknown) => { + setError(err) + setVerifying(false) + } + ) + }) + + return ( + + {message} + + {error == null ? null : } + + + ) +} + +export const showOtpVerificationModal = async ( + params: OtpVerificationModalParams +): Promise => { + const { inputLabel, message, onVerify, submitLabel, title } = params + return await Airship.show(bridge => ( + } + title={title} + message={message} + inputLabel={inputLabel} + submitLabel={submitLabel} + onVerify={onVerify as (code: string) => Promise} + /> + )) +} diff --git a/src/components/rows/EdgeRow.tsx b/src/components/rows/EdgeRow.tsx index ba39bb26ef8..669ca5e4bfc 100644 --- a/src/components/rows/EdgeRow.tsx +++ b/src/components/rows/EdgeRow.tsx @@ -6,15 +6,19 @@ import { View, type ViewStyle } from 'react-native' -import FontAwesomeIcon from 'react-native-vector-icons/FontAwesome' -import SimpleLineIcons from 'react-native-vector-icons/SimpleLineIcons' import { useHandler } from '../../hooks/useHandler' import { lstrings } from '../../locales/strings' import { triggerHaptic } from '../../util/haptic' import { fixSides, mapSides, sidesToMargin } from '../../util/sides' import { EdgeTouchableOpacity } from '../common/EdgeTouchableOpacity' -import { ChevronRightIcon } from '../icons/ThemedIcons' +import { + ChevronRightIcon, + CopyIcon, + DeleteIcon, + EditIcon, + QuestionIcon +} from '../icons/ThemedIcons' import { showToast } from '../services/AirshipInstance' import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' import { EdgeText } from '../themed/EdgeText' @@ -160,32 +164,16 @@ export const EdgeRow = (props: Props) => { /> ) : null} {rightButtonType === 'editable' ? ( - + ) : null} {rightButtonType === 'copy' ? ( - + ) : null} {rightButtonType === 'delete' ? ( - + ) : null} {rightButtonType === 'questionable' ? ( - + ) : null} ) : null diff --git a/src/components/scenes/RampBankFormScene.tsx b/src/components/scenes/RampBankFormScene.tsx index d3a89e6820d..19d67268603 100644 --- a/src/components/scenes/RampBankFormScene.tsx +++ b/src/components/scenes/RampBankFormScene.tsx @@ -1,5 +1,6 @@ import * as React from 'react' import { type TextInput, View } from 'react-native' +import { sprintf } from 'sprintf-js' import { useBackEvent } from '../../hooks/useBackEvent' import { useHandler } from '../../hooks/useHandler' @@ -8,6 +9,7 @@ import type { EdgeAppSceneProps } from '../../types/routerTypes' import { SceneButtons } from '../buttons/SceneButtons' import { ErrorCard } from '../cards/ErrorCard' import { SceneWrapper } from '../common/SceneWrapper' +import { SceneContainer } from '../layout/SceneContainer' import { showError } from '../services/AirshipInstance' import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' import { FilledTextInput } from '../themed/FilledTextInput' @@ -23,6 +25,8 @@ export interface BankFormData { } export interface RampBankFormParams { + /** ISO country code for region-specific validation */ + countryCode: string onSubmit: (formData: BankFormData) => Promise /** * Callback invoked when the user navigates away from the scene. @@ -32,9 +36,15 @@ export interface RampBankFormParams { interface Props extends EdgeAppSceneProps<'rampBankForm'> {} +// Validation result type +interface ValidationResult { + isValid: boolean + errorMessage: string +} + export const RampBankFormScene: React.FC = props => { const { navigation, route } = props - const { onSubmit, onCancel } = route.params + const { countryCode, onSubmit, onCancel } = route.params const theme = useTheme() const styles = getStyles(theme) @@ -50,6 +60,10 @@ export const RampBankFormScene: React.FC = props => { const [ownerLastName, setOwnerLastName] = React.useState('') const [isSubmitting, setIsSubmitting] = React.useState(false) const [error, setError] = React.useState(null) + const [fieldErrors, setFieldErrors] = React.useState({ + accountNumber: '', + routingNumber: '' + }) // Create refs for each input field const ownerFirstNameRef = React.useRef(null) @@ -58,22 +72,58 @@ export const RampBankFormScene: React.FC = props => { const accountNumberRef = React.useRef(null) const routingNumberRef = React.useRef(null) + const handleAccountNumberBlur = useHandler(() => { + const result = validateAccountNumber(accountNumber, countryCode) + setFieldErrors(prev => ({ ...prev, accountNumber: result.errorMessage })) + }) + + const handleRoutingNumberBlur = useHandler(() => { + const result = validateRoutingNumber(routingNumber, countryCode) + setFieldErrors(prev => ({ ...prev, routingNumber: result.errorMessage })) + }) + + const handleAccountNumberChange = useHandler((text: string) => { + setAccountNumber(text) + setFieldErrors(prev => ({ ...prev, accountNumber: '' })) + }) + + const handleRoutingNumberChange = useHandler((text: string) => { + setRoutingNumber(text) + setFieldErrors(prev => ({ ...prev, routingNumber: '' })) + }) + const isFormValid = React.useMemo(() => { - return ( + const hasRequiredFields = bankName.trim() !== '' && accountNumber.trim() !== '' && routingNumber.trim() !== '' && accountName.trim() !== '' && ownerFirstName.trim() !== '' && ownerLastName.trim() !== '' - ) + + // Use the same validation functions for form validity + const accountValid = validateAccountNumber( + accountNumber, + countryCode + ).isValid + const routingValid = validateRoutingNumber( + routingNumber, + countryCode + ).isValid + + const hasNoFieldErrors = + fieldErrors.accountNumber === '' && fieldErrors.routingNumber === '' + + return hasRequiredFields && accountValid && routingValid && hasNoFieldErrors }, [ bankName, accountNumber, + countryCode, routingNumber, accountName, ownerFirstName, - ownerLastName + ownerLastName, + fieldErrors ]) const handleSubmit = useHandler(async () => { @@ -101,7 +151,7 @@ export const RampBankFormScene: React.FC = props => { return ( - + = props => { onSubmitEditing={() => accountNumberRef.current?.focus()} /> + {/* TODO: Adjust maxLength for other countries when internationalized */} routingNumberRef.current?.focus()} /> { handleSubmit().catch(showError) }} /> - - {error != null && } - + {error != null && } + + ) } const getStyles = cacheStyles((theme: Theme) => ({ - container: { - paddingHorizontal: theme.rem(0.5) - }, row: { flexDirection: 'row' } })) + +// Single source of truth for field validation +// TODO: Extend validation for other countries when the form is internationalized +const validateAccountNumber = ( + value: string, + countryCode: string +): ValidationResult => { + const trimmed = value.trim() + if (trimmed === '') { + return { isValid: false, errorMessage: '' } + } + + // US bank account numbers typically range from 4-17 digits + if (countryCode === 'US') { + if (trimmed.length < 4) { + return { + isValid: false, + errorMessage: sprintf( + lstrings.ramp_account_number_error_min_length_1s, + '4' + ) + } + } + } + + return { isValid: true, errorMessage: '' } +} + +const validateRoutingNumber = ( + value: string, + countryCode: string +): ValidationResult => { + const trimmed = value.trim() + if (trimmed === '') { + return { isValid: false, errorMessage: '' } + } + + // US ABA routing numbers are exactly 9 digits + if (countryCode === 'US') { + if (trimmed.length !== 9) { + return { + isValid: false, + errorMessage: sprintf(lstrings.ramp_routing_number_error_length_1s, '9') + } + } + } + + return { isValid: true, errorMessage: '' } +} diff --git a/src/components/scenes/RampBankRoutingDetailsScene.tsx b/src/components/scenes/RampBankRoutingDetailsScene.tsx index 60cc922d9f4..75afdae0a0c 100644 --- a/src/components/scenes/RampBankRoutingDetailsScene.tsx +++ b/src/components/scenes/RampBankRoutingDetailsScene.tsx @@ -1,15 +1,21 @@ +import Clipboard from '@react-native-clipboard/clipboard' import * as React from 'react' import { View } from 'react-native' import IonIcon from 'react-native-vector-icons/Ionicons' +import { useHandler } from '../../hooks/useHandler' import { lstrings } from '../../locales/strings' import type { EdgeAppSceneProps } from '../../types/routerTypes' import { SceneButtons } from '../buttons/SceneButtons' import { EdgeCard } from '../cards/EdgeCard' +import { EdgeTouchableOpacity } from '../common/EdgeTouchableOpacity' import { SceneWrapper } from '../common/SceneWrapper' -import { styled } from '../hoc/styled' +import { SectionHeader } from '../common/SectionHeader' +import { CopyIcon } from '../icons/ThemedIcons' import { SceneContainer } from '../layout/SceneContainer' -import { useTheme } from '../services/ThemeContext' +import { EdgeRow } from '../rows/EdgeRow' +import { showToast } from '../services/AirshipInstance' +import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' import { EdgeText, Paragraph } from '../themed/EdgeText' export interface BankInfo { @@ -31,61 +37,73 @@ export const RampBankRoutingDetailsScene: React.FC = props => { const { route } = props const { bank, fiatCurrencyCode, fiatAmount, onDone } = route.params - const amountToSendText = `${fiatAmount} ${fiatCurrencyCode}` - const theme = useTheme() + const styles = getStyles(theme) - const renderInfoRow = (label: string, value: string): React.ReactElement => ( - - {label} - {value} - - ) + const amountToSendText = `${fiatAmount} ${fiatCurrencyCode}` + + const handleCopyAmount = useHandler(() => { + Clipboard.setString(amountToSendText) + showToast(lstrings.fragment_copied) + }) return ( - - - - - {lstrings.ramp_bank_routing_instructions} - - - - - - {lstrings.ramp_send_amount_label} - {amountToSendText} - - - - - - - {lstrings.ramp_bank_details_section_title} - - - {renderInfoRow(lstrings.ramp_bank_name_label, bank.name)} - {renderInfoRow( - lstrings.ramp_account_number_label, - bank.accountNumber - )} - {renderInfoRow( - lstrings.ramp_routing_number_label, - bank.routingNumber - )} - - - - - {lstrings.ramp_bank_routing_warning} - - + {/* TODO: This is a strange one-off UI. Consider a warning card here instead? */} + + + + {lstrings.ramp_bank_routing_instructions} + + + + + + + + + {lstrings.ramp_send_amount_label} + + + {amountToSendText} + + + + + + + + + + + + + + + + + + + {lstrings.ramp_bank_routing_warning} + + = props => { ) } -// Styled components -const ContentContainer = styled(View)(theme => ({ - flex: 1, - paddingHorizontal: theme.rem(0.5) -})) - -const InstructionContainer = styled(View)(theme => ({ - alignItems: 'center', - flexDirection: 'row', - paddingVertical: theme.rem(0.5), - paddingHorizontal: theme.rem(0.5) -})) - -const StyledIcon = styled(IonIcon)(theme => ({ - marginBottom: theme.rem(0.5) -})) - -const CardContent = styled(View)(theme => ({ - padding: theme.rem(1) -})) - -const AmountLabel = styled(EdgeText)(theme => ({ - fontSize: theme.rem(0.75), - color: theme.secondaryText, - marginBottom: theme.rem(0.25) -})) - -const AmountValue = styled(EdgeText)(theme => ({ - fontSize: theme.rem(1.5), - fontFamily: theme.fontFaceBold, - color: theme.primaryText -})) - -const SectionTitle = styled(EdgeText)(theme => ({ - fontSize: theme.rem(1), - fontFamily: theme.fontFaceMedium, - marginBottom: theme.rem(1) -})) - -const InfoRowContainer = styled(View)(theme => ({ - marginBottom: theme.rem(0.75) -})) - -const InfoLabel = styled(EdgeText)(theme => ({ - fontSize: theme.rem(0.75), - color: theme.secondaryText, - marginBottom: theme.rem(0.25) -})) - -const InfoValue = styled(EdgeText)(theme => ({ - fontSize: theme.rem(1), - fontFamily: theme.fontFaceMedium, - color: theme.primaryText -})) - -const WarningTextContainer = styled(View)(theme => ({ - paddingHorizontal: theme.rem(1) -})) - -const WarningText = styled(EdgeText)(theme => ({ - fontSize: theme.rem(0.75), - fontStyle: 'italic', - textAlign: 'center' +const getStyles = cacheStyles((theme: Theme) => ({ + instructionContainer: { + alignItems: 'center', + flexDirection: 'row', + paddingVertical: theme.rem(0.5), + paddingHorizontal: theme.rem(0.5) + }, + copyIcon: { + color: theme.iconTappable + }, + instructionText: { + flexShrink: 1 + }, + cardContent: { + padding: theme.rem(0.5) + }, + amountLabel: { + fontSize: theme.rem(0.75), + color: theme.secondaryText, + marginBottom: theme.rem(0.25) + }, + amountValue: { + fontSize: theme.rem(1.5), + fontFamily: theme.fontFaceBold, + color: theme.primaryText + }, + amountRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between' + }, + amountTextContainer: { + flex: 1 + }, + sectionTitle: { + fontSize: theme.rem(1), + fontFamily: theme.fontFaceMedium, + marginBottom: theme.rem(1) + }, + warningTextContainer: { + paddingHorizontal: theme.rem(1) + }, + warningText: { + fontSize: theme.rem(0.75), + fontStyle: 'italic', + textAlign: 'center' + } })) diff --git a/src/components/scenes/RampConfirmationScene.tsx b/src/components/scenes/RampConfirmationScene.tsx index 1dd063cdf98..5ad1680ec05 100644 --- a/src/components/scenes/RampConfirmationScene.tsx +++ b/src/components/scenes/RampConfirmationScene.tsx @@ -1,4 +1,5 @@ import * as React from 'react' +import { View } from 'react-native' import { sprintf } from 'sprintf-js' import { useBackEvent } from '../../hooks/useBackEvent' @@ -10,6 +11,7 @@ import { ErrorCard } from '../cards/ErrorCard' import { SceneWrapper } from '../common/SceneWrapper' import { SceneContainer } from '../layout/SceneContainer' import { EdgeRow } from '../rows/EdgeRow' +import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' import { SafeSlider } from '../themed/SafeSlider' export interface RampConfirmationParams { @@ -35,6 +37,9 @@ export const RampConfirmationScene: React.FC = props => { const { navigation, route } = props const { direction, source, target, onCancel, onConfirm } = route.params + const theme = useTheme() + const styles = getStyles(theme) + const [error, setError] = React.useState(null) const [isConfirming, setIsConfirming] = React.useState(false) @@ -60,7 +65,7 @@ export const RampConfirmationScene: React.FC = props => { : sprintf(lstrings.sell_1s, source.currencyCode) return ( - + = props => { {error != null && } - + + + ) } + +const getStyles = cacheStyles((theme: Theme) => ({ + sliderContainer: { + flex: 1, + justifyContent: 'flex-end', + padding: theme.rem(1) + } +})) diff --git a/src/components/scenes/RampKycFormScene.tsx b/src/components/scenes/RampKycFormScene.tsx index ee00121c05b..195d69d17d0 100644 --- a/src/components/scenes/RampKycFormScene.tsx +++ b/src/components/scenes/RampKycFormScene.tsx @@ -6,7 +6,7 @@ import { lstrings } from '../../locales/strings' import { GuiFormField } from '../../plugins/gui/components/GuiFormField' import { GuiFormRow } from '../../plugins/gui/components/GuiFormRow' import type { BuySellTabSceneProps } from '../../types/routerTypes' -import { KavButtons } from '../buttons/KavButtons' +import { SceneButtons } from '../buttons/SceneButtons' import { ErrorCard } from '../cards/ErrorCard' import { SceneWrapper } from '../common/SceneWrapper' import { SceneContainer } from '../layout/SceneContainer' @@ -189,22 +189,7 @@ export const RampKycFormScene = React.memo((props: Props) => { postalCode.trim() !== '' return ( - - ) - }} - > + { returnKeyType="done" fieldRef={postalCodeRef} /> - {error == null ? null : } + ) diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index 9340d74ecb6..c9aa9ad6d01 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -2393,14 +2393,6 @@ const strings = { ramp_kyc_not_approved: 'KYC verification was not completed. Please try again.', ramp_kyc_rejected: 'KYC verification was rejected. Please contact support.', - ramp_tos_status_accepted: 'Terms of service was accepted', - ramp_tos_status_pending: 'Terms of service acceptance is pending', - ramp_tos_status_not_required: 'Terms of service is not required', - ramp_tos_pending_title: 'Terms of Service', - ramp_tos_pending_message: 'Please wait while we check the status.', - ramp_tos_error_title: 'Terms of Service Error', - ramp_tos_timeout_message: - 'Terms of service acceptance timed out. Please try again.', ramp_kyc_additional_info_required: 'Additional information is required for KYC verification.', ramp_kyc_unknown_status: 'Unknown verification status.', @@ -2408,6 +2400,13 @@ const strings = { ramp_signup_failed_account_existsmessage: 'An account already exists using this email address. Please contact support to recover your account.', + // Ramp OTP Verification + ramp_otp_verification_title: 'Email Verification', + ramp_otp_verification_message: + 'An account with this email already exists. Please enter the verification code sent to your email to continue.', + ramp_otp_input_label: '6-digit code', + ramp_otp_invalid_code: 'Invalid or expired verification code.', + ramp_plugin_authenticating_with_s: 'Authenticating with %s. Please wait.', ramp_plugin_kyc_title: 'KYC Information', @@ -2418,6 +2417,10 @@ const strings = { ramp_account_name_placeholder: 'Account Name', ramp_account_number_placeholder: 'Account Number', ramp_routing_number_placeholder: 'Routing Number', + ramp_account_number_error_min_length_1s: + 'Account number must be at least %1$s digits', + ramp_routing_number_error_length_1s: + 'Routing number must be exactly %1$s digits', string_submit: 'Submit', // Ramp Bank Routing Details diff --git a/src/locales/strings/de.json b/src/locales/strings/de.json index 80d8f81686b..414e03102ed 100644 --- a/src/locales/strings/de.json +++ b/src/locales/strings/de.json @@ -1829,13 +1829,6 @@ "ramp_kyc_error_title": "Verification Error", "ramp_kyc_not_approved": "KYC verification was not completed. Please try again.", "ramp_kyc_rejected": "KYC verification was rejected. Please contact support.", - "ramp_tos_status_accepted": "Terms of service was accepted", - "ramp_tos_status_pending": "Terms of service acceptance is pending", - "ramp_tos_status_not_required": "Terms of service is not required", - "ramp_tos_pending_title": "Terms of Service", - "ramp_tos_pending_message": "Please wait while we check the status.", - "ramp_tos_error_title": "Terms of Service Error", - "ramp_tos_timeout_message": "Terms of service acceptance timed out. Please try again.", "ramp_kyc_additional_info_required": "Additional information is required for KYC verification.", "ramp_kyc_unknown_status": "Unknown verification status.", "ramp_signup_failed_title": "Failed to Sign Up", diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index b675aecca4c..2471c65a4e7 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -1872,17 +1872,14 @@ "ramp_kyc_error_title": "Verification Error", "ramp_kyc_not_approved": "KYC verification was not completed. Please try again.", "ramp_kyc_rejected": "KYC verification was rejected. Please contact support.", - "ramp_tos_status_accepted": "Terms of service was accepted", - "ramp_tos_status_pending": "Terms of service acceptance is pending", - "ramp_tos_status_not_required": "Terms of service is not required", - "ramp_tos_pending_title": "Terms of Service", - "ramp_tos_pending_message": "Please wait while we check the status.", - "ramp_tos_error_title": "Terms of Service Error", - "ramp_tos_timeout_message": "Terms of service acceptance timed out. Please try again.", "ramp_kyc_additional_info_required": "Additional information is required for KYC verification.", "ramp_kyc_unknown_status": "Unknown verification status.", "ramp_signup_failed_title": "Failed to Sign Up", "ramp_signup_failed_account_existsmessage": "An account already exists using this email address. Please contact support to recover your account.", + "ramp_otp_verification_title": "Email Verification", + "ramp_otp_verification_message": "An account with this email already exists. Please enter the verification code sent to your email to continue.", + "ramp_otp_input_label": "6-digit code", + "ramp_otp_invalid_code": "Invalid or expired verification code.", "ramp_plugin_authenticating_with_s": "Authenticating with %s. Please wait.", "ramp_plugin_kyc_title": "KYC Information", "ramp_bank_details_title": "Bank Account Details", @@ -1891,6 +1888,8 @@ "ramp_account_name_placeholder": "Account Name", "ramp_account_number_placeholder": "Account Number", "ramp_routing_number_placeholder": "Routing Number", + "ramp_account_number_error_min_length_1s": "Account number must be at least %1$s digits", + "ramp_routing_number_error_length_1s": "Routing number must be exactly %1$s digits", "string_submit": "Submit", "ramp_bank_routing_title": "Send Bank Transfer", "ramp_bank_routing_instructions": "Please send the exact amount shown below to the following bank account.", diff --git a/src/locales/strings/es.json b/src/locales/strings/es.json index cddf9717985..22dbd9851cf 100644 --- a/src/locales/strings/es.json +++ b/src/locales/strings/es.json @@ -1829,13 +1829,6 @@ "ramp_kyc_error_title": "Verification Error", "ramp_kyc_not_approved": "KYC verification was not completed. Please try again.", "ramp_kyc_rejected": "KYC verification was rejected. Please contact support.", - "ramp_tos_status_accepted": "Terms of service was accepted", - "ramp_tos_status_pending": "Terms of service acceptance is pending", - "ramp_tos_status_not_required": "Terms of service is not required", - "ramp_tos_pending_title": "Terms of Service", - "ramp_tos_pending_message": "Please wait while we check the status.", - "ramp_tos_error_title": "Terms of Service Error", - "ramp_tos_timeout_message": "Terms of service acceptance timed out. Please try again.", "ramp_kyc_additional_info_required": "Additional information is required for KYC verification.", "ramp_kyc_unknown_status": "Unknown verification status.", "ramp_signup_failed_title": "Failed to Sign Up", diff --git a/src/locales/strings/esMX.json b/src/locales/strings/esMX.json index e6d603b9ec9..184cb52a776 100644 --- a/src/locales/strings/esMX.json +++ b/src/locales/strings/esMX.json @@ -1829,13 +1829,6 @@ "ramp_kyc_error_title": "Verification Error", "ramp_kyc_not_approved": "KYC verification was not completed. Please try again.", "ramp_kyc_rejected": "KYC verification was rejected. Please contact support.", - "ramp_tos_status_accepted": "Terms of service was accepted", - "ramp_tos_status_pending": "Terms of service acceptance is pending", - "ramp_tos_status_not_required": "Terms of service is not required", - "ramp_tos_pending_title": "Terms of Service", - "ramp_tos_pending_message": "Please wait while we check the status.", - "ramp_tos_error_title": "Terms of Service Error", - "ramp_tos_timeout_message": "Terms of service acceptance timed out. Please try again.", "ramp_kyc_additional_info_required": "Additional information is required for KYC verification.", "ramp_kyc_unknown_status": "Unknown verification status.", "ramp_signup_failed_title": "Failed to Sign Up", diff --git a/src/locales/strings/fr.json b/src/locales/strings/fr.json index 32c43aa772f..f86b6797b53 100644 --- a/src/locales/strings/fr.json +++ b/src/locales/strings/fr.json @@ -1829,13 +1829,6 @@ "ramp_kyc_error_title": "Verification Error", "ramp_kyc_not_approved": "KYC verification was not completed. Please try again.", "ramp_kyc_rejected": "KYC verification was rejected. Please contact support.", - "ramp_tos_status_accepted": "Terms of service was accepted", - "ramp_tos_status_pending": "Terms of service acceptance is pending", - "ramp_tos_status_not_required": "Terms of service is not required", - "ramp_tos_pending_title": "Terms of Service", - "ramp_tos_pending_message": "Please wait while we check the status.", - "ramp_tos_error_title": "Terms of Service Error", - "ramp_tos_timeout_message": "Terms of service acceptance timed out. Please try again.", "ramp_kyc_additional_info_required": "Additional information is required for KYC verification.", "ramp_kyc_unknown_status": "Unknown verification status.", "ramp_signup_failed_title": "Failed to Sign Up", diff --git a/src/locales/strings/it.json b/src/locales/strings/it.json index da35b265493..baf228344cc 100644 --- a/src/locales/strings/it.json +++ b/src/locales/strings/it.json @@ -1829,13 +1829,6 @@ "ramp_kyc_error_title": "Errore di verifica", "ramp_kyc_not_approved": "La verifica di KYC non è stata completata. Riprova.", "ramp_kyc_rejected": "La verifica di KYC è stata respinta. Contatta l'assistenza.", - "ramp_tos_status_accepted": "Condizioni di servizio accettate", - "ramp_tos_status_pending": "I termini di accettazione del servizio sono in attesa", - "ramp_tos_status_not_required": "I termini di servizio non sono richiesti", - "ramp_tos_pending_title": "Termini di servizio", - "ramp_tos_pending_message": "Attendere mentre controlliamo lo stato.", - "ramp_tos_error_title": "Errore di termini di servizio", - "ramp_tos_timeout_message": "Termini di accettazione del servizio scaduti. Per favore riprova.", "ramp_kyc_additional_info_required": "Per la verifica di KYC sono necessarie ulteriori informazioni.", "ramp_kyc_unknown_status": "Stato di verifica sconosciuto.", "ramp_signup_failed_title": "Iscrizione non riuscita", diff --git a/src/locales/strings/ja.json b/src/locales/strings/ja.json index be2337235ba..40745a526a7 100644 --- a/src/locales/strings/ja.json +++ b/src/locales/strings/ja.json @@ -1829,13 +1829,6 @@ "ramp_kyc_error_title": "Verification Error", "ramp_kyc_not_approved": "KYC verification was not completed. Please try again.", "ramp_kyc_rejected": "KYC verification was rejected. Please contact support.", - "ramp_tos_status_accepted": "Terms of service was accepted", - "ramp_tos_status_pending": "Terms of service acceptance is pending", - "ramp_tos_status_not_required": "Terms of service is not required", - "ramp_tos_pending_title": "Terms of Service", - "ramp_tos_pending_message": "Please wait while we check the status.", - "ramp_tos_error_title": "Terms of Service Error", - "ramp_tos_timeout_message": "Terms of service acceptance timed out. Please try again.", "ramp_kyc_additional_info_required": "Additional information is required for KYC verification.", "ramp_kyc_unknown_status": "Unknown verification status.", "ramp_signup_failed_title": "Failed to Sign Up", diff --git a/src/locales/strings/kaa.json b/src/locales/strings/kaa.json index 85633add7bd..91ce61bbea9 100644 --- a/src/locales/strings/kaa.json +++ b/src/locales/strings/kaa.json @@ -1829,13 +1829,6 @@ "ramp_kyc_error_title": "Verification Error", "ramp_kyc_not_approved": "KYC verification was not completed. Please try again.", "ramp_kyc_rejected": "KYC verification was rejected. Please contact support.", - "ramp_tos_status_accepted": "Terms of service was accepted", - "ramp_tos_status_pending": "Terms of service acceptance is pending", - "ramp_tos_status_not_required": "Terms of service is not required", - "ramp_tos_pending_title": "Terms of Service", - "ramp_tos_pending_message": "Please wait while we check the status.", - "ramp_tos_error_title": "Terms of Service Error", - "ramp_tos_timeout_message": "Terms of service acceptance timed out. Please try again.", "ramp_kyc_additional_info_required": "Additional information is required for KYC verification.", "ramp_kyc_unknown_status": "Unknown verification status.", "ramp_signup_failed_title": "Failed to Sign Up", diff --git a/src/locales/strings/ko.json b/src/locales/strings/ko.json index be778e1264f..49298cbe583 100644 --- a/src/locales/strings/ko.json +++ b/src/locales/strings/ko.json @@ -1829,13 +1829,6 @@ "ramp_kyc_error_title": "Verification Error", "ramp_kyc_not_approved": "KYC verification was not completed. Please try again.", "ramp_kyc_rejected": "KYC verification was rejected. Please contact support.", - "ramp_tos_status_accepted": "Terms of service was accepted", - "ramp_tos_status_pending": "Terms of service acceptance is pending", - "ramp_tos_status_not_required": "Terms of service is not required", - "ramp_tos_pending_title": "Terms of Service", - "ramp_tos_pending_message": "Please wait while we check the status.", - "ramp_tos_error_title": "Terms of Service Error", - "ramp_tos_timeout_message": "Terms of service acceptance timed out. Please try again.", "ramp_kyc_additional_info_required": "Additional information is required for KYC verification.", "ramp_kyc_unknown_status": "Unknown verification status.", "ramp_signup_failed_title": "Failed to Sign Up", diff --git a/src/locales/strings/pt.json b/src/locales/strings/pt.json index 228abdce7b8..15a876c01c4 100644 --- a/src/locales/strings/pt.json +++ b/src/locales/strings/pt.json @@ -1829,13 +1829,6 @@ "ramp_kyc_error_title": "Verification Error", "ramp_kyc_not_approved": "KYC verification was not completed. Please try again.", "ramp_kyc_rejected": "KYC verification was rejected. Please contact support.", - "ramp_tos_status_accepted": "Terms of service was accepted", - "ramp_tos_status_pending": "Terms of service acceptance is pending", - "ramp_tos_status_not_required": "Terms of service is not required", - "ramp_tos_pending_title": "Terms of Service", - "ramp_tos_pending_message": "Please wait while we check the status.", - "ramp_tos_error_title": "Terms of Service Error", - "ramp_tos_timeout_message": "Terms of service acceptance timed out. Please try again.", "ramp_kyc_additional_info_required": "Additional information is required for KYC verification.", "ramp_kyc_unknown_status": "Unknown verification status.", "ramp_signup_failed_title": "Failed to Sign Up", diff --git a/src/locales/strings/ru.json b/src/locales/strings/ru.json index 51ea8faecd0..c60e93a6320 100644 --- a/src/locales/strings/ru.json +++ b/src/locales/strings/ru.json @@ -1829,13 +1829,6 @@ "ramp_kyc_error_title": "Verification Error", "ramp_kyc_not_approved": "KYC verification was not completed. Please try again.", "ramp_kyc_rejected": "KYC verification was rejected. Please contact support.", - "ramp_tos_status_accepted": "Terms of service was accepted", - "ramp_tos_status_pending": "Terms of service acceptance is pending", - "ramp_tos_status_not_required": "Terms of service is not required", - "ramp_tos_pending_title": "Terms of Service", - "ramp_tos_pending_message": "Please wait while we check the status.", - "ramp_tos_error_title": "Terms of Service Error", - "ramp_tos_timeout_message": "Terms of service acceptance timed out. Please try again.", "ramp_kyc_additional_info_required": "Additional information is required for KYC verification.", "ramp_kyc_unknown_status": "Unknown verification status.", "ramp_signup_failed_title": "Failed to Sign Up", diff --git a/src/locales/strings/vi.json b/src/locales/strings/vi.json index bf56ec3446f..561459ec13b 100644 --- a/src/locales/strings/vi.json +++ b/src/locales/strings/vi.json @@ -1829,13 +1829,6 @@ "ramp_kyc_error_title": "Verification Error", "ramp_kyc_not_approved": "KYC verification was not completed. Please try again.", "ramp_kyc_rejected": "KYC verification was rejected. Please contact support.", - "ramp_tos_status_accepted": "Terms of service was accepted", - "ramp_tos_status_pending": "Terms of service acceptance is pending", - "ramp_tos_status_not_required": "Terms of service is not required", - "ramp_tos_pending_title": "Terms of Service", - "ramp_tos_pending_message": "Please wait while we check the status.", - "ramp_tos_error_title": "Terms of Service Error", - "ramp_tos_timeout_message": "Terms of service acceptance timed out. Please try again.", "ramp_kyc_additional_info_required": "Additional information is required for KYC verification.", "ramp_kyc_unknown_status": "Unknown verification status.", "ramp_signup_failed_title": "Failed to Sign Up", diff --git a/src/locales/strings/zh.json b/src/locales/strings/zh.json index a19d963563b..4c32edf0929 100644 --- a/src/locales/strings/zh.json +++ b/src/locales/strings/zh.json @@ -1829,13 +1829,6 @@ "ramp_kyc_error_title": "Verification Error", "ramp_kyc_not_approved": "KYC verification was not completed. Please try again.", "ramp_kyc_rejected": "KYC verification was rejected. Please contact support.", - "ramp_tos_status_accepted": "Terms of service was accepted", - "ramp_tos_status_pending": "Terms of service acceptance is pending", - "ramp_tos_status_not_required": "Terms of service is not required", - "ramp_tos_pending_title": "Terms of Service", - "ramp_tos_pending_message": "Please wait while we check the status.", - "ramp_tos_error_title": "Terms of Service Error", - "ramp_tos_timeout_message": "Terms of service acceptance timed out. Please try again.", "ramp_kyc_additional_info_required": "Additional information is required for KYC verification.", "ramp_kyc_unknown_status": "Unknown verification status.", "ramp_signup_failed_title": "Failed to Sign Up", diff --git a/src/plugins/ramps/infinite/infiniteApi.ts b/src/plugins/ramps/infinite/infiniteApi.ts index 9647950e25a..3e53d9d4fb1 100644 --- a/src/plugins/ramps/infinite/infiniteApi.ts +++ b/src/plugins/ramps/infinite/infiniteApi.ts @@ -13,9 +13,11 @@ import { asInfiniteCustomerAccountsResponse, asInfiniteCustomerResponse, asInfiniteErrorResponse, + asInfiniteHttpErrorResponse, + asInfiniteKycLinkResponse, asInfiniteKycStatusResponse, + asInfiniteOtpSentResponse, asInfiniteQuoteResponse, - asInfiniteTosResponse, asInfiniteTransferResponse, type AuthState, type InfiniteApi, @@ -30,11 +32,13 @@ import { type InfiniteCustomerAccountsResponse, type InfiniteCustomerRequest, type InfiniteCustomerResponse, + type InfiniteKycLinkResponse, type InfiniteKycStatus, type InfiniteKycStatusResponse, + type InfiniteOtpSentResponse, type InfiniteQuoteResponse, - type InfiniteTosResponse, - type InfiniteTransferResponse + type InfiniteTransferResponse, + type InfiniteVerifyOtpRequest } from './infiniteApiTypes' // Toggle between dummy data and real API per function @@ -49,8 +53,9 @@ const USE_DUMMY_DATA: Record = { createTransfer: false, getTransferStatus: false, createCustomer: false, + verifyOtp: false, getKycStatus: false, - getTos: false, + getKycLink: false, getCustomerAccounts: false, addBankAccount: false, getCountries: false, @@ -148,6 +153,19 @@ export const makeInfiniteApi = (config: InfiniteApiConfig): InfiniteApi => { data ) + // Try to parse as HTTP error response + const httpErrorResponse = asMaybe(asInfiniteHttpErrorResponse)(data) + if (httpErrorResponse != null) { + const detail = Array.isArray(httpErrorResponse.message) + ? httpErrorResponse.message.join('; ') + : httpErrorResponse.message + throw new InfiniteApiError( + httpErrorResponse.statusCode, + httpErrorResponse.error, + detail + ) + } + // Try to parse as JSON error response const errorResponse = asMaybe(asInfiniteErrorResponse)(data) if (errorResponse != null) { @@ -171,7 +189,7 @@ export const makeInfiniteApi = (config: InfiniteApiConfig): InfiniteApi => { getChallenge: async (publicKey: string) => { if (!USE_DUMMY_DATA.getChallenge) { const response = await fetchInfinite( - `/v1/auth/wallet/challenge?publicKey=${publicKey}`, + `/v1/headless/auth/wallet/challenge?publicKey=${publicKey}`, { headers: makeHeaders() } @@ -189,21 +207,21 @@ export const makeInfiniteApi = (config: InfiniteApiConfig): InfiniteApi => { .substring(7)}` const dummyResponse: InfiniteChallengeResponse = { nonce, - message: `Sign this message to authenticate with Infinite Agents.\n\nPublicKey: ${publicKey}\nNonce: ${nonce}\nTimestamp: ${timestamp}`, - domain: null, - expires_at: timestamp + 300, - expires_at_iso: new Date((timestamp + 300) * 1000).toISOString() + message: `Sign this message to authenticate with Infinite Agents.\n\nPublicKey: ${publicKey}\nNonce: ${nonce}\nTimestamp: ${timestamp}` } return dummyResponse }, verifySignature: async params => { if (!USE_DUMMY_DATA.verifySignature) { - const response = await fetchInfinite('/v1/auth/wallet/verify', { - method: 'POST', - headers: makeHeaders(), - body: JSON.stringify(params) - }) + const response = await fetchInfinite( + '/v1/headless/auth/wallet/verify', + { + method: 'POST', + headers: makeHeaders(), + body: JSON.stringify(params) + } + ) const data = await response.text() const authResponse = asInfiniteAuthResponse(data) @@ -224,11 +242,9 @@ export const makeInfiniteApi = (config: InfiniteApiConfig): InfiniteApi => { // Dummy response const dummyAuthResponse: InfiniteAuthResponse = { access_token: `dummy_token_${Date.now()}`, - token_type: 'Bearer', expires_in: 3600, customer_id: `cust_${Math.random().toString(36).substring(7)}`, session_id: `sess_${Math.random().toString(36).substring(7)}`, - platform: params.platform, onboarded: true } @@ -280,29 +296,13 @@ export const makeInfiniteApi = (config: InfiniteApiConfig): InfiniteApi => { ) } - const fee = Math.abs(sourceAmount - targetAmount) - const dummyResponse: InfiniteQuoteResponse = { - quoteId: `quote_hls_${Date.now()}_${Math.random() - .toString(36) - .substring(7)}`, - flow: params.flow, source: { - asset: params.source.asset, - amount: sourceAmount, - network: params.source.network + amount: sourceAmount }, target: { - asset: params.target.asset, - amount: targetAmount, - network: params.target.network + amount: targetAmount }, - infiniteFee: fee * 0.5, - edgeFee: fee * 0.5, - // Headless quotes have simpler format - fee: undefined, - totalReceived: undefined, - rate: undefined, expiresAt: undefined } @@ -338,56 +338,24 @@ export const makeInfiniteApi = (config: InfiniteApiConfig): InfiniteApi => { // Dummy response - New format const dummyResponse: InfiniteTransferResponse = { id: `transfer_${params.type.toLowerCase()}_${Date.now()}`, - type: params.type, - status: 'AWAITING_FUNDS', - stage: params.type === 'ONRAMP' ? 'awaiting_funds' : 'awaiting_funds', - amount: params.amount, - currency: - params.type === 'ONRAMP' - ? 'USD' - : params.destination.currency.toUpperCase(), - source: { - currency: params.source.currency, - network: params.source.network, - accountId: params.source.accountId ?? null, - fromAddress: params.source.fromAddress ?? null - }, - destination: { - currency: params.destination.currency, - network: params.destination.network, - accountId: params.destination.accountId ?? null, - toAddress: params.destination.toAddress ?? null - }, sourceDepositInstructions: params.type === 'ONRAMP' ? { - network: 'wire', - currency: 'usd', amount: params.amount, - depositMessage: `Your reference code is ${Date.now()}. Please include this code in your wire transfer.`, bankAccountNumber: '8312008517', bankRoutingNumber: '021000021', - bankBeneficiaryName: 'Customer Bank Account', bankName: 'JPMorgan Chase Bank', - toAddress: null, - fromAddress: null + toAddress: null } : { - network: params.source.network, - currency: params.source.currency, amount: params.amount, - depositMessage: null, bankAccountNumber: null, bankRoutingNumber: null, - bankBeneficiaryName: null, bankName: null, toAddress: `0xdeadbeef2${params.source.currency}${ params.source.network - }${Date.now().toString(16)}`, - fromAddress: params.source.fromAddress ?? null - }, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() + }${Date.now().toString(16)}` + } } return dummyResponse @@ -414,33 +382,16 @@ export const makeInfiniteApi = (config: InfiniteApiConfig): InfiniteApi => { // Dummy response - simulate a completed transfer const dummyResponse: InfiniteTransferResponse = { id: transferId, - type: 'ONRAMP', - status: 'COMPLETED', - stage: 'completed', - amount: 100.0, - currency: 'USD', - source: { - currency: 'usd', - network: 'wire', - accountId: 'da4d1f78-7cdb-47a9-b577-8b4623901f03', - fromAddress: null - }, - destination: { - currency: 'usdc', - network: 'ethereum', - accountId: null, - toAddress: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' - }, - sourceDepositInstructions: undefined, - createdAt: new Date(Date.now() - 3600000).toISOString(), - updatedAt: new Date().toISOString() + sourceDepositInstructions: undefined } return dummyResponse }, // Customer methods - createCustomer: async (params: InfiniteCustomerRequest) => { + createCustomer: async ( + params: InfiniteCustomerRequest + ): Promise => { if (!USE_DUMMY_DATA.createCustomer) { const response = await fetchInfinite('/v1/headless/customers', { method: 'POST', @@ -450,24 +401,54 @@ export const makeInfiniteApi = (config: InfiniteApiConfig): InfiniteApi => { const data = await response.text() console.log('createCustomer response:', data) + + // Check if OTP was sent (existing email case) + const otpResponse = asMaybe(asInfiniteOtpSentResponse)(data) + if (otpResponse != null) { + return otpResponse + } + return asInfiniteCustomerResponse(data) } - // Dummy response - updated with UUID format + // Dummy response - new customers start with PENDING status const dummyResponse: InfiniteCustomerResponse = { customer: { id: `9b0d801f-41ac-4269-abec-${Date.now() .toString(16) .padStart(12, '0') - .substring(0, 12)}`, - type: params.type === 'individual' ? 'INDIVIDUAL' : 'BUSINESS', - status: 'ACTIVE', - countryCode: params.countryCode, - createdAt: new Date().toISOString() - }, - schemaDocumentUploadUrls: null, - kycLinkUrl: `http://localhost:5223/v1/kyc?session=${Date.now()}&callback=edge%3A%2F%2Fkyc-complete`, - usedPersonaKyc: true + .substring(0, 12)}` + } + } + + return dummyResponse + }, + + verifyOtp: async ( + params: InfiniteVerifyOtpRequest + ): Promise => { + if (!USE_DUMMY_DATA.verifyOtp) { + const response = await fetchInfinite( + '/v1/headless/customers/verify-otp', + { + method: 'POST', + headers: makeHeaders(), + body: JSON.stringify(params) + } + ) + + const data = await response.text() + return asInfiniteCustomerResponse(data) + } + + // Dummy response - return a customer after OTP verification + const dummyResponse: InfiniteCustomerResponse = { + customer: { + id: `9b0d801f-41ac-4269-abec-${Date.now() + .toString(16) + .padStart(12, '0') + .substring(0, 12)}` + } } return dummyResponse @@ -493,8 +474,8 @@ export const makeInfiniteApi = (config: InfiniteApiConfig): InfiniteApi => { return kycStatusResponse } - // Dummy response - return 'under_review' initially, then 'approved' after 2 seconds - let kycStatus: InfiniteKycStatus = 'under_review' + // Dummy response - return 'IN_REVIEW' initially, then 'ACTIVE' after 2 seconds + let kycStatus: InfiniteKycStatus = 'IN_REVIEW' // Check if we've seen this customer before if (!kycApprovalTimers.has(customerId)) { @@ -504,15 +485,12 @@ export const makeInfiniteApi = (config: InfiniteApiConfig): InfiniteApi => { // Check if 2 seconds have passed const approvalTime = kycApprovalTimers.get(customerId)! if (Date.now() >= approvalTime) { - kycStatus = 'approved' + kycStatus = 'ACTIVE' } } const dummyResponse: InfiniteKycStatusResponse = { - customerId, - kycStatus, - kycCompletedAt: - kycStatus === 'approved' ? new Date().toISOString() : undefined + kycStatus } authState.kycStatus = dummyResponse.kycStatus @@ -520,31 +498,31 @@ export const makeInfiniteApi = (config: InfiniteApiConfig): InfiniteApi => { return dummyResponse }, - getTos: async (customerId: string) => { + getKycLink: async (customerId: string, redirectUrl: string) => { // Check if we need to authenticate if (authState.token == null || isTokenExpired()) { throw new Error('Authentication required') } - if (!USE_DUMMY_DATA.getTos) { + if (!USE_DUMMY_DATA.getKycLink) { const response = await fetchInfinite( - `/v1/headless/customers/${customerId}/tos`, + `/v1/headless/customers/${customerId}/kyc-link?redirectUrl=${encodeURIComponent( + redirectUrl + )}`, { headers: makeHeaders({ includeAuth: true }) } ) const data = await response.text() - return asInfiniteTosResponse(data) + return asInfiniteKycLinkResponse(data) } // Dummy response - const dummyResponse: InfiniteTosResponse = { - tosUrl: `https://api.infinite.dev/v1/headless/tos?session=dummy_${Date.now()}&customerId=${customerId}`, - status: Math.random() > 0.5 ? 'accepted' : 'pending', - acceptedAt: null, - customerName: 'Test User', - email: 'test@example.com' + const dummyResponse: InfiniteKycLinkResponse = { + url: `https://infinite.dev/kyc?session=kyc_sess_${Date.now()}&redirect=${encodeURIComponent( + redirectUrl + )}` } return dummyResponse @@ -573,23 +551,8 @@ export const makeInfiniteApi = (config: InfiniteApiConfig): InfiniteApi => { // Dummy response - transform cached bank accounts to new format const dummyResponse: InfiniteCustomerAccountsResponse = { accounts: bankAccountCache.map(account => ({ - id: account.id, - type: 'EXTERNAL_BANK_ACCOUNT', - status: - account.verificationStatus === 'pending' ? 'PENDING' : 'ACTIVE', - currency: 'USD', - bankName: account.bankName, - accountNumber: `****${account.last4}`, - routingNumber: '****0021', - accountType: 'checking', - holderName: account.accountName, - createdAt: new Date().toISOString(), - metadata: { - bridgeAccountId: `ext_acct_${Date.now()}`, - verificationStatus: account.verificationStatus - } - })), - totalCount: bankAccountCache.length + id: account.id + })) } return dummyResponse @@ -614,14 +577,7 @@ export const makeInfiniteApi = (config: InfiniteApiConfig): InfiniteApi => { // Dummy response const dummyResponse: InfiniteBankAccountResponse = { - id: `acct_bank_${Date.now()}_${Math.random() - .toString(36) - .substring(7)}`, - type: 'bank_account', - bankName: params.bankName, - accountName: params.accountName, - last4: params.accountNumber.slice(-4), - verificationStatus: 'pending' + id: `acct_bank_${Date.now()}_${Math.random().toString(36).substring(7)}` } // Add to cache @@ -645,7 +601,6 @@ export const makeInfiniteApi = (config: InfiniteApiConfig): InfiniteApi => { countries: [ { code: 'US', - name: 'United States', isAllowed: true, supportedFiatCurrencies: ['USD'], supportedPaymentMethods: { @@ -681,40 +636,8 @@ export const makeInfiniteApi = (config: InfiniteApiConfig): InfiniteApi => { networkCode: 'ETH', contractAddress: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', confirmationsRequired: 12 - }, - { - network: 'polygon', - networkCode: 'POLYGON', - contractAddress: '0x2791bca1f2de4661ed88a30c99a7a9449aa84174', - confirmationsRequired: 30 - }, - { - network: 'arbitrum', - networkCode: 'ARB', - contractAddress: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', - confirmationsRequired: 1 - }, - { - network: 'optimism', - networkCode: 'OP', - contractAddress: '0x7F5c764cBc14f9669B88837ca1490cCa17c31607', - confirmationsRequired: 1 - }, - { - network: 'base', - networkCode: 'BASE', - contractAddress: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', - confirmationsRequired: 1 - }, - { - network: 'solana', - networkCode: 'SOL', - contractAddress: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', - confirmationsRequired: 1 } ], - supportedPaymentRails: undefined, - countryCode: undefined, supportsOnRamp: true, supportsOffRamp: true, onRampCountries: ['US'], @@ -728,15 +651,13 @@ export const makeInfiniteApi = (config: InfiniteApiConfig): InfiniteApi => { name: 'US Dollar', type: 'fiat' as const, supportedNetworks: undefined, - supportedPaymentRails: ['ach', 'wire'], - countryCode: 'US', supportsOnRamp: undefined, supportsOffRamp: undefined, onRampCountries: undefined, offRampCountries: undefined, - precision: 2, minAmount: '50', - maxAmount: '50000' + maxAmount: '50000', + precision: 2 }, { code: 'BTC', @@ -750,8 +671,6 @@ export const makeInfiniteApi = (config: InfiniteApiConfig): InfiniteApi => { confirmationsRequired: 6 } ], - supportedPaymentRails: undefined, - countryCode: undefined, supportsOnRamp: true, supportsOffRamp: true, onRampCountries: ['US'], diff --git a/src/plugins/ramps/infinite/infiniteApiTypes.ts b/src/plugins/ramps/infinite/infiniteApiTypes.ts index 48175a377b0..cd4efaa9a78 100644 --- a/src/plugins/ramps/infinite/infiniteApiTypes.ts +++ b/src/plugins/ramps/infinite/infiniteApiTypes.ts @@ -21,10 +21,11 @@ export interface InfiniteApiConfig { export const asInfiniteChallengeResponse = asJSON( asObject({ nonce: asString, - message: asString, - domain: asOptional(asNull), - expires_at: asNumber, - expires_at_iso: asString + message: asString + // UNUSED fields: + // domain: asOptional(asNull), + // expires_at: asNumber, + // expires_at_iso: asString }) ) @@ -32,12 +33,13 @@ export const asInfiniteChallengeResponse = asJSON( export const asInfiniteAuthResponse = asJSON( asObject({ access_token: asString, - token_type: asString, expires_in: asNumber, customer_id: asEither(asString, asNull), session_id: asString, - platform: asString, onboarded: asBoolean + // UNUSED fields: + // token_type: asString, + // platform: asString }) ) @@ -48,24 +50,27 @@ export type InfiniteQuoteFlow = ReturnType // Quote response export const asInfiniteQuoteResponse = asJSON( asObject({ - quoteId: asString, - flow: asInfiniteQuoteFlow, source: asObject({ - asset: asString, - amount: asNumber, - network: asOptional(asString) + amount: asNumber + // UNUSED fields: + // asset: asString, + // network: asOptional(asString) }), target: asObject({ - asset: asString, - amount: asNumber, - network: asOptional(asString) + amount: asNumber + // UNUSED fields: + // asset: asString, + // network: asOptional(asString) }), - fee: asOptional(asNumber), - infiniteFee: asOptional(asNumber), - edgeFee: asOptional(asNumber), - totalReceived: asOptional(asNumber), - rate: asOptional(asNumber), expiresAt: asOptional(asString) + // UNUSED fields: + // quoteId: asString, + // flow: asInfiniteQuoteFlow, + // fee: asOptional(asNumber), + // infiniteFee: asOptional(asNumber), + // edgeFee: asOptional(asNumber), + // totalReceived: asOptional(asNumber), + // rate: asOptional(asNumber) }) ) @@ -73,93 +78,46 @@ export const asInfiniteQuoteResponse = asJSON( export const asInfiniteTransferResponse = asJSON( asObject({ id: asString, - type: asInfiniteQuoteFlow, - status: asString, // "PENDING", "AWAITING_FUNDS", "IN_REVIEW", "PROCESSING", "COMPLETED", "FAILED", "CANCELLED" - stage: asString, - amount: asNumber, - currency: asString, - source: asObject({ - currency: asString, - network: asString, - accountId: asEither(asString, asNull), - fromAddress: asEither(asString, asNull) - }), - destination: asObject({ - currency: asString, - network: asString, - accountId: asEither(asString, asNull), - toAddress: asEither(asString, asNull) - }), sourceDepositInstructions: asOptional( asObject({ - network: asString, - currency: asString, amount: asNumber, - depositMessage: asEither(asString, asNull), - bankAccountNumber: asEither(asString, asNull), - bankRoutingNumber: asEither(asString, asNull), - bankBeneficiaryName: asEither(asString, asNull), - bankName: asEither(asString, asNull), - toAddress: asEither(asString, asNull), - fromAddress: asEither(asString, asNull) + bankAccountNumber: asOptional(asString, null), + bankRoutingNumber: asOptional(asString, null), + bankName: asOptional(asString, null), + toAddress: asOptional(asString, null) + // UNUSED fields: + // network: asString, + // currency: asString, + // depositMessage: asOptional(asString, null), + // bankBeneficiaryName: asOptional(asString, null), + // fromAddress: asOptional(asString, null) }) - ), - createdAt: asString, - updatedAt: asString - }) -) - -// Legacy transfer response for backwards compatibility -export const asInfiniteTransferResponseLegacy = asJSON( - asObject({ - data: asObject({ - id: asString, - organizationId: asString, - type: asInfiniteQuoteFlow, - source: asObject({ - asset: asString, - amount: asNumber, - network: asString - }), - destination: asObject({ - asset: asString, - amount: asNumber, - network: asString - }), - status: asString, - stage: asString, - createdAt: asString, - updatedAt: asString, - completedAt: asOptional(asString), - sourceDepositInstructions: asOptional( - asObject({ - amount: asOptional(asNumber), - currency: asOptional(asString), - paymentRail: asOptional(asString), - bank: asOptional( - asObject({ - name: asString, - accountNumber: asString, - routingNumber: asString - }) - ), - accountHolder: asOptional( - asObject({ - name: asString - }) - ), - memo: asOptional(asString), - depositAddress: asOptional(asString) - }) - ), - fees: asArray(asObject({})) - }) + ) + // UNUSED fields: + // type: asInfiniteQuoteFlow, + // status: asString, // "PENDING", "AWAITING_FUNDS", "IN_REVIEW", "PROCESSING", "COMPLETED", "FAILED", "CANCELLED" + // stage: asString, + // amount: asNumber, + // currency: asString, + // source: asObject({ + // currency: asString, + // network: asString, + // accountId: asOptional(asString, null), + // fromAddress: asOptional(asString, null) + // }), + // destination: asObject({ + // currency: asString, + // network: asString, + // accountId: asOptional(asString, null), + // toAddress: asOptional(asString, null) + // }), + // createdAt: asString, + // updatedAt: asString }) ) // Customer types export const asInfiniteCustomerType = asValue('individual', 'business') -export type InfiniteCustomerType = ReturnType export const asInfiniteCustomerStatus = asValue( 'ACTIVE', @@ -168,56 +126,64 @@ export const asInfiniteCustomerStatus = asValue( 'REJECTED', 'PENDING' ) -export type InfiniteCustomerStatus = ReturnType - -// Customer request +// Customer request - flattened structure (no nested data object) export const asInfiniteCustomerRequest = asObject({ type: asInfiniteCustomerType, countryCode: asString, - data: asObject({ - personalInfo: asOptional( - asObject({ - firstName: asString, - lastName: asString - }) - ), - companyInformation: asOptional( - asObject({ - legalName: asString, - website: asOptional(asString) - }) - ), - contactInformation: asObject({ - email: asString - }), - residentialAddress: asOptional( - asObject({ - streetLine1: asString, - streetLine2: asOptional(asString), - city: asString, - state: asString, - postalCode: asString - }) - ) - }) + contactInformation: asObject({ + email: asString + }), + personalInfo: asOptional( + asObject({ + firstName: asString, + lastName: asString + }) + ), + address: asOptional( + asObject({ + addressLine1: asString, + addressLine2: asOptional(asString), + city: asString, + state: asString, + postalCode: asString, + country: asString + }) + ), + companyInformation: asOptional( + asObject({ + legalName: asString, + website: asOptional(asString) + }) + ) }) -// Customer response +// Customer response - kycLinkUrl and usedPersonaKyc removed, use getKycLink endpoint export const asInfiniteCustomerResponse = asJSON( asObject({ customer: asObject({ - id: asString, - type: asString, - status: asInfiniteCustomerStatus, - countryCode: asString, - createdAt: asString - }), - schemaDocumentUploadUrls: asOptional(asNull), - kycLinkUrl: asString, - usedPersonaKyc: asBoolean + id: asString + // UNUSED fields: + // type: asString, + // status: asInfiniteCustomerStatus, + // countryCode: asEither(asString, asNull), + // createdAt: asString + }) + }) +) + +// OTP sent response - returned when email already exists +export const asInfiniteOtpSentResponse = asJSON( + asObject({ + otpSent: asValue(true) }) ) +// OTP verification request +export const asInfiniteVerifyOtpRequest = asObject({ + email: asString, + code: asString +}) + // Bank account types - API expects camelCase export const asInfiniteBankAccountRequest = asObject({ type: asValue('bank_account'), @@ -231,12 +197,17 @@ export const asInfiniteBankAccountRequest = asObject({ // Bank account response - API returns camelCase export const asInfiniteBankAccountResponse = asJSON( asObject({ - id: asString, - type: asValue('bank_account'), - bankName: asString, - accountName: asString, - last4: asString, - verificationStatus: asString + id: asString + // UNUSED fields: + // type: asString, // 'EXTERNAL_BANK', etc. + // status: asString, // 'ACTIVE', 'PENDING', etc. + // currency: asString, + // bankName: asString, + // accountNumber: asString, // Masked like '****6666' + // routingNumber: asString, // Masked like '****6789' + // holderName: asString, + // createdAt: asString, + // metadata: asObject({}) }) ) @@ -245,63 +216,62 @@ export const asInfiniteCustomerAccountsResponse = asJSON( asObject({ accounts: asArray( asObject({ - id: asString, - type: asString, // "EXTERNAL_BANK_ACCOUNT", "EXTERNAL_WALLET_ACCOUNT" - status: asString, // "ACTIVE", "PENDING", "INACTIVE" - currency: asString, - bankName: asOptional(asString), - accountNumber: asOptional(asString), // Masked like "****1234" - routingNumber: asOptional(asString), // Masked like "****0021" - accountType: asOptional(asString), // "checking", "savings" - holderName: asString, - createdAt: asString, - metadata: asObject({ - bridgeAccountId: asEither(asString, asNull), - verificationStatus: asString - }) + id: asString + // UNUSED fields: + // type: asString, // "EXTERNAL_BANK_ACCOUNT", "EXTERNAL_WALLET_ACCOUNT" + // status: asString, // "ACTIVE", "PENDING", "INACTIVE" + // currency: asString, + // bankName: asOptional(asString), + // accountNumber: asOptional(asString), // Masked like "****1234" + // routingNumber: asOptional(asString), // Masked like "****0021" + // accountType: asOptional(asString), // "checking", "savings" + // holderName: asString, + // createdAt: asString, + // metadata: asObject({ + // externalAccountId: asOptional(asEither(asString, asNull)), + // verificationStatus: asOptional(asString) + // }) }) - ), - totalCount: asNumber + ) + // UNUSED fields: + // totalCount: asNumber }) ) -// KYC Status types (from Bridge) +// KYC Status types (Infinite format) export const asInfiniteKycStatus = asValue( - 'not_started', - 'incomplete', - 'awaiting_ubo', - 'under_review', - 'approved', - 'rejected', - 'paused', - 'offboarded' + 'PENDING', + 'IN_REVIEW', + 'ACTIVE', + 'NEED_ACTIONS', + 'REJECTED' ) export type InfiniteKycStatus = ReturnType export const asInfiniteKycStatusResponse = asJSON( asObject({ - customerId: asString, - kycStatus: asInfiniteKycStatus, - kycCompletedAt: asOptional(asString) - // Note: approvedLimit removed in new API + kycStatus: asInfiniteKycStatus + // UNUSED fields: + // customerId: asString, + // sessionStatus: asOptional(asString), + // kycCompletedAt: asOptional(asString) }) ) -// TOS types -export const asInfiniteTosStatus = asValue( - 'pending', - 'accepted', - 'not_required' -) -export type InfiniteTosStatus = ReturnType - -export const asInfiniteTosResponse = asJSON( +// KYC Link response - separate endpoint from customer creation +export const asInfiniteKycLinkResponse = asJSON( asObject({ - tosUrl: asString, - status: asInfiniteTosStatus, - acceptedAt: asEither(asString, asNull), - customerName: asEither(asString, asNull), - email: asEither(asString, asNull) + url: asString + // UNUSED fields: + // organizationName: asOptional(asString), + // branding: asOptional( + // asObject({ + // primaryColor: asOptional(asString), + // secondaryColor: asOptional(asString), + // logoUrl: asOptional(asString), + // companyName: asOptional(asString) + // }) + // ) }) ) @@ -311,7 +281,6 @@ export const asInfiniteCountriesResponse = asJSON( countries: asArray( asObject({ code: asString, - name: asString, isAllowed: asBoolean, supportedFiatCurrencies: asArray(asString), supportedPaymentMethods: asObject({ @@ -319,6 +288,8 @@ export const asInfiniteCountriesResponse = asJSON( offRamp: asArray(asString) }), memberStates: asOptional(asArray(asString)) + // UNUSED fields: + // name: asString }) ) }) @@ -342,8 +313,6 @@ export const asInfiniteCurrenciesResponse = asJSON( }) ) ), - supportedPaymentRails: asOptional(asArray(asString)), - countryCode: asOptional(asString), supportsOnRamp: asOptional(asBoolean), supportsOffRamp: asOptional(asBoolean), onRampCountries: asOptional(asArray(asString)), @@ -351,6 +320,9 @@ export const asInfiniteCurrenciesResponse = asJSON( minAmount: asString, maxAmount: asString, precision: asNumber + // UNUSED fields: + // supportedPaymentRails: asOptional(asArray(asString)), + // countryCode: asOptional(asString) }) ) }) @@ -366,6 +338,14 @@ export const asInfiniteErrorResponse = asJSON( }) ) +export const asInfiniteHttpErrorResponse = asJSON( + asObject({ + message: asEither(asString, asArray(asString)), + error: asString, + statusCode: asNumber + }) +) + // Type exports export type InfiniteChallengeResponse = ReturnType< typeof asInfiniteChallengeResponse @@ -381,6 +361,12 @@ export type InfiniteCustomerRequest = ReturnType< export type InfiniteCustomerResponse = ReturnType< typeof asInfiniteCustomerResponse > +export type InfiniteOtpSentResponse = ReturnType< + typeof asInfiniteOtpSentResponse +> +export type InfiniteVerifyOtpRequest = ReturnType< + typeof asInfiniteVerifyOtpRequest +> export type InfiniteBankAccountRequest = ReturnType< typeof asInfiniteBankAccountRequest > @@ -393,14 +379,15 @@ export type InfiniteCustomerAccountsResponse = ReturnType< export type InfiniteKycStatusResponse = ReturnType< typeof asInfiniteKycStatusResponse > +export type InfiniteKycLinkResponse = ReturnType< + typeof asInfiniteKycLinkResponse +> export type InfiniteCountriesResponse = ReturnType< typeof asInfiniteCountriesResponse > export type InfiniteCurrenciesResponse = ReturnType< typeof asInfiniteCurrenciesResponse > -export type InfiniteErrorResponse = ReturnType -export type InfiniteTosResponse = ReturnType // Custom error class for API errors export class InfiniteApiError extends Error { @@ -475,9 +462,15 @@ export interface InfiniteApi { // Customer methods createCustomer: ( params: InfiniteCustomerRequest + ) => Promise + verifyOtp: ( + params: InfiniteVerifyOtpRequest ) => Promise getKycStatus: (customerId: string) => Promise - getTos: (customerId: string) => Promise + getKycLink: ( + customerId: string, + redirectUrl: string + ) => Promise // Bank account methods getCustomerAccounts: ( diff --git a/src/plugins/ramps/infinite/infiniteRampPlugin.ts b/src/plugins/ramps/infinite/infiniteRampPlugin.ts index 94225e44591..a1de64e86a2 100644 --- a/src/plugins/ramps/infinite/infiniteRampPlugin.ts +++ b/src/plugins/ramps/infinite/infiniteRampPlugin.ts @@ -41,7 +41,6 @@ import { authenticateWorkflow } from './workflows/authenticateWorkflow' import { bankAccountWorkflow } from './workflows/bankAccountWorkflow' import { confirmationWorkflow } from './workflows/confirmationWorkflow' import { kycWorkflow } from './workflows/kycWorkflow' -import { tosWorkflow } from './workflows/tosWorkflow' const pluginId = 'infinite' const partnerIcon = `${EDGE_CONTENT_SERVER_URI}/infinite.png` @@ -661,20 +660,16 @@ export const infiniteRampPlugin: RampPluginFactory = ( // User needs to complete KYC await kycWorkflow({ + countryCode: request.regionCode.countryCode, infiniteApi, navigationFlow, pluginId, vault }) - // User needs to accept TOS - await tosWorkflow({ - infiniteApi, - navigationFlow - }) - // Ensure we have a bank account const bankAccountResult = await bankAccountWorkflow({ + countryCode: request.regionCode.countryCode, infiniteApi, navigationFlow, vault diff --git a/src/plugins/ramps/infinite/workflows/bankAccountWorkflow.ts b/src/plugins/ramps/infinite/workflows/bankAccountWorkflow.ts index 9740ac9142b..1a4f2f87468 100644 --- a/src/plugins/ramps/infinite/workflows/bankAccountWorkflow.ts +++ b/src/plugins/ramps/infinite/workflows/bankAccountWorkflow.ts @@ -5,6 +5,7 @@ import type { InfiniteApi } from '../infiniteApiTypes' import type { NavigationFlow } from '../utils/navigationFlow' interface Params { + countryCode: string infiniteApi: InfiniteApi navigationFlow: NavigationFlow vault: EdgeVault @@ -15,7 +16,7 @@ interface Result { } export const bankAccountWorkflow = async (params: Params): Promise => { - const { infiniteApi, navigationFlow, vault } = params + const { countryCode, infiniteApi, navigationFlow, vault } = params const authState = infiniteApi.getAuthState() @@ -37,6 +38,7 @@ export const bankAccountWorkflow = async (params: Params): Promise => { const bankAccountId = await new Promise((resolve, reject) => { navigationFlow.navigate('rampBankForm', { + countryCode, onSubmit: async (formData: BankFormData) => { const bankAccount = await infiniteApi.addBankAccount({ type: 'bank_account', diff --git a/src/plugins/ramps/infinite/workflows/kycWorkflow.ts b/src/plugins/ramps/infinite/workflows/kycWorkflow.ts index 7ea83c41263..4d4b94fb3ae 100644 --- a/src/plugins/ramps/infinite/workflows/kycWorkflow.ts +++ b/src/plugins/ramps/infinite/workflows/kycWorkflow.ts @@ -1,18 +1,20 @@ import { I18nError } from '../../../../components/cards/ErrorCard' +import { showOtpVerificationModal } from '../../../../components/modals/OtpVerificationModal' import type { KycFormData } from '../../../../components/scenes/RampKycFormScene' import type { RampPendingSceneStatus } from '../../../../components/scenes/RampPendingScene' import { lstrings } from '../../../../locales/strings' import type { EdgeVault } from '../../../../util/vault/edgeVault' import { ExitError } from '../../utils/exitUtils' -import { openWebView } from '../../utils/webViewUtils' import { type InfiniteApi, InfiniteApiError, + type InfiniteCustomerResponse, type InfiniteKycStatus } from '../infiniteApiTypes' import type { NavigationFlow } from '../utils/navigationFlow' interface Params { + countryCode: string infiniteApi: InfiniteApi navigationFlow: NavigationFlow pluginId: string @@ -21,7 +23,7 @@ interface Params { // Exports export const kycWorkflow = async (params: Params): Promise => { - const { infiniteApi, navigationFlow, pluginId, vault } = params + const { countryCode, infiniteApi, navigationFlow, pluginId, vault } = params let customerId = infiniteApi.getAuthState().customerId @@ -29,142 +31,158 @@ export const kycWorkflow = async (params: Params): Promise => { if (customerId != null) { const kycStatus = await infiniteApi.getKycStatus(customerId) - // If already approved, we're done - no scene shown - if (kycStatus.kycStatus === 'approved') { + // If already approved (ACTIVE), we're done - no scene shown + if (kycStatus.kycStatus === 'ACTIVE') { return } - // If not_started or incomplete, show KYC form - if ( - kycStatus.kycStatus !== 'not_started' && - kycStatus.kycStatus !== 'incomplete' - ) { - // For all other statuses (under_review, awaiting_ubo, etc.), show pending scene - await showKycPendingScene( - navigationFlow, - infiniteApi, - customerId, - kycStatus.kycStatus - ) - return + // Determine the status to use for the pending scene + let statusForPendingScene: InfiniteKycStatus = kycStatus.kycStatus + + // If PENDING, redirect directly to KYC webview (skip form since customer exists) + if (kycStatus.kycStatus === 'PENDING') { + await openKycWebView(navigationFlow, infiniteApi, customerId, pluginId) + + // Check status after webview closes + const currentKycStatus = await infiniteApi.getKycStatus(customerId) + if (currentKycStatus.kycStatus === 'ACTIVE') { + return + } + statusForPendingScene = currentKycStatus.kycStatus } + + // Show pending scene for non-ACTIVE statuses + await showKycPendingScene( + navigationFlow, + infiniteApi, + customerId, + statusForPendingScene + ) + return } - // Show KYC form for new customers or those with not_started/incomplete status + // Show KYC form for new customers or those with PENDING status const userSubmittedKycForm = await new Promise((resolve, reject) => { navigationFlow.navigate('kycForm', { headerTitle: lstrings.ramp_plugin_kyc_title, onSubmit: async (contactInfo: KycFormData) => { - try { - // Create customer profile - const customerResponse = await infiniteApi - .createCustomer({ - type: 'individual', - countryCode: 'US', - data: { - personalInfo: { - firstName: contactInfo.firstName, - lastName: contactInfo.lastName - }, - companyInformation: undefined, - contactInformation: { - email: contactInfo.email - }, - residentialAddress: { - streetLine1: contactInfo.address1, - streetLine2: contactInfo.address2, - city: contactInfo.city, - state: contactInfo.state, - postalCode: contactInfo.postalCode - } - } - }) - .catch((error: unknown) => { - return { error } - }) - - if ('error' in customerResponse) { - if ( - customerResponse.error instanceof InfiniteApiError && - customerResponse.error.detail.includes('duplicate_record') - ) { - throw new I18nError( - lstrings.ramp_signup_failed_title, - lstrings.ramp_signup_failed_account_existsmessage - ) - } - - throw customerResponse.error - } - - // Store customer ID directly in state - infiniteApi.saveCustomerId(customerResponse.customer.id) - - // Save or update personal info in vault - const personalInfoUuid = await vault.getUuid('personalInfo', 0) - const personalInfo = { - type: 'personalInfo' as const, - name: { + // Create customer profile with flattened schema + const customerResponse = await infiniteApi + .createCustomer({ + type: 'individual', + countryCode, + contactInformation: { + email: contactInfo.email + }, + personalInfo: { firstName: contactInfo.firstName, lastName: contactInfo.lastName }, - email: contactInfo.email - } - if (personalInfoUuid != null) { - await vault.updatePersonalInfo(personalInfoUuid, personalInfo) - } else { - await vault.createPersonalInfo(personalInfo) - } + address: { + addressLine1: contactInfo.address1, + addressLine2: contactInfo.address2, + city: contactInfo.city, + state: contactInfo.state, + postalCode: contactInfo.postalCode, + country: 'US' + }, + companyInformation: undefined + }) + .catch((error: unknown) => { + return { error } + }) - // Save or update address info in vault - const addressInfoUuid = await vault.getUuid('addressInfo', 0) - const addressInfo = { - type: 'addressInfo' as const, - line1: contactInfo.address1, - line2: contactInfo.address2, - city: contactInfo.city, - state: contactInfo.state, - postalCode: contactInfo.postalCode, - countryCode: 'US' + if ('error' in customerResponse) { + if ( + customerResponse.error instanceof InfiniteApiError && + customerResponse.error.detail.includes('duplicate_record') + ) { + throw new I18nError( + lstrings.ramp_signup_failed_title, + lstrings.ramp_signup_failed_account_existsmessage + ) } - if (addressInfoUuid != null) { - await vault.updateAddressInfo(addressInfoUuid, addressInfo) - } else { - await vault.createAddressInfo(addressInfo) + + throw customerResponse.error + } + + // Check if OTP was sent (existing email case) + if ('otpSent' in customerResponse) { + const otpResult = await showOtpModal(infiniteApi, contactInfo.email) + + if (otpResult == null) { + // User cancelled OTP verification - resolve(false) to let the + // workflow handle this gracefully at the top level rather than + // showing an error in the form scene. + resolve(false) + return } - // Inject deeplink callback into KYC URL - const kycUrl = new URL(customerResponse.kycLinkUrl) - const callbackUrl = `https://deep.edge.app/ramp/buy/${pluginId}` - kycUrl.searchParams.set('callback', callbackUrl) - - // Open KYC webview with close detection - let hasResolved = false - await openWebView({ - url: kycUrl.toString(), - deeplink: { - direction: 'buy', - providerId: pluginId, - handler: () => { - if (!hasResolved) { - hasResolved = true - resolve(true) - } - } - }, - onClose: () => { - if (!hasResolved) { - hasResolved = true - resolve(true) - } - return true // Allow close - } - }) - } catch (err) { - reject(new ExitError('KYC failed')) - throw err + // Store customer ID from OTP verification response + infiniteApi.saveCustomerId(otpResult.customer.id) + } else { + // Store customer ID directly in state + infiniteApi.saveCustomerId(customerResponse.customer.id) + } + + // Save or update personal info in vault + const personalInfoUuid = await vault.getUuid('personalInfo', 0) + const personalInfo = { + type: 'personalInfo' as const, + name: { + firstName: contactInfo.firstName, + lastName: contactInfo.lastName + }, + email: contactInfo.email + } + if (personalInfoUuid != null) { + await vault.updatePersonalInfo(personalInfoUuid, personalInfo) + } else { + await vault.createPersonalInfo(personalInfo) + } + + // Save or update address info in vault + const addressInfoUuid = await vault.getUuid('addressInfo', 0) + const addressInfo = { + type: 'addressInfo' as const, + line1: contactInfo.address1, + line2: contactInfo.address2, + city: contactInfo.city, + state: contactInfo.state, + postalCode: contactInfo.postalCode, + countryCode + } + if (addressInfoUuid != null) { + await vault.updateAddressInfo(addressInfoUuid, addressInfo) + } else { + await vault.createAddressInfo(addressInfo) + } + + // Get customer ID from auth state (set either from direct response or OTP) + const newCustomerId = infiniteApi.getAuthState().customerId + if (newCustomerId == null) { + throw new ExitError('Customer ID is missing after creation') } + + // If KYC is already approved (possible when linking an existing email), + // skip opening the KYC webview entirely. + const newCustomerKycStatus = await infiniteApi.getKycStatus( + newCustomerId + ) + if (newCustomerKycStatus.kycStatus === 'ACTIVE') { + resolve(true) + return + } + + // Open KYC webview + await openKycWebView( + navigationFlow, + infiniteApi, + newCustomerId, + pluginId + ) + resolve(true) }, onCancel: () => { resolve(false) @@ -187,12 +205,12 @@ export const kycWorkflow = async (params: Params): Promise => { // Get current KYC status after form submission const currentKycStatus = await infiniteApi.getKycStatus(customerId) - // If already approved after form submission, we're done - if (currentKycStatus.kycStatus === 'approved') { + // If already approved (ACTIVE) after form submission, we're done + if (currentKycStatus.kycStatus === 'ACTIVE') { return } - // Show pending scene for non-approved statuses + // Show pending scene for non-ACTIVE statuses await showKycPendingScene( navigationFlow, infiniteApi, @@ -208,6 +226,16 @@ const showKycPendingScene = async ( customerId: string, initialStatus: InfiniteKycStatus ): Promise => { + // Handle REJECTED status before entering the Promise to ensure consistent + // error handling. This throws I18nError synchronously which is then caught + // by handleExitErrorsGracefully. + if (initialStatus === 'REJECTED') { + throw new I18nError( + lstrings.ramp_kyc_error_title, + lstrings.ramp_kyc_rejected + ) + } + await new Promise((resolve, reject) => { const startTime = Date.now() const stepOffThreshold = 60000 // 1 minute @@ -270,7 +298,7 @@ const kycStatusToSceneStatus = ( kycStatus: InfiniteKycStatus ): RampPendingSceneStatus => { switch (kycStatus) { - case 'approved': { + case 'ACTIVE': { // KYC is approved, stop polling and continue workflow. // The next scene will use navigation.replace to replace this verification scene return { @@ -278,23 +306,25 @@ const kycStatusToSceneStatus = ( message: lstrings.ramp_kyc_approved_message } } - case 'not_started': - case 'incomplete': - // KYC is flow needs to be completed + case 'PENDING': + // KYC flow needs to be started/completed return { isChecking: false, message: lstrings.ramp_kyc_incomplete_message } - case 'awaiting_ubo': - case 'under_review': + case 'IN_REVIEW': // KYC is still pending, continue polling return { isChecking: true, message: lstrings.ramp_kyc_pending_message } - case 'rejected': - case 'paused': - case 'offboarded': { + case 'NEED_ACTIONS': + // Additional information required + return { + isChecking: false, + message: lstrings.ramp_kyc_additional_info_required + } + case 'REJECTED': { // Throw error instead of returning it throw new I18nError( lstrings.ramp_kyc_error_title, @@ -303,3 +333,82 @@ const kycStatusToSceneStatus = ( } } } + +// Helper function to open KYC webview +const openKycWebView = async ( + navigationFlow: NavigationFlow, + infiniteApi: InfiniteApi, + customerId: string, + pluginId: string +): Promise => { + const callbackUrl = `https://deep.edge.app/ramp/buy/${pluginId}` + const kycLinkResponse = await infiniteApi.getKycLink(customerId, callbackUrl) + const kycUrl = new URL(kycLinkResponse.url) + + await new Promise((resolve, reject) => { + let hasResolved = false + + navigationFlow.navigate('guiPluginWebView', { + url: kycUrl.toString(), + onUrlChange: async (url: string) => { + // Only intercept the specific callback URL that ends the KYC flow. + // This avoids relying on OS Universal Links behavior inside SafariView. + let shouldClose = false + try { + const parsed = new URL(url) + shouldClose = + parsed.protocol === 'https:' && + parsed.host === 'deep.edge.app' && + parsed.pathname.startsWith(`/ramp/buy/${pluginId}`) + } catch { + // Some webviews may surface non-URL strings. Ignore. + } + + if (shouldClose) { + if (!hasResolved) { + hasResolved = true + // Close the webview scene: + navigationFlow.goBack() + resolve() + } + } + }, + onClose: () => { + if (!hasResolved) { + hasResolved = true + resolve() + } + return true // Allow close + } + }) + }) +} + +// Helper function to show OTP verification modal +const showOtpModal = async ( + infiniteApi: InfiniteApi, + email: string +): Promise => { + return await showOtpVerificationModal({ + title: lstrings.ramp_otp_verification_title, + message: lstrings.ramp_otp_verification_message, + inputLabel: lstrings.ramp_otp_input_label, + onVerify: async (code: string) => { + try { + return await infiniteApi.verifyOtp({ email, code }) + } catch (error: unknown) { + if ( + error instanceof InfiniteApiError && + error.status === 400 && + error.detail.includes('Invalid or expired verification code') + ) { + throw new I18nError( + lstrings.ramp_kyc_error_title, + lstrings.ramp_otp_invalid_code + ) + } + throw error + } + } + }) +} diff --git a/src/plugins/ramps/infinite/workflows/tosWorkflow.ts b/src/plugins/ramps/infinite/workflows/tosWorkflow.ts deleted file mode 100644 index a0bfae59fa4..00000000000 --- a/src/plugins/ramps/infinite/workflows/tosWorkflow.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { RampPendingSceneStatus } from '../../../../components/scenes/RampPendingScene' -import { lstrings } from '../../../../locales/strings' -import { ExitError } from '../../utils/exitUtils' -import { openWebView } from '../../utils/webViewUtils' -import type { InfiniteApi, InfiniteTosStatus } from '../infiniteApiTypes' -import type { NavigationFlow } from '../utils/navigationFlow' - -interface Params { - infiniteApi: InfiniteApi - navigationFlow: NavigationFlow -} - -export const tosWorkflow = async (params: Params): Promise => { - const { infiniteApi, navigationFlow } = params - const authState = infiniteApi.getAuthState() - - // Ensure we have a customer ID - const customerId = authState.customerId - if (customerId == null) { - throw new ExitError('Customer ID is missing') - } - - // Get TOS status - const tosResponse = await infiniteApi.getTos(customerId) - - // If TOS is already accepted or not required, skip - if ( - tosResponse.status === 'accepted' || - tosResponse.status === 'not_required' - ) { - return - } - - // Show TOS in webview if pending - if (tosResponse.status === 'pending' && tosResponse.tosUrl !== '') { - await new Promise((resolve, reject) => { - openWebView({ - url: tosResponse.tosUrl, - onClose: () => { - resolve() - return true // Allow close - } - }).catch(reject) - }) - - await new Promise((resolve, reject) => { - const startTime = Date.now() - const stepOffThreshold = 60000 // 1 minute - - // Navigate to pending scene to check status - navigationFlow.navigate('rampPending', { - title: lstrings.ramp_tos_pending_title, - initialStatus: tosStatusToSceneStatus('pending'), - onStatusCheck: async () => { - // Check if we've exceeded the timeout threshold - if (Date.now() - startTime > stepOffThreshold) { - return { - isChecking: false, - message: lstrings.ramp_tos_timeout_message - } - } - - const updatedTos = await infiniteApi.getTos(customerId) - - if ( - updatedTos.status === 'accepted' || - updatedTos.status === 'not_required' - ) { - resolve() - } - return tosStatusToSceneStatus(updatedTos.status) - }, - onCancel: () => { - reject(new ExitError('User canceled the Terms of Service screen')) - }, - onClose: () => { - navigationFlow.goBack() - reject(new ExitError('Terms of Service not accepted')) - } - }) - }) - } -} - -// Helper function to convert TOS status to scene status -const tosStatusToSceneStatus = ( - status: InfiniteTosStatus -): RampPendingSceneStatus => { - switch (status) { - case 'accepted': - return { - isChecking: false, - message: lstrings.ramp_tos_status_accepted - } - case 'pending': - return { - isChecking: true, - message: lstrings.ramp_tos_pending_message - } - case 'not_required': - return { - isChecking: false, - message: lstrings.ramp_tos_status_not_required - } - } -} diff --git a/src/plugins/ramps/utils/exitUtils.ts b/src/plugins/ramps/utils/exitUtils.ts index 2c60a681275..9d45ae0ebae 100644 --- a/src/plugins/ramps/utils/exitUtils.ts +++ b/src/plugins/ramps/utils/exitUtils.ts @@ -31,7 +31,7 @@ export const handleExitErrorsGracefully = async ( ): Promise => { try { await fn() - } catch (error) { + } catch (error: unknown) { // Handle graceful exit - don't propagate the error if (error instanceof ExitError) { return