diff --git a/.clinerules/01-general-rules.md b/.clinerules/01-general-rules.md index 2aea4808c..90f08874a 100644 --- a/.clinerules/01-general-rules.md +++ b/.clinerules/01-general-rules.md @@ -7,7 +7,7 @@ ## Architecture Decision Records -Create ADRs in /docs/adr for: +Create ADRs in /docs/adr for: - Major dependency changes - Architectural pattern changes diff --git a/.github/workflows/autodeploy.yaml b/.github/workflows/autodeploy.yaml index 61229a231..c7db62125 100644 --- a/.github/workflows/autodeploy.yaml +++ b/.github/workflows/autodeploy.yaml @@ -3,7 +3,7 @@ run-name: Gitlab Pipeline Executor on: push: branches: - - staging + - main jobs: Execute-Gitlab-Pipeline: runs-on: ubuntu-latest @@ -13,4 +13,4 @@ jobs: - name: Triggers Gitlab Pipeline run: | ls ${{ github.workspace }} - curl -X POST -F token=${{ secrets.GITLAB_TRIGGER }} -F "ref=development" -F "variables[PRODUCTION]=N" -F "variables[STAGING]=N" -F "variables[STAGVTX]=Y" -F "variables[PRODPOL]=N" -F "variables[STAGPOL]=N" https://gitlab.com/api/v4/projects/${{ secrets.PROJECT_ID }}/trigger/pipeline \ No newline at end of file + curl -X POST -F token=${{ secrets.GITLAB_TRIGGER }} -F "ref=development" -F "variables[PRODVTX]=Y" https://gitlab.com/api/v4/projects/${{ secrets.PROJECT_ID }}/trigger/pipeline diff --git a/apps/api/src/api/controllers/alfredpay.controller.ts b/apps/api/src/api/controllers/alfredpay.controller.ts index ecca314c4..c6de2dab6 100644 --- a/apps/api/src/api/controllers/alfredpay.controller.ts +++ b/apps/api/src/api/controllers/alfredpay.controller.ts @@ -130,8 +130,20 @@ export class AlfredpayController { const alfredpayService = AlfredpayApiService.getInstance(); - const newCustomer = await alfredpayService.createCustomer(userEmail, AlfredpayCustomerType.INDIVIDUAL, country); - const customerId = newCustomer.customerId; + let customerId: string; + try { + const newCustomer = await alfredpayService.createCustomer(userEmail, AlfredpayCustomerType.INDIVIDUAL, country); + customerId = newCustomer.customerId; + } catch (error) { + const errorMessage = (error as Error)?.message || ""; + if (errorMessage.includes("409") || errorMessage.includes("already registered")) { + logger.info("Customer already exists in Alfredpay, fetching existing customer"); + const existingCustomer = await alfredpayService.findCustomer(userEmail, country); + customerId = existingCustomer.customerId; + } else { + throw error; + } + } await AlfredPayCustomer.create({ alfredPayId: customerId, @@ -340,7 +352,7 @@ export class AlfredpayController { const linkResponse = await alfredpayService.getKybRedirectLink(alfredPayCustomer.alfredPayId); await alfredPayCustomer.update({ status: AlfredPayStatus.Consulted }); return res.json(linkResponse as AlfredpayGetKybRedirectLinkResponse); - } else if (country === "MX" || country === "CO") { + } else if (country === "MX" || country === "CO" || country === "AR") { // MX/CO use API-based (form) KYC — no redirect link needed. // Just reset status so the user can re-fill the form. await alfredPayCustomer.update({ status: AlfredPayStatus.Consulted }); @@ -379,8 +391,20 @@ export class AlfredpayController { const alfredpayService = AlfredpayApiService.getInstance(); - const newCustomer = await alfredpayService.createCustomer(userEmail, type, country); - const customerId = newCustomer.customerId; + let customerId: string; + try { + const newCustomer = await alfredpayService.createCustomer(userEmail, type, country); + customerId = newCustomer.customerId; + } catch (error) { + const errorMessage = (error as Error)?.message || ""; + if (errorMessage.includes("409") || errorMessage.includes("already registered")) { + logger.info("Business customer already exists in Alfredpay, fetching existing customer"); + const existingCustomer = await alfredpayService.findCustomer(userEmail, country); + customerId = existingCustomer.customerId; + } else { + throw error; + } + } await AlfredPayCustomer.create({ alfredPayId: customerId, @@ -443,7 +467,7 @@ export class AlfredpayController { static async submitKycInformation(req: Request, res: Response) { try { - const { country, ...kycData } = req.body as SubmitKycInformationRequest & { country: string }; + const { country, ...kycData } = req.body as SubmitKycInformationRequest; const userId = req.userId!; const alfredPayCustomer = await AlfredPayCustomer.findOne({ @@ -455,7 +479,21 @@ export class AlfredpayController { } const alfredpayService = AlfredpayApiService.getInstance(); - const result = await alfredpayService.submitKycInformation(alfredPayCustomer.alfredPayId, { ...kycData, country }); + let result: Awaited>; + try { + result = await alfredpayService.submitKycInformation(alfredPayCustomer.alfredPayId, { ...kycData, country }); + } catch (error) { + const errorMessage = (error as Error)?.message || ""; + if (errorMessage.includes("422") && errorMessage.includes("KYC record cannot be retried")) { + logger.info("KYC record cannot be retried, fetching existing submission"); + const existingSubmission = await alfredpayService.getLastKycSubmission(alfredPayCustomer.alfredPayId); + result = { submissionId: existingSubmission.submissionId } as Awaited< + ReturnType + >; + } else { + throw error; + } + } res.json(result); } catch (error) { @@ -481,7 +519,12 @@ export class AlfredpayController { if (!alfredPayCustomer) { return res.status(404).json({ error: "Alfredpay customer not found" }); } - + console.log("Received request to submit KYC file with data:", { + country, + fileName: req.file.originalname, + fileType, + submissionId + }); const fileBlob = new File([new Uint8Array(req.file.buffer)], req.file.originalname, { type: req.file.mimetype }); const alfredpayService = AlfredpayApiService.getInstance(); await alfredpayService.submitKycFile( @@ -537,7 +580,21 @@ export class AlfredpayController { } const alfredpayService = AlfredpayApiService.getInstance(); - const result = await alfredpayService.submitKybInformation(alfredPayCustomer.alfredPayId, { ...kybData, country }); + let result: Awaited>; + try { + result = await alfredpayService.submitKybInformation(alfredPayCustomer.alfredPayId, { ...kybData, country }); + } catch (error) { + const errorMessage = (error as Error)?.message || ""; + if (errorMessage.includes("422") && errorMessage.includes("KYC record cannot be retried")) { + logger.info("KYB record cannot be retried, fetching existing submission"); + const existingSubmission = await alfredpayService.getLastKybSubmission(alfredPayCustomer.alfredPayId); + result = { submissionId: existingSubmission.submissionId } as Awaited< + ReturnType + >; + } else { + throw error; + } + } res.json(result); } catch (error) { @@ -729,6 +786,11 @@ export class AlfredpayController { accountType: accountType ?? "", metadata: { accountHolderName: accountName, documentNumber, documentType } }; + } else if (alfredpayFiatAccountType === AlfredpayFiatAccountType.COELSA) { + fiatAccountFields = { + accountNumber, + accountType: accountType ?? "" + }; } else { // BANK_USA — external accounts need address fields inside metadata fiatAccountFields = isExternal diff --git a/apps/api/src/api/middlewares/validators.ts b/apps/api/src/api/middlewares/validators.ts index 303090653..8f252b4da 100644 --- a/apps/api/src/api/middlewares/validators.ts +++ b/apps/api/src/api/middlewares/validators.ts @@ -14,6 +14,7 @@ import { PriceProvider, QuoteError, RampDirection, + SubmitKycInformationRequest, TokenConfig, VALID_CRYPTO_CURRENCIES, VALID_FIAT_CURRENCIES, @@ -483,6 +484,34 @@ export const validateGetWidgetUrlInput: RequestHandler string | null> = { + AR: ({ phoneNumber, cuit, nationalities, pep }) => { + if (!phoneNumber) return "Phone number is required for Argentina"; + if (!phoneNumber.startsWith("+54")) return "Phone number must use Argentina country code (+54)"; + if (cuit && !/^\d{11}$/.test(cuit)) return "CUIT must be exactly 11 digits"; + if (nationalities && !nationalities.every(n => /^[A-Z]{2}$/.test(n))) return "Nationalities must use alpha-2 country codes"; + if (typeof pep !== "boolean") return "PEP declaration is required for Argentina"; + return null; + } +}; + +export const validateKycSubmission: RequestHandler = (req, res, next) => { + const body = req.body as SubmitKycInformationRequest; + const validator = countryValidators[body.country]; + + if (!validator) { + return next(); + } + + const error = validator(body); + if (error) { + res.status(httpStatus.BAD_REQUEST).json({ error }); + return; + } + + next(); +}; + export const validateStartKyc2: RequestHandler = (req, res, next) => { const { documentType } = req.body as AveniaKYCDataUploadRequest; diff --git a/apps/api/src/api/routes/v1/alfredpay.route.ts b/apps/api/src/api/routes/v1/alfredpay.route.ts index e2009db8e..862fc869f 100644 --- a/apps/api/src/api/routes/v1/alfredpay.route.ts +++ b/apps/api/src/api/routes/v1/alfredpay.route.ts @@ -3,6 +3,7 @@ import multer from "multer"; import { AlfredpayController } from "../../controllers/alfredpay.controller"; import { validateResultCountry } from "../../middlewares/alfredpay.middleware"; import { requireAuth } from "../../middlewares/supabaseAuth"; +import { validateKycSubmission } from "../../middlewares/validators"; const router = Router(); const upload = multer({ limits: { fileSize: 5 * 1024 * 1024 }, storage: multer.memoryStorage() }); @@ -18,7 +19,13 @@ router.post("/createBusinessCustomer", requireAuth, validateResultCountry, Alfre router.get("/getKybRedirectLink", requireAuth, validateResultCountry, AlfredpayController.getKybRedirectLink); // MXN/CO API-based KYC -router.post("/submitKycInformation", requireAuth, validateResultCountry, AlfredpayController.submitKycInformation); +router.post( + "/submitKycInformation", + requireAuth, + validateResultCountry, + validateKycSubmission, + AlfredpayController.submitKycInformation +); router.post("/submitKycFile", requireAuth, upload.single("file"), validateResultCountry, AlfredpayController.submitKycFile); router.post("/sendKycSubmission", requireAuth, validateResultCountry, AlfredpayController.sendKycSubmission); diff --git a/apps/api/src/api/services/phases/handlers/brla-onramp-mint-handler.ts b/apps/api/src/api/services/phases/handlers/brla-onramp-mint-handler.ts index 016ae5846..d74a2dea4 100644 --- a/apps/api/src/api/services/phases/handlers/brla-onramp-mint-handler.ts +++ b/apps/api/src/api/services/phases/handlers/brla-onramp-mint-handler.ts @@ -85,9 +85,7 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler { // transferred to the ephemeral. We accept a balance of at least 95% of the // pre-computed expected amount to account for fee differences between quote // creation time and execution time. - const recoveryThresholdRaw = new Big(preComputedExpectedAmountRaw) - .times(EPHEMERAL_FUNDED_TOLERANCE_FACTOR) - .toFixed(0, 0); + const recoveryThresholdRaw = new Big(preComputedExpectedAmountRaw).times(EPHEMERAL_FUNDED_TOLERANCE_FACTOR).toFixed(0, 0); if (await this.ephemeralAlreadyFunded(tokenDetails.erc20AddressSourceChain, evmEphemeralAddress, recoveryThresholdRaw)) { logger.info( @@ -149,10 +147,7 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler { // Derive the expected on-chain amount from the live quote's outputAmount rather than // the stale pre-computed metadata value. The live quote accounts for the actual fees // applied at execution time, so this is the amount that will truly arrive on Base. - const expectedAmountReceived = multiplyByPowerOfTen( - new Big(aveniaQuote.outputAmount), - tokenDetails.decimals - ).toFixed(0, 0); + const expectedAmountReceived = multiplyByPowerOfTen(new Big(aveniaQuote.outputAmount), tokenDetails.decimals).toFixed(0, 0); logger.info( `BrlaOnrampMintHandler: Live Avenia quote output is ${aveniaQuote.outputAmount} BRLA (raw: ${expectedAmountReceived}). Pre-computed metadata value was ${preComputedExpectedAmountRaw}.` diff --git a/apps/api/src/api/services/priceFeed.service.ts b/apps/api/src/api/services/priceFeed.service.ts index d3fdcabf1..631919f23 100644 --- a/apps/api/src/api/services/priceFeed.service.ts +++ b/apps/api/src/api/services/priceFeed.service.ts @@ -116,7 +116,7 @@ export class PriceFeedService { logger.debug(`Cache miss for ${cacheKey}. Fetching from CoinGecko API.`); try { - logger.debug(`Fetching price for ${tokenId} in ${vsCurrency} from CoinGecko`); + logger.info(`Fetching price for ${tokenId} in ${vsCurrency} from CoinGecko`); // Construct the API URL const url = new URL(`${this.coingeckoApiBaseUrl}/simple/price`); @@ -200,7 +200,7 @@ export class PriceFeedService { } // Check if the currency has a Pendulum representative (Nabla pool). - // Currencies like MXN and COP are TokenType.Fiat with no Pendulum pool — use CoinGecko for those. + // Currencies like MXN, COP, and ARS are TokenType.Fiat with no Pendulum pool — use CoinGecko for those. let outputTokenPendulumDetails; try { outputTokenPendulumDetails = getPendulumDetails(toCurrency); diff --git a/apps/api/src/api/services/quote/core/helpers.ts b/apps/api/src/api/services/quote/core/helpers.ts index abb6b96ca..01a3dd1c5 100644 --- a/apps/api/src/api/services/quote/core/helpers.ts +++ b/apps/api/src/api/services/quote/core/helpers.ts @@ -31,6 +31,7 @@ export const SUPPORTED_CHAINS: { from: [ EPaymentMethod.PIX as DestinationType, EPaymentMethod.SEPA as DestinationType, + EPaymentMethod.CBU as DestinationType, EPaymentMethod.ACH as DestinationType, EPaymentMethod.SPEI as DestinationType ], diff --git a/apps/api/src/api/services/quote/engines/finalize/onramp.ts b/apps/api/src/api/services/quote/engines/finalize/onramp.ts index 942a748c5..8115669bb 100644 --- a/apps/api/src/api/services/quote/engines/finalize/onramp.ts +++ b/apps/api/src/api/services/quote/engines/finalize/onramp.ts @@ -49,7 +49,8 @@ export class OnRampFinalizeEngine extends BaseFinalizeEngine { } else if ( request.inputCurrency === FiatToken.USD || request.inputCurrency === FiatToken.MXN || - request.inputCurrency === FiatToken.COP + request.inputCurrency === FiatToken.COP || + request.inputCurrency === FiatToken.ARS ) { // evmToEvm is set when Squid Router ran (e.g. USDC Polygon → USDT Arbitrum). // When destination is USDC on Polygon, Squid Router is skipped (skipRouteCalculation) diff --git a/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-alfredpay.ts b/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-alfredpay.ts index 0e1fe432e..bda109097 100644 --- a/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-alfredpay.ts +++ b/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-alfredpay.ts @@ -30,7 +30,6 @@ export class OffRampFromEvmInitializeEngine extends BaseInitializeEngine { rampType: req.rampType, toNetwork: this.network }; - const bridgeQuote = await getEvmBridgeQuote(quoteRequest); ctx.evmToEvm = { diff --git a/apps/api/src/api/services/quote/routes/route-resolver.ts b/apps/api/src/api/services/quote/routes/route-resolver.ts index a581ee6ad..fbdb46bee 100644 --- a/apps/api/src/api/services/quote/routes/route-resolver.ts +++ b/apps/api/src/api/services/quote/routes/route-resolver.ts @@ -70,9 +70,10 @@ export class RouteResolver { case "wire": case "ach": case "spei": + case "cbu": return offrampEvmToAlfredpayStrategy; case "sepa": - case "cbu": + default: return offrampToStellarStrategy; } diff --git a/apps/api/src/api/services/transactions/onramp/routes/alfredpay-to-evm.ts b/apps/api/src/api/services/transactions/onramp/routes/alfredpay-to-evm.ts index 3c8f75519..83cd22253 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/alfredpay-to-evm.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/alfredpay-to-evm.ts @@ -72,7 +72,8 @@ export async function prepareAlfredpayToEvmOnrampTransactions({ const fiatToCountry: Partial> = { [FiatToken.USD]: AlfredPayCountry.US, [FiatToken.MXN]: AlfredPayCountry.MX, - [FiatToken.COP]: AlfredPayCountry.CO + [FiatToken.COP]: AlfredPayCountry.CO, + [FiatToken.ARS]: AlfredPayCountry.AR }; const customerCountry = fiatToCountry[quote.inputCurrency as FiatToken]; if (!customerCountry) { diff --git a/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx b/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx index 8cf853765..54e880b4e 100644 --- a/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx +++ b/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx @@ -1,5 +1,6 @@ import { useCallback } from "react"; import { useAlfredpayKycActor, useAlfredpayKycSelector } from "../../contexts/rampState"; +import { ArKycFormScreen } from "./ArKycFormScreen"; import { ColKycFormScreen } from "./ColKycFormScreen"; import { CustomerDefinitionScreen } from "./CustomerDefinitionScreen"; import { DoneScreen } from "./DoneScreen"; @@ -59,6 +60,7 @@ export const AlfredpayKycFlow = () => { const kycOrKyb = context.business ? "KYB" : "KYC"; const isMxn = context.country === "MX"; const isCo = context.country === "CO"; + const isAr = context.country === "AR"; if ( stateValue === "CheckingStatus" || @@ -86,8 +88,13 @@ export const AlfredpayKycFlow = () => { return ; } - if (stateValue === "UploadingDocuments" && (isMxn || isCo)) { - return ; + if (stateValue === "FillingKycForm" && isAr) { + return ; + } + + if (stateValue === "UploadingDocuments" && (isMxn || isCo || isAr)) { + const includeSelfie = isAr; + return ; } if (stateValue === "FillingKybForm") { diff --git a/apps/frontend/src/components/Alfredpay/ArKycFormScreen.tsx b/apps/frontend/src/components/Alfredpay/ArKycFormScreen.tsx new file mode 100644 index 000000000..39127ae76 --- /dev/null +++ b/apps/frontend/src/components/Alfredpay/ArKycFormScreen.tsx @@ -0,0 +1,264 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { AlfredpayArgentinaDocumentType } from "@vortexfi/shared"; +import { useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { z } from "zod"; +import type { MxnKycFormData } from "../../machines/alfredpayKyc.machine"; +import { MenuButtons } from "../MenuButtons"; + +const schema = z + .object({ + address: z.string().min(1), + city: z.string().min(1), + countryCode: z.literal("AR"), + cuit: z.string().optional(), + dateOfBirth: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Use YYYY-MM-DD format"), + dni: z.string().min(1), + email: z.string().email(), + firstName: z.string().min(1), + lastName: z.string().min(1), + nationalities: z.array(z.string().regex(/^[A-Z]{2}$/)).optional(), + pep: z.boolean(), + phoneNumber: z.string().regex(/^\+54[\d\s\-()]{7,}$/, "Use Argentina format (+54...)"), + state: z.string().min(1), + typeDocumentAr: z.nativeEnum(AlfredpayArgentinaDocumentType), + zipCode: z.string().min(1) + }) + .superRefine((data, ctx) => { + if (data.cuit && !/^\d{11}$/.test(data.cuit)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "CUIT must be exactly 11 digits", path: ["cuit"] }); + } + }); + +type ArKycFormValues = z.infer; + +interface ArKycFormScreenProps { + onSubmit: (data: MxnKycFormData) => void; +} + +export function ArKycFormScreen({ onSubmit }: ArKycFormScreenProps) { + const { t } = useTranslation(); + + const { + formState: { errors }, + handleSubmit, + register, + watch + } = useForm({ + defaultValues: { + countryCode: "AR", + cuit: "", + nationalities: ["AR"], + pep: false, + typeDocumentAr: AlfredpayArgentinaDocumentType.DNI + }, + resolver: zodResolver(schema) + }); + + const documentType = watch("typeDocumentAr"); + + const inputClass = (hasError: boolean) => + `input-vortex-primary input-ghost w-full rounded-lg border p-2 text-base ${hasError ? "border-error" : "border-neutral-300"}`; + + return ( +
+ +

{t("components.arKycForm.title")}

+

{t("components.arKycForm.subtitle")}

+ +
+
+
+ + + {errors.firstName && {errors.firstName.message}} +
+ +
+ + + {errors.lastName && {errors.lastName.message}} +
+
+ +
+ + + {errors.dateOfBirth && {errors.dateOfBirth.message}} +
+ +
+ + + {errors.email && {errors.email.message}} +
+ +
+ + + {errors.phoneNumber && {errors.phoneNumber.message}} +
+ +
+ + + {errors.typeDocumentAr && {errors.typeDocumentAr.message}} +
+ +
+ + + {errors.dni && {errors.dni.message}} +
+ +
+ + + {errors.cuit && {errors.cuit.message}} +
+ +
+ + + {errors.address && {errors.address.message}} +
+ +
+
+ + + {errors.city && {errors.city.message}} +
+ +
+ + + {errors.state && {errors.state.message}} +
+
+ +
+ + + {errors.zipCode && {errors.zipCode.message}} +
+ +
+ + +
+ {errors.pep && {errors.pep.message}} + + +
+
+ ); +} diff --git a/apps/frontend/src/components/Alfredpay/MxnDocumentUploadScreen.tsx b/apps/frontend/src/components/Alfredpay/MxnDocumentUploadScreen.tsx index a16646d10..4e16ad79b 100644 --- a/apps/frontend/src/components/Alfredpay/MxnDocumentUploadScreen.tsx +++ b/apps/frontend/src/components/Alfredpay/MxnDocumentUploadScreen.tsx @@ -7,6 +7,7 @@ const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB const ACCEPTED_TYPES = ["image/jpeg", "image/png", "application/pdf"]; interface MxnDocumentUploadScreenProps { + includeSelfie?: boolean; onSubmit: (files: MxnKycFiles) => void; } @@ -59,16 +60,18 @@ function FileDropZone({ label, file, onChange }: { label: string; file: File | n ); } -export function MxnDocumentUploadScreen({ onSubmit }: MxnDocumentUploadScreenProps) { +export function MxnDocumentUploadScreen({ onSubmit, includeSelfie = false }: MxnDocumentUploadScreenProps) { const { t } = useTranslation(); const [front, setFront] = useState(null); const [back, setBack] = useState(null); + const [selfie, setSelfie] = useState(null); - const isValid = front !== null && back !== null; + const isValid = front !== null && back !== null && (!includeSelfie || selfie !== null); const handleSubmit = () => { if (!front || !back) return; - onSubmit({ back, front }); + if (includeSelfie && !selfie) return; + onSubmit({ back, front, selfie: selfie ?? undefined }); }; return ( @@ -80,6 +83,9 @@ export function MxnDocumentUploadScreen({ onSubmit }: MxnDocumentUploadScreenPro
+ {includeSelfie && ( + + )}

{t("components.mxnDocumentUpload.fileHint")}

diff --git a/apps/frontend/src/components/TokenSelection/TokenSelectionList/helpers.tsx b/apps/frontend/src/components/TokenSelection/TokenSelectionList/helpers.tsx index 8d3ec5700..9eabda5f6 100644 --- a/apps/frontend/src/components/TokenSelection/TokenSelectionList/helpers.tsx +++ b/apps/frontend/src/components/TokenSelection/TokenSelectionList/helpers.tsx @@ -157,10 +157,6 @@ function getFiatTokens(filterEurcOnly = false): ExtendedTokenDefinition[] { })); } -function isFilterEurcOnly(type: "from" | "to", direction: RampDirection) { - return direction === RampDirection.BUY && type === "from"; -} - export function useIsFiatDirection() { const { tokenSelectModalType } = useTokenSelectionState(); const rampDirection = useRampDirection(); @@ -174,7 +170,7 @@ function isFiatDirection(type: "from" | "to", direction: RampDirection) { function getAllSupportedTokenDefinitions(type: "from" | "to", direction: RampDirection): ExtendedTokenDefinition[] { if (isFiatDirection(type, direction)) { - return getFiatTokens(isFilterEurcOnly(type, direction)); + return getFiatTokens(); } else { return getAllOnChainTokens(); } diff --git a/apps/frontend/src/constants/fiatAccountForms.ts b/apps/frontend/src/constants/fiatAccountForms.ts index 966796383..26f1b9697 100644 --- a/apps/frontend/src/constants/fiatAccountForms.ts +++ b/apps/frontend/src/constants/fiatAccountForms.ts @@ -84,6 +84,38 @@ export const FORMS: Record = { type: "select" } ], + COELSA: [ + { + field: "accountNumber", + label: "components.fiatAccountForms.accountNumber", + placeholder: "22-digit CBU/CVU or Alias", + required: true, + type: "text" + }, + { + field: "accountType", + label: "components.fiatAccountForms.accountType", + options: [ + { label: "CBU", value: "CBU" }, + { label: "CVU", value: "CVU" }, + { label: "Alias", value: "ALIAS" } + ], + required: true, + type: "select" + }, + { field: "accountName", label: "components.fiatAccountForms.accountName", required: true, type: "text" }, + { + defaultValue: "own", + field: "isOwnAccount", + label: "components.fiatAccountForms.isOwnAccount", + options: [ + { label: "components.fiatAccountForms.options.ownAccount", value: "own" }, + { label: "components.fiatAccountForms.options.externalAccount", value: "external" } + ], + required: true, + type: "select" + } + ], SPEI: [ { field: "accountNumber", diff --git a/apps/frontend/src/constants/fiatAccountMethods.ts b/apps/frontend/src/constants/fiatAccountMethods.ts index b516c2331..380df9e02 100644 --- a/apps/frontend/src/constants/fiatAccountMethods.ts +++ b/apps/frontend/src/constants/fiatAccountMethods.ts @@ -2,7 +2,7 @@ import { BuildingLibraryIcon, CreditCardIcon } from "@heroicons/react/24/outline import { GlobeAmericasIcon } from "@heroicons/react/24/solid"; import { AlfredpayFiatAccountType, FiatToken } from "@vortexfi/shared"; -export type FiatAccountTypeKey = "SPEI" | "ACH" | "ACH_COL" | "WIRE"; +export type FiatAccountTypeKey = "SPEI" | "ACH" | "ACH_COL" | "WIRE" | "COELSA"; export interface CountryFiatAccountConfig { country: string; @@ -33,12 +33,20 @@ export const ALFREDPAY_COUNTRY_METHODS: CountryFiatAccountConfig[] = [ currency: "COP", offramp: ["ACH_COL"], onramp: ["ACH_COL"] + }, + { + country: "AR", + countryName: "Argentina", + currency: "ARS", + offramp: ["COELSA"], + onramp: ["COELSA"] } ]; export const ACCOUNT_TYPE_ICONS: Record>> = { ACH: BuildingLibraryIcon, ACH_COL: BuildingLibraryIcon, + COELSA: BuildingLibraryIcon, SPEI: CreditCardIcon, WIRE: GlobeAmericasIcon }; @@ -46,6 +54,7 @@ export const ACCOUNT_TYPE_ICONS: Record = { ACH: "components.fiatAccountMethods.labels.ACH", ACH_COL: "components.fiatAccountMethods.labels.ACH_COL", + COELSA: "components.fiatAccountMethods.labels.COELSA", SPEI: "components.fiatAccountMethods.labels.SPEI", WIRE: "components.fiatAccountMethods.labels.WIRE" }; @@ -53,6 +62,7 @@ export const ACCOUNT_TYPE_LABELS: Record = { export const ACCOUNT_TYPE_DESCRIPTIONS: Record = { ACH: "components.fiatAccountMethods.descriptions.ACH", ACH_COL: "components.fiatAccountMethods.descriptions.ACH_COL", + COELSA: "components.fiatAccountMethods.descriptions.COELSA", SPEI: "components.fiatAccountMethods.descriptions.SPEI", WIRE: "components.fiatAccountMethods.descriptions.WIRE" }; @@ -60,16 +70,16 @@ export const ACCOUNT_TYPE_DESCRIPTIONS: Record = { export const ACCOUNT_TYPE_TO_ALFRED_TYPE: Record = { ACH: AlfredpayFiatAccountType.ACH, ACH_COL: AlfredpayFiatAccountType.ACH, + COELSA: AlfredpayFiatAccountType.COELSA, SPEI: AlfredpayFiatAccountType.SPEI, WIRE: AlfredpayFiatAccountType.BANK_USA }; -// ACH_COL and ACH both map to AlfredpayFiatAccountType.ACH on the API side. -// We prefer "ACH" as the display key for ACH accounts since it's the more general label. export const ALFRED_TO_ACCOUNT_TYPE: Partial> = { [AlfredpayFiatAccountType.ACH]: "ACH", [AlfredpayFiatAccountType.SPEI]: "SPEI", - [AlfredpayFiatAccountType.BANK_USA]: "WIRE" + [AlfredpayFiatAccountType.BANK_USA]: "WIRE", + [AlfredpayFiatAccountType.COELSA]: "COELSA" }; // Resolves the display key for a fiat account, taking country into account. diff --git a/apps/frontend/src/machines/alfredpayKyc.machine.ts b/apps/frontend/src/machines/alfredpayKyc.machine.ts index 3ff7c7686..98105bfdb 100644 --- a/apps/frontend/src/machines/alfredpayKyc.machine.ts +++ b/apps/frontend/src/machines/alfredpayKyc.machine.ts @@ -19,6 +19,7 @@ export type KybFormData = Omit; export interface MxnKycFiles { front: File; back: File; + selfie?: File; } export interface KybBusinessFiles { @@ -184,6 +185,10 @@ export const alfredpayKycMachine = setup({ if (!input.mxnFiles) throw new Error("KYC files missing"); await AlfredpayService.submitKycFile(country, input.submissionId, AlfredpayKycFileType.FRONT, input.mxnFiles.front); await AlfredpayService.submitKycFile(country, input.submissionId, AlfredpayKycFileType.BACK, input.mxnFiles.back); + if (country === "AR") { + if (!input.mxnFiles.selfie) throw new Error("Selfie file missing"); + await AlfredpayService.submitKycFile(country, input.submissionId, AlfredpayKycFileType.SELFIE, input.mxnFiles.selfie); + } } ), @@ -375,8 +380,8 @@ export const alfredpayKycMachine = setup({ target: "FailureKyc" }, { - // MXN and CO use API-based form, not iFrame link - guard: ({ context }) => context.country === "MX" || context.country === "CO", + // MXN, CO, and AR use API-based form, not iFrame link + guard: ({ context }) => context.country === "MX" || context.country === "CO" || context.country === "AR", target: "FillingKycForm" }, { @@ -415,11 +420,12 @@ export const alfredpayKycMachine = setup({ input: ({ context }) => context, onDone: [ { - guard: ({ context }) => (context.country === "MX" || context.country === "CO") && !!context.business, + guard: ({ context }) => + (context.country === "MX" || context.country === "CO" || context.country === "AR") && !!context.business, target: "FillingKybForm" }, { - guard: ({ context }) => context.country === "MX" || context.country === "CO", + guard: ({ context }) => context.country === "MX" || context.country === "CO" || context.country === "AR", target: "FillingKycForm" }, { @@ -654,11 +660,12 @@ export const alfredpayKycMachine = setup({ input: ({ context }) => context, onDone: [ { - guard: ({ context }) => (context.country === "MX" || context.country === "CO") && !!context.business, + guard: ({ context }) => + (context.country === "MX" || context.country === "CO" || context.country === "AR") && !!context.business, target: "FillingKybForm" }, { - guard: ({ context }) => context.country === "MX" || context.country === "CO", + guard: ({ context }) => context.country === "MX" || context.country === "CO" || context.country === "AR", target: "FillingKycForm" }, { @@ -721,11 +728,7 @@ export const alfredpayKycMachine = setup({ target: "SendingSubmission" }, onError: { - actions: assign({ - error: () => - new AlfredpayKycMachineError("Failed to upload ID documents", AlfredpayKycMachineErrorType.UnknownError) - }), - target: "Failure" + target: "UploadingDocuments" }, src: "submitFiles" } diff --git a/apps/frontend/src/translations/en.json b/apps/frontend/src/translations/en.json index 8656f2b7e..5520227d1 100644 --- a/apps/frontend/src/translations/en.json +++ b/apps/frontend/src/translations/en.json @@ -33,6 +33,32 @@ "verifyingStatus": "Verifying {{kycOrKyb}} Status", "verifyingStatusDescription": "This may take a few moments. Please do not close this window." }, + "arDocumentUpload": { + "backLabel": "Back of DNI", + "fileHint": "Accepted formats: JPG, PNG, PDF — max 5 MB each", + "fileTooLarge": "File exceeds the 5 MB limit.", + "frontLabel": "Front of DNI", + "invalidType": "Only JPG, PNG, or PDF files are accepted.", + "selfieLabel": "Selfie", + "submit": "Submit Documents", + "subtitle": "Please upload a photo or scan of your DNI (front and back) and a selfie. Max 5 MB per file.", + "tapToSelect": "Tap to select file", + "title": "Upload ID Documents" + }, + "arKycForm": { + "cuit": "CUIT", + "cuitPlaceholder": "CUIT", + "dni": "DNI Number", + "dniPlaceholder": "DNI number", + "documentType": "Document Type", + "options": { + "dni": "DNI (Documento Nacional de Identidad)" + }, + "pepLabel": "I am a Politically Exposed Person (PEP)", + "phoneNumber": "Phone Number", + "subtitle": "Please provide your personal information to complete KYC.", + "title": "Identity Verification" + }, "authEmailStep": { "buttons": { "continue": "Continue", @@ -390,12 +416,14 @@ "descriptions": { "ACH": "1-2 business days", "ACH_COL": "Colombian bank transfer", + "COELSA": "Argentine bank transfer", "SPEI": "Instant interbank transfer via CLABE number", "WIRE": "Same day" }, "labels": { "ACH": "ACH Transfer", "ACH_COL": "ACH Colombia", + "COELSA": "COELSA", "SPEI": "SPEI", "WIRE": "Wire Transfer" }, @@ -542,6 +570,7 @@ "fileTooLarge": "File exceeds the 5 MB limit.", "frontLabel": "Front of Document", "invalidType": "Only JPG, PNG, or PDF files are accepted.", + "selfieLabel": "Selfie", "submit": "Submit Documents", "subtitle": "Please upload a photo or scan of your ID document. Max 5 MB per file (JPG, PNG, PDF).", "tapToSelect": "Tap to select file", diff --git a/apps/frontend/src/translations/pt.json b/apps/frontend/src/translations/pt.json index 1b28095d1..efaf2e54f 100644 --- a/apps/frontend/src/translations/pt.json +++ b/apps/frontend/src/translations/pt.json @@ -33,6 +33,32 @@ "verifyingStatus": "Verificando Status {{kycOrKyb}}", "verifyingStatusDescription": "Isso pode levar alguns momentos. Por favor, não feche esta janela." }, + "arDocumentUpload": { + "backLabel": "Verso do DNI", + "fileHint": "Formatos aceitos: JPG, PNG, PDF — máx. 5 MB cada", + "fileTooLarge": "O arquivo excede o limite de 5 MB.", + "frontLabel": "Frente do DNI", + "invalidType": "Somente arquivos JPG, PNG ou PDF são aceitos.", + "selfieLabel": "Selfie", + "submit": "Enviar Documentos", + "subtitle": "Por favor, envie uma foto ou digitalização do seu DNI (frente e verso) e uma selfie. Máx. 5 MB por arquivo.", + "tapToSelect": "Toque para selecionar o arquivo", + "title": "Enviar Documentos de Identidade" + }, + "arKycForm": { + "cuit": "CUIT", + "cuitPlaceholder": "CUIT", + "dni": "Número do DNI", + "dniPlaceholder": "Número do DNI", + "documentType": "Tipo de Documento", + "options": { + "dni": "DNI (Documento Nacional de Identidad)" + }, + "pepLabel": "Sou uma Pessoa Politicamente Exposta (PEP)", + "phoneNumber": "Número de Telefone", + "subtitle": "Por favor, forneça suas informações pessoais para concluir o KYC.", + "title": "Verificação de Identidade" + }, "authEmailStep": { "buttons": { "continue": "Continuar", @@ -393,12 +419,14 @@ "descriptions": { "ACH": "1 a 2 dias úteis", "ACH_COL": "Transferência bancária colombiana", + "COELSA": "Transferência bancária argentina", "SPEI": "Transferência interbancária instantânea via número CLABE", "WIRE": "No mesmo dia" }, "labels": { "ACH": "Transferência ACH", "ACH_COL": "ACH Colômbia", + "COELSA": "COELSA", "SPEI": "SPEI", "WIRE": "Transferência Internacional" }, @@ -546,6 +574,7 @@ "fileTooLarge": "O arquivo excede o limite de 5 MB.", "frontLabel": "Frente do Documento", "invalidType": "Somente arquivos JPG, PNG ou PDF são aceitos.", + "selfieLabel": "Selfie", "submit": "Enviar Documentos", "subtitle": "Por favor, envie uma foto ou digitalização do seu documento. Máx. 5 MB por arquivo (JPG, PNG, PDF).", "tapToSelect": "Toque para selecionar o arquivo", diff --git a/packages/shared/src/services/alfredpay/alfredpayApiService.ts b/packages/shared/src/services/alfredpay/alfredpayApiService.ts index eef4ec2f7..4a6de4652 100644 --- a/packages/shared/src/services/alfredpay/alfredpayApiService.ts +++ b/packages/shared/src/services/alfredpay/alfredpayApiService.ts @@ -250,10 +250,17 @@ export class AlfredpayApiService { data: SubmitKycInformationRequest ): Promise { const path = `/api/v1/third-party-service/penny/customers/${customerId}/kyc`; - const kycSubmission: Record = { ...data, nationalities: [data.country] }; + const kycSubmission: Record = { ...data }; + if (!kycSubmission.nationalities) kycSubmission.nationalities = [data.country]; if (!data.typeDocument) delete kycSubmission.typeDocument; if (!data.typeDocumentCol) delete kycSubmission.typeDocumentCol; + delete kycSubmission.typeDocumentAr; // Currently not required, (typeDocument throws an error on Alfredpay side) if (!data.phoneNumber) delete kycSubmission.phoneNumber; + if (!data.cuit) delete kycSubmission.cuit; + if (data.pep !== false && !data.pep) delete kycSubmission.pep; + if (!data.countryCode) delete kycSubmission.countryCode; + console.log("Submitting KYC information with payload:", kycSubmission); + throw new Error("KYC submission is currently disabled for testing purposes."); return (await this.executeRequest(path, "POST", { kycSubmission })) as SubmitKycInformationResponse; } @@ -264,7 +271,7 @@ export class AlfredpayApiService { file: Blob ): Promise { const formData = new FormData(); - formData.append("rawBody", file); + formData.append("fileBody", file); formData.append("fileType", fileType); const url = `${ALFREDPAY_BASE_URL}/api/v1/third-party-service/penny/customers/${customerId}/kyc/${submissionId}/files`; diff --git a/packages/shared/src/services/alfredpay/types.ts b/packages/shared/src/services/alfredpay/types.ts index 1e58e4466..166087cd5 100644 --- a/packages/shared/src/services/alfredpay/types.ts +++ b/packages/shared/src/services/alfredpay/types.ts @@ -356,7 +356,7 @@ export interface AlfredpayFiatAccount extends AlfredpayFiatAccountFields { export type ListAlfredpayFiatAccountsResponse = AlfredpayFiatAccount[]; -const ALFREDPAY_FIAT_TOKEN_SET = new Set([FiatToken.USD, FiatToken.MXN, FiatToken.COP]); +const ALFREDPAY_FIAT_TOKEN_SET = new Set([FiatToken.USD, FiatToken.MXN, FiatToken.COP, FiatToken.ARS]); export const isAlfredpayToken = (token: FiatToken): boolean => ALFREDPAY_FIAT_TOKEN_SET.has(token); @@ -397,7 +397,12 @@ export interface SubmitKycInformationRequest { dni: string; typeDocument?: string; typeDocumentCol?: AlfredpayColombiaDocumentType; - phoneNumber?: string; // Colombia + typeDocumentAr?: AlfredpayArgentinaDocumentType; + phoneNumber?: string; // Colombia, Argentina + countryCode?: string; // Argentina + nationalities?: string[]; // Argentina + pep?: boolean; // Argentina + cuit?: string; // Argentina, mandatory 11 digits } export interface SubmitKycInformationResponse { @@ -406,7 +411,12 @@ export interface SubmitKycInformationResponse { export enum AlfredpayKycFileType { FRONT = "National ID Front", - BACK = "National ID Back" + BACK = "National ID Back", + SELFIE = "Selfie" +} + +export enum AlfredpayArgentinaDocumentType { + DNI = "DNI" } // KYB form submission types diff --git a/packages/shared/src/tokens/freeTokens/config.ts b/packages/shared/src/tokens/freeTokens/config.ts index 3f4bbec72..788e16b57 100644 --- a/packages/shared/src/tokens/freeTokens/config.ts +++ b/packages/shared/src/tokens/freeTokens/config.ts @@ -46,5 +46,19 @@ export const freeTokenConfig: Partial> = minBuyAmountRaw: "3500000", minSellAmountRaw: "100", type: TokenType.Fiat + }, + [FiatToken.ARS]: { + assetSymbol: "ARS", + decimals: 2, + fiat: { + assetIcon: "ars", + name: "Argentine Peso", + symbol: "ARS" + }, + maxBuyAmountRaw: "10000000000", + maxSellAmountRaw: "100000000000000000000", + minBuyAmountRaw: "110000", + minSellAmountRaw: "1100", + type: TokenType.Fiat } }; diff --git a/packages/shared/src/tokens/stellar/config.ts b/packages/shared/src/tokens/stellar/config.ts index c174e6726..d51b36c28 100644 --- a/packages/shared/src/tokens/stellar/config.ts +++ b/packages/shared/src/tokens/stellar/config.ts @@ -49,48 +49,5 @@ export const stellarTokenConfig: Partial> type: TokenType.Stellar, usesMemo: false, vaultAccountId: "6dgJM1ijyHFEfzUokJ1AHq3z3R3Z8ouc8B5SL9YjMRUaLsjh" - }, - [FiatToken.ARS]: { - anchorHomepageUrl: "https://home.anclap.com", - assetSymbol: "ARS", - decimals: 12, - fiat: { - assetIcon: "ars", - name: "Argentine Peso", - symbol: "ARS" - }, - maxBuyAmountRaw: "500000000000000000", - maxSellAmountRaw: "500000000000000000", - minBuyAmountRaw: "11000000000000", - minSellAmountRaw: "11000000000000", - pendulumRepresentative: { - assetSymbol: "ARS", - currency: FiatToken.ARS, - currencyId: { - Stellar: { - AlphaNum4: { - code: "0x41525300", - issuer: "0xb04f8bff207a0b001aec7b7659a8d106e54e659cdf9533528f468e079628fba1" - } - } - }, - decimals: 12, - erc20WrapperAddress: "6f7VMG1ERxpZMvFE2CbdWb7phxDgnoXrdornbV3CCd51nFsj" - }, - stellarAsset: { - code: { - hex: "0x41525300", - string: "ARS" - }, - issuer: { - hex: "0xb04f8bff207a0b001aec7b7659a8d106e54e659cdf9533528f468e079628fba1", - stellarEncoding: "GCYE7C77EB5AWAA25R5XMWNI2EDOKTTFTTPZKM2SR5DI4B4WFD52DARS" - } - }, // 11 ARS - supportsClientDomain: true, // 500000 ARS - tomlFileUrl: getTomlFileUrl("ARS"), // 2% - type: TokenType.Stellar, // 10 ARS - usesMemo: true, - vaultAccountId: "6bE2vjpLRkRNoVDqDtzokxE34QdSJC2fz7c87R9yCVFFDNWs" } };