diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index edce3df1e..646304733 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -21,6 +21,7 @@ import { useWebSocket } from '@/hooks/useWebSocket' import { useWithdrawFlow } from '@/context/WithdrawFlowContext' import { Account } from '@/interfaces' import PeanutLoading from '../Global/PeanutLoading' +import { getCountryCodeForWithdraw } from '@/utils/withdraw.utils' interface AddWithdrawCountriesListProps { flow: 'add' | 'withdraw' @@ -195,7 +196,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { /> diff --git a/src/components/AddWithdraw/DynamicBankAccountForm.tsx b/src/components/AddWithdraw/DynamicBankAccountForm.tsx index 8401d8dce..5133348d3 100644 --- a/src/components/AddWithdraw/DynamicBankAccountForm.tsx +++ b/src/components/AddWithdraw/DynamicBankAccountForm.tsx @@ -7,12 +7,13 @@ import { AddBankAccountPayload, BridgeAccountOwnerType, BridgeAccountType } from import BaseInput from '@/components/0_Bruddle/BaseInput' import { countryCodeMap } from '@/components/AddMoney/consts' import { useParams } from 'next/navigation' -import { validateBankAccount, validateIban, validateBic, isValidRoutingNumber } from '@/utils/bridge-accounts.utils' +import { validateIban, validateBic, isValidRoutingNumber } from '@/utils/bridge-accounts.utils' import ErrorAlert from '@/components/Global/ErrorAlert' 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 @@ -58,6 +59,8 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D const [firstName, ...lastNameParts] = (user?.user.fullName ?? '').split(' ') const lastName = lastNameParts.join(' ') + let selectedCountry = (countryNameFromProps ?? (countryName as string)).toLowerCase() + const { control, handleSubmit, @@ -89,9 +92,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 @@ -125,7 +128,7 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D accountType, accountNumber: accountNumber.replace(/\s/g, ''), countryCode: isUs ? 'USA' : country.toUpperCase(), - countryName: (countryName ?? countryNameFromProps) as string, + countryName: selectedCountry, accountOwnerType: BridgeAccountOwnerType.INDIVIDUAL, accountOwnerName: { firstName: firstName.trim(), @@ -164,9 +167,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, @@ -265,14 +268,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, @@ -286,7 +300,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 aed3f45e9..be27c9a2b 100644 --- a/src/components/Claim/Link/views/BankFlowManager.view.tsx +++ b/src/components/Claim/Link/views/BankFlowManager.view.tsx @@ -27,6 +27,7 @@ import NavHeader from '@/components/Global/NavHeader' import { InitiateKYCModal } from '@/components/Kyc' import { useWebSocket } from '@/hooks/useWebSocket' import { KYCStatus } from '@/utils/bridge-accounts.utils' +import { getCountryCodeForWithdraw } from '@/utils/withdraw.utils' /** * @name BankFlowManager @@ -410,7 +411,7 @@ 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 +}