Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/components/AddWithdraw/AddWithdrawCountriesList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -195,7 +196,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => {
/>
<DynamicBankAccountForm
ref={formRef}
country={currentCountry.id}
country={getCountryCodeForWithdraw(currentCountry.id)}
onSuccess={handleFormSubmit}
initialData={{}}
/>
Expand Down
32 changes: 23 additions & 9 deletions src/components/AddWithdraw/DynamicBankAccountForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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'
)}
Expand Down
3 changes: 2 additions & 1 deletion src/components/Claim/Link/views/BankFlowManager.view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -410,7 +411,7 @@ export const BankFlowManager = (props: IClaimScreenProps) => {
<DynamicBankAccountForm
ref={formRef}
key={selectedCountry?.id}
country={selectedCountry?.id ?? ''}
country={getCountryCodeForWithdraw(selectedCountry?.id ?? '')}
countryName={selectedCountry?.title ?? ''}
onSuccess={handleSuccess}
flow={'claim'}
Expand Down
147 changes: 147 additions & 0 deletions src/utils/withdraw.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { countryData, countryCodeMap } from '@/components/AddMoney/consts'

/**
* Extracts the country name from an IBAN by parsing the first 2 characters (country code)
* @param iban - The IBAN string (with or without spaces)
* @returns The country name if found, null if invalid IBAN or country not found
*/
export const getCountryFromIban = (iban: string): string | null => {
// 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
}
Loading