diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index 24ce2f325..0d0ad6365 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -1,6 +1,11 @@ 'use client' -import { COUNTRY_SPECIFIC_METHODS, countryData, SpecificPaymentMethod } from '@/components/AddMoney/consts' +import { + COUNTRY_SPECIFIC_METHODS, + countryCodeMap, + countryData, + SpecificPaymentMethod, +} from '@/components/AddMoney/consts' import StatusBadge from '@/components/Global/Badges/StatusBadge' import { IconName } from '@/components/Global/Icons/Icon' import NavHeader from '@/components/Global/NavHeader' @@ -11,7 +16,7 @@ import Image, { StaticImageData } from 'next/image' import { useParams, useRouter } from 'next/navigation' import EmptyState from '../Global/EmptyStates/EmptyState' import { useAuth } from '@/context/authContext' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { InitiateKYCModal } from '@/components/Kyc' import { DynamicBankAccountForm, IBankAccountDetails } from './DynamicBankAccountForm' import { addBankAccount, updateUserById } from '@/app/actions/users' @@ -23,6 +28,7 @@ import { useWithdrawFlow } from '@/context/WithdrawFlowContext' import { useOnrampFlow } from '@/context/OnrampFlowContext' import { Account } from '@/interfaces' import PeanutLoading from '../Global/PeanutLoading' +import { getCountryCodeForWithdraw } from '@/utils/withdraw.utils' interface AddWithdrawCountriesListProps { flow: 'add' | 'withdraw' @@ -212,7 +218,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => {
diff --git a/src/components/AddWithdraw/DynamicBankAccountForm.tsx b/src/components/AddWithdraw/DynamicBankAccountForm.tsx index 5cb050530..038ee3bed 100644 --- a/src/components/AddWithdraw/DynamicBankAccountForm.tsx +++ b/src/components/AddWithdraw/DynamicBankAccountForm.tsx @@ -13,6 +13,7 @@ import { getBicFromIban } from '@/app/actions/ibanToBic' import PeanutActionDetailsCard, { PeanutActionDetailsCardProps } from '../Global/PeanutActionDetailsCard' import { PEANUT_WALLET_TOKEN_SYMBOL } from '@/constants' import { useWithdrawFlow } from '@/context/WithdrawFlowContext' +import { getCountryFromIban, validateMXCLabeAccount, validateUSBankAccount } from '@/utils/withdraw.utils' const isIBANCountry = (country: string) => { return countryCodeMap[country.toUpperCase()] !== undefined @@ -41,19 +42,22 @@ interface DynamicBankAccountFormProps { initialData?: Partial flow?: 'claim' | 'withdraw' actionDetailsProps?: Partial + countryName?: string } export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, DynamicBankAccountFormProps>( - ({ country, onSuccess, initialData, flow = 'withdraw', actionDetailsProps }, ref) => { + ({ country, countryName, onSuccess, initialData, flow = 'withdraw', actionDetailsProps }, ref) => { const { user } = useAuth() const [isSubmitting, setIsSubmitting] = useState(false) const [submissionError, setSubmissionError] = useState(null) const [showBicField, setShowBicField] = useState(false) - const { country: countryName } = useParams() + const { country: countryNameParams } = useParams() const { amountToWithdraw } = useWithdrawFlow() const [firstName, ...lastNameParts] = (user?.user.fullName ?? '').split(' ') const lastName = lastNameParts.join(' ') + let selectedCountry = (countryName ?? (countryNameParams as string)).toLowerCase() + const { control, handleSubmit, @@ -85,9 +89,9 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D setIsSubmitting(true) setSubmissionError(null) try { - const isIban = isIBANCountry(country) - const isUs = country.toUpperCase() === 'US' + const isUs = country.toUpperCase() === 'USA' const isMx = country.toUpperCase() === 'MX' + const isIban = isUs || isMx ? false : isIBANCountry(country) let accountType: BridgeAccountType if (isIban) accountType = BridgeAccountType.IBAN @@ -121,7 +125,7 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D accountType, accountNumber: accountNumber.replace(/\s/g, ''), countryCode: isUs ? 'USA' : country.toUpperCase(), - countryName: countryName as string, + countryName: selectedCountry, accountOwnerType: BridgeAccountOwnerType.INDIVIDUAL, accountOwnerName: { firstName: firstName.trim(), @@ -159,9 +163,9 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D } } - const isIban = isIBANCountry(country) - const isUs = country.toUpperCase() === 'US' const isMx = country.toUpperCase() === 'MX' + const isUs = country.toUpperCase() === 'USA' + const isIban = isUs || isMx ? false : isIBANCountry(country) const renderInput = ( name: keyof IBankAccountDetails, @@ -246,14 +250,25 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D required: 'CLABE is required', minLength: { value: 18, message: 'CLABE must be 18 digits' }, maxLength: { value: 18, message: 'CLABE must be 18 digits' }, + validate: async (value: string) => + validateMXCLabeAccount(value).isValid || 'Invalid CLABE', }) : isIban ? renderInput( 'accountNumber', 'IBAN', { - required: 'Account number is required', - validate: async (val: string) => (await validateIban(val)) || 'Invalid IBAN', + required: 'IBAN is required', + validate: async (val: string) => { + const isValidIban = await validateIban(val) + if (!isValidIban) return 'Invalid IBAN' + + if (getCountryFromIban(val)?.toLowerCase() !== selectedCountry) { + return 'IBAN does not match the selected country' + } + + return true + }, }, 'text', undefined, @@ -267,7 +282,7 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D { required: 'Account number is required', validate: async (value: string) => - (await validateBankAccount(value)) || 'Invalid account number', + validateUSBankAccount(value).isValid || 'Invalid account number', }, 'text' )} diff --git a/src/components/Claim/Link/views/BankFlowManager.view.tsx b/src/components/Claim/Link/views/BankFlowManager.view.tsx index 7d98e6f3b..60afba58b 100644 --- a/src/components/Claim/Link/views/BankFlowManager.view.tsx +++ b/src/components/Claim/Link/views/BankFlowManager.view.tsx @@ -20,6 +20,7 @@ import { getBridgeChainName, getBridgeTokenName } from '@/utils/bridge-accounts. import peanut from '@squirrel-labs/peanut-sdk' import { getUserById } from '@/app/actions/users' import NavHeader from '@/components/Global/NavHeader' +import { getCountryCodeForWithdraw } from '@/utils/withdraw.utils' export const BankFlowManager = (props: IClaimScreenProps) => { const { onCustom, claimLinkData, setTransactionHash } = props @@ -214,7 +215,8 @@ export const BankFlowManager = (props: IClaimScreenProps) => {
{ + // Remove spaces and convert to uppercase + const cleanIban = iban.replace(/\s/g, '').toUpperCase() + + // Extract the first 2 characters as country code + const countryCode = cleanIban.substring(0, 2) + + // Try to find country by 2-letter code directly in countryData + let country = countryData.find((c) => c.type === 'country' && c.id === countryCode) + + // If not found, get the 3-letter code and try that + if (!country) { + const threeLetterCode = getCountryCodeForWithdraw(countryCode) + if (threeLetterCode !== countryCode) { + country = countryData.find((c) => c.type === 'country' && c.id === threeLetterCode) + } + } + + return country ? country.title : null +} + +/** + * Validates a US bank account number with comprehensive checks + * @param accountNumber - The bank account number to validate + * @returns Object with isValid boolean and error message if invalid + */ +export const validateUSBankAccount = (accountNumber: string) => { + // Remove spaces and hyphens for validation + const cleanAccountNumber = accountNumber.replace(/[\s-]/g, '') + + // Check if contains only digits + if (!/^\d+$/.test(cleanAccountNumber)) { + return { + isValid: false, + error: 'Account number must contain only digits', + } + } + + // Check minimum length (US bank accounts are typically 6-17 digits) + if (cleanAccountNumber.length < 6) { + return { + isValid: false, + error: 'Account number must be at least 6 digits', + } + } + + // Check maximum length + if (cleanAccountNumber.length > 17) { + return { + isValid: false, + error: 'Account number cannot exceed 17 digits', + } + } + + // Check for obviously invalid patterns + if (/^0+$/.test(cleanAccountNumber)) { + return { + isValid: false, + error: 'Account number cannot be all zeros', + } + } + + return { + isValid: true, + error: null, + } +} + +/** + * Validates a Mexican CLABE (Clave Bancaria Estandarizada) account number + * CLABE is exactly 18 digits with a specific structure and check digit validation + * @param accountNumber - The CLABE account number to validate + * @returns Object with isValid boolean and error message if invalid + */ +export const validateMXCLabeAccount = (accountNumber: string) => { + // Remove spaces and hyphens for validation + const cleanAccountNumber = accountNumber.replace(/[\s-]/g, '') + + // Check if contains only digits + if (!/^\d+$/.test(cleanAccountNumber)) { + return { + isValid: false, + error: 'CLABE must contain only digits', + } + } + + // CLABE must be exactly 18 digits + if (cleanAccountNumber.length !== 18) { + return { + isValid: false, + error: 'CLABE must be exactly 18 digits', + } + } + + // Check for obviously invalid patterns + if (/^0+$/.test(cleanAccountNumber)) { + return { + isValid: false, + error: 'CLABE cannot be all zeros', + } + } + + // Validate CLABE check digit using the official algorithm + const digits = cleanAccountNumber.split('').map(Number) + const weights = [3, 7, 1, 3, 7, 1, 3, 7, 1, 3, 7, 1, 3, 7, 1, 3, 7] + + let sum = 0 + for (let i = 0; i < 17; i++) { + sum += digits[i] * weights[i] + } + + const remainder = sum % 10 + const calculatedCheckDigit = remainder === 0 ? 0 : 10 - remainder + const providedCheckDigit = digits[17] + + if (calculatedCheckDigit !== providedCheckDigit) { + return { + isValid: false, + error: 'CLABE check digit is invalid', + } + } + + return { + isValid: true, + error: null, + } +} + +// Returns the 3-letter country code for the given country code +export const getCountryCodeForWithdraw = (country: string) => { + // If the input is already a 3-digit code and exists in the map, return it + if (countryCodeMap[country]) { + return country + } + + // If the input is a 2-digit code, find the corresponding 3-digit code + const threeDigitCode = Object.keys(countryCodeMap).find((key) => countryCodeMap[key] === country) + + return threeDigitCode || country +}