Skip to content
Open
556 changes: 539 additions & 17 deletions apps/api/src/api/controllers/brla.controller.test.ts

Large diffs are not rendered by default.

306 changes: 275 additions & 31 deletions apps/api/src/api/controllers/brla.controller.ts

Large diffs are not rendered by default.

96 changes: 94 additions & 2 deletions apps/api/src/api/middlewares/validators.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import { Networks, QuoteError, RampDirection } from "@vortexfi/shared";
import { AveniaDocumentType, Networks, QuoteError, RampDirection } from "@vortexfi/shared";
import { describe, expect, it, mock } from "bun:test";
import type { NextFunction, Request, Response } from "express";
import httpStatus from "http-status";
import { APIError } from "../errors/api-error";
import { validateCreateBestQuoteInput, validateKycSubmission } from "./validators";
import {
validateAveniaKybDocument,
validateAveniaKybLevel1,
validateAveniaKybUbo,
validateCreateBestQuoteInput,
validateKycSubmission
} from "./validators";

function buildRes() {
const res: Partial<Response> & { statusCode?: number; body?: unknown } = {};
Expand Down Expand Up @@ -94,6 +100,92 @@ describe("validateCreateBestQuoteInput - networks whitelist", () => {
});
});

describe("Avenia API KYB validators", () => {
it("rejects double-sided corporate documents", () => {
const req = {
body: { documentType: AveniaDocumentType.CERTIFICATE_OF_INCORPORATION, isDoubleSided: true }
} as Request;
const res = buildRes();
const next = mock(() => undefined) as unknown as NextFunction;

validateAveniaKybDocument(req, res, next);

expect(res.statusCode).toBe(httpStatus.BAD_REQUEST);
expect(next).not.toHaveBeenCalled();
});

it("accepts double-sided UBO identification documents", () => {
const req = {
body: { documentType: AveniaDocumentType.ID, isDoubleSided: true }
} as Request;
const res = buildRes();
const next = mock(() => undefined) as unknown as NextFunction;

validateAveniaKybDocument(req, res, next);

expect(next).toHaveBeenCalledTimes(1);
expect(res.statusCode).toBeUndefined();
});

it("rejects final submission without a UBO", () => {
const req = {
body: {
businessActivityDescription: "Software development",
certificateOfIncorporationDocumentId: "certificate-1",
companyCity: "Sao Paulo",
companyCountry: "BRA",
companyLegalName: "ACME LTDA",
companyRegistrationNumber: "42731085000167",
companyState: "SP",
companyStreetLine1: "Av Paulista 1000",
companyZipCode: "01310-100",
countryTaxResidence: "BRA",
estimatedAnnualRevenueUsd: "less_than_100k",
estimatedMonthlyVolumeUsd: "2000",
numberOfEmployees: "1-10",
reasonForAccountOpening: "receive_payments_for_goods_and_services",
sourceOfFundsAndIncome: "sales_of_goods_and_services",
taxIdentificationDocumentId: "tax-document-1",
taxIdentificationNumberTin: "42731085000167",
uboIds: []
}
} as unknown as Request;
const res = buildRes();
const next = mock(() => undefined) as unknown as NextFunction;

validateAveniaKybLevel1(req, res, next);

expect(res.statusCode).toBe(httpStatus.BAD_REQUEST);
expect(next).not.toHaveBeenCalled();
});

it("rejects an underage UBO before calling Avenia", () => {
const req = {
body: {
city: "Sao Paulo",
country: "BRA",
countryOfTaxId: "BRA",
dateOfBirth: new Date().toISOString().slice(0, 10),
documentCountry: "BRA",
fullName: "Test Owner",
percentageOfOwnership: "100",
state: "SP",
streetLine1: "Av Paulista 1000",
taxIdNumber: "08786985906",
uploadedIdentificationId: "identity-1",
zipCode: "01310-100"
}
} as unknown as Request;
const res = buildRes();
const next = mock(() => undefined) as unknown as NextFunction;

validateAveniaKybUbo(req, res, next);

expect(res.statusCode).toBe(httpStatus.BAD_REQUEST);
expect(next).not.toHaveBeenCalled();
});
});

describe("validateKycSubmission", () => {
it("forwards structured API errors for invalid Argentina submissions", () => {
const req = {
Expand Down
182 changes: 182 additions & 0 deletions apps/api/src/api/middlewares/validators.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import {
AveniaDocumentType,
AveniaKYCDataUploadRequest,
AveniaKybLevel1Payload,
AveniaUboPayload,
CreateAveniaSubaccountRequest,
CreateBestQuoteRequest,
CreateQuoteRequest,
Expand All @@ -9,6 +12,7 @@ import {
getCaseSensitiveNetwork,
isSupportedFiatCurrency,
isValidAveniaAccountType,
isValidCpf,
isValidCurrencyForDirection,
isValidDirection,
isValidKYCDocType,
Expand All @@ -26,6 +30,7 @@ import {
} from "@vortexfi/shared";
import { Request, RequestHandler, Response } from "express";
import httpStatus from "http-status";
import { z } from "zod";
import logger from "../../config/logger";
import { CONTACT_SHEET_HEADER_VALUES } from "../controllers/contact.controller";
import { EMAIL_SHEET_HEADER_VALUES } from "../controllers/email.controller";
Expand Down Expand Up @@ -625,3 +630,180 @@ export const validateStartKyc2: RequestHandler = (req, res, next) => {

next();
};

const nonEmptyString = z.string().trim().min(1);
const isoAlpha3 = z.string().regex(/^[A-Z]{3}$/, "Must be an ISO 3166-1 alpha-3 country code");

function isAdultDate(value: string): boolean {
const [year, month, day] = value.split("-").map(Number);
const date = new Date(Date.UTC(year, month - 1, day));
if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) {
return false;
}
const minimumBirthDate = new Date();
minimumBirthDate.setUTCFullYear(minimumBirthDate.getUTCFullYear() - 18);
return date <= minimumBirthDate;
}

const aveniaDocumentUploadSchema = z
.object({
documentType: z.enum(AveniaDocumentType),
isDoubleSided: z.boolean().optional()
})
.strict()
.superRefine((value, context) => {
const identificationTypes = new Set([
AveniaDocumentType.ID,
AveniaDocumentType.DRIVERS_LICENSE,
AveniaDocumentType.PASSPORT,
AveniaDocumentType.RESIDENCE_PERMIT
]);
if (value.isDoubleSided && !identificationTypes.has(value.documentType)) {
context.addIssue({ code: "custom", message: "Only identification documents may be double-sided" });
}
});

const aveniaUboSchema: z.ZodType<AveniaUboPayload> = z
.object({
city: nonEmptyString,
country: isoAlpha3,
countryOfTaxId: isoAlpha3,
dateOfBirth: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.refine(isAdultDate, "UBO must be at least 18 years old"),
documentCountry: isoAlpha3,
email: z.email().optional(),
fullName: nonEmptyString.max(256),
hasControl: z
.enum([
"CEO",
"CFO",
"COO",
"CTO",
"President",
"Vice President",
"Director",
"Managing Director",
"Managing Partner",
"General Partner",
"Partner",
"Secretary",
"Treasurer",
"Chairman",
"Board Member",
"Authorized Signatory",
"General Counsel",
"Owner",
"Founder",
"Manager",
"Member",
"Comptroller",
"Chief Compliance Officer"
])
.optional(),
percentageOfOwnership: nonEmptyString.refine(value => {
const percentage = Number(value);
return Number.isFinite(percentage) && percentage >= 0 && percentage <= 100;
}, "percentageOfOwnership must be between 0 and 100"),
phone: z
.string()
.regex(/^\+[1-9]\d{7,14}$/, "Phone must use E.164 format")
.optional(),
state: nonEmptyString,
streetLine1: nonEmptyString.max(256),
streetLine2: z.string().optional(),
streetLine3: z.string().optional(),
taxIdNumber: nonEmptyString,
uploadedIdentificationId: nonEmptyString,
uploadedSelfieId: nonEmptyString.optional(),
zipCode: nonEmptyString
})
.strict()
.superRefine((value, context) => {
if (value.countryOfTaxId === "BRA" && !isValidCpf(value.taxIdNumber)) {
context.addIssue({ code: "custom", message: "taxIdNumber must be a valid CPF for BRA", path: ["taxIdNumber"] });
}
if (value.countryOfTaxId === "USA" && !/^\d{9}$/.test(value.taxIdNumber)) {
context.addIssue({ code: "custom", message: "taxIdNumber must contain 9 digits for USA", path: ["taxIdNumber"] });
}
});

const aveniaKybLevel1Schema: z.ZodType<AveniaKybLevel1Payload> = z
.object({
businessActivityDescription: nonEmptyString.max(2000),
certificateOfIncorporationDocumentId: nonEmptyString,
companyCity: nonEmptyString.max(256),
companyCountry: nonEmptyString,
companyLegalName: nonEmptyString,
companyRegistrationNumber: nonEmptyString,
companyState: nonEmptyString,
companyStreetLine1: nonEmptyString.max(256),
companyStreetLine2: z.string().optional(),
companyStreetLine3: z.string().optional(),
companyZipCode: nonEmptyString.max(256),
countrySubdivisionTaxResidence: nonEmptyString.optional(),
countryTaxResidence: z.union([isoAlpha3, z.literal("N/A")]),
emailPixKey: z.email().optional(),
estimatedAnnualRevenueUsd: z.enum([
"less_than_100k",
"100k_to_1m",
"1m_to_10m",
"10m_to_50m",
"50m_to_100m",
"more_than_100m"
]),
estimatedMonthlyVolumeUsd: z.string().regex(/^[1-9]\d*$/, "Must be a positive integer"),
numberOfEmployees: z.enum(["1-10", "11-50", "51-200", "201-500", "501-1000", "1001+"]),
reasonForAccountOpening: z.enum([
"charitable_donations",
"ecommerce_retail_payments",
"investment_purposes",
"other",
"payments_to_friends_or_family_abroad",
"payroll",
"personal_or_living_expenses",
"protect_wealth",
"purchase_goods_and_services",
"receive_payments_for_goods_and_services",
"tax_optimization",
"third_party_money_transmission",
"treasury_management"
]),
sandboxReject: z.boolean().optional(),
socialMedia: z.url().optional(),
sourceOfFundsAndIncome: z.enum([
"business_loans",
"grants",
"inter_company_funds",
"investment_proceeds",
"legal_settlement",
"owners_capital",
"pension_retirement",
"sale_of_assets",
"sales_of_goods_and_services",
"third_party_funds",
"treasury_reserves"
]),
taxIdentificationDocumentId: nonEmptyString,
taxIdentificationNumberTin: nonEmptyString,
uboIds: z.array(nonEmptyString).min(1).max(50),
website: z.url().optional()
})
.strict();

function validateAveniaKybBody(schema: z.ZodType): RequestHandler {
return (req, res, next) => {
const parsed = schema.safeParse(req.body);
if (!parsed.success) {
res.status(httpStatus.BAD_REQUEST).json({ details: z.prettifyError(parsed.error), error: "Invalid request" });
return;
}
req.body = parsed.data;
next();
};
}

export const validateAveniaKybDocument = validateAveniaKybBody(aveniaDocumentUploadSchema);
export const validateAveniaKybUbo = validateAveniaKybBody(aveniaUboSchema);
export const validateAveniaKybLevel1 = validateAveniaKybBody(aveniaKybLevel1Schema);
41 changes: 39 additions & 2 deletions apps/api/src/api/routes/v1/brla.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ import { RequestHandler, Router } from "express";
import * as brlaController from "../../controllers/brla.controller";
import { optionalPartnerOrUserAuth, requirePartnerOrUserAuth } from "../../middlewares/dualAuth";
import { authorizeManagedProfile } from "../../middlewares/managedProfileAuth";
import { validateStartKyc2, validateSubaccountCreation } from "../../middlewares/validators";
import {
validateAveniaKybDocument,
validateAveniaKybLevel1,
validateAveniaKybUbo,
validateStartKyc2,
validateSubaccountCreation
} from "../../middlewares/validators";

const router: Router = Router({ mergeParams: true });

Expand Down Expand Up @@ -67,9 +73,40 @@ router
.route("/kyb/new-level-1/web-sdk")
.post(requirePartnerOrUserAuth(), authorizeManagedProfile({ corridor: "BR" }), brlaController.initiateKybLevel1);

router
.route("/kyb/documents")
.post(
validateAveniaKybDocument,
requirePartnerOrUserAuth(),
authorizeManagedProfile({ corridor: "BR" }),
brlaController.createKybDocument as unknown as RequestHandler
);

router
.route("/kyb/documents/:documentId")
.get(requirePartnerOrUserAuth(), authorizeManagedProfile(), brlaController.getKybDocument as unknown as RequestHandler);

router
.route("/kyb/ubos")
.post(
validateAveniaKybUbo,
requirePartnerOrUserAuth(),
authorizeManagedProfile({ corridor: "BR" }),
brlaController.createKybUbo as unknown as RequestHandler
);

router
.route("/kyb/new-level-1/api")
.post(
validateAveniaKybLevel1,
requirePartnerOrUserAuth(),
authorizeManagedProfile({ corridor: "BR" }),
brlaController.submitKybLevel1Api as unknown as RequestHandler
);

router
.route("/kyb/attempt-status")
.get(requirePartnerOrUserAuth(), authorizeManagedProfile(), brlaController.getKybAttemptStatus);
.get(requirePartnerOrUserAuth(), authorizeManagedProfile(), brlaController.getKybAttemptStatus as unknown as RequestHandler);

router
.route("/kyc/record-attempt")
Expand Down
8 changes: 7 additions & 1 deletion apps/api/src/api/services/avenia/avenia-customer.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,12 @@ export async function upsertAveniaKycCase(

const existing = await KycCase.findOne({ where: { providerCustomerId: record.id } });
if (existing) {
await existing.update({ ...(providerCaseId ? { providerCaseId } : {}), status, statusExternal, ...lifecycle });
await existing.update({
...(providerCaseId ? { providerCaseId, submissionStatus: "submitted" as const } : {}),
status,
statusExternal,
...lifecycle
});
return;
}
await KycCase.create({
Expand All @@ -85,6 +90,7 @@ export async function upsertAveniaKycCase(
providerCustomerId: record.id,
status,
statusExternal,
submissionStatus: providerCaseId ? "submitted" : "not_started",
type: record.customerType === "business" ? "kyb" : "kyc",
...lifecycle
});
Expand Down
Loading
Loading