diff --git a/.gitignore b/.gitignore index befe71000..3cd42950a 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,5 @@ CLAUDE.local.md # hardhat generated files in workspace contract projects contracts/*/artifacts contracts/*/cache + +.mcp.json \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index ec7fa3d9d..71371528c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -161,3 +161,63 @@ bun test ## Type Issues If IDE doesn't detect `@pendulum-chain/types` properly, ensure all `@polkadot/*` packages match versions in the types package. The root `package.json` uses `catalog:` for version management. + +--- + +# AI AGENT RULES + +## 1. Think Before Coding + +**Don't assume. Don't hide confusion. Surface tradeoffs.** + +Before implementing: +- State your assumptions explicitly. If uncertain, ask. +- If multiple interpretations exist, present them - don't pick silently. +- If a simpler approach exists, say so. Push back when warranted. +- If something is unclear, stop. Name what's confusing. Ask. + +## 2. Simplicity First + +**Minimum code that solves the problem. Nothing speculative.** + +- No features beyond what was asked. +- No abstractions for single-use code. +- No "flexibility" or "configurability" that wasn't requested. +- No error handling for impossible scenarios. +- If you write 200 lines and it could be 50, rewrite it. + +Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify. + +## 3. Surgical Changes + +**Touch only what you must. Clean up only your own mess.** + +When editing existing code: +- Don't "improve" adjacent code, comments, or formatting. +- Don't refactor things that aren't broken. +- Match existing style, even if you'd do it differently. +- If you notice unrelated dead code, mention it - don't delete it. + +When your changes create orphans: +- Remove imports/variables/functions that YOUR changes made unused. +- Don't remove pre-existing dead code unless asked. + +The test: Every changed line should trace directly to the user's request. + +## 4. Goal-Driven Execution + +**Define success criteria. Loop until verified.** + +Transform tasks into verifiable goals: +- "Add validation" → "Write tests for invalid inputs, then make them pass" +- "Fix the bug" → "Write a test that reproduces it, then make it pass" +- "Refactor X" → "Ensure tests pass before and after" + +For multi-step tasks, state a brief plan: +``` +1. [Step] → verify: [check] +2. [Step] → verify: [check] +3. [Step] → verify: [check] +``` + +Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification. diff --git a/apps/api/package.json b/apps/api/package.json index 43e2381dd..b4037afe5 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -12,9 +12,10 @@ "@polkadot/util-crypto": "catalog:", "@scure/bip39": "^1.5.4", "@supabase/supabase-js": "catalog:", + "@types/multer": "^2.1.0", "@vortexfi/shared": "workspace:*", "@wagmi/core": "catalog:", - "axios": "catalog:", + "bcrypt": "catalog:", "big.js": "catalog:", "body-parser": "^1.17.0", @@ -34,6 +35,7 @@ "joi": "^17.13.3", "method-override": "^3.0.0", "morgan": "^1.8.1", + "multer": "^2.1.1", "node-cache": "^5.1.2", "p-limit": "^6.1.0", "pg": "^8.14.1", @@ -80,7 +82,7 @@ "typescript": "catalog:" }, "engines": { - "node": ">=12" + "node": ">=18" }, "license": "MIT", "name": "vortex-backend", diff --git a/apps/api/src/api/controllers/alfredpay.controller.ts b/apps/api/src/api/controllers/alfredpay.controller.ts index 1feafa3b0..1e628b314 100644 --- a/apps/api/src/api/controllers/alfredpay.controller.ts +++ b/apps/api/src/api/controllers/alfredpay.controller.ts @@ -1,23 +1,29 @@ import { AlfredPayCountry, AlfredPayStatus, + AlfredpayAddFiatAccountRequest, AlfredpayApiService, AlfredpayCreateCustomerRequest, AlfredpayCreateCustomerResponse, AlfredpayCustomerType, + AlfredpayFiatAccountType, AlfredpayGetKybRedirectLinkResponse, AlfredpayGetKycRedirectLinkRequest, AlfredpayGetKycRedirectLinkResponse, AlfredpayGetKycStatusResponse, + AlfredpayKybFileType, + AlfredpayKybRelatedPersonFileType, AlfredpayKybStatus, + AlfredpayKycFileType, AlfredpayKycStatus, AlfredpayStatusRequest, - AlfredpayStatusResponse + AlfredpayStatusResponse, + SubmitKybInformationRequest, + SubmitKycInformationRequest } from "@vortexfi/shared"; import { Request, Response } from "express"; import logger from "../../config/logger"; import AlfredPayCustomer from "../../models/alfredPayCustomer.model"; -import { SupabaseAuthService } from "../services/auth/supabase.service"; export class AlfredpayController { private static mapKycStatus(status: AlfredpayKycStatus): AlfredPayStatus | null { @@ -28,10 +34,11 @@ export class AlfredpayController { return AlfredPayStatus.Failed; case AlfredpayKycStatus.COMPLETED: return AlfredPayStatus.Success; + case AlfredpayKycStatus.UPDATE_REQUIRED: + return AlfredPayStatus.UpdateRequired; case AlfredpayKycStatus.CREATED: default: return null; // Do nothing - // TODO how do we map their UPDATE_REQUIRED required? what does it mean in terms of flow, for our user? } } @@ -77,6 +84,16 @@ export class AlfredpayController { } } catch (error) { logger.error("Error refreshing Alfredpay status:", error); + + // If the upstream API returns 404 (KYC submission not found), the local status is stale. + // Reset to Consulted so the frontend re-triggers the KYC flow. + const errorMessage = ((error as any)?.message || (error as any)?.toString() || "").toLowerCase(); + if (errorMessage.includes("404") || errorMessage.includes("not found")) { + if (alfredPayCustomer.status === AlfredPayStatus.Success) { + logger.info("Resetting stale AlfredPay status to Consulted due to upstream 404"); + await alfredPayCustomer.update({ status: AlfredPayStatus.Consulted }); + } + } } const response: AlfredpayStatusResponse = { @@ -96,10 +113,10 @@ export class AlfredpayController { try { const { country } = req.body as AlfredpayCreateCustomerRequest; const userId = req.userId!; + const userEmail = req.userEmail; - const user = await SupabaseAuthService.getUserProfile(userId); - if (!user || !user.email) { - return res.status(404).json({ error: "User not found or email missing" }); + if (!userEmail) { + return res.status(400).json({ error: "User email not available" }); } // Check if customer already exists in our DB @@ -113,7 +130,7 @@ export class AlfredpayController { const alfredpayService = AlfredpayApiService.getInstance(); - const newCustomer = await alfredpayService.createCustomer(user.email, AlfredpayCustomerType.INDIVIDUAL, country); + const newCustomer = await alfredpayService.createCustomer(userEmail, AlfredpayCustomerType.INDIVIDUAL, country); const customerId = newCustomer.customerId; await AlfredPayCustomer.create({ @@ -166,7 +183,8 @@ export class AlfredpayController { logger.info("No previous KYC submission found or error fetching it, proceeding."); } - const linkResponse = await alfredpayService.getKycRedirectLink(alfredPayCustomer.alfredPayId, country); + const normalizedCountry = country.toLowerCase() === "us" ? "USA" : country; + const linkResponse = await alfredpayService.getKycRedirectLink(alfredPayCustomer.alfredPayId, normalizedCountry); res.json(linkResponse as AlfredpayGetKycRedirectLinkResponse); } catch (error) { @@ -322,6 +340,11 @@ 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") { + // 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 }); + return res.json({ success: true }); } else { await alfredpayService.retryKycSubmission(alfredPayCustomer.alfredPayId, lastSubmission.submissionId); const linkResponse = await alfredpayService.getKycRedirectLink(alfredPayCustomer.alfredPayId, country); @@ -338,10 +361,10 @@ export class AlfredpayController { try { const { country } = req.body as { country: string }; const userId = req.userId!; + const userEmail = req.userEmail; - const user = await SupabaseAuthService.getUserProfile(userId); - if (!user || !user.email) { - return res.status(404).json({ error: "User not found or email missing" }); + if (!userEmail) { + return res.status(400).json({ error: "User email not available" }); } const type = AlfredpayCustomerType.BUSINESS; @@ -356,7 +379,7 @@ export class AlfredpayController { const alfredpayService = AlfredpayApiService.getInstance(); - const newCustomer = await alfredpayService.createCustomer(user.email, type, country); + const newCustomer = await alfredpayService.createCustomer(userEmail, type, country); const customerId = newCustomer.customerId; await AlfredPayCustomer.create({ @@ -417,4 +440,357 @@ export class AlfredpayController { res.status(500).json({ error: "Internal server error" }); } } + + static async submitKycInformation(req: Request, res: Response) { + try { + const { country, ...kycData } = req.body as SubmitKycInformationRequest & { country: string }; + const userId = req.userId!; + + const alfredPayCustomer = await AlfredPayCustomer.findOne({ + where: { country: country as AlfredPayCountry, type: AlfredpayCustomerType.INDIVIDUAL, userId } + }); + + if (!alfredPayCustomer) { + return res.status(404).json({ error: "Alfredpay customer not found" }); + } + + const alfredpayService = AlfredpayApiService.getInstance(); + const result = await alfredpayService.submitKycInformation(alfredPayCustomer.alfredPayId, { ...kycData, country }); + + res.json(result); + } catch (error) { + logger.error("Error submitting KYC information:", error); + const message = error instanceof Error ? error.message : "Internal server error"; + res.status(500).json({ error: message }); + } + } + + static async submitKycFile(req: Request, res: Response) { + try { + const { country, submissionId, fileType } = req.body as { country: string; submissionId: string; fileType: string }; + const userId = req.userId!; + + if (!req.file) { + return res.status(400).json({ error: "No file uploaded" }); + } + + const alfredPayCustomer = await AlfredPayCustomer.findOne({ + where: { country: country as AlfredPayCountry, type: AlfredpayCustomerType.INDIVIDUAL, userId } + }); + + if (!alfredPayCustomer) { + return res.status(404).json({ error: "Alfredpay customer not found" }); + } + + const fileBlob = new File([new Uint8Array(req.file.buffer)], req.file.originalname, { type: req.file.mimetype }); + const alfredpayService = AlfredpayApiService.getInstance(); + await alfredpayService.submitKycFile( + alfredPayCustomer.alfredPayId, + submissionId, + fileType as AlfredpayKycFileType, + fileBlob + ); + + res.json({ success: true }); + } catch (error) { + logger.error("Error submitting KYC file:", error); + const message = error instanceof Error ? error.message : "Internal server error"; + res.status(500).json({ error: message }); + } + } + + static async sendKycSubmission(req: Request, res: Response) { + try { + const { country, submissionId } = req.body as { country: string; submissionId: string }; + const userId = req.userId!; + + const alfredPayCustomer = await AlfredPayCustomer.findOne({ + where: { country: country as AlfredPayCountry, type: AlfredpayCustomerType.INDIVIDUAL, userId } + }); + + if (!alfredPayCustomer) { + return res.status(404).json({ error: "Alfredpay customer not found" }); + } + + const alfredpayService = AlfredpayApiService.getInstance(); + await alfredpayService.sendKycSubmission(alfredPayCustomer.alfredPayId, submissionId); + + res.json({ success: true }); + } catch (error) { + logger.error("Error sending KYC submission:", error); + const message = error instanceof Error ? error.message : "Internal server error"; + res.status(500).json({ error: message }); + } + } + + static async submitKybInformation(req: Request, res: Response) { + try { + const { country, ...kybData } = req.body as SubmitKybInformationRequest & { country: string }; + const userId = req.userId!; + + const alfredPayCustomer = await AlfredPayCustomer.findOne({ + where: { country: country as AlfredPayCountry, type: AlfredpayCustomerType.BUSINESS, userId } + }); + + if (!alfredPayCustomer) { + return res.status(404).json({ error: "Alfredpay business customer not found" }); + } + + const alfredpayService = AlfredpayApiService.getInstance(); + const result = await alfredpayService.submitKybInformation(alfredPayCustomer.alfredPayId, { ...kybData, country }); + + res.json(result); + } catch (error) { + logger.error("Error submitting KYB information:", error); + const message = error instanceof Error ? error.message : "Internal server error"; + res.status(500).json({ error: message }); + } + } + + static async submitKybFile(req: Request, res: Response) { + try { + const { country, submissionId, fileType } = req.body as { country: string; submissionId: string; fileType: string }; + const userId = req.userId!; + + if (!req.file) { + return res.status(400).json({ error: "No file uploaded" }); + } + + const alfredPayCustomer = await AlfredPayCustomer.findOne({ + where: { country: country as AlfredPayCountry, type: AlfredpayCustomerType.BUSINESS, userId } + }); + + if (!alfredPayCustomer) { + return res.status(404).json({ error: "Alfredpay business customer not found" }); + } + + const fileBlob = new File([new Uint8Array(req.file.buffer)], req.file.originalname, { type: req.file.mimetype }); + const alfredpayService = AlfredpayApiService.getInstance(); + await alfredpayService.submitKybFiles( + alfredPayCustomer.alfredPayId, + submissionId, + fileType as AlfredpayKybFileType, + fileBlob + ); + + res.json({ success: true }); + } catch (error) { + logger.error("Error submitting KYB file:", error); + const message = error instanceof Error ? error.message : "Internal server error"; + res.status(500).json({ error: message }); + } + } + + static async submitKybRelatedPersonFile(req: Request, res: Response) { + try { + const { country, relatedPersonId, fileType } = req.body as { + country: string; + relatedPersonId: string; + fileType: string; + }; + const userId = req.userId!; + + if (!req.file) { + return res.status(400).json({ error: "No file uploaded" }); + } + + const alfredPayCustomer = await AlfredPayCustomer.findOne({ + where: { country: country as AlfredPayCountry, type: AlfredpayCustomerType.BUSINESS, userId } + }); + + if (!alfredPayCustomer) { + return res.status(404).json({ error: "Alfredpay business customer not found" }); + } + + const fileBlob = new File([new Uint8Array(req.file.buffer)], req.file.originalname, { type: req.file.mimetype }); + const alfredpayService = AlfredpayApiService.getInstance(); + await alfredpayService.submitKybRelatedPersonFiles( + alfredPayCustomer.alfredPayId, + relatedPersonId, + fileType as AlfredpayKybRelatedPersonFileType, + fileBlob + ); + + res.json({ success: true }); + } catch (error) { + logger.error("Error submitting KYB related person file:", error); + const message = error instanceof Error ? error.message : "Internal server error"; + res.status(500).json({ error: message }); + } + } + + static async sendKybSubmission(req: Request, res: Response) { + try { + const { country, submissionId } = req.body as { country: string; submissionId: string }; + const userId = req.userId!; + + const alfredPayCustomer = await AlfredPayCustomer.findOne({ + where: { country: country as AlfredPayCountry, type: AlfredpayCustomerType.BUSINESS, userId } + }); + + if (!alfredPayCustomer) { + return res.status(404).json({ error: "Alfredpay business customer not found" }); + } + + const alfredpayService = AlfredpayApiService.getInstance(); + await alfredpayService.sendKybSubmission(alfredPayCustomer.alfredPayId, submissionId); + + res.json({ success: true }); + } catch (error) { + logger.error("Error sending KYB submission:", error); + const message = error instanceof Error ? error.message : "Internal server error"; + res.status(500).json({ error: message }); + } + } + + static async addFiatAccount(req: Request, res: Response) { + try { + const { + country, + type, + accountNumber, + accountType, + accountName, + accountBankCode, + routingNumber, + bankStreet, + bankCity, + bankState, + bankCountry, + bankPostalCode, + beneficiaryStreet, + beneficiaryCity, + beneficiaryState, + beneficiaryCountry, + beneficiaryPostalCode, + documentType, + documentNumber, + isExternal = false + } = req.body as AlfredpayAddFiatAccountRequest; + const userId = req.userId!; + + const alfredPayCustomer = await AlfredPayCustomer.findOne({ + order: [["updatedAt", "DESC"]], + where: { country: country as AlfredPayCountry, userId } + }); + + if (!alfredPayCustomer) { + return res.status(404).json({ error: "Alfredpay customer not found" }); + } + + const alfredpayFiatAccountType = type as AlfredpayFiatAccountType; + + let fiatAccountFields; + if (alfredpayFiatAccountType === AlfredpayFiatAccountType.SPEI) { + fiatAccountFields = { + accountNumber, + accountType: "CLABE", + metadata: { accountHolderName: accountName } + }; + } else if (alfredpayFiatAccountType === AlfredpayFiatAccountType.ACH) { + fiatAccountFields = { + accountName: accountBankCode, + accountNumber, + accountType: accountType ?? "", + metadata: { accountHolderName: accountName, documentNumber, documentType } + }; + } else { + // BANK_USA — external accounts need address fields inside metadata + fiatAccountFields = isExternal + ? { + accountName: accountBankCode, + accountNumber, + accountType: accountType ?? "", + metadata: { + bankCity, + bankCountry, + bankPostalCode, + bankState: bankState?.toUpperCase(), + bankStreet, + beneficiaryAddress: { + city: beneficiaryCity, + country: beneficiaryCountry, + postalCode: beneficiaryPostalCode, + stateProvince: beneficiaryState?.toUpperCase(), + street: beneficiaryStreet + } + }, + routingNumber + } + : { + accountName: accountBankCode, + accountNumber, + accountType: accountType ?? "", + bankCity, + bankCountry, + bankPostalCode, + bankState: bankState?.toUpperCase(), + bankStreet, + routingNumber + }; + } + + const alfredpayService = AlfredpayApiService.getInstance(); + const result = await alfredpayService.createFiatAccount( + alfredPayCustomer.alfredPayId, + alfredpayFiatAccountType, + fiatAccountFields, + isExternal + ); + + res.json(result); + } catch (error) { + logger.error("Error adding fiat account:", error); + res.status(500).json({ error: "Internal server error" }); + } + } + + static async listFiatAccounts(req: Request, res: Response) { + try { + const { country } = req.query as { country: string }; + const userId = req.userId!; + + const alfredPayCustomer = await AlfredPayCustomer.findOne({ + order: [["updatedAt", "DESC"]], + where: { country: country as AlfredPayCountry, userId } + }); + + if (!alfredPayCustomer) { + return res.status(404).json({ error: "Alfredpay customer not found" }); + } + + const alfredpayService = AlfredpayApiService.getInstance(); + const accounts = await alfredpayService.listFiatAccounts(alfredPayCustomer.alfredPayId); + + res.json(accounts); + } catch (error) { + logger.error("Error listing fiat accounts:", error); + res.status(500).json({ error: "Internal server error" }); + } + } + + static async deleteFiatAccount(req: Request, res: Response) { + try { + const { fiatAccountId } = req.params as { fiatAccountId: string }; + const { country } = req.query as { country: string }; + const userId = req.userId!; + + const alfredPayCustomer = await AlfredPayCustomer.findOne({ + order: [["updatedAt", "DESC"]], + where: { country: country as AlfredPayCountry, userId } + }); + + if (!alfredPayCustomer) { + return res.status(404).json({ error: "Alfredpay customer not found" }); + } + + const alfredpayService = AlfredpayApiService.getInstance(); + await alfredpayService.deleteFiatAccount(alfredPayCustomer.alfredPayId, fiatAccountId); + + res.status(204).send(); + } catch (error) { + logger.error("Error deleting fiat account:", error); + res.status(500).json({ error: "Internal server error" }); + } + } } diff --git a/apps/api/src/api/controllers/brla.controller.ts b/apps/api/src/api/controllers/brla.controller.ts index 5639ca038..5f970d878 100644 --- a/apps/api/src/api/controllers/brla.controller.ts +++ b/apps/api/src/api/controllers/brla.controller.ts @@ -28,6 +28,7 @@ import { KycFailureReason, KycLevel1Payload, KycLevel1Response, + normalizeTaxId, RampDirection } from "@vortexfi/shared"; import { Request, Response } from "express"; @@ -40,6 +41,7 @@ import { APIError } from "../errors/api-error"; // Helper functions for TaxId updates async function updateTaxIdToAccepted(taxId: string, quoteId?: string, sessionId?: string): Promise { + const normalized = normalizeTaxId(taxId); await TaxId.update( { finalQuoteId: quoteId, @@ -50,13 +52,14 @@ async function updateTaxIdToAccepted(taxId: string, quoteId?: string, sessionId? { where: { internalStatus: TaxIdInternalStatus.Requested, - taxId + taxId: normalized } } ); } async function updateTaxIdToRejected(taxId: string, quoteId?: string, sessionId?: string): Promise { + const normalized = normalizeTaxId(taxId); await TaxId.update( { finalQuoteId: quoteId, @@ -67,7 +70,7 @@ async function updateTaxIdToRejected(taxId: string, quoteId?: string, sessionId? { where: { internalStatus: TaxIdInternalStatus.Requested, - taxId + taxId: normalized } } ); @@ -152,7 +155,7 @@ export const getAveniaUser = async ( internalStatus: { [Op.ne]: TaxIdInternalStatus.Consulted }, - taxId + taxId: normalizeTaxId(taxId) } }); if (!taxIdRecord) { @@ -201,7 +204,7 @@ export const recordInitialKycAttempt = async ( const taxIdRecord = await TaxId.findOne({ where: { - taxId + taxId: normalizeTaxId(taxId) } }); @@ -245,7 +248,7 @@ export const getAveniaUserRemainingLimit = async ( return; } - const taxIdRecord = await TaxId.findByPk(taxId); + const taxIdRecord = await TaxId.findByPk(normalizeTaxId(taxId)); if (!taxIdRecord) { throw new APIError({ message: "Ramp disabled", @@ -309,7 +312,7 @@ export const createSubaccount = async ( const brlaApiService = BrlaApiService.getInstance(); const { id } = await brlaApiService.createAveniaSubaccount(accountType, name); - const existingTaxId = await TaxId.findByPk(taxId); + const existingTaxId = await TaxId.findByPk(normalizeTaxId(taxId)); if (existingTaxId) { await existingTaxId.update({ @@ -353,7 +356,7 @@ export const fetchSubaccountKycStatus = async ( return; } - const taxIdRecord = await TaxId.findByPk(taxId); + const taxIdRecord = await TaxId.findByPk(normalizeTaxId(taxId)); if (!taxIdRecord) { res.status(httpStatus.NOT_FOUND).json({ error: "Subaccount not found" }); return; @@ -445,7 +448,7 @@ export const getSelfieLivenessUrl = async ( return; } - const taxIdRecord = await TaxId.findByPk(taxId); + const taxIdRecord = await TaxId.findByPk(normalizeTaxId(taxId)); if (!taxIdRecord) { res.status(httpStatus.BAD_REQUEST).json({ error: "Ramp disabled" }); return; @@ -503,7 +506,7 @@ export const getUploadUrls = async ( return; } - const taxIdRecord = await TaxId.findByPk(taxId); + const taxIdRecord = await TaxId.findByPk(normalizeTaxId(taxId)); if (!taxIdRecord) { // Invalid state. Cannot happen since we create the subaccount first for every tax. res.status(httpStatus.BAD_REQUEST).json({ error: "Ramp disabled" }); diff --git a/apps/api/src/api/controllers/fiat-currencies.controller.ts b/apps/api/src/api/controllers/fiat-currencies.controller.ts index b083ad08d..f8b26231d 100644 --- a/apps/api/src/api/controllers/fiat-currencies.controller.ts +++ b/apps/api/src/api/controllers/fiat-currencies.controller.ts @@ -19,7 +19,7 @@ export const getSupportedFiatCurrenciesHandler = async ( ): Promise => { try { res.status(httpStatus.OK).json({ - currencies: SUPPORTED_FIAT_CURRENCIES + currencies: SUPPORTED_FIAT_CURRENCIES.filter(c => c.enabled) }); } catch (error) { next(error); diff --git a/apps/api/src/api/middlewares/supabaseAuth.ts b/apps/api/src/api/middlewares/supabaseAuth.ts index f599aa586..2c0d64e92 100644 --- a/apps/api/src/api/middlewares/supabaseAuth.ts +++ b/apps/api/src/api/middlewares/supabaseAuth.ts @@ -6,6 +6,7 @@ declare global { namespace Express { interface Request { userId?: string; + userEmail?: string; } } } @@ -33,6 +34,7 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio } req.userId = result.user_id; + req.userEmail = result.email; next(); } catch (error) { logger.error("Auth middleware error:", error); diff --git a/apps/api/src/api/routes/v1/alfredpay.route.ts b/apps/api/src/api/routes/v1/alfredpay.route.ts index 3fd75cfe0..1a464668d 100644 --- a/apps/api/src/api/routes/v1/alfredpay.route.ts +++ b/apps/api/src/api/routes/v1/alfredpay.route.ts @@ -1,9 +1,11 @@ import { Router } from "express"; +import multer from "multer"; import { AlfredpayController } from "../../controllers/alfredpay.controller"; import { validateResultCountry } from "../../middlewares/alfredpay.middleware"; import { requireAuth } from "../../middlewares/supabaseAuth"; const router = Router(); +const upload = multer({ limits: { fileSize: 5 * 1024 * 1024 }, storage: multer.memoryStorage() }); router.get("/alfredpayStatus", requireAuth, validateResultCountry, AlfredpayController.alfredpayStatus); router.post("/createIndividualCustomer", requireAuth, validateResultCountry, AlfredpayController.createIndividualCustomer); @@ -15,4 +17,26 @@ router.post("/retryKyc", requireAuth, validateResultCountry, AlfredpayController router.post("/createBusinessCustomer", requireAuth, validateResultCountry, AlfredpayController.createBusinessCustomer); router.get("/getKybRedirectLink", requireAuth, validateResultCountry, AlfredpayController.getKybRedirectLink); +// MXN/CO API-based KYC +router.post("/submitKycInformation", requireAuth, validateResultCountry, AlfredpayController.submitKycInformation); +router.post("/submitKycFile", requireAuth, upload.single("file"), validateResultCountry, AlfredpayController.submitKycFile); +router.post("/sendKycSubmission", requireAuth, validateResultCountry, AlfredpayController.sendKycSubmission); + +// Business API-based KYB +router.post("/submitKybInformation", requireAuth, validateResultCountry, AlfredpayController.submitKybInformation); +router.post("/submitKybFile", requireAuth, upload.single("file"), validateResultCountry, AlfredpayController.submitKybFile); +router.post( + "/submitKybRelatedPersonFile", + requireAuth, + upload.single("file"), + validateResultCountry, + AlfredpayController.submitKybRelatedPersonFile +); +router.post("/sendKybSubmission", requireAuth, validateResultCountry, AlfredpayController.sendKybSubmission); + +// Fiat accounts (USD + MXN) +router.post("/fiatAccounts", requireAuth, validateResultCountry, AlfredpayController.addFiatAccount); +router.get("/fiatAccounts", requireAuth, validateResultCountry, AlfredpayController.listFiatAccounts); +router.delete("/fiatAccounts/:fiatAccountId", requireAuth, validateResultCountry, AlfredpayController.deleteFiatAccount); + export default router; diff --git a/apps/api/src/api/services/auth/supabase.service.ts b/apps/api/src/api/services/auth/supabase.service.ts index 1743ee606..50aaa56e3 100644 --- a/apps/api/src/api/services/auth/supabase.service.ts +++ b/apps/api/src/api/services/auth/supabase.service.ts @@ -143,6 +143,7 @@ export class SupabaseAuthService { static async verifyToken(accessToken: string): Promise<{ valid: boolean; user_id?: string; + email?: string; }> { const { data, error } = await supabase.auth.getUser(accessToken); @@ -151,6 +152,7 @@ export class SupabaseAuthService { } return { + email: data.user.email, user_id: data.user.id, valid: true }; diff --git a/apps/api/src/api/services/phases/handlers/alfredpay-offramp-transfer-handler.ts b/apps/api/src/api/services/phases/handlers/alfredpay-offramp-transfer-handler.ts index c65cd490d..dd2066f23 100644 --- a/apps/api/src/api/services/phases/handlers/alfredpay-offramp-transfer-handler.ts +++ b/apps/api/src/api/services/phases/handlers/alfredpay-offramp-transfer-handler.ts @@ -1,13 +1,16 @@ import { + ALFREDPAY_ONCHAIN_CURRENCY, AlfredpayApiService, + AlfredpayChain, + AlfredpayFiatCurrency, AlfredpayOfframpStatus, + AlfredpayPaymentMethodType, EvmClientManager, EvmNetworks, Networks, RampPhase } from "@vortexfi/shared"; import logger from "../../../../config/logger"; -import QuoteTicket from "../../../../models/quoteTicket.model"; import RampState from "../../../../models/rampState.model"; import { BasePhaseHandler } from "../base-phase-handler"; import { StateMetadata } from "../meta-state-types"; @@ -30,19 +33,33 @@ export class AlfredpayOfframpTransferHandler extends BasePhaseHandler { const alfredpayApiService = AlfredpayApiService.getInstance(); const evmClientManager = EvmClientManager.getInstance(); - const alfredpayTx = await alfredpayApiService.getOfframpTransaction(alfredpayTransactionId); + let alfredpayTx = await alfredpayApiService.getOfframpTransaction(alfredpayTransactionId); if (!alfredpayTx) { throw new Error(`AlfredpayOfframpTransferHandler: Transaction ${alfredpayTransactionId} not found in Alfredpay.`); } - const expirationDate = new Date(alfredpayTx.expiration); - if (expirationDate < new Date()) { - logger.error(`AlfredpayOfframpTransferHandler: Alfredpay transaction ${alfredpayTransactionId} has expired.`); - return this.transitionToNextPhase(state, "failed"); + // Only attempt expiration recovery if we haven't sent the final transfer yet. + if (!alfredpayOfframpTransferTxHash && new Date(alfredpayTx.expiration) < new Date()) { + logger.warn( + `AlfredpayOfframpTransferHandler: Alfredpay transaction ${alfredpayTransactionId} expired before transfer. Attempting recovery.` + ); + + const recovered = await this.recreateAlfredpayOfframp(state, alfredpayTx); + if (!recovered) { + logger.error( + `AlfredpayOfframpTransferHandler: Recovery failed for ${alfredpayTransactionId} (deposit address changed or API error).` + ); + return this.transitionToNextPhase(state, "failed"); + } + + alfredpayTx = recovered.alfredpayTx; + state = recovered.state; } if (!alfredpayOfframpTransferTxHash) { - logger.info(`AlfredpayOfframpTransferHandler: Executing final transfer for Alfredpay offramp ${alfredpayTransactionId}`); + logger.info( + `AlfredpayOfframpTransferHandler: Executing final transfer for Alfredpay offramp ${alfredpayTx.transactionId}` + ); const { txData: offrampTransfer } = this.getPresignedTransaction(state, "alfredpayOfframpTransfer"); @@ -76,7 +93,7 @@ export class AlfredpayOfframpTransferHandler extends BasePhaseHandler { } try { - await this.pollAlfredpayOfframpStatus(alfredpayTransactionId, ALFREDPAY_POLL_INTERVAL_MS); + await this.pollAlfredpayOfframpStatus(alfredpayTx.transactionId, ALFREDPAY_POLL_INTERVAL_MS); } catch (error: any) { if (error?.kind === "failed") { logger.error(`AlfredpayOfframpTransferHandler: Alfredpay offramp FAILED. Reason: ${error.failureReason ?? "unknown"}`); @@ -91,6 +108,70 @@ export class AlfredpayOfframpTransferHandler extends BasePhaseHandler { return this.transitionToNextPhase(state, "complete"); } + private async recreateAlfredpayOfframp( + state: RampState, + expiredTx: Awaited> + ): Promise<{ state: RampState; alfredpayTx: Awaited> } | null> { + const { alfredpayUserId, fiatAccountId, walletAddress } = state.state as StateMetadata; + + if (!alfredpayUserId || !fiatAccountId || !walletAddress) { + logger.error("AlfredpayOfframpTransferHandler: Missing fields required for recovery of expired offramp order."); + return null; + } + + const alfredpayApiService = AlfredpayApiService.getInstance(); + + try { + const toCurrency = expiredTx.toCurrency as AlfredpayFiatCurrency; + + const freshQuote = await alfredpayApiService.createOfframpQuote({ + chain: AlfredpayChain.MATIC, + fromAmount: expiredTx.fromAmount, + fromCurrency: ALFREDPAY_ONCHAIN_CURRENCY, + metadata: { businessId: "vortex", customerId: alfredpayUserId }, + paymentMethodType: AlfredpayPaymentMethodType.BANK, + toCurrency + }); + + const newOrder = await alfredpayApiService.createOfframp({ + amount: expiredTx.fromAmount, + chain: AlfredpayChain.MATIC, + customerId: alfredpayUserId, + fiatAccountId, + fromCurrency: ALFREDPAY_ONCHAIN_CURRENCY, + originAddress: walletAddress, + quoteId: freshQuote.quoteId, + toCurrency + }); + + if (newOrder.depositAddress.toLowerCase() !== expiredTx.depositAddress.toLowerCase()) { + logger.error( + `AlfredpayOfframpTransferHandler: New deposit address ${newOrder.depositAddress} does not match expired ${expiredTx.depositAddress}. Cannot reuse presigned final transfer.` + ); + return null; + } + + await state.update({ + state: { + ...state.state, + alfredpayTransactionId: newOrder.transactionId + } + }); + + logger.info( + `AlfredpayOfframpTransferHandler: Recovery successful. New Alfredpay transactionId: ${newOrder.transactionId}` + ); + + const refreshedTx = await alfredpayApiService.getOfframpTransaction(newOrder.transactionId); + return { alfredpayTx: refreshedTx, state }; + } catch (error) { + logger.error( + `AlfredpayOfframpTransferHandler: Error during recovery: ${error instanceof Error ? error.message : String(error)}` + ); + return null; + } + } + private async pollAlfredpayOfframpStatus(transactionId: string, intervalMs: number): Promise { const alfredpayApiService = AlfredpayApiService.getInstance(); const startTime = Date.now(); @@ -106,7 +187,7 @@ export class AlfredpayOfframpTransferHandler extends BasePhaseHandler { const response = await alfredpayApiService.getOfframpTransaction(transactionId); const { status } = response; - if (status === AlfredpayOfframpStatus.COMPLETED) { + if (status === AlfredpayOfframpStatus.FIAT_TRANSFER_COMPLETED) { resolve(); return; } diff --git a/apps/api/src/api/services/phases/handlers/alfredpay-onramp-mint-handler.ts b/apps/api/src/api/services/phases/handlers/alfredpay-onramp-mint-handler.ts index d23b9810f..de4cc7bd0 100644 --- a/apps/api/src/api/services/phases/handlers/alfredpay-onramp-mint-handler.ts +++ b/apps/api/src/api/services/phases/handlers/alfredpay-onramp-mint-handler.ts @@ -1,11 +1,11 @@ import { + ALFREDPAY_ERC20_DECIMALS, + ALFREDPAY_ERC20_TOKEN, AlfredpayApiService, AlfredpayOnrampStatus, BalanceCheckError, BalanceCheckErrorType, checkEvmBalancePeriodically, - ERC20_USDC_POLYGON, - ERC20_USDC_POLYGON_DECIMALS, Networks, RampPhase } from "@vortexfi/shared"; @@ -47,12 +47,14 @@ export class AlfredpayOnrampMintHandler extends BasePhaseHandler { const expectedAmountRaw = quote.metadata.alfredpayMint.outputAmountRaw; logger.info( - `AlfredpayOnrampMintHandler: Waiting for ${expectedAmountRaw} USDC (raw, ${ERC20_USDC_POLYGON_DECIMALS} decimals) ` + + `AlfredpayOnrampMintHandler: Waiting for ${expectedAmountRaw} (raw, ${ALFREDPAY_ERC20_DECIMALS} decimals) ` + `on Polygon at ephemeral address ${evmEphemeralAddress}. Alfredpay transactionId: ${alfredpayTransactionId}` ); + const abortController = new AbortController(); + const balanceCheckPromise = checkEvmBalancePeriodically( - ERC20_USDC_POLYGON, + ALFREDPAY_ERC20_TOKEN, evmEphemeralAddress, expectedAmountRaw, BALANCE_POLL_INTERVAL_MS, @@ -60,7 +62,12 @@ export class AlfredpayOnrampMintHandler extends BasePhaseHandler { Networks.Polygon ); - const alfredpayPollingPromise = this.pollAlfredpayOnrampStatus(alfredpayTransactionId, state, ALFREDPAY_POLL_INTERVAL_MS); + const alfredpayPollingPromise = this.pollAlfredpayOnrampStatus( + alfredpayTransactionId, + state, + ALFREDPAY_POLL_INTERVAL_MS, + abortController.signal + ); // - balanceCheckPromise resolves when the USDC balance is met → proceed, or rejects if timeout → recoverable error. // - alfredpayPollingPromise rejects if FAILED → transition to failed. Not recoverable @@ -83,20 +90,36 @@ export class AlfredpayOnrampMintHandler extends BasePhaseHandler { throw this.createRecoverableError( `AlfredpayOnrampMintHandler: Failed to check balance or poll Alfredpay status: ${error instanceof Error ? error.message : String(error)}` ); + } finally { + abortController.abort(); } logger.info( - `AlfredpayOnrampMintHandler: USDC balance reached on Polygon ephemeral ${evmEphemeralAddress}. Proceeding to fundEphemeral.` + `AlfredpayOnrampMintHandler: Balance reached on Polygon ephemeral ${evmEphemeralAddress}. Proceeding to fundEphemeral.` ); return this.transitionToNextPhase(state, "fundEphemeral"); } - private async pollAlfredpayOnrampStatus(transactionId: string, state: RampState, intervalMs: number): Promise { + private async pollAlfredpayOnrampStatus( + transactionId: string, + state: RampState, + intervalMs: number, + signal: AbortSignal + ): Promise { const alfredpayApiService = AlfredpayApiService.getInstance(); return new Promise((_, reject) => { + let timeoutId: ReturnType | undefined; + + const onAbort = () => { + if (timeoutId) clearTimeout(timeoutId); + }; + signal.addEventListener("abort", onAbort, { once: true }); + const poll = async () => { + if (signal.aborted) return; + try { const response = await alfredpayApiService.getOnrampTransaction(transactionId); const { status, metadata } = response; @@ -133,7 +156,7 @@ export class AlfredpayOnrampMintHandler extends BasePhaseHandler { logger.warn(`AlfredpayOnrampMintHandler: Error polling Alfredpay status for ${transactionId}: ${error}`); } - setTimeout(poll, intervalMs); + timeoutId = setTimeout(poll, intervalMs); }; poll(); diff --git a/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts b/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts index da7229287..696e7fb17 100644 --- a/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts +++ b/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts @@ -1,4 +1,5 @@ import { + ALFREDPAY_EVM_TOKEN, checkEvmBalanceForToken, EvmClientManager, EvmNetworks, @@ -9,6 +10,7 @@ import { getNetworkId, getOnChainTokenDetails, getRoute, + isAlfredpayToken, isNativeEvmToken, multiplyByPowerOfTen, NATIVE_TOKEN_ADDRESS, @@ -51,12 +53,13 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { } private getNextPhase(state: RampState, quote: QuoteTicket): RampPhase { - return state.type === RampDirection.SELL && quote.outputCurrency === FiatToken.USD + return state.type === RampDirection.SELL && isAlfredpayToken(quote.outputCurrency as FiatToken) ? "alfredpayOfframpTransfer" : "destinationTransfer"; } protected async executePhase(state: RampState): Promise { + logger.debug(`FinalSettlementSubsidyHandler: Starting phase execution for ramp ${state.id}, type=${state.type}`); const evmClientManager = EvmClientManager.getInstance(); const fundingAccount = privateKeyToAccount(MOONBEAM_FUNDING_PRIVATE_KEY as `0x${string}`); @@ -64,11 +67,18 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { if (!quote) { throw new Error("FinalSettlementSubsidyHandler: Quote not found for the given state"); } + logger.debug( + `FinalSettlementSubsidyHandler: Quote found. inputCurrency=${quote.inputCurrency}, outputCurrency=${quote.outputCurrency}, network=${quote.network}` + ); + + const isAlfredpaySell = state.type === RampDirection.SELL && isAlfredpayToken(quote.outputCurrency as FiatToken); const outTokenDetails = state.type === RampDirection.BUY ? (getOnChainTokenDetails(quote.network, quote.outputCurrency) as EvmTokenDetails) - : getOnChainTokenDetails(Networks.Polygon, EvmToken.USDC); + : isAlfredpaySell + ? getOnChainTokenDetails(Networks.Polygon, ALFREDPAY_EVM_TOKEN) + : getOnChainTokenDetails(Networks.Polygon, EvmToken.USDC); if (!outTokenDetails || outTokenDetails.type === TokenType.AssetHub) { // Should not happen. Destination onchain token or USDC must be defined. @@ -80,20 +90,12 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { let expectedAmountRaw: Big | undefined; switch (state.type) { case RampDirection.BUY: - if (quote.inputCurrency === FiatToken.USD) { - if (!quote.metadata.alfredpayMint) { - throw new Error("FinalSettlementSubsidyHandler: Missing AlfredPay mint metadata for USD onramp quote"); - } - expectedAmountRaw = Big(quote.metadata.alfredpayMint.outputAmountRaw); - break; - } expectedAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outTokenDetails.decimals); break; - case RampDirection.SELL: - if (quote.outputCurrency === FiatToken.USD) { + if (isAlfredpayToken(quote.outputCurrency as FiatToken)) { if (!quote.metadata.alfredpayOfframp) { - throw new Error("FinalSettlementSubsidyHandler: Missing AlfredPay offramp metadata for USD sell quote"); + throw new Error("FinalSettlementSubsidyHandler: Missing Alfredpay offramp metadata"); } expectedAmountRaw = Big(quote.metadata.alfredpayOfframp.inputAmountRaw); break; @@ -109,6 +111,10 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { const publicClient = evmClientManager.getClient(destinationNetwork); const ephemeralAddress = state.state.evmEphemeralAddress as `0x${string}`; + logger.debug( + `FinalSettlementSubsidyHandler: expectedAmountRaw=${expectedAmountRaw.toString()}, destinationNetwork=${destinationNetwork}, ephemeralAddress=${ephemeralAddress}, isNative=${isNative}` + ); + // 1. Idempotency Check if (state.state.finalSettlementSubsidyTxHash) { const receipt = await publicClient @@ -126,6 +132,9 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { } // 2. Check ephemeral address balance (handles both native and ERC-20 automatically) + logger.debug( + `FinalSettlementSubsidyHandler: Polling ephemeral balance for ${ephemeralAddress} on ${destinationNetwork} (timeout=${EVM_BALANCE_CHECK_TIMEOUT_MS}ms, interval=${BALANCE_POLLING_TIME_MS}ms)` + ); const actualBalance = await checkEvmBalanceForToken({ amountDesiredRaw: "1", // If we passed expectedAmountRaw, we might timeout if the bridge slipped and delivered slightly less. chain: destinationNetwork, @@ -134,15 +143,21 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { timeoutMs: EVM_BALANCE_CHECK_TIMEOUT_MS, tokenDetails: outTokenDetails }); + logger.debug(`FinalSettlementSubsidyHandler: Ephemeral balance=${actualBalance.toString()}`); // 3. Check funding account balance (handles both native and ERC-20 automatically) + logger.debug(`FinalSettlementSubsidyHandler: Checking funding account balance at ${fundingAccount.address}`); const actualBalanceFundingAccount = await getEvmBalance({ chain: destinationNetwork, ownerAddress: fundingAccount.address as `0x${string}`, tokenDetails: outTokenDetails }); + logger.debug(`FinalSettlementSubsidyHandler: Funding account balance=${actualBalanceFundingAccount.toString()}`); const subsidyAmountRaw = expectedAmountRaw.minus(actualBalance); + logger.debug( + `FinalSettlementSubsidyHandler: subsidyAmountRaw=${subsidyAmountRaw.toString()} (expected=${expectedAmountRaw.toString()} - actual=${actualBalance.toString()})` + ); if (subsidyAmountRaw.lte(0)) { logger.info( @@ -213,24 +228,20 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { ); } - const swapRouteResult = await getRoute( - { - bypassGuardrails: true, - enableExpress: true, - fromAddress: fundingAccount.address, - fromAmount: requiredNativeRaw, - fromChain: chainId, - fromToken: NATIVE_TOKEN_ADDRESS, - slippageConfig: { - autoMode: 1 - }, - toAddress: fundingAccount.address, - toChain: chainId, - toToken: outTokenDetails.erc20AddressSourceChain + const swapRouteResult = await getRoute({ + bypassGuardrails: true, + enableExpress: true, + fromAddress: fundingAccount.address, + fromAmount: requiredNativeRaw, + fromChain: chainId, + fromToken: NATIVE_TOKEN_ADDRESS, + slippageConfig: { + autoMode: 1 }, - // Do not use cache for routes that will be executed on-chain - { useCache: false } - ); + toAddress: fundingAccount.address, + toChain: chainId, + toToken: outTokenDetails.erc20AddressSourceChain + }); const { route: swapRoute } = swapRouteResult.data; @@ -274,6 +285,7 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler { let attempt = 0; while (attempt < 5 && (!receipt || receipt.status !== "success")) { + logger.debug(`FinalSettlementSubsidyHandler: Subsidy transfer attempt ${attempt + 1}/5, isNative=${isNative}`); if (isNative) { // Native token: simple value transfer, no contract interaction txHash = await evmClientManager.sendTransactionWithBlindRetry(destinationNetwork, fundingAccount, { diff --git a/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts b/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts index 3ffc1b0cc..863c51d7e 100644 --- a/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts +++ b/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts @@ -4,6 +4,7 @@ import { EvmNetworks, FiatToken, getNetworkFromDestination, + isAlfredpayToken, isNetworkEVM, Networks, RampDirection, @@ -69,13 +70,13 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { // or alfredpay offramp if ( isOnramp(state) && - (inputCurrency === FiatToken.EURC || inputCurrency === FiatToken.USD) && + (inputCurrency === FiatToken.EURC || isAlfredpayToken(inputCurrency as FiatToken)) && state.to !== Networks.AssetHub ) { return false; } - if (!isOnramp(state) && outputCurrency === FiatToken.USD) { + if (!isOnramp(state) && isAlfredpayToken(outputCurrency as FiatToken)) { return false; } return true; @@ -83,10 +84,10 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { protected getRequiresPolygonEphemeralAddress(state: RampState, inputCurrency?: string, outputCurrency?: string): boolean { // Only required for Monerium and Alfredpay onramps and offramps. - if (isOnramp(state) && (inputCurrency === FiatToken.EURC || inputCurrency === FiatToken.USD)) { + if (isOnramp(state) && (inputCurrency === FiatToken.EURC || isAlfredpayToken(inputCurrency as FiatToken))) { return true; } - if (!isOnramp(state) && outputCurrency === FiatToken.USD) { + if (!isOnramp(state) && isAlfredpayToken(outputCurrency as FiatToken)) { return true; } @@ -235,7 +236,7 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { return "moonbeamToPendulumXcm"; } // alfredpay onramp case - if (isOnramp(state) && quote.inputCurrency === FiatToken.USD) { + if (isOnramp(state) && isAlfredpayToken(quote.inputCurrency as FiatToken)) { return "squidRouterSwap"; } // monerium onramp case @@ -246,7 +247,7 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { // off ramp cases if (state.type === RampDirection.SELL && state.from === Networks.AssetHub) { return "distributeFees"; - } else if (state.type === RampDirection.SELL && quote.outputCurrency === FiatToken.USD) { + } else if (state.type === RampDirection.SELL && isAlfredpayToken(quote.outputCurrency as FiatToken)) { return "finalSettlementSubsidy"; } else { return "moonbeamToPendulum"; // Via contract.subsidizePreSwap diff --git a/apps/api/src/api/services/phases/handlers/initial-phase-handler.ts b/apps/api/src/api/services/phases/handlers/initial-phase-handler.ts index a3c3c0507..e2ee9af11 100644 --- a/apps/api/src/api/services/phases/handlers/initial-phase-handler.ts +++ b/apps/api/src/api/services/phases/handlers/initial-phase-handler.ts @@ -1,4 +1,4 @@ -import { FiatToken, RampDirection, RampPhase } from "@vortexfi/shared"; +import { FiatToken, isAlfredpayToken, RampDirection, RampPhase } from "@vortexfi/shared"; import logger from "../../../../config/logger"; import { SANDBOX_ENABLED } from "../../../../constants/constants"; import QuoteTicket from "../../../../models/quoteTicket.model"; @@ -38,9 +38,9 @@ export class InitialPhaseHandler extends BasePhaseHandler { return this.transitionToNextPhase(state, "brlaOnrampMint"); } else if (state.type === RampDirection.BUY && quote.inputCurrency === FiatToken.EURC) { return this.transitionToNextPhase(state, "moneriumOnrampMint"); - } else if (state.type === RampDirection.BUY && quote.inputCurrency === FiatToken.USD) { + } else if (state.type === RampDirection.BUY && isAlfredpayToken(quote.inputCurrency as FiatToken)) { return this.transitionToNextPhase(state, "alfredpayOnrampMint"); - } else if (state.type === RampDirection.SELL && quote.outputCurrency === FiatToken.USD) { + } else if (state.type === RampDirection.SELL && isAlfredpayToken(quote.outputCurrency as FiatToken)) { return this.transitionToNextPhase(state, "squidRouterPermitExecute"); } diff --git a/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts b/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts index f4d9fdf3a..697faad8e 100644 --- a/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts +++ b/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts @@ -11,6 +11,7 @@ import { getOnChainTokenDetails, getStatus, getStatusAxelarScan, + isAlfredpayToken, Networks, nativeToDecimal, OnChainToken, @@ -376,9 +377,11 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { private async getSquidrouterStatus(swapHash: string, state: RampState, quote: QuoteTicket): Promise { try { - // Always Polygon for Monerium onramp, Moonbeam for BRL + // Always Polygon for Monerium/Alfredpay onramp, Moonbeam for BRL const fromChain = - quote.inputCurrency === FiatToken.EURC || quote.inputCurrency === FiatToken.USD ? Networks.Polygon : Networks.Moonbeam; + quote.inputCurrency === FiatToken.EURC || isAlfredpayToken(quote.inputCurrency as FiatToken) + ? Networks.Polygon + : Networks.Moonbeam; const fromChainId = getNetworkId(fromChain)?.toString(); const toChain = quote.to === Networks.AssetHub ? Networks.Moonbeam : quote.to; const toChainId = getNetworkId(toChain)?.toString(); diff --git a/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts b/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts index 0d43c57c0..be2f04038 100644 --- a/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts +++ b/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts @@ -1,9 +1,9 @@ import { EvmClientManager, - EvmToken, FiatToken, getNetworkFromDestination, getNetworkId, + isAlfredpayToken, Networks, RampDirection, RampPhase @@ -53,8 +53,13 @@ export class SquidRouterPhaseHandler extends BasePhaseHandler { return state; } - // handle special "singularity" case: Alfredpay onrapm USDC in Polygon. - if (quote.to === Networks.Polygon && quote.outputCurrency === EvmToken.USDC) { + // Alfredpay onramps mint directly to Polygon in the alfredpay token (e.g. USDT), + // so no squidRouter swap is needed — skip straight to destination transfer. + const isAlfredpayOnramp = + state.type === RampDirection.BUY && isAlfredpayToken(quote.inputCurrency as FiatToken) && !!quote.metadata.alfredpayMint; + + if (isAlfredpayOnramp) { + logger.info(`SquidRouterPhaseHandler: Skipping squidRouter for Alfredpay onramp (ramp ${state.id})`); return this.transitionToNextPhase(state, "destinationTransfer"); } diff --git a/apps/api/src/api/services/phases/handlers/squidrouter-permit-execution-handler.ts b/apps/api/src/api/services/phases/handlers/squidrouter-permit-execution-handler.ts index 4c71cf573..57455415f 100644 --- a/apps/api/src/api/services/phases/handlers/squidrouter-permit-execution-handler.ts +++ b/apps/api/src/api/services/phases/handlers/squidrouter-permit-execution-handler.ts @@ -1,13 +1,12 @@ import { EvmClientManager, + EvmNetworks, getNetworkFromDestination, isNetworkEVM, isSignedTypedDataArray, - Networks, RampPhase, SignedTypedData } from "@vortexfi/shared"; -import { recoverTypedDataAddress } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import logger from "../../../../config/logger"; import { MOONBEAM_EXECUTOR_PRIVATE_KEY } from "../../../../constants/constants"; @@ -16,7 +15,51 @@ import RampState from "../../../../models/rampState.model"; import { PhaseError } from "../../../errors/phase-error"; import { RELAYER_ADDRESS } from "../../transactions/offramp/routes/evm-to-alfredpay"; import { BasePhaseHandler } from "../base-phase-handler"; -import { StateMetadata } from "../meta-state-types"; + +type VrsSignature = { v: number; r: `0x${string}`; s: `0x${string}` }; + +const permitAbi = [ + { + inputs: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + { name: "value", type: "uint256" }, + { name: "deadline", type: "uint256" }, + { name: "v", type: "uint8" }, + { name: "r", type: "bytes32" }, + { name: "s", type: "bytes32" } + ], + name: "permit", + outputs: [], + stateMutability: "nonpayable", + type: "function" + } +] as const; + +const transferFromAbi = [ + { + inputs: [ + { name: "from", type: "address" }, + { name: "to", type: "address" }, + { name: "value", type: "uint256" } + ], + name: "transferFrom", + outputs: [{ name: "", type: "bool" }], + stateMutability: "nonpayable", + type: "function" + } +] as const; + +function extractPermitFields(permitTypedData: SignedTypedData) { + const permitMessage = permitTypedData.message; + return { + deadline: BigInt(permitMessage.deadline as string), + owner: permitMessage.owner as `0x${string}`, + spender: permitMessage.spender as `0x${string}`, + token: permitTypedData.domain.verifyingContract as `0x${string}`, + value: BigInt(permitMessage.value as string) + }; +} // Phase description: call the relayer contract's `execute` function with both the token permit and // the signed squidrouter call. @@ -32,6 +75,133 @@ export class SquidrouterPermitExecuteHandler extends BasePhaseHandler { return "squidRouterPermitExecute"; } + private getExecutorClients(fromNetwork: EvmNetworks) { + const executorAccount = privateKeyToAccount(MOONBEAM_EXECUTOR_PRIVATE_KEY as `0x${string}`); + return { + publicClient: this.evmClientManager.getClient(fromNetwork), + walletClient: this.evmClientManager.getWalletClient(fromNetwork, executorAccount) + }; + } + + private extractSignature(typedData: SignedTypedData, label: string): VrsSignature { + const sig = typedData.signature as VrsSignature | undefined; + if (!sig) { + throw this.createUnrecoverableError(`${label} signature not found`); + } + return sig; + } + + private async saveHashAndAwaitReceipt( + state: RampState, + hash: `0x${string}`, + fromNetwork: EvmNetworks, + label: string + ): Promise { + logger.info(`${label} tx sent: ${hash}`); + + const updatedState = await state.update({ + state: { ...state.state, squidRouterPermitExecutionHash: hash } + }); + + const { publicClient } = this.getExecutorClients(fromNetwork); + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + + if (!receipt || receipt.status !== "success") { + throw this.createRecoverableError(`${label} tx failed: ${hash}`); + } + + logger.info(`${label} tx confirmed: ${hash}`); + return this.transitionToNextPhase(updatedState, "fundEphemeral"); + } + + private async executeDirectTransfer( + state: RampState, + signedTypedDataArray: SignedTypedData[], + fromNetwork: EvmNetworks + ): Promise { + if (!isSignedTypedDataArray(signedTypedDataArray) || signedTypedDataArray.length !== 1) { + throw this.createUnrecoverableError("Invalid txData format for direct transfer: expected array of 1 SignedTypedData"); + } + + const [permitTypedData] = signedTypedDataArray; + const permitSig = this.extractSignature(permitTypedData, "Permit"); + const { token, owner, spender, value, deadline } = extractPermitFields(permitTypedData); + const ephemeralAddress = state.state.evmEphemeralAddress as `0x${string}`; + + const { walletClient, publicClient } = this.getExecutorClients(fromNetwork); + + const permitHash = await walletClient.writeContract({ + abi: permitAbi, + address: token, + args: [owner, spender, value, deadline, permitSig.v, permitSig.r, permitSig.s], + functionName: "permit" + }); + logger.info(`Direct transfer permit tx sent: ${permitHash}`); + + const permitReceipt = await publicClient.waitForTransactionReceipt({ hash: permitHash }); + if (!permitReceipt || permitReceipt.status !== "success") { + throw this.createRecoverableError(`Direct transfer permit tx failed: ${permitHash}`); + } + + const transferHash = await walletClient.writeContract({ + abi: transferFromAbi, + address: token, + args: [owner, ephemeralAddress, value], + functionName: "transferFrom" + }); + + return this.saveHashAndAwaitReceipt(state, transferHash, fromNetwork, "Direct transfer"); + } + + private async executeRelayerTransfer( + state: RampState, + signedTypedDataArray: SignedTypedData[], + fromNetwork: EvmNetworks + ): Promise { + if (!isSignedTypedDataArray(signedTypedDataArray) || signedTypedDataArray.length !== 2) { + throw this.createUnrecoverableError("Invalid txData format: expected array of 2 SignedTypedData objects"); + } + + const [permitTypedData, payloadTypedData] = signedTypedDataArray; + const permitSig = this.extractSignature(permitTypedData, "Permit"); + const payloadSig = this.extractSignature(payloadTypedData, "Payload"); + const { token, owner, value, deadline } = extractPermitFields(permitTypedData); + + const payloadMessage = payloadTypedData.message; + const payloadData = payloadMessage.data as `0x${string}`; + const payloadNonce = BigInt(payloadMessage.nonce as string); + const payloadDeadline = BigInt(payloadMessage.deadline as string); + + const { walletClient } = this.getExecutorClients(fromNetwork); + + const hash = await walletClient.writeContract({ + abi: tokenRelayerAbi, + address: RELAYER_ADDRESS as `0x${string}`, + args: [ + { + deadline, + owner, + payloadData, + payloadDeadline, + payloadNonce, + payloadR: payloadSig.r, + payloadS: payloadSig.s, + payloadV: payloadSig.v, + payloadValue: state.state.squidRouterPermitExecutionValue, + permitR: permitSig.r, + permitS: permitSig.s, + permitV: permitSig.v, + token, + value + } + ], + functionName: "execute", + value: BigInt(state.state.squidRouterPermitExecutionValue!) + }); + + return this.saveHashAndAwaitReceipt(state, hash, fromNetwork, "Relayer execute"); + } + protected async executePhase(state: RampState): Promise { logger.info(`Executing squidRouterPermitExecute phase for ramp ${state.id}`); @@ -71,88 +241,13 @@ export class SquidrouterPermitExecuteHandler extends BasePhaseHandler { throw this.createUnrecoverableError("Missing presigned transaction for squidRouterPermitExecute phase"); } - // For this special phase, txData is of type SignedTypedData[], where the first element is the permit typed data and the second element is the payload typed data const signedTypedDataArray = permitExecuteTransaction.txData as SignedTypedData[]; - if (!isSignedTypedDataArray(signedTypedDataArray) || signedTypedDataArray.length !== 2) { - throw this.createUnrecoverableError("Invalid txData format: expected array of 2 SignedTypedData objects"); - } - - const [permitTypedData, payloadTypedData] = signedTypedDataArray; - - const permitSignature = permitTypedData.signature; - if (!permitSignature) { - throw this.createUnrecoverableError("Permit signature not found or invalid format"); - } - const permitSig = permitSignature as { v: number; r: `0x${string}`; s: `0x${string}` }; - const { v: permitV, r: permitR, s: permitS } = permitSig; - - const payloadSignature = payloadTypedData.signature; - if (!payloadSignature) { - throw this.createUnrecoverableError("Payload signature not found or invalid format"); - } - const payloadSig = payloadSignature as { v: number; r: `0x${string}`; s: `0x${string}` }; - const { v: payloadV, r: payloadR, s: payloadS } = payloadSig; - - const permitMessage = permitTypedData.message; - const token = permitTypedData.domain.verifyingContract as `0x${string}`; - const owner = permitMessage.owner as `0x${string}`; - const value = BigInt(permitMessage.value as string); - const deadline = BigInt(permitMessage.deadline as string); - - const payloadMessage = payloadTypedData.message; - const payloadData = payloadMessage.data as `0x${string}`; - const payloadNonce = BigInt(payloadMessage.nonce as string); - const payloadDeadline = BigInt(payloadMessage.deadline as string); - - const relayerAccount = privateKeyToAccount(MOONBEAM_EXECUTOR_PRIVATE_KEY as `0x${string}`); - const walletClient = this.evmClientManager.getWalletClient(fromNetwork, relayerAccount); - - const hash = await walletClient.writeContract({ - abi: tokenRelayerAbi, - address: RELAYER_ADDRESS as `0x${string}`, - args: [ - { - deadline: deadline, - owner: owner, - payloadData: payloadData, - payloadDeadline: payloadDeadline, - payloadNonce: payloadNonce, - payloadR: payloadR, - payloadS: payloadS, - payloadV: payloadV, - payloadValue: state.state.squidRouterPermitExecutionValue, - permitR: permitR, - permitS: permitS, - permitV: permitV, - token: token, - value: value - } - ], - functionName: "execute", - value: BigInt(state.state.squidRouterPermitExecutionValue!) - }); - - logger.info(`Relayer execute transaction sent with hash: ${hash}`); - - const updatedState = await state.update({ - state: { - ...state.state, - squidRouterPermitExecutionHash: hash - } - }); - - const publicClient = this.evmClientManager.getClient(fromNetwork); - const receipt = await publicClient.waitForTransactionReceipt({ - hash: hash as `0x${string}` - }); - if (!receipt || receipt.status !== "success") { - throw this.createRecoverableError(`Relayer execute transaction failed: ${hash}`); + if (state.state.isDirectTransfer) { + return await this.executeDirectTransfer(state, signedTypedDataArray, fromNetwork); } - logger.info(`Relayer execute transaction confirmed: ${hash}`); - - return this.transitionToNextPhase(updatedState, "fundEphemeral"); + return await this.executeRelayerTransfer(state, signedTypedDataArray, fromNetwork); } catch (error) { logger.error(`Error in squidRouterPermitExecute phase for ramp ${state.id}:`, error); @@ -160,7 +255,6 @@ export class SquidrouterPermitExecuteHandler extends BasePhaseHandler { throw error; } - // Default to recoverable error const errorMessage = error instanceof Error ? error.message : "Unknown error"; throw this.createRecoverableError(`SquidrouterPermitExecuteHandler: ${errorMessage}`); } diff --git a/apps/api/src/api/services/phases/meta-state-types.ts b/apps/api/src/api/services/phases/meta-state-types.ts index 1b4b71879..87f45f78f 100644 --- a/apps/api/src/api/services/phases/meta-state-types.ts +++ b/apps/api/src/api/services/phases/meta-state-types.ts @@ -70,4 +70,5 @@ export interface StateMetadata { alfredpayOfframpTransferTxHash?: string; squidRouterPermitExecutionHash?: string; squidRouterPermitExecutionValue?: string; + isDirectTransfer?: boolean; } diff --git a/apps/api/src/api/services/priceFeed.service.ts b/apps/api/src/api/services/priceFeed.service.ts index c6b3ae996..bc36ad285 100644 --- a/apps/api/src/api/services/priceFeed.service.ts +++ b/apps/api/src/api/services/priceFeed.service.ts @@ -199,6 +199,26 @@ export class PriceFeedService { return cachedEntry.value; } + // 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. + let outputTokenPendulumDetails; + try { + outputTokenPendulumDetails = getPendulumDetails(toCurrency); + } catch { + // No Pendulum representative — fall back to CoinGecko using USDC as a USD proxy. + logger.debug(`Cache miss for ${cacheKey}. No Pendulum pool for ${toCurrency}, fetching from CoinGecko.`); + try { + const rate = await this.getCryptoPrice("usd-coin", toCurrency.toLowerCase()); + this.fiatExchangeRateCache.set(cacheKey, { expiresAt: now + this.fiatCacheTtlMs, value: rate }); + return rate; + } catch (cgError) { + if (cgError instanceof Error) { + logger.error(`Error fetching fiat exchange rate from ${fromCurrency} to ${toCurrency}: ${cgError.message}`); + } + throw cgError; + } + } + logger.debug(`Cache miss for ${cacheKey}. Fetching from Nabla.`); try { @@ -211,7 +231,6 @@ export class PriceFeedService { // We assume that the exchange rate from axlUSDC to the target currency in the Forex AMM // resemble the real fiat exchange rate. const inputTokenPendulumDetails = PENDULUM_USDC_AXL; - const outputTokenPendulumDetails = getPendulumDetails(toCurrency); // Call getTokenOutAmount to get the exchange rate const amountOut = await getTokenOutAmount({ diff --git a/apps/api/src/api/services/quote/core/helpers.ts b/apps/api/src/api/services/quote/core/helpers.ts index 7f68d00d3..abb6b96ca 100644 --- a/apps/api/src/api/services/quote/core/helpers.ts +++ b/apps/api/src/api/services/quote/core/helpers.ts @@ -23,14 +23,16 @@ export const SUPPORTED_CHAINS: { EPaymentMethod.PIX as DestinationType, EPaymentMethod.SEPA as DestinationType, EPaymentMethod.CBU as DestinationType, - EPaymentMethod.ACH as DestinationType + EPaymentMethod.ACH as DestinationType, + EPaymentMethod.SPEI as DestinationType ] }, [RampDirection.BUY]: { from: [ EPaymentMethod.PIX as DestinationType, EPaymentMethod.SEPA as DestinationType, - EPaymentMethod.ACH as DestinationType + EPaymentMethod.ACH as DestinationType, + EPaymentMethod.SPEI as DestinationType ], to: [ Networks.AssetHub, diff --git a/apps/api/src/api/services/quote/engines/fee/offramp-evm-to-alfredpay.ts b/apps/api/src/api/services/quote/engines/fee/offramp-evm-to-alfredpay.ts index e0fd6401f..271343fe1 100644 --- a/apps/api/src/api/services/quote/engines/fee/offramp-evm-to-alfredpay.ts +++ b/apps/api/src/api/services/quote/engines/fee/offramp-evm-to-alfredpay.ts @@ -1,4 +1,4 @@ -import { EvmToken, FiatToken, RampCurrency, RampDirection } from "@vortexfi/shared"; +import { ALFREDPAY_EVM_TOKEN, RampCurrency, RampDirection } from "@vortexfi/shared"; import { QuoteContext } from "../../core/types"; import { BaseFeeEngine, FeeComputation, FeeConfig } from "./index"; @@ -9,14 +9,20 @@ export class OffRampEvmToAlfredpayFeeEngine extends BaseFeeEngine { }; protected validate(ctx: QuoteContext): void { - // No specific validation needed + if (!ctx.alfredpayOfframp) { + throw new Error("OffRampEvmToAlfredpayFeeEngine requires alfredpayOfframp in context"); + } } protected async compute(ctx: QuoteContext, anchorFee: string, feeCurrency: RampCurrency): Promise { - // TODO apply fees from quote. + // biome-ignore lint/style/noNonNullAssertion: Context is validated in `validate` + const alfredpayFee = ctx.alfredpayOfframp!.fee.toString(); + // biome-ignore lint/style/noNonNullAssertion: Context is validated in `validate` + const alfredpayFeeCurrency = ctx.alfredpayOfframp!.currency as RampCurrency; + return { - anchor: { amount: "0", currency: FiatToken.USD as RampCurrency }, - network: { amount: "0", currency: EvmToken.USDC as RampCurrency } + anchor: { amount: alfredpayFee, currency: alfredpayFeeCurrency }, + network: { amount: "0", currency: ALFREDPAY_EVM_TOKEN as RampCurrency } }; } } diff --git a/apps/api/src/api/services/quote/engines/fee/onramp-alfredpay-to-evm.ts b/apps/api/src/api/services/quote/engines/fee/onramp-alfredpay-to-evm.ts index bf289ba25..3e803d394 100644 --- a/apps/api/src/api/services/quote/engines/fee/onramp-alfredpay-to-evm.ts +++ b/apps/api/src/api/services/quote/engines/fee/onramp-alfredpay-to-evm.ts @@ -1,4 +1,4 @@ -import { EvmToken, FiatToken, RampCurrency, RampDirection } from "@vortexfi/shared"; +import { ALFREDPAY_EVM_TOKEN, RampCurrency, RampDirection } from "@vortexfi/shared"; import { QuoteContext } from "../../core/types"; import { BaseFeeEngine, FeeComputation, FeeConfig } from "./index"; @@ -22,7 +22,7 @@ export class OnRampAlfredpayToEvmFeeEngine extends BaseFeeEngine { return { anchor: { amount: alfredpayFee, currency: alfredpayFeeCurrency }, - network: { amount: "0", currency: EvmToken.USDC as RampCurrency } + network: { amount: "0", currency: ALFREDPAY_EVM_TOKEN as RampCurrency } }; } } diff --git a/apps/api/src/api/services/quote/engines/finalize/index.ts b/apps/api/src/api/services/quote/engines/finalize/index.ts index b2344b26b..3c829aa09 100644 --- a/apps/api/src/api/services/quote/engines/finalize/index.ts +++ b/apps/api/src/api/services/quote/engines/finalize/index.ts @@ -17,6 +17,16 @@ export interface FinalizeComputation { decimals: number; } +function getExpirationDate(ctx: QuoteContext): Date { + if (ctx.alfredpayMint?.expirationDate) { + return ctx.alfredpayMint.expirationDate; + } + if (ctx.alfredpayOfframp?.expirationDate) { + return ctx.alfredpayOfframp.expirationDate; + } + return new Date(Date.now() + 10 * 60 * 1000); +} + export function buildQuoteResponse(quoteTicket: QuoteTicket): QuoteResponse { const usdFees = quoteTicket.metadata.fees?.usd; const fiatFees = quoteTicket.metadata.fees?.displayFiat; @@ -32,6 +42,7 @@ export function buildQuoteResponse(quoteTicket: QuoteTicket): QuoteResponse { return { anchorFeeFiat: fiatFees.anchor, anchorFeeUsd: usdFees.anchor, + createdAt: quoteTicket.createdAt, expiresAt: quoteTicket.expiresAt, feeCurrency: fiatFees.currency, from: quoteTicket.from, @@ -95,10 +106,13 @@ export abstract class BaseFinalizeEngine implements Stage { const processingFeeFiat = new Big(fiatFees.anchor).plus(fiatFees.vortex).toFixed(); const processingFeeUsd = new Big(usdFees.anchor).plus(usdFees.vortex).toFixed(); + const expiresAt = getExpirationDate(ctx); + ctx.builtResponse = { anchorFeeFiat: fiatFees.anchor, anchorFeeUsd: usdFees.anchor, - expiresAt: new Date(Date.now() + 10 * 60 * 1000), + createdAt: new Date(), + expiresAt, feeCurrency: fiatFees.currency, from: request.from, id: "temp-" + Date.now(), // Temporary ID for comparison @@ -127,10 +141,12 @@ export abstract class BaseFinalizeEngine implements Stage { } // Normal flow: persist to database + const expiresAt = getExpirationDate(ctx); + const record = await QuoteTicket.create({ apiKey: request.apiKey || null, countryCode: request.countryCode, - expiresAt: new Date(Date.now() + 10 * 60 * 1000), + expiresAt, fee: ctx.fees.displayFiat, from: request.from, inputAmount: request.inputAmount, diff --git a/apps/api/src/api/services/quote/engines/finalize/offramp.ts b/apps/api/src/api/services/quote/engines/finalize/offramp.ts index c7f93577e..4841a65d9 100644 --- a/apps/api/src/api/services/quote/engines/finalize/offramp.ts +++ b/apps/api/src/api/services/quote/engines/finalize/offramp.ts @@ -14,29 +14,37 @@ export class OffRampFinalizeEngine extends BaseFinalizeEngine { } as const; protected async computeOutput(ctx: QuoteContext): Promise { - const anchorFee = ctx.fees?.displayFiat?.anchor; - if (anchorFee === undefined) { - throw new APIError({ - message: "OffRampFinalizeEngine requires computed anchor fees", - status: httpStatus.INTERNAL_SERVER_ERROR - }); - } - - const offrampAmountBeforeAnchorFees = + const offrampAmount = ctx.request.to === "pix" ? ctx.pendulumToMoonbeamXcm?.outputAmountDecimal : ctx.alfredpayOfframp - ? ctx.alfredpayOfframp.inputAmountDecimal + ? ctx.alfredpayOfframp.outputAmountDecimal : ctx.pendulumToStellar?.outputAmountDecimal; - if (!offrampAmountBeforeAnchorFees) { + if (!offrampAmount) { throw new APIError({ message: "OffRampFinalizeEngine requires pendulumToMoonbeamXcm, alfredpayOfframp or pendulumToStellar output", status: httpStatus.INTERNAL_SERVER_ERROR }); } - const amount = new Big(offrampAmountBeforeAnchorFees).minus(anchorFee); + // AlfredPay's toAmount is already net-of-fees, so no fee subtraction needed. + // For other providers (Stellar, BRLA), the anchor fee must still be subtracted. + const isAlfredpay = !!ctx.alfredpayOfframp; + let amount: Big; + + if (isAlfredpay) { + amount = new Big(offrampAmount); + } else { + const anchorFee = ctx.fees?.displayFiat?.anchor; + if (anchorFee === undefined) { + throw new APIError({ + message: "OffRampFinalizeEngine requires computed anchor fees", + status: httpStatus.INTERNAL_SERVER_ERROR + }); + } + amount = new Big(offrampAmount).minus(anchorFee); + } return { amount, 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 418d04881..088ab8263 100644 --- a/apps/api/src/api/services/quote/engines/finalize/onramp.ts +++ b/apps/api/src/api/services/quote/engines/finalize/onramp.ts @@ -46,11 +46,18 @@ export class OnRampFinalizeEngine extends BaseFinalizeEngine { }); } finalOutputAmountDecimal = new Big(output); - } else if (request.inputCurrency === FiatToken.USD) { - const output = ctx.alfredpayMint?.outputAmountDecimal; + } else if ( + request.inputCurrency === FiatToken.USD || + request.inputCurrency === FiatToken.MXN || + request.inputCurrency === FiatToken.COP + ) { + // evmToEvm is set when Squid Router ran (e.g. USDC Polygon → USDT Arbitrum). + // When destination is USDC on Polygon, Squid Router is skipped (skipRouteCalculation) + // because Alfredpay already minted USDC there — use the mint output directly. + const output = ctx.evmToEvm?.outputAmountDecimal ?? ctx.alfredpayMint?.outputAmountDecimal; if (!output) { throw new APIError({ - message: "OnRampFinalizeEngine requires alfredpayMint output for EVM", + message: "OnRampFinalizeEngine requires evmToEvm or alfredpayMint output for EVM", status: httpStatus.INTERNAL_SERVER_ERROR }); } @@ -59,7 +66,7 @@ export class OnRampFinalizeEngine extends BaseFinalizeEngine { const output = ctx.moonbeamToEvm?.outputAmountDecimal; if (!output) { throw new APIError({ - message: "OnRampFinalizeEngine requires bridge output for EVM", + message: "OnRampFinalizeEngine requires moonbeamToEvm bridge output for EVM", status: httpStatus.INTERNAL_SERVER_ERROR }); } 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 e9e3db70c..221df1098 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 @@ -1,8 +1,8 @@ -import { EvmToken, Networks, OnChainToken, RampDirection } from "@vortexfi/shared"; +import { ALFREDPAY_EVM_TOKEN, Networks, OnChainToken, RampDirection } from "@vortexfi/shared"; import Big from "big.js"; import { EvmBridgeQuoteRequest, getEvmBridgeQuote } from "../../core/squidrouter"; import { QuoteContext } from "../../core/types"; -import { assignPreNablaContext, BaseInitializeEngine } from "./index"; +import { BaseInitializeEngine } from "./index"; export class AlfredpayOffRampFromEvmInitializeEngine extends BaseInitializeEngine { readonly config = { @@ -18,7 +18,7 @@ export class AlfredpayOffRampFromEvmInitializeEngine extends BaseInitializeEngin amountDecimal: req.inputAmount, fromNetwork: req.from as Networks, inputCurrency: req.inputCurrency as OnChainToken, - outputCurrency: EvmToken.USDC, + outputCurrency: ALFREDPAY_EVM_TOKEN, rampType: req.rampType, toNetwork: Networks.Polygon }; @@ -37,7 +37,7 @@ export class AlfredpayOffRampFromEvmInitializeEngine extends BaseInitializeEngin }; ctx.addNote?.( - `Initialized: input=${req.inputAmount} ${req.inputCurrency}, raw=${ctx.evmToPendulum?.inputAmountRaw}, output=${ctx.evmToPendulum?.outputAmountDecimal.toString()} ${ctx.evmToPendulum?.toToken}, raw=${ctx.evmToPendulum?.outputAmountRaw}` + `Initialized: input=${req.inputAmount} ${req.inputCurrency}, raw=${ctx.evmToEvm?.inputAmountRaw}, output=${ctx.evmToEvm?.outputAmountDecimal.toString()} ${ctx.evmToEvm?.toToken}, raw=${ctx.evmToEvm?.outputAmountRaw}` ); } } diff --git a/apps/api/src/api/services/quote/engines/initialize/onramp-alfredpay.ts b/apps/api/src/api/services/quote/engines/initialize/onramp-alfredpay.ts index 516c706e7..d1b20d5be 100644 --- a/apps/api/src/api/services/quote/engines/initialize/onramp-alfredpay.ts +++ b/apps/api/src/api/services/quote/engines/initialize/onramp-alfredpay.ts @@ -1,14 +1,12 @@ import { + ALFREDPAY_ERC20_DECIMALS, + ALFREDPAY_ONCHAIN_CURRENCY, AlfredpayApiService, AlfredpayChain, AlfredpayFiatCurrency, - AlfredpayOnChainCurrency, AlfredpayPaymentMethodType, CreateAlfredpayOnrampQuoteRequest, - CreateAlfredpayOnrampRequest, - ERC20_USDC_POLYGON_DECIMALS, multiplyByPowerOfTen, - Networks, RampDirection } from "@vortexfi/shared"; import Big from "big.js"; @@ -24,7 +22,7 @@ export class OnRampInitializeAlfredpayEngine extends BaseInitializeEngine { protected async executeInternal(ctx: QuoteContext): Promise { const req = ctx.request; - const usdTokenDecimals = ERC20_USDC_POLYGON_DECIMALS; + const usdTokenDecimals = ALFREDPAY_ERC20_DECIMALS; const inputAmountDecimal = new Big(req.inputAmount); const alfredpayService = AlfredpayApiService.getInstance(); @@ -38,7 +36,7 @@ export class OnRampInitializeAlfredpayEngine extends BaseInitializeEngine { customerId: req.userId || "unknown" }, // Mints hardcoded to Polygon. paymentMethodType: AlfredpayPaymentMethodType.BANK, - toCurrency: AlfredpayOnChainCurrency.USDC // Mints hardcoded to USDC, on Polygon. + toCurrency: ALFREDPAY_ONCHAIN_CURRENCY }; const quote = await alfredpayService.createOnrampQuote(quoteRequest); @@ -56,7 +54,7 @@ export class OnRampInitializeAlfredpayEngine extends BaseInitializeEngine { expirationDate: new Date(quote.expiration), fee: alfredpayFee, inputAmountDecimal: fromAmount, - inputAmountRaw: multiplyByPowerOfTen(fromAmount, usdTokenDecimals).toFixed(0, 0), + inputAmountRaw: multiplyByPowerOfTen(fromAmount, 2).toFixed(0, 0), // Fiat uses 2 decimals outputAmountDecimal: toAmount, outputAmountRaw: multiplyByPowerOfTen(toAmount, usdTokenDecimals).toFixed(0, 0), quoteId: quote.quoteId diff --git a/apps/api/src/api/services/quote/engines/partners/offramp-alfredpay.ts b/apps/api/src/api/services/quote/engines/partners/offramp-alfredpay.ts index 0e8d1d0ed..07431935f 100644 --- a/apps/api/src/api/services/quote/engines/partners/offramp-alfredpay.ts +++ b/apps/api/src/api/services/quote/engines/partners/offramp-alfredpay.ts @@ -1,11 +1,11 @@ import { + ALFREDPAY_ERC20_DECIMALS, + ALFREDPAY_ONCHAIN_CURRENCY, AlfredpayApiService, AlfredpayChain, AlfredpayFiatCurrency, - AlfredpayOnChainCurrency, AlfredpayPaymentMethodType, CreateAlfredpayOfframpQuoteRequest, - ERC20_USDC_POLYGON_DECIMALS, multiplyByPowerOfTen, RampDirection } from "@vortexfi/shared"; @@ -26,7 +26,7 @@ export class OfframpTransactionAlfredpayEngine extends BaseInitializeEngine { throw new Error("OfframpTransactionAlfredpayEngine: No evmToEvm quote"); } - const usdTokenDecimals = ERC20_USDC_POLYGON_DECIMALS; + const usdTokenDecimals = ALFREDPAY_ERC20_DECIMALS; const inputAmountDecimal = new Big(ctx.evmToEvm.outputAmountDecimal); const alfredpayService = AlfredpayApiService.getInstance(); @@ -34,7 +34,7 @@ export class OfframpTransactionAlfredpayEngine extends BaseInitializeEngine { const quoteRequest: CreateAlfredpayOfframpQuoteRequest = { chain: AlfredpayChain.MATIC, fromAmount: inputAmountDecimal.toString(), - fromCurrency: AlfredpayOnChainCurrency.USDC, // Offramp deposit is USDC + fromCurrency: ALFREDPAY_ONCHAIN_CURRENCY, metadata: { businessId: "vortex", customerId: req.userId || "unknown" @@ -48,7 +48,10 @@ export class OfframpTransactionAlfredpayEngine extends BaseInitializeEngine { const fromAmount = new Big(ctx.evmToEvm.outputAmountDecimal); const toAmount = new Big(quote.toAmount); - const alfredpayFee = Big(0); + const alfredpayFee = AlfredpayApiService.sumFeesByCurrency( + quote.fees, + req.outputCurrency as unknown as AlfredpayFiatCurrency + ); ctx.alfredpayOfframp = { currency: ctx.request.outputCurrency, diff --git a/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-evm-alfredpay.ts b/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-evm-alfredpay.ts index b26f342ef..d60c27f0f 100644 --- a/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-evm-alfredpay.ts +++ b/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-evm-alfredpay.ts @@ -1,7 +1,6 @@ import { - ERC20_USDC_POLYGON, - ERC20_USDC_POLYGON_DECIMALS, - EvmToken, + ALFREDPAY_ERC20_TOKEN, + ALFREDPAY_EVM_TOKEN, getNetworkFromDestination, Networks, OnChainToken, @@ -34,7 +33,7 @@ export class OnRampSquidRouterUsdToEvmEngine extends BaseSquidRouterEngine { } protected compute(ctx: QuoteContext): SquidRouterComputation { - if (ctx.to === Networks.Polygon && ctx.request.outputCurrency === EvmToken.USDC) { + if (ctx.to === Networks.Polygon && ctx.request.outputCurrency === ALFREDPAY_EVM_TOKEN) { return { data: { skipRouteCalculation: true @@ -52,7 +51,7 @@ export class OnRampSquidRouterUsdToEvmEngine extends BaseSquidRouterEngine { }); } - const toToken = getTokenDetailsForEvmDestination(req.outputCurrency as OnChainToken, req.to).erc20AddressSourceChain; + const toTokenDetails = getTokenDetailsForEvmDestination(req.outputCurrency as OnChainToken, req.to); // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate const alfredpayMint = ctx.alfredpayMint!; @@ -60,12 +59,12 @@ export class OnRampSquidRouterUsdToEvmEngine extends BaseSquidRouterEngine { data: { amountRaw: alfredpayMint.outputAmountRaw, fromNetwork: Networks.Polygon, - fromToken: ERC20_USDC_POLYGON, + fromToken: ALFREDPAY_ERC20_TOKEN, inputAmountDecimal: alfredpayMint.outputAmountDecimal, inputAmountRaw: alfredpayMint.outputAmountRaw, - outputDecimals: ERC20_USDC_POLYGON_DECIMALS, + outputDecimals: toTokenDetails.decimals, toNetwork, - toToken + toToken: toTokenDetails.erc20AddressSourceChain }, type: "evm-to-evm" }; diff --git a/apps/api/src/api/services/quote/index.ts b/apps/api/src/api/services/quote/index.ts index bba4852b0..74f02d060 100644 --- a/apps/api/src/api/services/quote/index.ts +++ b/apps/api/src/api/services/quote/index.ts @@ -1,4 +1,5 @@ import { + AlfredpayTradeLimitError, CreateBestQuoteRequest, CreateQuoteRequest, DestinationType, @@ -188,6 +189,17 @@ export class QuoteService extends BaseRampService { throw error; } + // Detect Alfredpay trade limit error and surface it as a user-facing limit error + if (error instanceof AlfredpayTradeLimitError) { + const isOnramp = ctx.request.rampType === RampDirection.BUY; + throw new APIError({ + message: isOnramp + ? `${QuoteError.BelowLowerLimitBuy} ${error.minQuantity} ${error.fromCurrency}` + : `${QuoteError.BelowLowerLimitSell} ${error.minQuantity} ${error.fromCurrency}`, + status: httpStatus.BAD_REQUEST + }); + } + // Wrap unexpected errors as generic failure throw new APIError({ message: QuoteError.FailedToCalculateQuote, status: httpStatus.INTERNAL_SERVER_ERROR }); } 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 9012fc271..29948ceda 100644 --- a/apps/api/src/api/services/quote/routes/route-resolver.ts +++ b/apps/api/src/api/services/quote/routes/route-resolver.ts @@ -1,7 +1,17 @@ /** * RouteResolver selects a route strategy based on direction and destination. */ -import { AssetHubToken, FiatToken, Networks, RampDirection } from "@vortexfi/shared"; +import { + AssetHubToken, + EPaymentMethod, + FiatToken, + isAlfredpayToken, + Networks, + QuoteError, + RampDirection +} from "@vortexfi/shared"; +import httpStatus from "http-status"; +import { APIError } from "../../../errors/api-error"; import type { QuoteContext } from "../core/types"; import { IRouteStrategy } from "../core/types"; import { OfframpEvmToAlfredpayStrategy } from "./strategies/offramp-evm-to-alfredpay.strategy"; @@ -13,11 +23,16 @@ import { OnrampAveniaToEvmStrategy } from "./strategies/onramp-avenia-to-evm.str import { OnrampMoneriumToAssethubStrategy } from "./strategies/onramp-monerium-to-assethub.strategy"; import { OnrampMoneriumToEvmStrategy } from "./strategies/onramp-monerium-to-evm.strategy"; +const ALFREDPAY_PAYMENT_METHODS: ReadonlySet = new Set([EPaymentMethod.ACH, EPaymentMethod.SPEI, EPaymentMethod.WIRE]); + export class RouteResolver { resolve(ctx: QuoteContext): IRouteStrategy { // Onramps if (ctx.direction === RampDirection.BUY) { if (ctx.to === Networks.AssetHub) { + if (isAlfredpayToken(ctx.request.inputCurrency as FiatToken)) { + throw new APIError({ message: QuoteError.AssetHubNotSupportedForAlfredPay, status: httpStatus.BAD_REQUEST }); + } if (ctx.from === "pix") { return new OnrampAveniaToAssethubStrategy(); } else { @@ -26,7 +41,7 @@ export class RouteResolver { } else { if (ctx.request.inputCurrency === FiatToken.EURC) { return new OnrampMoneriumToEvmStrategy(); - } else if (ctx.request.inputCurrency === FiatToken.USD) { + } else if (isAlfredpayToken(ctx.request.inputCurrency as FiatToken)) { return new OnrampAlfredpayToEvmStrategy(); } else { return new OnrampAveniaToEvmStrategy(); @@ -38,6 +53,9 @@ export class RouteResolver { // Explicitly disallow Assethub USDT and DOT if (ctx.from === Networks.AssetHub) { + if (ALFREDPAY_PAYMENT_METHODS.has(ctx.to)) { + throw new APIError({ message: QuoteError.AssetHubNotSupportedForAlfredPay, status: httpStatus.BAD_REQUEST }); + } if (ctx.request.inputCurrency === AssetHubToken.USDT) { throw new Error("Offramp from USDT on AssetHub is currently not supported"); } else if (ctx.request.inputCurrency === AssetHubToken.DOT) { @@ -48,7 +66,9 @@ export class RouteResolver { switch (ctx.to) { case "pix": return new OfframpToPixStrategy(); + case "wire": case "ach": + case "spei": return new OfframpEvmToAlfredpayStrategy(); case "sepa": case "cbu": diff --git a/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts b/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts index 503e339a8..a03037939 100644 --- a/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts +++ b/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts @@ -9,7 +9,7 @@ export class OfframpEvmToAlfredpayStrategy implements IRouteStrategy { readonly name = "OfframpEvmToAlfredpay"; getStages(_ctx: QuoteContext): StageKey[] { - return [StageKey.Initialize, StageKey.Fee, StageKey.PartnerOperation, StageKey.Finalize]; + return [StageKey.Initialize, StageKey.PartnerOperation, StageKey.Fee, StageKey.Finalize]; } getEngines(_ctx: QuoteContext): EnginesRegistry { diff --git a/apps/api/src/api/services/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index c37db9cea..67d39ca83 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -1,10 +1,10 @@ import { AccountMeta, + ALFREDPAY_ONCHAIN_CURRENCY, AlfredpayApiService, AlfredpayChain, AlfredpayFiatCurrency, AlfredpayFiatPaymentInstructions, - AlfredpayOnChainCurrency, AlfredpayPaymentMethodType, AveniaPaymentMethod, BrlaApiService, @@ -18,8 +18,11 @@ import { GetRampStatusResponse, generateReferenceLabel, IbanPaymentData, + isAlfredpayToken, + Limit, MoneriumErrors, Networks, + normalizeTaxId, QuoteError, RampDirection, RampErrorLog, @@ -46,10 +49,10 @@ import RampState from "../../../models/rampState.model"; import TaxId from "../../../models/taxId.model"; import { APIError } from "../../errors/api-error"; import { ActivePartner, handleQuoteConsumptionForDiscountState } from "../../services/quote/engines/discount/helpers"; -import { SupabaseAuthService } from "../auth/supabase.service"; import { createEpcQrCodeData, getIbanForAddress, getMoneriumUserProfile } from "../monerium"; import { StateMetadata } from "../phases/meta-state-types"; import phaseProcessor from "../phases/phase-processor"; +import { PriceFeedService } from "../priceFeed.service"; import { prepareOfframpTransactions } from "../transactions/offramp"; import { prepareOnrampTransactions } from "../transactions/onramp"; import { AveniaOnrampTransactionParams, MoneriumOnrampTransactionParams } from "../transactions/onramp/common/types"; @@ -272,13 +275,12 @@ export class RampService extends BaseRampService { ); let achPaymentData: AlfredpayFiatPaymentInstructions | undefined = undefined; - if (quote.inputCurrency === FiatToken.USD) { + if (isAlfredpayToken(quote.inputCurrency as FiatToken)) { achPaymentData = await this.processAlfredpayOnrampStart(rampState, quote, transaction); } - if (quote.outputCurrency === FiatToken.USD) { - // TODO mocking. Currently failing in sandbox. - //await this.processAlfredpayOfframpStart(rampState, quote, transaction); + if (isAlfredpayToken(quote.outputCurrency as FiatToken)) { + await this.processAlfredpayOfframpStart(rampState, quote, transaction); } // Create response @@ -487,6 +489,7 @@ export class RampService extends BaseRampService { } const response: GetRampStatusResponse = { + achPaymentData: rampState.state.fiatPaymentInstructions, anchorFeeFiat: fiatFees.anchor, anchorFeeUsd: usdFees.anchor, countryCode: quote.countryCode || undefined, @@ -654,6 +657,90 @@ export class RampService extends BaseRampService { }); } + /** + * Sum the BRL-equivalent volume of all in-progress ramps for a given taxId and direction. + */ + private async getPendingBrlVolume(taxId: string, direction: RampDirection): Promise { + const normalizedTaxId = normalizeTaxId(taxId); + + const pendingRamps = await RampState.findAll({ + include: [{ as: "quote", model: QuoteTicket }], + where: { + currentPhase: { [Op.notIn]: ["complete", "failed", "timedOut", "initial"] }, + "state.taxId": normalizedTaxId, + type: direction + } + }); + + let totalPendingBrl = new Big(0); + for (const ramp of pendingRamps) { + const quote = (ramp as RampState & { quote: QuoteTicket }).quote; + if (!quote) continue; + + const brlAmount = direction === RampDirection.BUY ? quote.inputAmount : quote.outputAmount; + totalPendingBrl = totalPendingBrl.plus(brlAmount); + } + + return totalPendingBrl; + } + + /** + * Validate the ramp amount against both per-currency (BRL) and global (*) limits, + * accounting for pending ramp volume that hasn't settled on Avenia yet. + */ + private async validateAveniaLimits( + amountBrl: string, + limits: Limit[], + direction: RampDirection, + taxId: string + ): Promise { + const pendingBrl = await this.getPendingBrlVolume(taxId, direction); + const effectiveAmountBrl = new Big(amountBrl).plus(pendingBrl); + + const brlLimits = limits.find(limit => limit.currency === BrlaCurrency.BRL); + if (!brlLimits) { + throw new APIError({ + message: "BRL limits not found.", + status: httpStatus.BAD_REQUEST + }); + } + + const brlRemaining = + direction === RampDirection.BUY + ? Number(brlLimits.maxFiatIn) - Number(brlLimits.usedLimit.usedFiatIn) + : Number(brlLimits.maxFiatOut) - Number(brlLimits.usedLimit.usedFiatOut); + + if (effectiveAmountBrl.gt(brlRemaining)) { + throw new APIError({ + message: "Amount exceeds BRL limit.", + status: httpStatus.BAD_REQUEST + }); + } + + const globalLimits = limits.find(limit => limit.currency === "*"); + if (globalLimits) { + const priceFeedService = PriceFeedService.getInstance(); + const effectiveAmountUsd = await priceFeedService.convertCurrency( + effectiveAmountBrl.toFixed(2), + FiatToken.BRL, + FiatToken.USD, + 2 + ); + + const globalRemaining = + direction === RampDirection.BUY + ? Number(globalLimits.maxFiatIn) - Number(globalLimits.usedLimit.usedFiatIn) + : Number(globalLimits.maxFiatOut) - Number(globalLimits.usedLimit.usedFiatOut); + + if (Number(effectiveAmountUsd) > globalRemaining) { + throw new APIError({ + message: "Amount exceeds global limit.", + status: httpStatus.BAD_REQUEST + }); + } + } + } + /** * BRLA. Get subaccount and validate pix and tax id. */ @@ -665,7 +752,7 @@ export class RampService extends BaseRampService { ): Promise<{ wallets: { evm: string }; brCode: string }> { const brlaApiService = BrlaApiService.getInstance(); - const taxIdRecord = await TaxId.findByPk(taxId); + const taxIdRecord = await TaxId.findByPk(normalizeTaxId(taxId)); if (!taxIdRecord) { throw new APIError({ message: "Subaccount not found", @@ -685,7 +772,7 @@ export class RampService extends BaseRampService { try { const pixKeyData = await brlaApiService.validatePixKey(pixKey); //validate the recipient's taxId with partial information - if (!validateMaskedNumber(pixKeyData.taxId, receiverTaxId)) { + if (!validateMaskedNumber(normalizeTaxId(pixKeyData.taxId), normalizeTaxId(receiverTaxId))) { throw new APIError({ message: "Invalid pixKey or receiverTaxId.", status: httpStatus.BAD_REQUEST @@ -698,20 +785,7 @@ export class RampService extends BaseRampService { }); } - const brlLimits = subaccountLimits.limitInfo.limits.find(limit => limit.currency === BrlaCurrency.BRL); - if (!brlLimits) { - throw new APIError({ - message: "BRL limits not found.", - status: httpStatus.INTERNAL_SERVER_ERROR - }); - } - - if (Number(amount) > Number(brlLimits.maxFiatOut) - Number(brlLimits.usedLimit.usedFiatOut)) { - throw new APIError({ - message: "Amount exceeds limit.", - status: httpStatus.BAD_REQUEST - }); - } + await this.validateAveniaLimits(amount, subaccountLimits.limitInfo.limits, RampDirection.SELL, taxId); const evmAddress = subAccountData?.wallets.find(w => w.chain === "EVM")?.walletAddress; @@ -736,7 +810,7 @@ export class RampService extends BaseRampService { ): Promise<{ brCode: string; aveniaTicketId: string }> { const brlaApiService = BrlaApiService.getInstance(); - const taxIdRecord = await TaxId.findByPk(taxId); + const taxIdRecord = await TaxId.findByPk(normalizeTaxId(taxId)); if (!taxIdRecord) { throw new APIError({ message: "Subaccount not found.", @@ -745,22 +819,14 @@ export class RampService extends BaseRampService { } const accountLimits = await brlaApiService.getSubaccountUsedLimit(taxIdRecord.subAccountId); - // Filter for BRL specific limits - const brlaLimits = accountLimits?.limitInfo.limits.filter(entry => entry.currency === BrlaCurrency.BRL); - if (!brlaLimits || brlaLimits.length === 0) { + if (!accountLimits) { throw new APIError({ - message: "BRL limits not found.", - status: httpStatus.BAD_REQUEST + message: "Failed to fetch subaccount limits.", + status: httpStatus.INTERNAL_SERVER_ERROR }); } - const { maxFiatIn, usedLimit } = brlaLimits[0] || {}; - if (Number(amount) > Number(maxFiatIn) - Number(usedLimit.usedFiatIn)) { - throw new APIError({ - message: "Amount exceeds KYC limits.", - status: httpStatus.BAD_REQUEST - }); - } + await this.validateAveniaLimits(amount, accountLimits.limitInfo.limits, RampDirection.BUY, taxId); const aveniaQuote = await brlaApiService.createPayInQuote({ inputAmount: String(amount), @@ -827,6 +893,7 @@ export class RampService extends BaseRampService { userId?: string ): Promise<{ unsignedTxs: UnsignedTx[]; stateMeta: Partial }> { const { unsignedTxs, stateMeta } = await prepareOfframpTransactions({ + fiatAccountId: additionalData?.fiatAccountId as string | undefined, quote, signingAccounts: normalizedSigningAccounts, stellarPaymentData: additionalData?.paymentData, @@ -1021,7 +1088,7 @@ export class RampService extends BaseRampService { } else { if (quote.inputCurrency === FiatToken.EURC) { return this.prepareMoneriumOnrampTransactions(quote, normalizedSigningAccounts, additionalData); - } else if (quote.inputCurrency === FiatToken.USD) { + } else if (isAlfredpayToken(quote.inputCurrency as FiatToken)) { return this.prepareAlfredpayOnrampTransactions(quote, normalizedSigningAccounts, additionalData, userId); } return this.prepareAveniaOnrampTransactions(quote, normalizedSigningAccounts, additionalData, signingAccounts); @@ -1040,7 +1107,7 @@ export class RampService extends BaseRampService { } private validateRampStateData(rampState: RampState, quote: QuoteTicket): void { - if (rampState.type === RampDirection.SELL && quote.outputCurrency !== FiatToken.USD) { + if (rampState.type === RampDirection.SELL && !isAlfredpayToken(quote.outputCurrency as FiatToken)) { if (rampState.from === Networks.AssetHub && !rampState.state.assethubToPendulumHash) { throw new APIError({ message: `Missing required additional data 'assethubToPendulumHash' for ${rampState.type} ramp. Cannot proceed.`, @@ -1160,10 +1227,10 @@ export class RampService extends BaseRampService { chain: AlfredpayChain.MATIC, customerId: rampState.state.alfredpayUserId, depositAddress: rampState.state.evmEphemeralAddress, - fromCurrency: AlfredpayFiatCurrency.USD, + fromCurrency: quote.inputCurrency as unknown as AlfredpayFiatCurrency, paymentMethodType: AlfredpayPaymentMethodType.BANK, quoteId: alfredpayQuoteId, - toCurrency: AlfredpayOnChainCurrency.USDC + toCurrency: ALFREDPAY_ONCHAIN_CURRENCY }; const order = await alfredpayService.createOnramp(orderRequest); @@ -1231,7 +1298,7 @@ export class RampService extends BaseRampService { chain: AlfredpayChain.MATIC, customerId: rampState.state.alfredpayUserId, fiatAccountId: rampState.state.fiatAccountId, - fromCurrency: AlfredpayOnChainCurrency.USDC, + fromCurrency: ALFREDPAY_ONCHAIN_CURRENCY, originAddress: rampState.state.walletAddress, quoteId: alfredpayQuoteId, toCurrency: quote.outputCurrency as unknown as AlfredpayFiatCurrency diff --git a/apps/api/src/api/services/transactions/offramp/common/types.ts b/apps/api/src/api/services/transactions/offramp/common/types.ts index c97f216b3..f1c1a9f57 100644 --- a/apps/api/src/api/services/transactions/offramp/common/types.ts +++ b/apps/api/src/api/services/transactions/offramp/common/types.ts @@ -12,6 +12,7 @@ export interface OfframpTransactionParams { brlaEvmAddress?: string; moneriumAuthToken?: string; userId?: string; + fiatAccountId?: string; } export interface OfframpTransactionsWithMeta { diff --git a/apps/api/src/api/services/transactions/offramp/common/validation.ts b/apps/api/src/api/services/transactions/offramp/common/validation.ts index 67a1b8af9..edc3a136c 100644 --- a/apps/api/src/api/services/transactions/offramp/common/validation.ts +++ b/apps/api/src/api/services/transactions/offramp/common/validation.ts @@ -7,6 +7,7 @@ import { isFiatToken, isOnChainToken, isStellarTokenDetails, + normalizeTaxId, PaymentData, StellarTokenDetails } from "@vortexfi/shared"; @@ -95,7 +96,7 @@ export function validateBRLOfframp( offrampAmountBeforeAnchorFeesRaw: quote.metadata.pendulumToMoonbeamXcm.outputAmountRaw, pixDestination, receiverTaxId, - taxId + taxId: normalizeTaxId(taxId) }; } diff --git a/apps/api/src/api/services/transactions/offramp/index.ts b/apps/api/src/api/services/transactions/offramp/index.ts index 4793d7b6f..4edc1d6bf 100644 --- a/apps/api/src/api/services/transactions/offramp/index.ts +++ b/apps/api/src/api/services/transactions/offramp/index.ts @@ -2,6 +2,7 @@ import { FiatToken, getNetworkFromDestination, getOnChainTokenDetails, + isAlfredpayToken, isEvmTokenDetails, OnChainToken } from "@vortexfi/shared"; @@ -32,8 +33,8 @@ export async function prepareOfframpTransactions(params: OfframpTransactionParam } else if (quote.outputCurrency === FiatToken.EURC && params.moneriumAuthToken) { // Monerium EVM offramp return prepareEvmToMoneriumEvmOfframpTransactions(params); - } else if (quote.outputCurrency === FiatToken.USD) { - // Alfredpay offramp + } else if (isAlfredpayToken(quote.outputCurrency as FiatToken)) { + // Alfredpay offramp (USD, MXN, COP) return prepareEvmToAlfredpayOfframpTransactions(params); } else { // Stellar offramp diff --git a/apps/api/src/api/services/transactions/offramp/routes/evm-to-alfredpay.ts b/apps/api/src/api/services/transactions/offramp/routes/evm-to-alfredpay.ts index 23cc936e7..12797b6f7 100644 --- a/apps/api/src/api/services/transactions/offramp/routes/evm-to-alfredpay.ts +++ b/apps/api/src/api/services/transactions/offramp/routes/evm-to-alfredpay.ts @@ -1,11 +1,16 @@ import { + ALFREDPAY_ERC20_TOKEN, + ALFREDPAY_ONCHAIN_CURRENCY, + AlfredPayCountry, AlfredPayStatus, + AlfredpayApiService, + AlfredpayChain, + AlfredpayFiatCurrency, createOfframpSquidrouterTransactionsToEvm, - ERC20_USDC_POLYGON, EvmClientManager, EvmNetworks, EvmTokenDetails, - EvmTransactionData, + FiatToken, getNetworkFromDestination, getNetworkId, getOnChainTokenDetails, @@ -13,15 +18,15 @@ import { isNetworkEVM, Networks, SignedTypedData, - SQUDROUTER_MAIN_CONTRACT_POLYGON, TypedDataDomain, UnsignedTx } from "@vortexfi/shared"; import Big from "big.js"; import { encodeAbiParameters, keccak256, PublicClient, pad, parseAbiParameters, toHex } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { MOONBEAM_EXECUTOR_PRIVATE_KEY } from "../../../../../constants/constants"; import AlfredPayCustomer from "../../../../../models/alfredPayCustomer.model"; import { StateMetadata } from "../../../phases/meta-state-types"; -import { encodeEvmTransactionData } from "../../index"; import { addOnrampDestinationChainTransactions } from "../../onramp/common/transactions"; import { OfframpTransactionParams, OfframpTransactionsWithMeta } from "../common/types"; @@ -123,6 +128,7 @@ const erc20Abi = [ * This route handles: EVM → Polygon (USDC) → Alfredpay (Fiat) */ export async function prepareEvmToAlfredpayOfframpTransactions({ + fiatAccountId, quote, signingAccounts, userAddress, @@ -160,8 +166,18 @@ export async function prepareEvmToAlfredpayOfframpTransactions({ throw new Error(`Unsupported source network ${fromNetwork} for EVM to Alfredpay type offramp`); } + const fiatToCountry: Partial> = { + [FiatToken.USD]: AlfredPayCountry.US, + [FiatToken.MXN]: AlfredPayCountry.MX, + [FiatToken.COP]: AlfredPayCountry.CO + }; + const customerCountry = fiatToCountry[quote.outputCurrency as FiatToken]; + if (!customerCountry) { + throw new Error(`Unsupported Alfredpay output currency: ${quote.outputCurrency}`); + } + const customer = await AlfredPayCustomer.findOne({ - where: { userId } + where: { country: customerCountry, userId } }); if (!customer) { @@ -172,134 +188,212 @@ export async function prepareEvmToAlfredpayOfframpTransactions({ throw new Error(`Alfredpay customer status is ${customer.status}, expected Success. Proceed first with KYC.`); } - const inputAmountRaw = new Big(quote.inputAmount).mul(new Big(10).pow(inputTokenDetails.decimals)).toFixed(0, 0); + if (!fiatAccountId) { + throw new Error("fiatAccountId is required for Alfredpay offramp"); + } - const bridgeResult = await createOfframpSquidrouterTransactionsToEvm({ - destinationAddress: evmEphemeralEntry.address, - fromAddress: userAddress, - fromNetwork, - fromToken: (inputTokenDetails as EvmTokenDetails).erc20AddressSourceChain, - rawAmount: inputAmountRaw, - toNetwork: Networks.Polygon, - toToken: ERC20_USDC_POLYGON + const alfredpayQuoteId = quote.metadata.alfredpayOfframp?.quoteId; + if (!alfredpayQuoteId) { + throw new Error("Missing alfredpayOfframp.quoteId in quote metadata"); + } + + const alfredpayService = AlfredpayApiService.getInstance(); + const offrampOrder = await alfredpayService.createOfframp({ + amount: quote.metadata.alfredpayOfframp.inputAmountDecimal.toString(), + chain: AlfredpayChain.MATIC, + customerId: customer.alfredPayId, + fiatAccountId, + fromCurrency: ALFREDPAY_ONCHAIN_CURRENCY, + originAddress: evmEphemeralEntry.address, + quoteId: alfredpayQuoteId, + toCurrency: quote.outputCurrency as unknown as AlfredpayFiatCurrency }); - const permitDeadline = BigInt(Math.floor(Date.now() / 1000) + 24 * 60 * 60); // 24 hours from "now" + const inputAmountRaw = new Big(quote.inputAmount).mul(new Big(10).pow(inputTokenDetails.decimals)).toFixed(0, 0); + const inputTokenAddress = (inputTokenDetails as EvmTokenDetails).erc20AddressSourceChain; + + const isDirectPolygonTransfer = + fromNetwork === Networks.Polygon && inputTokenAddress.toLowerCase() === ALFREDPAY_ERC20_TOKEN.toLowerCase(); + const permitDeadline = BigInt(Math.floor(Date.now() / 1000) + 24 * 60 * 60); const publicClient = evmClientManager.getClient(fromNetwork); const userNonce = (await publicClient.readContract({ abi: erc20Abi, - address: (inputTokenDetails as EvmTokenDetails).erc20AddressSourceChain, + address: inputTokenAddress, args: [userAddress], functionName: "nonces" })) as bigint; const tokenName = (await publicClient.readContract({ abi: erc20Abi, - address: (inputTokenDetails as EvmTokenDetails).erc20AddressSourceChain, + address: inputTokenAddress, functionName: "name" })) as string; const chainId = getNetworkId(fromNetwork)!; - const resolvedDomain = await resolvePermitDomain( - publicClient, - (inputTokenDetails as EvmTokenDetails).erc20AddressSourceChain, - chainId, - tokenName - ); - - const permitTypedData: SignedTypedData = { - domain: resolvedDomain, - message: { - deadline: permitDeadline.toString(), - nonce: userNonce.toString(), - owner: userAddress, - spender: RELAYER_ADDRESS, - value: inputAmountRaw.toString() - }, - primaryType: "Permit", - types: { - Permit: [ - { name: "owner", type: "address" }, - { name: "spender", type: "address" }, - { name: "value", type: "uint256" }, - { name: "nonce", type: "uint256" }, - { name: "deadline", type: "uint256" } - ] - } - }; - - // Create payload typed data for the relayer - const payloadNonce = BigInt(Math.floor(Date.now() / 1000)); // Use timestamp as nonce - const payloadDeadline = BigInt(Math.floor(Date.now() / 1000) + 3600); - - const payloadTypedData: SignedTypedData = { - domain: { - chainId: getNetworkId(fromNetwork)!, - name: "TokenRelayer", - verifyingContract: RELAYER_ADDRESS, - version: "1" - }, - message: { - data: bridgeResult.swapData.data, - deadline: payloadDeadline.toString(), - destination: bridgeResult.swapData.to, - ethValue: bridgeResult.swapData.value, - nonce: payloadNonce.toString(), - owner: userAddress, - token: (inputTokenDetails as EvmTokenDetails).erc20AddressSourceChain, - value: inputAmountRaw.toString() - }, - primaryType: "Payload", - types: { - Payload: [ - { name: "destination", type: "address" }, - { name: "owner", type: "address" }, - { name: "token", type: "address" }, - { name: "value", type: "uint256" }, - { name: "data", type: "bytes" }, - { name: "ethValue", type: "uint256" }, - { name: "nonce", type: "uint256" }, - { name: "deadline", type: "uint256" } - ] - } - }; + const resolvedDomain = await resolvePermitDomain(publicClient, inputTokenAddress, chainId, tokenName); + + if (isDirectPolygonTransfer) { + // Source is already Polygon USDT — user permits the executor to transferFrom directly. + // The executor has gas; the ephemeral is not yet funded at the squidRouterPermitExecute phase. + const executorAccount = privateKeyToAccount(MOONBEAM_EXECUTOR_PRIVATE_KEY as `0x${string}`); + const permitTypedData: SignedTypedData = { + domain: resolvedDomain, + message: { + deadline: permitDeadline.toString(), + nonce: userNonce.toString(), + owner: userAddress, + spender: executorAccount.address, + value: inputAmountRaw.toString() + }, + primaryType: "Permit", + types: { + Permit: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + { name: "value", type: "uint256" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" } + ] + } + }; + + unsignedTxs.push({ + meta: {}, + network: fromNetwork, + nonce: 0, + phase: "squidRouterPermitExecute", + signer: userAddress, + txData: [permitTypedData] + }); + + stateMeta = { + ...stateMeta, + alfredpayTransactionId: offrampOrder.transactionId, + alfredpayUserId: customer.alfredPayId, + evmEphemeralAddress: evmEphemeralEntry.address, + fiatAccountId, + isDirectTransfer: true, + walletAddress: userAddress + }; + } else { + const bridgeResult = await createOfframpSquidrouterTransactionsToEvm({ + destinationAddress: evmEphemeralEntry.address, + fromAddress: userAddress, + fromNetwork, + fromToken: inputTokenAddress, + rawAmount: inputAmountRaw, + toNetwork: Networks.Polygon, + toToken: ALFREDPAY_ERC20_TOKEN + }); + + const permitTypedData: SignedTypedData = { + domain: resolvedDomain, + message: { + deadline: permitDeadline.toString(), + nonce: userNonce.toString(), + owner: userAddress, + spender: RELAYER_ADDRESS, + value: inputAmountRaw.toString() + }, + primaryType: "Permit", + types: { + Permit: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + { name: "value", type: "uint256" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" } + ] + } + }; + + const payloadNonce = BigInt(Math.floor(Date.now() / 1000)); + const payloadDeadline = BigInt(Math.floor(Date.now() / 1000) + 3600); + + const payloadTypedData: SignedTypedData = { + domain: { + chainId: getNetworkId(fromNetwork)!, + name: "TokenRelayer", + verifyingContract: RELAYER_ADDRESS, + version: "1" + }, + message: { + data: bridgeResult.swapData.data, + deadline: payloadDeadline.toString(), + destination: bridgeResult.swapData.to, + ethValue: bridgeResult.swapData.value, + nonce: payloadNonce.toString(), + owner: userAddress, + token: inputTokenAddress, + value: inputAmountRaw.toString() + }, + primaryType: "Payload", + types: { + Payload: [ + { name: "destination", type: "address" }, + { name: "owner", type: "address" }, + { name: "token", type: "address" }, + { name: "value", type: "uint256" }, + { name: "data", type: "bytes" }, + { name: "ethValue", type: "uint256" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" } + ] + } + }; + + unsignedTxs.push({ + meta: {}, + network: fromNetwork, + nonce: 0, + phase: "squidRouterPermitExecute", + signer: userAddress, + txData: [permitTypedData, payloadTypedData] + }); + + stateMeta = { + ...stateMeta, + alfredpayTransactionId: offrampOrder.transactionId, + alfredpayUserId: customer.alfredPayId, + evmEphemeralAddress: evmEphemeralEntry.address, + fiatAccountId, + squidRouterPermitExecutionValue: bridgeResult.swapData.value, + walletAddress: userAddress + }; + } - // Bundle both signatures into a single transaction - const typedDataArray: SignedTypedData[] = [permitTypedData, payloadTypedData]; + const finalTransferTxData = await addOnrampDestinationChainTransactions({ + amountRaw: quote.metadata.alfredpayOfframp.inputAmountRaw, + destinationNetwork: Networks.Polygon as EvmNetworks, + toAddress: offrampOrder.depositAddress as `0x${string}`, + toToken: ALFREDPAY_ERC20_TOKEN + }); unsignedTxs.push({ meta: {}, - network: fromNetwork, + network: Networks.Polygon, nonce: 0, - phase: "squidRouterPermitExecute", - signer: userAddress, - txData: typedDataArray + phase: "alfredpayOfframpTransfer", + signer: evmEphemeralEntry.address, + txData: finalTransferTxData }); - stateMeta = { - ...stateMeta, - alfredpayUserId: customer.alfredPayId, - evmEphemeralAddress: evmEphemeralEntry.address, - squidRouterPermitExecutionValue: bridgeResult.swapData.value, - walletAddress: userAddress - }; - - const finalTransferTxData = await addOnrampDestinationChainTransactions({ + const fallbackTransferTxData = await addOnrampDestinationChainTransactions({ amountRaw: quote.metadata.alfredpayOfframp.inputAmountRaw, destinationNetwork: Networks.Polygon as EvmNetworks, - toAddress: "0x7Ba99e99Bc669B3508AFf9CC0A898E869459F877", // TODO placeholder - toToken: ERC20_USDC_POLYGON + toAddress: userAddress, + toToken: ALFREDPAY_ERC20_TOKEN }); unsignedTxs.push({ meta: {}, network: Networks.Polygon, - nonce: 0, - phase: "alfredpayOfframpTransfer", + nonce: 0, // Also use nonce 0 to ensure transaction is available immediately + phase: "alfredpayOfframpTransferFallback", signer: evmEphemeralEntry.address, - txData: finalTransferTxData + txData: fallbackTransferTxData }); return { stateMeta, unsignedTxs }; diff --git a/apps/api/src/api/services/transactions/onramp/index.ts b/apps/api/src/api/services/transactions/onramp/index.ts index 4227e3184..d2f99e229 100644 --- a/apps/api/src/api/services/transactions/onramp/index.ts +++ b/apps/api/src/api/services/transactions/onramp/index.ts @@ -1,4 +1,4 @@ -import { FiatToken, Networks } from "@vortexfi/shared"; +import { FiatToken, isAlfredpayToken, Networks } from "@vortexfi/shared"; import { AlfredpayOnrampTransactionParams, AveniaOnrampTransactionParams, @@ -44,7 +44,7 @@ export async function prepareOnrampTransactions( } else { return prepareMoneriumToEvmOnrampTransactions(params); } - } else if (quote.inputCurrency === FiatToken.USD) { + } else if (isAlfredpayToken(quote.inputCurrency as FiatToken)) { if (!("userId" in params)) { throw new Error("Alfredpay onramps requires logged in user"); } 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 3eac4077d..eb6618730 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 @@ -1,13 +1,15 @@ import { + ALFREDPAY_ERC20_TOKEN, + AlfredPayCountry, AlfredPayStatus, createOnrampSquidrouterTransactionsFromPolygonToEvm, createOnrampSquidrouterTransactionsOnDestinationChain, - ERC20_USDC_POLYGON, EvmNetworks, EvmToken, EvmTokenDetails, EvmTransactionData, evmTokenConfig, + FiatToken, getNetworkFromDestination, getOnChainTokenDetails, getOnChainTokenDetailsOrDefault, @@ -42,7 +44,7 @@ export async function prepareAlfredpayToEvmOnrampTransactions({ throw new Error("EVM ephemeral entry not found"); } - if (!quote.metadata.alfredpayMint?.outputAmountRaw) { + if (quote.metadata.alfredpayMint?.outputAmountRaw === undefined) { throw new Error("Missing alfredpay raw mint amount in quote metadata"); } @@ -60,8 +62,18 @@ export async function prepareAlfredpayToEvmOnrampTransactions({ throw new Error(`Output token details not found for ${quote.outputCurrency} on network ${toNetwork}`); } + const fiatToCountry: Partial> = { + [FiatToken.USD]: AlfredPayCountry.US, + [FiatToken.MXN]: AlfredPayCountry.MX, + [FiatToken.COP]: AlfredPayCountry.CO + }; + const customerCountry = fiatToCountry[quote.inputCurrency as FiatToken]; + if (!customerCountry) { + throw new Error(`Unsupported Alfredpay input currency: ${quote.inputCurrency}`); + } + const customer = await AlfredPayCustomer.findOne({ - where: { userId } + where: { country: customerCountry, userId } }); if (!customer) { @@ -81,8 +93,8 @@ export async function prepareAlfredpayToEvmOnrampTransactions({ let polygonAccountNonce = 0; // Starts fresh - // Special case, onramping USDC on Polygon. We need to skip the SquidRouter step and go directly to the destination transfer. - if ((outputTokenDetails as EvmTokenDetails).erc20AddressSourceChain === ERC20_USDC_POLYGON) { + // Special case: onramping the AlfredPay token directly on Polygon. Skip SquidRouter and transfer directly. + if ((outputTokenDetails as EvmTokenDetails).erc20AddressSourceChain === ALFREDPAY_ERC20_TOKEN) { const finalTransferTxData = await addOnrampDestinationChainTransactions({ amountRaw: quote.metadata.alfredpayMint.outputAmountRaw, destinationNetwork: toNetwork as EvmNetworks, @@ -109,7 +121,7 @@ export async function prepareAlfredpayToEvmOnrampTransactions({ await createOnrampSquidrouterTransactionsFromPolygonToEvm({ destinationAddress: evmEphemeralEntry.address, fromAddress: evmEphemeralEntry.address, - fromToken: ERC20_USDC_POLYGON, + fromToken: ALFREDPAY_ERC20_TOKEN, rawAmount: quote.metadata.alfredpayMint.outputAmountRaw, toNetwork, toToken: (outputTokenDetails as EvmTokenDetails).erc20AddressSourceChain @@ -208,6 +220,22 @@ export async function prepareAlfredpayToEvmOnrampTransactions({ txData: backupApproveTransaction }); + const alfredMintFallbackTransferTxData = await addOnrampDestinationChainTransactions({ + amountRaw: quote.metadata.alfredpayMint.outputAmountRaw, + destinationNetwork: Networks.Polygon as EvmNetworks, + toAddress: destinationAddress, + toToken: ALFREDPAY_ERC20_TOKEN + }); + + unsignedTxs.push({ + meta: {}, + network: Networks.Polygon, + nonce: polygonAccountNonce++, + phase: "alfredOnrampMintFallback", + signer: evmEphemeralEntry.address, + txData: encodeEvmTransactionData(alfredMintFallbackTransferTxData) as EvmTransactionData + }); + stateMeta = { ...stateMeta, squidRouterQuoteId, diff --git a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-assethub.ts b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-assethub.ts index dda965aea..9eb9ccd50 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-assethub.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-assethub.ts @@ -6,6 +6,7 @@ import { getPendulumDetails, isAssetHubTokenDetails, Networks, + normalizeTaxId, UnsignedTx } from "@vortexfi/shared"; import { StateMetadata } from "../../../phases/meta-state-types"; @@ -44,7 +45,7 @@ export async function prepareAveniaToAssethubOnrampTransactions({ destinationAddress, evmEphemeralAddress: evmEphemeralEntry.address, substrateEphemeralAddress: substrateEphemeralEntry.address, - taxId + taxId: normalizeTaxId(taxId) }; // Moonbeam: Initial BRLA transfer to Pendulum diff --git a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts index c90f0824b..6165fea3c 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts @@ -16,6 +16,7 @@ import { isNativeEvmToken, multiplyByPowerOfTen, Networks, + normalizeTaxId, UnsignedTx } from "@vortexfi/shared"; import { privateKeyToAccount } from "viem/accounts"; @@ -62,7 +63,7 @@ export async function prepareAveniaToEvmOnrampTransactions({ destinationAddress, evmEphemeralAddress: evmEphemeralEntry.address, substrateEphemeralAddress: substrateEphemeralEntry.address, - taxId + taxId: normalizeTaxId(taxId) }; let moonbeamNonce = 0; diff --git a/apps/api/src/api/workers/unhandled-payment.worker.ts b/apps/api/src/api/workers/unhandled-payment.worker.ts index 8571d83b2..d9e681f38 100644 --- a/apps/api/src/api/workers/unhandled-payment.worker.ts +++ b/apps/api/src/api/workers/unhandled-payment.worker.ts @@ -1,4 +1,4 @@ -import { BrlaApiService, generateReferenceLabel } from "@vortexfi/shared"; +import { BrlaApiService, generateReferenceLabel, normalizeTaxId } from "@vortexfi/shared"; import { CronJob } from "cron"; import { Op } from "sequelize"; import logger from "../../config/logger"; @@ -151,7 +151,7 @@ class UnhandledPaymentWorker { for (const taxId in statesByTaxId) { try { - const taxIdRecord = await TaxId.findOne({ where: { taxId } }); + const taxIdRecord = await TaxId.findOne({ where: { taxId: normalizeTaxId(taxId) } }); if (!taxIdRecord) { logger.warn(`No TaxId record found for taxId: ${taxId}. Skipping states.`); statesByTaxId[taxId].forEach(state => this.processedStateIds.add(state.id)); diff --git a/apps/api/src/config/express.ts b/apps/api/src/config/express.ts index 3c27ff5e4..f224d352a 100644 --- a/apps/api/src/config/express.ts +++ b/apps/api/src/config/express.ts @@ -31,7 +31,7 @@ app.use( origin: [ "https://app.vortexfinance.co", "https://metrics.vortexfinance.co", - "https://staging--pendulum-pay.netlify.app", + "https://staging--vortexfi.netlify.app", process.env.NODE_ENV === "development" ? "http://localhost:5173" : null, process.env.NODE_ENV === "development" ? "http://localhost:6006" : null ].filter(Boolean) as string[] diff --git a/apps/api/src/config/logger.ts b/apps/api/src/config/logger.ts index 635265fec..b4bce39ac 100644 --- a/apps/api/src/config/logger.ts +++ b/apps/api/src/config/logger.ts @@ -10,7 +10,7 @@ const customFormat = winston.format.printf(({ timestamp, level, message, label = }); const logger = winston.createLogger({ - level: "info", + level: process.env.LOG_LEVEL || "info", transports: [ new winston.transports.File({ filename: "error.log", diff --git a/apps/api/src/database/migrations/025-normalize-tax-ids.ts b/apps/api/src/database/migrations/025-normalize-tax-ids.ts new file mode 100644 index 000000000..e4a19da03 --- /dev/null +++ b/apps/api/src/database/migrations/025-normalize-tax-ids.ts @@ -0,0 +1,37 @@ +import { QueryInterface } from "sequelize"; + +export async function up(queryInterface: QueryInterface): Promise { + // Normalizes all tax_id values to digits-only (e.g. "758.444.017-77" → "75844401777"). + // + // If both a formatted and unformatted entry exist for the same digits, a naive UPDATE would + // hit a primary key collision. To handle this: + // 1. Partition rows by their normalized digits and rank them — prefer the row with a + // non-empty sub_account_id, then most recent updated_at. + // 2. Delete all lower-ranked duplicates. + // 3. UPDATE the surviving rows to digits-only (now collision-free). + await queryInterface.sequelize.query(` + WITH normalized AS ( + SELECT + ctid, + "tax_id", + regexp_replace("tax_id", '[^0-9]', '', 'g') AS digits, + ROW_NUMBER() OVER ( + PARTITION BY regexp_replace("tax_id", '[^0-9]', '', 'g') + ORDER BY + CASE WHEN "sub_account_id" IS NOT NULL AND "sub_account_id" <> '' THEN 0 ELSE 1 END, + "updated_at" DESC + ) AS rn + FROM "tax_ids" + ) + DELETE FROM "tax_ids" + WHERE ctid IN (SELECT ctid FROM normalized WHERE rn > 1); + + UPDATE "tax_ids" + SET "tax_id" = regexp_replace("tax_id", '[^0-9]', '', 'g') + WHERE "tax_id" ~ '[^0-9]'; + `); +} + +export async function down(_queryInterface: QueryInterface): Promise { + // Irreversible: original formatting cannot be reconstructed from digits-only values. +} diff --git a/apps/api/src/models/taxId.model.ts b/apps/api/src/models/taxId.model.ts index 0735710e8..73d92686a 100644 --- a/apps/api/src/models/taxId.model.ts +++ b/apps/api/src/models/taxId.model.ts @@ -1,4 +1,4 @@ -import { AveniaAccountType } from "@vortexfi/shared"; +import { AveniaAccountType, normalizeTaxId } from "@vortexfi/shared"; import { DataTypes, Model, Optional } from "sequelize"; import sequelize from "../config/database"; @@ -143,6 +143,16 @@ TaxId.init( } }, { + hooks: { + beforeCreate: instance => { + instance.taxId = normalizeTaxId(instance.taxId); + }, + beforeUpdate: instance => { + if (instance.changed("taxId")) { + instance.taxId = normalizeTaxId(instance.taxId); + } + } + }, indexes: [ { fields: ["sub_account_id"], diff --git a/apps/frontend/App.css b/apps/frontend/App.css index b81ed9dff..ab040cb6c 100644 --- a/apps/frontend/App.css +++ b/apps/frontend/App.css @@ -1,23 +1,24 @@ @import "tailwindcss"; @import "tw-animate-css"; -@custom-variant dark (&:is(.dark *)); @plugin "daisyui"; :root { + color-scheme: only light; + /* DaisyUI theme colors */ --color-primary: oklch(0.45 0.2 260); --color-primary-content: oklch(1 0 0); --color-secondary: oklch(0.97 0.003 260); - --color-secondary-content: oklch(0.5 0.04 250); - --color-accent: oklch(0.55 0.22 350); + --color-secondary-content: oklch(0.4 0.04 250); + --color-accent: oklch(0.55 0.2 350); --color-accent-content: oklch(0 0 0); --color-neutral: oklch(0.96 0.005 250); - --color-neutral-content: oklch(0.5 0.04 250); + --color-neutral-content: oklch(0.4 0.04 250); --color-base-100: oklch(0.98 0.005 210); --color-base-200: oklch(1 0 0); --color-base-300: oklch(0.92 0.008 250); - --color-base-content: oklch(0.5 0.04 250); + --color-base-content: oklch(0.4 0.04 250); /* Semantic state colors */ --color-warning: oklch(0.77 0.16 83); @@ -44,6 +45,22 @@ --rounded-btn: 9px; --btn-text-case: none; + /* Brand / third-party colors */ + --color-telegram: oklch(0.659 0.147 222); + --color-w3m-accent: oklch(0.26 0.07 260); + + /* Progress / chart colors */ + --color-progress-track: oklch(0.928 0.007 264); + --color-progress-fill: oklch(0.623 0.214 259); + --color-progress-gradient-start: oklch(0.707 0.165 254); + + /* Surface / overlay colors */ + --color-dot-overlay: oklch(0 0 0 / 0.05); + --color-input-disabled: oklch(0.928 0.007 264); + --color-shadow: oklch(0 0 0 / 0.2); + --color-shadow-border: oklch(0 0 0 / 0.08); + --color-shadow-drop: oklch(0 0 0 / 0.05); + /* Base radius for components */ --radius: 0.625rem; @@ -52,6 +69,20 @@ --widget-min-height: 620px; } +@media (color-gamut: p3) { + :root { + --color-accent: oklch(0.55 0.22 350); + } +} + +@variant hov { + @media (hover: hover) and (pointer: fine) { + &:hover { + @slot; + } + } +} + @layer base { html, body { @@ -78,7 +109,7 @@ .input-disabled { cursor: not-allowed; - color: rgb(229 231 235 / var(--tw-text-opacity)); + color: var(--color-input-disabled); border-color: var(--fallback-b2, oklch(var(--b2) / var(--tw-border-opacity))); background-color: var(--fallback-b2, oklch(var(--b2) / var(--tw-bg-opacity))); } @@ -235,7 +266,11 @@ } .btn-vortex-secondary { - @apply text-white bg-pink-600 rounded-[var(--radius-field)] border-pink-600 shadow-none; + @apply text-white; + @apply bg-accent; + @apply rounded-[var(--radius-field)]; + @apply border-accent; + @apply shadow-none; transition: scale 0.1s ease-in-out; } @@ -245,7 +280,7 @@ .btn-vortex-secondary:hover { background-color: var(--color-secondary-hover); - color: white; + color: var(--color-primary-content); border-color: var(--color-secondary-hover); } @@ -316,7 +351,13 @@ } .shadow-custom { - box-shadow: 0 0 10px rgba(0, 0, 0, 0.2); + box-shadow: 0 0 10px var(--color-shadow); + } + + .shadow-card { + box-shadow: + 0 0 0 1px var(--color-shadow-border), + 0 4px 12px var(--color-shadow-drop); } .no-scrollbar::-webkit-scrollbar { @@ -340,7 +381,7 @@ transition-duration: 0.5s; transition-timing-function: ease-out; transition-property: transform; - background-color: #24a1de; + background-color: var(--color-telegram); } .fadein-button-animation:hover::before { @@ -348,7 +389,7 @@ } .border-telegram { - border-color: #24a1de; + border-color: var(--color-telegram); } .animate-appear { diff --git a/apps/frontend/index.html b/apps/frontend/index.html index 7538c5a9e..0ab58518c 100644 --- a/apps/frontend/index.html +++ b/apps/frontend/index.html @@ -2,11 +2,11 @@ - - - - - + + + + + Vortex diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 45bc0c956..6b3930466 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -2,7 +2,7 @@ "dependencies": { "@fontsource/roboto": "^5.0.8", "@heroicons/react": "^2.1.3", - "@hookform/resolvers": "^3.4.2", + "@hookform/resolvers": "^4.1.3", "@monerium/sdk": "^3.4.2", "@pendulum-chain/api": "catalog:", "@pendulum-chain/api-solang": "catalog:", @@ -43,7 +43,7 @@ "@walletconnect/universal-provider": "^2.21.10", "@walletconnect/utils": "catalog:", "@xstate/react": "^6.0.0", - "axios": "catalog:", + "big.js": "catalog:", "bn.js": "^5.2.1", "buffer": "^6.0.3", @@ -72,8 +72,7 @@ "wagmi": "catalog:", "web3": "^4.16.0", "xstate": "^5.20.1", - "yup": "^1.4.0", - "zod": "3", + "zod": "^4.3.6", "zustand": "^5.0.2" }, "devDependencies": { diff --git a/apps/frontend/android-chrome-192x192.png b/apps/frontend/public/android-chrome-192x192.png similarity index 100% rename from apps/frontend/android-chrome-192x192.png rename to apps/frontend/public/android-chrome-192x192.png diff --git a/apps/frontend/android-chrome-512x512.png b/apps/frontend/public/android-chrome-512x512.png similarity index 100% rename from apps/frontend/android-chrome-512x512.png rename to apps/frontend/public/android-chrome-512x512.png diff --git a/apps/frontend/apple-touch-icon.png b/apps/frontend/public/apple-touch-icon.png similarity index 100% rename from apps/frontend/apple-touch-icon.png rename to apps/frontend/public/apple-touch-icon.png diff --git a/apps/frontend/favicon-16x16.png b/apps/frontend/public/favicon-16x16.png similarity index 100% rename from apps/frontend/favicon-16x16.png rename to apps/frontend/public/favicon-16x16.png diff --git a/apps/frontend/favicon-32x32.png b/apps/frontend/public/favicon-32x32.png similarity index 100% rename from apps/frontend/favicon-32x32.png rename to apps/frontend/public/favicon-32x32.png diff --git a/apps/frontend/favicon.ico b/apps/frontend/public/favicon.ico similarity index 100% rename from apps/frontend/favicon.ico rename to apps/frontend/public/favicon.ico diff --git a/apps/frontend/site.webmanifest b/apps/frontend/public/site.webmanifest similarity index 72% rename from apps/frontend/site.webmanifest rename to apps/frontend/public/site.webmanifest index 085af57de..0e55f1192 100644 --- a/apps/frontend/site.webmanifest +++ b/apps/frontend/public/site.webmanifest @@ -4,12 +4,12 @@ "icons": [ { "sizes": "192x192", - "src": "/frontend/android-chrome-192x192.png", + "src": "/android-chrome-192x192.png", "type": "image/png" }, { "sizes": "512x512", - "src": "/frontend/android-chrome-512x512.png", + "src": "/android-chrome-512x512.png", "type": "image/png" } ], diff --git a/apps/frontend/src/assets/business-check-business-success.svg b/apps/frontend/src/assets/business-check-business-success.svg new file mode 100644 index 000000000..9cdd15d95 --- /dev/null +++ b/apps/frontend/src/assets/business-check-business-success.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/frontend/src/assets/business-check-representative-success.svg b/apps/frontend/src/assets/business-check-representative-success.svg new file mode 100644 index 000000000..72554cf26 --- /dev/null +++ b/apps/frontend/src/assets/business-check-representative-success.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/frontend/src/assets/document_ready_success.svg b/apps/frontend/src/assets/document_ready_success.svg new file mode 100644 index 000000000..2e82f64ef --- /dev/null +++ b/apps/frontend/src/assets/document_ready_success.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/apps/frontend/src/assets/liveness-check-success.svg b/apps/frontend/src/assets/liveness-check-success.svg new file mode 100644 index 000000000..cdf5310b3 --- /dev/null +++ b/apps/frontend/src/assets/liveness-check-success.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/frontend/src/assets/liveness-check.svg b/apps/frontend/src/assets/liveness-check.svg index 03789489e..f6dd719c7 100644 --- a/apps/frontend/src/assets/liveness-check.svg +++ b/apps/frontend/src/assets/liveness-check.svg @@ -12,9 +12,9 @@ + transform="translate(736 161.332)" fill="#0049c1"/> + transform="translate(736 161.332)" fill="#0049c1"/> + transform="matrix(0.914, -0.407, 0.407, 0.914, 356.801, 457.531)" fill="#0049c1"/> + transform="matrix(0.914, -0.407, 0.407, 0.914, 1026.098, 714.082)" fill="#0049c1"/> diff --git a/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx b/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx index 9b43d8150..3b12570ce 100644 --- a/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx +++ b/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx @@ -1,12 +1,18 @@ import { useCallback } from "react"; import { useAlfredpayKycActor, useAlfredpayKycSelector } from "../../contexts/rampState"; +import { ColKycFormScreen } from "./ColKycFormScreen"; import { CustomerDefinitionScreen } from "./CustomerDefinitionScreen"; import { DoneScreen } from "./DoneScreen"; import { FailureKycScreen } from "./FailureKycScreen"; import { FailureScreen } from "./FailureScreen"; import { FillingScreen } from "./FillingScreen"; +import { KybBusinessDocsScreen } from "./KybBusinessDocsScreen"; +import { KybFormScreen } from "./KybFormScreen"; +import { KybPersonDocsScreen } from "./KybPersonDocsScreen"; import { LinkReadyScreen } from "./LinkReadyScreen"; import { LoadingScreen } from "./LoadingScreen"; +import { MxnDocumentUploadScreen } from "./MxnDocumentUploadScreen"; +import { MxnKycFormScreen } from "./MxnKycFormScreen"; import { OpeningLinkScreen } from "./OpeningLinkScreen"; import { PollingScreen } from "./PollingScreen"; @@ -23,34 +29,100 @@ export const AlfredpayKycFlow = () => { const userCancel = useCallback(() => actor?.send({ type: "USER_CANCEL" }), [actor]); const retryProcess = useCallback(() => actor?.send({ type: "RETRY_PROCESS" }), [actor]); const cancelProcess = useCallback(() => actor?.send({ type: "CANCEL_PROCESS" }), [actor]); + const submitForm = useCallback( + (data: import("../../machines/alfredpayKyc.machine").MxnKycFormData) => actor?.send({ data, type: "SUBMIT_FORM" }), + [actor] + ); + const submitFiles = useCallback( + (files: import("../../machines/alfredpayKyc.machine").MxnKycFiles) => actor?.send({ files, type: "SUBMIT_FILES" }), + [actor] + ); + const submitKybForm = useCallback( + (data: import("../../machines/alfredpayKyc.machine").KybFormData) => actor?.send({ data, type: "SUBMIT_KYB_FORM" }), + [actor] + ); + const submitKybBusinessFiles = useCallback( + (files: import("../../machines/alfredpayKyc.machine").KybBusinessFiles) => + actor?.send({ files, type: "SUBMIT_KYB_BUSINESS_FILES" }), + [actor] + ); + const submitKybPersonFiles = useCallback( + (files: import("../../machines/alfredpayKyc.machine").KybPersonFiles) => + actor?.send({ files, type: "SUBMIT_KYB_PERSON_FILES" }), + [actor] + ); + const goBack = useCallback(() => actor?.send({ type: "GO_BACK" }), [actor]); if (!actor || !state) return null; const { stateValue, context } = state; const kycOrKyb = context.business ? "KYB" : "KYC"; + const isMxn = context.country === "MX"; + const isCo = context.country === "CO"; if ( stateValue === "CheckingStatus" || stateValue === "CreatingCustomer" || stateValue === "GettingKycLink" || - stateValue === "Retrying" + stateValue === "Retrying" || + stateValue === "SubmittingKycInfo" || + stateValue === "SubmittingFiles" || + stateValue === "SendingSubmission" || + stateValue === "SubmittingKybInfo" || + stateValue === "SubmittingKybBusinessFiles" || + stateValue === "SubmittingKybPersonFiles" || + stateValue === "SendingKybSubmission" ) { return ; } + if (stateValue === "FillingKycForm" && isMxn) { + return ; + } + + if (stateValue === "FillingKycForm" && isCo) { + return ; + } + + if (stateValue === "UploadingDocuments" && (isMxn || isCo)) { + return ; + } + + if (stateValue === "FillingKybForm") { + return ; + } + + if (stateValue === "UploadingKybBusinessDocs") { + return ; + } + + if (stateValue === "UploadingKybPersonDocs") { + const totalPersons = context.kybRelatedPersonIds?.length ?? 1; + const currentIndex = context.kybRelatedPersonIndex ?? 0; + return ( + + ); + } + if (stateValue === "PollingStatus") { return ; } - if (stateValue === "LinkReady") { + // USD-only screens + if (stateValue === "LinkReady" && !isMxn) { return ; } - if (stateValue === "OpeningLink") { + if (stateValue === "OpeningLink" && !isMxn) { return ; } - if (stateValue === "FillingKyc" || stateValue === "FinishingFilling") { + if ((stateValue === "FillingKyc" || stateValue === "FinishingFilling") && !isMxn) { return ( { return ; } - if (stateValue === "CostumerDefinition") { + if (stateValue === "CustomerDefinition") { return ( { + if (data.typeDocumentCol === AlfredpayColombiaDocumentType.CC && data.dni.length !== 10) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "CC must be exactly 10 digits", path: ["dni"] }); + } + }); + +type ColKycFormValues = z.infer; + +interface ColKycFormScreenProps { + onSubmit: (data: MxnKycFormData) => void; +} + +export function ColKycFormScreen({ onSubmit }: ColKycFormScreenProps) { + const { t } = useTranslation(); + + const { + formState: { errors }, + handleSubmit, + register, + watch + } = useForm({ resolver: zodResolver(schema) }); + + const documentType = watch("typeDocumentCol"); + + 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.colKycForm.title")}

+

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

+ +
+
+
+ + + {errors.firstName && {errors.firstName.message}} +
+ +
+ + + {errors.lastName && {errors.lastName.message}} +
+
+ +
+ + + {errors.dateOfBirth && {errors.dateOfBirth.message}} +
+ +
+ + + {errors.typeDocumentCol && {errors.typeDocumentCol.message}} +
+ +
+ + + {errors.dni && {errors.dni.message}} +
+ +
+ + + {errors.phoneNumber && {errors.phoneNumber.message}} +
+ +
+ + + {errors.address && {errors.address.message}} +
+ +
+
+ + + {errors.city && {errors.city.message}} +
+ +
+ + + {errors.state && {errors.state.message}} +
+
+ +
+ + + {errors.zipCode && {errors.zipCode.message}} +
+ + +
+
+ ); +} diff --git a/apps/frontend/src/components/Alfredpay/DoneScreen.tsx b/apps/frontend/src/components/Alfredpay/DoneScreen.tsx index 510ff7703..272873d9a 100644 --- a/apps/frontend/src/components/Alfredpay/DoneScreen.tsx +++ b/apps/frontend/src/components/Alfredpay/DoneScreen.tsx @@ -15,7 +15,7 @@ export const DoneScreen = memo(({ kycOrKyb, onContinue }: DoneScreenProps) => { return (
- Business Handshake + Document verified

{t("components.alfredpayKycFlow.completed", { kycOrKyb })}

{t("components.alfredpayKycFlow.accountVerified")}

{onContinue && ( diff --git a/apps/frontend/src/components/Alfredpay/FillingScreen.tsx b/apps/frontend/src/components/Alfredpay/FillingScreen.tsx index 89d1368c5..facdfca70 100644 --- a/apps/frontend/src/components/Alfredpay/FillingScreen.tsx +++ b/apps/frontend/src/components/Alfredpay/FillingScreen.tsx @@ -1,7 +1,9 @@ +import { CheckCircleIcon } from "@heroicons/react/24/solid"; import { memo } from "react"; import { useTranslation } from "react-i18next"; -import documentReady from "../../assets/document_ready.svg"; +import documentReadySuccess from "../../assets/document_ready_success.svg"; import { MenuButtons } from "../MenuButtons"; +import { SparkleButton } from "../SparkleButton"; import { Spinner } from "../Spinner"; import { StepFooter } from "../StepFooter"; @@ -18,24 +20,26 @@ export const FillingScreen = memo(({ kycOrKyb, isSubmitting, onCompletedFilling, return (
- Business Handshake -

{t("components.alfredpayKycFlow.completeInNewWindow", { kycOrKyb })}

+ Document ready +

{t("components.alfredpayKycFlow.completeInNewWindow", { kycOrKyb })}

{onOpenLink && ( )} - + {isSubmitting ? ( + + ) : ( + } + label={t("components.alfredpayKycFlow.finishedVerification", { kycOrKyb })} + onClick={onCompletedFilling} + /> + )}
); diff --git a/apps/frontend/src/components/Alfredpay/KybBusinessDocsScreen.tsx b/apps/frontend/src/components/Alfredpay/KybBusinessDocsScreen.tsx new file mode 100644 index 000000000..1b6307cc8 --- /dev/null +++ b/apps/frontend/src/components/Alfredpay/KybBusinessDocsScreen.tsx @@ -0,0 +1,122 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { KybBusinessFiles } from "../../machines/alfredpayKyc.machine"; +import { MenuButtons } from "../MenuButtons"; + +const MAX_FILE_SIZE = 5 * 1024 * 1024; +const ACCEPTED_TYPES = ["image/jpeg", "image/png", "application/pdf"]; + +function FileDropZone({ + fieldKey, + label, + file, + onChange +}: { + fieldKey: string; + label: string; + file: File | null; + onChange: (file: File) => void; +}) { + const { t } = useTranslation(); + const [error, setError] = useState(null); + + const handleFile = (f: File) => { + setError(null); + if (!ACCEPTED_TYPES.includes(f.type)) { + setError(t("components.mxnDocumentUpload.invalidType")); + return; + } + if (f.size > MAX_FILE_SIZE) { + setError(t("components.mxnDocumentUpload.fileTooLarge")); + return; + } + onChange(f); + }; + + return ( +
+

{label}

+ + {error &&

{error}

} +
+ ); +} + +interface KybBusinessDocsScreenProps { + onBack: () => void; + onSubmit: (files: KybBusinessFiles) => void; +} + +export function KybBusinessDocsScreen({ onBack, onSubmit }: KybBusinessDocsScreenProps) { + const { t } = useTranslation(); + const [articlesIncorporation, setArticlesIncorporation] = useState(null); + const [proofAddress, setProofAddress] = useState(null); + const [shareholderRegistry, setShareholderRegistry] = useState(null); + + const isValid = articlesIncorporation !== null && proofAddress !== null && shareholderRegistry !== null; + + const handleSubmit = () => { + if (!articlesIncorporation || !proofAddress || !shareholderRegistry) return; + onSubmit({ articlesIncorporation, proofAddress, shareholderRegistry }); + }; + + return ( +
+ +

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

+

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

+ +
+ + + + +

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

+ + + +
+
+ ); +} diff --git a/apps/frontend/src/components/Alfredpay/KybFormScreen.tsx b/apps/frontend/src/components/Alfredpay/KybFormScreen.tsx new file mode 100644 index 000000000..fe0224d90 --- /dev/null +++ b/apps/frontend/src/components/Alfredpay/KybFormScreen.tsx @@ -0,0 +1,257 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { z } from "zod"; +import type { KybFormData } from "../../machines/alfredpayKyc.machine"; +import { MenuButtons } from "../MenuButtons"; + +const schema = z.object({ + address: z.string().min(1), + businessName: z.string().min(1), + city: z.string().min(1), + repDateOfBirth: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Use YYYY-MM-DD format"), + repDni: z.string().optional(), + repEmail: z.string().email(), + repFirstName: z.string().min(1), + repLastName: z.string().min(1), + repNationality: z.string().length(2, "Enter a 2-letter country code"), + state: z.string().min(1), + taxId: z.string().min(1), + website: z.string().url("Enter a valid URL").optional().or(z.literal("")), + zipCode: z.string().min(1) +}); + +type KybFormFields = z.infer; + +interface KybFormScreenProps { + onSubmit: (data: KybFormData) => void; +} + +export function KybFormScreen({ onSubmit }: KybFormScreenProps) { + const { t } = useTranslation(); + + const { + formState: { errors }, + handleSubmit, + register + } = useForm({ resolver: zodResolver(schema) }); + + const inputClass = (hasError: boolean) => + `input-vortex-primary input-ghost w-full rounded-lg border p-2 text-base ${hasError ? "border-error" : "border-neutral-300"}`; + + const handleFormSubmit = (fields: KybFormFields) => { + onSubmit({ + address: fields.address, + businessName: fields.businessName, + city: fields.city, + relatedPersons: [ + { + dateOfBirth: fields.repDateOfBirth, + dni: fields.repDni || undefined, + email: fields.repEmail, + firstName: fields.repFirstName, + lastName: fields.repLastName, + nationalities: [fields.repNationality] + } + ], + state: fields.state, + taxId: fields.taxId, + website: fields.website || undefined, + zipCode: fields.zipCode + }); + }; + + return ( +
+ +

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

+

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

+ +
+
+ + + {errors.businessName && {errors.businessName.message}} +
+ +
+ + + {errors.taxId && {errors.taxId.message}} +
+ +
+ + + {errors.website && {errors.website.message}} +
+ +
+ + + {errors.address && {errors.address.message}} +
+ +
+
+ + + {errors.city && {errors.city.message}} +
+
+ + + {errors.state && {errors.state.message}} +
+
+ +
+ + + {errors.zipCode && {errors.zipCode.message}} +
+ +

+ {t("components.kybForm.representativeTitle")} +

+ +
+
+ + + {errors.repFirstName && {errors.repFirstName.message}} +
+
+ + + {errors.repLastName && {errors.repLastName.message}} +
+
+ +
+ + + {errors.repEmail && {errors.repEmail.message}} +
+ +
+ + + {errors.repDateOfBirth && {errors.repDateOfBirth.message}} +
+ +
+
+ + v.toUpperCase() })} + /> + {errors.repNationality && {errors.repNationality.message}} +
+
+ + + {errors.repDni && {errors.repDni.message}} +
+
+ + +
+
+ ); +} diff --git a/apps/frontend/src/components/Alfredpay/KybPersonDocsScreen.tsx b/apps/frontend/src/components/Alfredpay/KybPersonDocsScreen.tsx new file mode 100644 index 000000000..55acdcd6f --- /dev/null +++ b/apps/frontend/src/components/Alfredpay/KybPersonDocsScreen.tsx @@ -0,0 +1,109 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { KybPersonFiles } from "../../machines/alfredpayKyc.machine"; +import { MenuButtons } from "../MenuButtons"; + +const MAX_FILE_SIZE = 5 * 1024 * 1024; +const ACCEPTED_TYPES = ["image/jpeg", "image/png", "application/pdf"]; + +function FileDropZone({ + fieldKey, + label, + file, + onChange +}: { + fieldKey: string; + label: string; + file: File | null; + onChange: (file: File) => void; +}) { + const { t } = useTranslation(); + const [error, setError] = useState(null); + + const handleFile = (f: File) => { + setError(null); + if (!ACCEPTED_TYPES.includes(f.type)) { + setError(t("components.mxnDocumentUpload.invalidType")); + return; + } + if (f.size > MAX_FILE_SIZE) { + setError(t("components.mxnDocumentUpload.fileTooLarge")); + return; + } + onChange(f); + }; + + return ( +
+

{label}

+ + {error &&

{error}

} +
+ ); +} + +interface KybPersonDocsScreenProps { + currentIndex: number; + onBack: () => void; + onSubmit: (files: KybPersonFiles) => void; + totalPersons: number; +} + +export function KybPersonDocsScreen({ currentIndex, totalPersons, onBack, onSubmit }: KybPersonDocsScreenProps) { + const { t } = useTranslation(); + const [front, setFront] = useState(null); + const [back, setBack] = useState(null); + + const isValid = front !== null && back !== null; + + const handleSubmit = () => { + if (!front || !back) return; + onSubmit({ back, front }); + }; + + return ( +
+ +

+ {t("components.kybPersonDocs.title", { current: currentIndex + 1, total: totalPersons })} +

+

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

+ +
+ + + +

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

+ + + +
+
+ ); +} diff --git a/apps/frontend/src/components/Alfredpay/MxnDocumentUploadScreen.tsx b/apps/frontend/src/components/Alfredpay/MxnDocumentUploadScreen.tsx new file mode 100644 index 000000000..a16646d10 --- /dev/null +++ b/apps/frontend/src/components/Alfredpay/MxnDocumentUploadScreen.tsx @@ -0,0 +1,92 @@ +import { useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { MxnKycFiles } from "../../machines/alfredpayKyc.machine"; +import { MenuButtons } from "../MenuButtons"; + +const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB +const ACCEPTED_TYPES = ["image/jpeg", "image/png", "application/pdf"]; + +interface MxnDocumentUploadScreenProps { + onSubmit: (files: MxnKycFiles) => void; +} + +function FileDropZone({ label, file, onChange }: { label: string; file: File | null; onChange: (file: File) => void }) { + const { t } = useTranslation(); + const inputRef = useRef(null); + const [error, setError] = useState(null); + + const handleFile = (f: File) => { + setError(null); + if (!ACCEPTED_TYPES.includes(f.type)) { + setError(t("components.mxnDocumentUpload.invalidType")); + return; + } + if (f.size > MAX_FILE_SIZE) { + setError(t("components.mxnDocumentUpload.fileTooLarge")); + return; + } + onChange(f); + }; + + return ( +
+

{label}

+ + {error &&

{error}

} +
+ ); +} + +export function MxnDocumentUploadScreen({ onSubmit }: MxnDocumentUploadScreenProps) { + const { t } = useTranslation(); + const [front, setFront] = useState(null); + const [back, setBack] = useState(null); + + const isValid = front !== null && back !== null; + + const handleSubmit = () => { + if (!front || !back) return; + onSubmit({ back, front }); + }; + + return ( +
+ +

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

+

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

+ +
+ + + +

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

+ + +
+
+ ); +} diff --git a/apps/frontend/src/components/Alfredpay/MxnKycFormScreen.tsx b/apps/frontend/src/components/Alfredpay/MxnKycFormScreen.tsx new file mode 100644 index 000000000..b1ba119f7 --- /dev/null +++ b/apps/frontend/src/components/Alfredpay/MxnKycFormScreen.tsx @@ -0,0 +1,199 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +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), + 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), + state: z.string().min(1), + typeDocument: z.enum(["INE", "Resident card", "passport"]), + zipCode: z.string().min(1) +}); + +interface MxnKycFormScreenProps { + onSubmit: (data: MxnKycFormData) => void; +} + +export function MxnKycFormScreen({ onSubmit }: MxnKycFormScreenProps) { + const { t } = useTranslation(); + + const { + formState: { errors }, + handleSubmit, + register + } = useForm({ resolver: zodResolver(schema) }); + + 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.mxnKycForm.title")}

+

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

+ +
+
+
+ + + {errors.firstName && {errors.firstName.message}} +
+ +
+ + + {errors.lastName && {errors.lastName.message}} +
+
+ +
+ + + {errors.dateOfBirth && {errors.dateOfBirth.message}} +
+ +
+ + + {errors.email && {errors.email.message}} +
+ +
+ + + {errors.typeDocument && {errors.typeDocument.message}} +
+ +
+ + + {errors.dni && {errors.dni.message}} +
+ +
+ + + {errors.address && {errors.address.message}} +
+ +
+
+ + + {errors.city && {errors.city.message}} +
+ +
+ + + {errors.state && {errors.state.message}} +
+
+ +
+ + + {errors.zipCode && {errors.zipCode.message}} +
+ + +
+
+ ); +} diff --git a/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerifyCompany.tsx b/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerifyCompany.tsx index 7320451e9..6a2b1d5a2 100644 --- a/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerifyCompany.tsx +++ b/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerifyCompany.tsx @@ -1,4 +1,5 @@ import BusinessCheck from "../../../assets/business-check-business.svg"; +import BusinessCheckSuccess from "../../../assets/business-check-business-success.svg"; import { useAveniaKycActor, useAveniaKycSelector } from "../../../contexts/rampState"; import { AveniaKYBVerifyStep } from "./AveniaKYBVerifyStep"; @@ -15,6 +16,7 @@ export const AveniaKYBVerifyCompany = () => { return ( aveniaKycActor.send({ type: "GO_BACK" })} onVerificationDone={() => aveniaKycActor.send({ type: "KYB_COMPANY_DONE" })} diff --git a/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerifyCompanyRepresentative.tsx b/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerifyCompanyRepresentative.tsx index ee276e448..46a5eea25 100644 --- a/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerifyCompanyRepresentative.tsx +++ b/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerifyCompanyRepresentative.tsx @@ -1,4 +1,5 @@ import BusinessCheckRepresentative from "../../../assets/business-check-representative.svg"; +import BusinessCheckRepresentativeSuccess from "../../../assets/business-check-representative-success.svg"; import { useAveniaKycActor, useAveniaKycSelector } from "../../../contexts/rampState"; import { AveniaKYBVerifyStep } from "./AveniaKYBVerifyStep"; @@ -15,6 +16,7 @@ export const AveniaKYBVerifyCompanyRepresentative = () => { return ( aveniaKycActor.send({ type: "KYB_COMPANY_BACK" })} diff --git a/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerifyStep.tsx b/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerifyStep.tsx index a5be09f5f..9bcd458fd 100644 --- a/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerifyStep.tsx +++ b/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerifyStep.tsx @@ -1,11 +1,13 @@ -import { ArrowTopRightOnSquareIcon } from "@heroicons/react/24/solid"; +import { ArrowTopRightOnSquareIcon, ShieldCheckIcon } from "@heroicons/react/24/solid"; import { Trans, useTranslation } from "react-i18next"; -import { useQuote } from "../../../stores/quote/useQuoteStore"; +import { cn } from "../../../helpers/cn"; +import { SparkleButton } from "../../SparkleButton"; import { StepFooter } from "../../StepFooter"; interface AveniaKYBVerifyStepProps { titleKey: string; imageSrc: string; + imageSrcVerified?: string; verificationUrl: string; isVerificationStarted: boolean; onCancel: () => void; @@ -19,6 +21,7 @@ interface AveniaKYBVerifyStepProps { export const AveniaKYBVerifyStep = ({ titleKey, imageSrc, + imageSrcVerified, verificationUrl, isVerificationStarted, onCancel, @@ -35,12 +38,19 @@ export const AveniaKYBVerifyStep = ({
-

{t(titleKey)}

+

+ {t(titleKey)} +

Business Check {!isVerificationStarted && ( @@ -79,15 +89,17 @@ export const AveniaKYBVerifyStep = ({
-
- {isVerificationStarted ? ( - + } + label={t("components.aveniaKYB.buttons.iHaveVerified")} + onClick={onVerificationDone} + /> ) : ( { if (!aveniaState) return null; - if (aveniaState.context.kybStep === "company") { - return ; - } else if (aveniaState.context.kybStep === "representative") { - return ; + let content; + if (aveniaState.context.kybStep === "representative") { + content = ; } else if (aveniaState.context.kybStep === "verification") { - return ; + content = ; + } else { + content = ; } - return ; + return ( +
+ + {content} +
+ ); }; diff --git a/apps/frontend/src/components/Avenia/AveniaKYBForm.tsx b/apps/frontend/src/components/Avenia/AveniaKYBForm.tsx index e40a3118e..ed90ea3a4 100644 --- a/apps/frontend/src/components/Avenia/AveniaKYBForm.tsx +++ b/apps/frontend/src/components/Avenia/AveniaKYBForm.tsx @@ -1,28 +1,22 @@ -import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { useAveniaKycActor, useAveniaKycSelector } from "../../contexts/rampState"; -import { useKYCForm } from "../../hooks/brla/useKYCForm"; -import { QuoteSummary } from "../QuoteSummary"; +import { useKYBForm } from "../../hooks/brla/useKYBForm"; +import { MenuButtons } from "../MenuButtons"; import { AveniaFieldProps, ExtendedAveniaFieldOptions } from "./AveniaField"; import { AveniaVerificationForm } from "./AveniaVerificationForm"; -/** - * AveniaKYBForm - A simplified KYC form for companies (CNPJ) - * Only collects the company name - */ export const AveniaKYBForm = () => { const aveniaKycActor = useAveniaKycActor(); const aveniaState = useAveniaKycSelector(); const { t } = useTranslation(); - const { kycForm } = useKYCForm({ cpfApiError: null, initialData: aveniaState?.context.kycFormData }); - - useEffect(() => { - if (aveniaState?.context.taxId) { - kycForm.setValue(ExtendedAveniaFieldOptions.TAX_ID, aveniaState.context.taxId); + const { kybForm } = useKYBForm({ + initialData: { + fullName: aveniaState?.context.kycFormData?.fullName, + taxId: aveniaState?.context.taxId } - }, [aveniaState?.context.taxId, kycForm]); + }); if (!aveniaState) return null; if (!aveniaKycActor) return null; @@ -41,7 +35,7 @@ export const AveniaKYBForm = () => { }, { id: ExtendedAveniaFieldOptions.TAX_ID, - index: 2, + index: 1, label: "CNPJ", placeholder: "", readOnly: true, @@ -52,10 +46,15 @@ export const AveniaKYBForm = () => { return (
-
- -
- + + { + aveniaKycActor.send({ formData: data, type: "FORM_SUBMIT" }); + }} + />
); }; diff --git a/apps/frontend/src/components/Avenia/AveniaKYCForm.tsx b/apps/frontend/src/components/Avenia/AveniaKYCForm.tsx index 4d02236d7..76544f1df 100644 --- a/apps/frontend/src/components/Avenia/AveniaKYCForm.tsx +++ b/apps/frontend/src/components/Avenia/AveniaKYCForm.tsx @@ -2,20 +2,63 @@ import { isValidCnpj } from "@vortexfi/shared"; import { useTranslation } from "react-i18next"; import { useAveniaKycActor, useAveniaKycSelector } from "../../contexts/rampState"; import { useKYCForm } from "../../hooks/brla/useKYCForm"; -import { QuoteSummary } from "../QuoteSummary"; -import { StepBackButton } from "../StepBackButton"; +import { AveniaKycActorRef, SelectedAveniaData } from "../../machines/types"; +import { MenuButtons } from "../MenuButtons"; import { AveniaLivenessStep } from "../widget-steps/AveniaLivenessStep"; import { AveniaFieldProps, ExtendedAveniaFieldOptions } from "./AveniaField"; import { AveniaVerificationForm } from "./AveniaVerificationForm"; import { DocumentUpload } from "./DocumentUpload"; import { VerificationStatus } from "./VerificationStatus"; +interface AveniaKYCContentProps { + aveniaKycActor: AveniaKycActorRef; + aveniaState: SelectedAveniaData; + fields: AveniaFieldProps[]; +} + +const AveniaKYCFormStep = ({ aveniaKycActor, aveniaState, fields }: AveniaKYCContentProps) => { + const { kycForm } = useKYCForm({ cpfApiError: null, initialData: aveniaState.context.kycFormData }); + return ( + { + aveniaKycActor.send({ formData: data, type: "FORM_SUBMIT" }); + }} + /> + ); +}; + +const AveniaKYCContent = ({ aveniaKycActor, aveniaState, fields }: AveniaKYCContentProps) => { + const { stateValue } = aveniaState; + + if ( + stateValue === "Verifying" || + stateValue === "Submit" || + stateValue === "Success" || + stateValue === "Rejected" || + stateValue === "Failure" + ) { + return ; + } + + if (stateValue === "DocumentUpload") { + return ; + } + + if (stateValue === "LivenessCheck" || stateValue === "RefreshingLivenessUrl") { + return ; + } + + return ; +}; + export const AveniaKYCForm = () => { const aveniaKycActor = useAveniaKycActor(); const aveniaState = useAveniaKycSelector(); const { t } = useTranslation(); - const { kycForm } = useKYCForm({ cpfApiError: null, initialData: aveniaState?.context.kycFormData }); if (!aveniaState) return null; if (!aveniaKycActor) return null; @@ -23,7 +66,7 @@ export const AveniaKYCForm = () => { return null; } - const pixformFields: AveniaFieldProps[] = [ + const fields: AveniaFieldProps[] = [ { id: ExtendedAveniaFieldOptions.FULL_NAME, index: 0, @@ -99,7 +142,7 @@ export const AveniaKYCForm = () => { ]; if (isValidCnpj(aveniaState.context.taxId)) { - pixformFields.push({ + fields.push({ id: ExtendedAveniaFieldOptions.COMPANY_NAME, index: 10, label: t("components.brlaExtendedForm.form.companyName"), @@ -107,14 +150,14 @@ export const AveniaKYCForm = () => { required: true, type: "text" }); - pixformFields.push({ + fields.push({ id: ExtendedAveniaFieldOptions.START_DATE, index: 11, label: t("components.brlaExtendedForm.form.startDate"), required: true, type: "date" }); - pixformFields.push({ + fields.push({ id: ExtendedAveniaFieldOptions.PARTNER_CPF, index: 12, label: t("components.brlaExtendedForm.form.partnerCpf"), @@ -123,36 +166,10 @@ export const AveniaKYCForm = () => { }); } - let content; - if ( - aveniaState.stateValue === "Verifying" || - aveniaState.stateValue === "Submit" || - aveniaState.stateValue === "Success" || - aveniaState.stateValue === "Rejected" || - aveniaState.stateValue === "Failure" - ) { - content = ; - } else if (aveniaState.stateValue === "DocumentUpload") { - content = ; - } else if (aveniaState.stateValue === "LivenessCheck" || aveniaState.stateValue === "RefreshingLivenessUrl") { - content = ; - } else { - content = ( - - ); - } - return (
-
-
-
- -
- {content} -
-
- + +
); }; diff --git a/apps/frontend/src/components/Avenia/AveniaVerificationForm/index.tsx b/apps/frontend/src/components/Avenia/AveniaVerificationForm/index.tsx index 3402ae8ef..3e2fd1ded 100644 --- a/apps/frontend/src/components/Avenia/AveniaVerificationForm/index.tsx +++ b/apps/frontend/src/components/Avenia/AveniaVerificationForm/index.tsx @@ -1,43 +1,34 @@ -import { motion } from "motion/react"; -import { FormProvider, UseFormReturn } from "react-hook-form"; +import { FieldValues, FormProvider, SubmitHandler, UseFormReturn } from "react-hook-form"; import { Trans, useTranslation } from "react-i18next"; -import { KYCFormData } from "../../../hooks/brla/useKYCForm"; import { useMaintenanceAwareButton } from "../../../hooks/useMaintenanceAware"; -import { AveniaKycActorRef } from "../../../machines/types"; import { StepFooter } from "../../StepFooter"; import { AveniaField, AveniaFieldProps, ExtendedAveniaFieldOptions } from "../AveniaField"; -interface AveniaVerificationFormProps { +interface AveniaVerificationFormProps { fields: AveniaFieldProps[]; - form: UseFormReturn; - aveniaKycActor: AveniaKycActorRef; + form: UseFormReturn; + onSubmit: SubmitHandler; isCompany?: boolean; } -export const AveniaVerificationForm = ({ form, fields, aveniaKycActor, isCompany = false }: AveniaVerificationFormProps) => { +export const AveniaVerificationForm = ({ + form, + fields, + onSubmit, + isCompany = false +}: AveniaVerificationFormProps) => { const { handleSubmit } = form; const { t } = useTranslation(); const { buttonProps, isMaintenanceDisabled } = useMaintenanceAwareButton(); - const onSubmit = () => { - const formData = form.getValues(); - aveniaKycActor.send({ formData, type: "FORM_SUBMIT" }); - }; - // formState.isValid is not working as expected, so we need to check the errors const isFormInvalid = Object.keys(form.formState.errors).length > 0 || form.formState.isSubmitting; return ( - +

{isCompany ? t("components.aveniaKYB.title.default") : t("components.aveniaKYC.title")} @@ -50,7 +41,8 @@ export const AveniaVerificationForm = ({ form, fields, aveniaKycActor, isCompany ExtendedAveniaFieldOptions.PIX_ID, ExtendedAveniaFieldOptions.TAX_ID, ExtendedAveniaFieldOptions.FULL_NAME, - ExtendedAveniaFieldOptions.COMPANY_NAME + ExtendedAveniaFieldOptions.COMPANY_NAME, + ExtendedAveniaFieldOptions.EMAIL ].includes(field.id as ExtendedAveniaFieldOptions) ? "col-span-2" : "" @@ -69,7 +61,7 @@ export const AveniaVerificationForm = ({ form, fields, aveniaKycActor, isCompany i18nKey={"components.aveniaKYC.description"} > Complete these quick identity checks (typically 90 seconds). Data is processed securely by{" "} - + Avenia {" "} using bank-grade encryption for transaction security. @@ -91,7 +83,7 @@ export const AveniaVerificationForm = ({ form, fields, aveniaKycActor, isCompany : t("components.aveniaKYC.buttons.next")} - + ); }; diff --git a/apps/frontend/src/components/Avenia/DocumentUpload/index.tsx b/apps/frontend/src/components/Avenia/DocumentUpload/index.tsx index 64692de17..9d220ea3f 100644 --- a/apps/frontend/src/components/Avenia/DocumentUpload/index.tsx +++ b/apps/frontend/src/components/Avenia/DocumentUpload/index.tsx @@ -5,6 +5,7 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import { durations, easings } from "../../../constants/animations"; +import { cn } from "../../../helpers/cn"; import { useMaintenanceAwareButton } from "../../../hooks/useMaintenanceAware"; import { AveniaKycActorRef } from "../../../machines/types"; import { BrlaService } from "../../../services/api"; @@ -156,7 +157,12 @@ export const DocumentUpload: React.FC = ({ aveniaKycActor, Icon: React.ComponentType>, fileName?: string ) => ( -