diff --git a/src/app/actions/external-accounts.ts b/src/app/actions/external-accounts.ts index a9a0d8c5d..8d45d8e03 100644 --- a/src/app/actions/external-accounts.ts +++ b/src/app/actions/external-accounts.ts @@ -2,6 +2,7 @@ import { fetchWithSentry } from '@/utils' import { AddBankAccountPayload } from './types/users.types' +import { IBridgeAccount } from '@/interfaces' const API_KEY = process.env.PEANUT_API_KEY! const API_URL = process.env.PEANUT_API_URL! @@ -9,7 +10,7 @@ const API_URL = process.env.PEANUT_API_URL! export async function createBridgeExternalAccountForGuest( customerId: string, accountDetails: AddBankAccountPayload -): Promise<{ id: string } | { error: string }> { +): Promise { try { const response = await fetchWithSentry(`${API_URL}/bridge/customers/${customerId}/external-accounts`, { method: 'POST', diff --git a/src/components/AddMoney/consts/index.ts b/src/components/AddMoney/consts/index.ts index 2401ef3b9..114b6fa45 100644 --- a/src/components/AddMoney/consts/index.ts +++ b/src/components/AddMoney/consts/index.ts @@ -143,6 +143,7 @@ export interface CountryData { currency?: string description?: string path: string + iso2?: string } export interface DepositMethods extends CountryData { @@ -777,6 +778,7 @@ export const countryData: CountryData[] = [ title: 'United Kingdom', currency: 'GBP', path: 'united-kingdom', + iso2: 'GB', }, { id: 'GD', diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index f7cd46c7a..c5c5ad369 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -240,6 +240,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { country={getCountryCodeForWithdraw(currentCountry.id)} onSuccess={handleFormSubmit} initialData={{}} + error={null} /> flow?: 'claim' | 'withdraw' actionDetailsProps?: Partial + error: string | null } export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, DynamicBankAccountFormProps>( ( - { country, onSuccess, initialData, flow = 'withdraw', actionDetailsProps, countryName: countryNameFromProps }, + { + country, + onSuccess, + initialData, + flow = 'withdraw', + actionDetailsProps, + countryName: countryNameFromProps, + error, + }, ref ) => { const { user } = useAuth() @@ -65,6 +74,7 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D control, handleSubmit, setValue, + getValues, formState: { errors, isValid, isValidating, touchedFields }, } = useForm({ defaultValues: { @@ -82,15 +92,33 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D ...initialData, }, mode: 'onBlur', + reValidateMode: 'onSubmit', }) useImperativeHandle(ref, () => ({ handleSubmit: handleSubmit(onSubmit), })) + // Clear submission error when form becomes valid and BIC field is filled (if shown) + useEffect(() => { + if (submissionError && isValid && (!showBicField || getValues('bic'))) { + setSubmissionError(null) + } + }, [isValid, submissionError, showBicField, getValues]) + const onSubmit = async (data: IBankAccountDetails) => { + // If validation is still running, don't proceed + if (isValidating) { + console.log('Validation still checking, skipping submission') + return + } + + // Clear any existing submission errors before starting + if (submissionError) { + setSubmissionError(null) + } + setIsSubmitting(true) - setSubmissionError(null) try { const isUs = country.toUpperCase() === 'USA' const isMx = country.toUpperCase() === 'MX' @@ -105,22 +133,38 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D const accountNumber = isMx ? data.clabe : data.accountNumber const { firstName, lastName } = data - let bic = data.bic + let bic = data.bic || getValues('bic') + const iban = data.iban || getValues('iban') - if (isIban && !bic) { - try { - bic = await getBicFromIban(accountNumber) - if (!bic) { + // for IBAN countries, ensure BIC is available + if (isIban) { + // if BIC field is shown but empty, don't proceed + if (showBicField && !bic) { + setIsSubmitting(false) + setSubmissionError('BIC is required') + return + } + + // if BIC field is not shown and no BIC available, try to get it automatically + if (!showBicField && !bic) { + try { + const autoBic = await getBicFromIban(accountNumber) + if (autoBic) { + bic = autoBic + // set the BIC value in the form without showing the field + setValue('bic', autoBic, { shouldValidate: false }) + } else { + setShowBicField(true) + setIsSubmitting(false) + setSubmissionError('BIC is required') + return + } + } catch (error) { setShowBicField(true) setIsSubmitting(false) setSubmissionError('BIC is required') return } - } catch (error) { - setShowBicField(true) - setIsSubmitting(false) - setSubmissionError('BIC is required') - return } } @@ -150,7 +194,7 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D const result = await onSuccess(payload as AddBankAccountPayload, { ...data, - iban: isIban ? data.accountNumber : undefined, + iban: isIban ? data.accountNumber || iban || '' : '', accountNumber: isIban ? '' : data.accountNumber, bic: bic, country, @@ -164,7 +208,6 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D } } catch (error: any) { setSubmissionError(error.message) - } finally { setIsSubmitting(false) } } @@ -314,10 +357,25 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D {isIban && showBicField && - renderInput('bic', 'BIC', { - required: 'BIC is required', - validate: async (value: string) => (await validateBic(value)) || 'Invalid BIC code', - })} + renderInput( + 'bic', + 'BIC', + { + required: 'BIC is required', + validate: async (value: string) => { + if (!value || value.trim().length === 0) return 'BIC is required' + const isValid = await validateBic(value.trim()) + return isValid || 'Invalid BIC code' + }, + }, + 'text', + undefined, + (field) => { + if (field.value && field.value.trim().length > 0 && submissionError) { + setSubmissionError(null) + } + } + )} {isUs && renderInput('routingNumber', 'Routing Number', { required: 'Routing number is required', @@ -345,12 +403,13 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D variant="purple" shadowSize="4" className="!mt-4 w-full" - loading={isSubmitting || isValidating} - disabled={isSubmitting || !isValid || isValidating} + loading={isSubmitting} + disabled={isSubmitting || !isValid} > Review {submissionError && } + {error && } diff --git a/src/components/Claim/Link/views/BankFlowManager.view.tsx b/src/components/Claim/Link/views/BankFlowManager.view.tsx index a5786412b..65a5135d8 100644 --- a/src/components/Claim/Link/views/BankFlowManager.view.tsx +++ b/src/components/Claim/Link/views/BankFlowManager.view.tsx @@ -29,6 +29,13 @@ import { useWebSocket } from '@/hooks/useWebSocket' import { KYCStatus } from '@/utils/bridge-accounts.utils' import { getCountryCodeForWithdraw } from '@/utils/withdraw.utils' +type BankAccountWithId = IBankAccountDetails & + ( + | { id: string; bridgeAccountId: string } + | { id: string; bridgeAccountId?: string } + | { id?: string; bridgeAccountId: string } + ) + /** * @name BankFlowManager * @description This component manages the entire bank claim flow, acting as a state machine. @@ -60,7 +67,7 @@ export const BankFlowManager = (props: IClaimScreenProps) => { const { claimLink } = useClaimLink() // local states for this component - const [localBankDetails, setLocalBankDetails] = useState(null) + const [localBankDetails, setLocalBankDetails] = useState(null) const [receiverFullName, setReceiverFullName] = useState('') const [error, setError] = useState(null) const formRef = useRef<{ handleSubmit: () => void }>(null) @@ -113,9 +120,7 @@ export const BankFlowManager = (props: IClaimScreenProps) => { * @name handleCreateOfframpAndClaim * @description creates an off-ramp transfer for the user, either as a guest or a logged-in user. */ - const handleCreateOfframpAndClaim = async ( - account: IBankAccountDetails & { id?: string; bridgeAccountId?: string } - ) => { + const handleCreateOfframpAndClaim = async (account: BankAccountWithId) => { try { setLoadingState('Executing transaction') setError(null) @@ -150,9 +155,7 @@ export const BankFlowManager = (props: IClaimScreenProps) => { const contractVersion = params.contractVersion const peanutContractAddress = peanut.getContractAddress(chainId, contractVersion) as Address - const externalAccountId = account?.bridgeAccountId ?? account?.id - - if (!externalAccountId) throw new Error('External account ID not found') + const externalAccountId = (account.bridgeAccountId ?? account.id) as string const destination = getOfframpCurrencyConfig(account.country ?? selectedCountry!.id) @@ -206,6 +209,9 @@ export const BankFlowManager = (props: IClaimScreenProps) => { payload: AddBankAccountPayload, rawData: IBankAccountDetails ): Promise<{ error?: string }> => { + //clean any error from previous step + setError(null) + // scenario 1: receiver needs KYC if (bankClaimType === BankClaimType.ReceiverKycNeeded && !justCompletedKyc) { // update user's name and email if they are not present @@ -242,16 +248,20 @@ export const BankFlowManager = (props: IClaimScreenProps) => { return { error: addBankAccountResponse.error } } if (addBankAccountResponse.data?.id) { - const bankDetails: IBankAccountDetails & { id?: string; bridgeAccountId?: string } = { + const bankDetails = { name: addBankAccountResponse.data.details.accountOwnerName || user?.user.fullName || '', iban: addBankAccountResponse.data.type === 'iban' - ? addBankAccountResponse.data.identifier - : undefined, + ? addBankAccountResponse.data.identifier || '' + : '', clabe: - addBankAccountResponse.data.type === 'clabe' ? addBankAccountResponse.data.identifier : '', + addBankAccountResponse.data.type === 'clabe' + ? addBankAccountResponse.data.identifier || '' + : '', accountNumber: - addBankAccountResponse.data.type === 'us' ? addBankAccountResponse.data.identifier : '', + addBankAccountResponse.data.type === 'us' + ? addBankAccountResponse.data.identifier || '' + : '', country: addBankAccountResponse.data.details.countryCode, id: addBankAccountResponse.data.id, bridgeAccountId: addBankAccountResponse.data.bridgeAccountId, @@ -311,7 +321,25 @@ export const BankFlowManager = (props: IClaimScreenProps) => { throw new Error('Failed to create external account') } - const finalBankDetails = { ...rawData, ...(externalAccountResponse as object) } + // merge the external account details with the user's details + const finalBankDetails = { + id: externalAccountResponse.id, + bridgeAccountId: externalAccountResponse.id, + name: externalAccountResponse.bank_name ?? rawData.name, + firstName: externalAccountResponse.first_name ?? rawData.firstName, + lastName: externalAccountResponse.last_name ?? rawData.lastName, + email: rawData.email, + accountNumber: externalAccountResponse.account_number ?? rawData.accountNumber, + bic: externalAccountResponse?.iban?.bic ?? rawData.bic, + routingNumber: externalAccountResponse?.account?.routing_number ?? rawData.routingNumber, + clabe: externalAccountResponse?.clabe?.account_number ?? rawData.clabe, + street: externalAccountResponse?.address?.street_line_1 ?? rawData.street, + city: externalAccountResponse?.address?.city ?? rawData.city, + state: externalAccountResponse?.address?.state ?? rawData.state, + postalCode: externalAccountResponse?.address?.postal_code ?? rawData.postalCode, + iban: externalAccountResponse?.iban?.account_number ?? rawData.iban, + country: externalAccountResponse?.iban?.country ?? rawData.country, + } setLocalBankDetails(finalBankDetails) setBankDetails(finalBankDetails) setReceiverFullName(payload.accountOwnerName.firstName + ' ' + payload.accountOwnerName.lastName) @@ -358,11 +386,11 @@ export const BankFlowManager = (props: IClaimScreenProps) => { ).split(' ') const lastName = lastNameParts.join(' ') - const bankDetails: IBankAccountDetails & { id?: string; bridgeAccountId?: string } = { + const bankDetails = { name: account.details.accountOwnerName || user?.user.fullName || '', - iban: account.type === 'iban' ? account.identifier : undefined, - clabe: account.type === 'clabe' ? account.identifier : '', - accountNumber: account.type === 'us' ? account.identifier : '', + iban: account.type === 'iban' ? account.identifier || '' : '', + clabe: account.type === 'clabe' ? account.identifier || '' : '', + accountNumber: account.type === 'us' ? account.identifier || '' : '', country: account.details.countryCode, id: account.id, bridgeAccountId: account.bridgeAccountId, @@ -432,6 +460,7 @@ export const BankFlowManager = (props: IClaimScreenProps) => { tokenSymbol: claimLinkData.tokenSymbol, }} initialData={{}} + error={error} /> {