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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 162 additions & 2 deletions apps/api/src/api/controllers/brla.controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import QuoteTicket from "../../models/quoteTicket.model";
import User from "../../models/user.model";
import { hashTaxReference } from "../services/avenia/avenia-customer.service";
import { SupabaseAuthService } from "../services/auth";
import { validateSubaccountCreation } from "../middlewares/validators";
import {
createSubaccount,
createKybDocument,
Expand Down Expand Up @@ -1861,6 +1862,76 @@ describe("createSubaccount", () => {
expect(subaccountInfoMock).toHaveBeenCalledWith("new-subaccount");
});

// The route runs validateSubaccountCreation ahead of the controller; drive both in order so the
// assertions cover what a real request reaches.
async function submitThroughRoute(body: unknown, userId = "squatter-user") {
const req = { body, userId } as any;
const res = createResponse();
let validated = false;
validateSubaccountCreation(req, res as any, () => {
validated = true;
});
if (validated) await createSubaccount(req, res as any);
return res;
}

it("never reaches the provider or the database for malformed input", async () => {
mockBrlaApi();
const findOne = mock(async () => null);
ProviderCustomer.findOne = findOne as unknown as typeof ProviderCustomer.findOne;

const malformed = [
{ accountType: AveniaAccountType.INDIVIDUAL, name: "Squatter", taxId: "12345678901" },
{ accountType: AveniaAccountType.INDIVIDUAL, name: "Squatter", taxId: "abc" },
{ accountType: AveniaAccountType.INDIVIDUAL, name: "Squatter" },
{ accountType: AveniaAccountType.INDIVIDUAL, name: "Squatter", taxId: 8786985906 },
{ accountType: AveniaAccountType.INDIVIDUAL, name: "Squatter", taxId: "11222333000181" },
{ accountType: AveniaAccountType.COMPANY, name: "Squatter Ltda", taxId: "08786985906" },
{ accountType: AveniaAccountType.INDIVIDUAL, name: " ", taxId: "08786985906" },
{ accountType: AveniaAccountType.INDIVIDUAL, taxId: "08786985906" },
{ accountType: AveniaAccountType.INDIVIDUAL, name: "Squatter", taxId: "08786985907" }
];
for (const body of malformed) {
const res = await submitThroughRoute(body);
expect(res.statusCode).toBe(httpStatus.BAD_REQUEST);
}

expect(createAveniaSubaccountMock).not.toHaveBeenCalled();
expect(FinancialOperation.findOrCreate).not.toHaveBeenCalled();
expect(sequelize.transaction).not.toHaveBeenCalled();
expect(findOne).not.toHaveBeenCalled();
});

it("stores the normalized tax id when the client sends a formatted valid CPF", async () => {
mockBrlaApi();
const providerCreateMock = mock(async (values: Record<string, unknown>) => ({ ...values }));
ProviderCustomer.findOne = mock(async () => null) as typeof ProviderCustomer.findOne;
ProviderCustomer.create = providerCreateMock as unknown as typeof ProviderCustomer.create;

const res = await submitThroughRoute({ ...validBody, taxId: "087.869.859-06" }, "new-user");

expect(res.statusCode).toBe(httpStatus.OK);
expect(createAveniaSubaccountMock).toHaveBeenCalledTimes(1);
expect(providerCreateMock.mock.calls[0]?.[0]).toMatchObject({
taxReference: "08786985906",
taxReferenceHash: hashTaxReference("08786985906")
});
});

it("sends the provider the trimmed name the validator measured", async () => {
mockBrlaApi();
ProviderCustomer.findOne = mock(async () => null) as typeof ProviderCustomer.findOne;
ProviderCustomer.create = mock(async (values: Record<string, unknown>) => ({
...values
})) as unknown as typeof ProviderCustomer.create;
const name = "a".repeat(255);

const res = await submitThroughRoute({ ...validBody, name: ` ${name} ` }, "new-user");

expect(res.statusCode).toBe(httpStatus.OK);
expect(createAveniaSubaccountMock).toHaveBeenCalledWith(AveniaAccountType.INDIVIDUAL, name);
});

it("rejects overwrite when a started record belongs to another entity", async () => {
mockBrlaApi();
createAveniaSubaccountMock.mockClear();
Expand Down Expand Up @@ -2104,14 +2175,61 @@ describe("newKyc", () => {
expect(getInstance).not.toHaveBeenCalled();
});

describe("tax id binding", () => {
function mockOwnedIndividual() {
CustomerEntity.findAll = mock(async () => [{ id: "entity-user-1" }]) as unknown as typeof CustomerEntity.findAll;
ProviderCustomer.findOne = mock(async () => ({
customerEntityId: "entity-user-1",
customerType: "individual",
id: "customer-1",
provider: "avenia",
providerSubaccountId: "subaccount-1",
taxReferenceHash: hashTaxReference("08786985906")
})) as unknown as typeof ProviderCustomer.findOne;
const getInstance = mock(() => ({}) as BrlaApiService);
BrlaApiService.getInstance = getInstance;
// Anything past the binding check opens the KYC claim transaction; fail loudly if reached.
const transaction = mock(async () => {
throw new Error("reached the KYC claim");
});
sequelize.transaction = transaction as unknown as typeof sequelize.transaction;
return { getInstance, transaction };
}

it("rejects a taxIdNumber that differs from the claimed CPF before any provider call", async () => {
const { getInstance, transaction } = mockOwnedIndividual();

for (const taxIdNumber of ["52998224725", "", undefined, 8786985906]) {
const res = createResponse();
await newKyc({ body: { subAccountId: "subaccount-1", taxIdNumber }, userId: "user-1" } as any, res as any);

expect(res.statusCode).toBe(httpStatus.BAD_REQUEST);
expect(res.body).toEqual({ error: "taxIdNumber does not match the tax ID claimed for this subaccount." });
}
expect(getInstance).not.toHaveBeenCalled();
expect(transaction).not.toHaveBeenCalled();
});

it("lets a formatted equivalent of the claimed CPF through to the submission", async () => {
const { transaction } = mockOwnedIndividual();

const res = createResponse();
await newKyc({ body: { subAccountId: "subaccount-1", taxIdNumber: "087.869.859-06" }, userId: "user-1" } as any, res as any);

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

it("rejects an imported-method case before provider document or submission calls", async () => {
CustomerEntity.findAll = mock(async () => [{ id: "entity-user-1" }]) as unknown as typeof CustomerEntity.findAll;
ProviderCustomer.findOne = mock(async () => ({
customerEntityId: "entity-user-1",
customerType: "individual",
id: "customer-1",
provider: "avenia",
providerSubaccountId: "subaccount-1"
providerSubaccountId: "subaccount-1",
taxReferenceHash: hashTaxReference("08786985906")
})) as unknown as typeof ProviderCustomer.findOne;
KycCase.findAll = mock(async () => [{ id: "case-1", verificationMethod: "sumsub_share_token" }]) as unknown as typeof KycCase.findAll;
sequelize.transaction = mock(async callback =>
Expand All @@ -2129,7 +2247,7 @@ describe("newKyc", () => {
);

const res = createResponse();
await newKyc({ body: { subAccountId: "subaccount-1" }, userId: "user-1" } as any, res as any);
await newKyc({ body: { subAccountId: "subaccount-1", taxIdNumber: "08786985906" }, userId: "user-1" } as any, res as any);

expect(res.statusCode).toBe(httpStatus.CONFLICT);
expect(getUploadedDocuments).not.toHaveBeenCalled();
Expand All @@ -2146,6 +2264,7 @@ describe("newKyc", () => {
provider: "avenia",
providerSubaccountId: "subaccount-1",
status: VerificationStatus.InReview,
taxReferenceHash: hashTaxReference("08786985906"),
update: customerUpdate
};
ProviderCustomer.findOne = mock(async () => customer) as unknown as typeof ProviderCustomer.findOne;
Expand Down Expand Up @@ -2203,6 +2322,7 @@ describe("newKyc", () => {
{
body: {
subAccountId: "subaccount-1",
taxIdNumber: "087.869.859-06",
uploadedDocumentId: "document-1",
uploadedSelfieId: "selfie-1"
},
Expand Down Expand Up @@ -2297,6 +2417,7 @@ describe("Avenia API KYB", () => {
providerSubaccountId: "subaccount-1",
status: VerificationStatus.Pending,
statusExternal: null,
taxReferenceHash: hashTaxReference(validSubmission.taxIdentificationNumberTin),
update
})) as unknown as typeof ProviderCustomer.findOne;
return update;
Expand Down Expand Up @@ -2430,6 +2551,45 @@ describe("Avenia API KYB", () => {
expect(createUbo).not.toHaveBeenCalled();
});

it("rejects a TIN that differs from the claimed CNPJ before any provider call", async () => {
const { customerUpdate, submit } = mockInitialSubmission();
const getInstance = mock(() => ({ submitKybLevel1: submit }) as unknown as BrlaApiService);
BrlaApiService.getInstance = getInstance;

const res = createResponse();
await submitKybLevel1Api(
{
body: { ...validSubmission, taxIdentificationNumberTin: "11222333000181" },
query: { subAccountId: "subaccount-1" },
userId: "user-1"
} as any,
res as any
);

expect(res.statusCode).toBe(httpStatus.BAD_REQUEST);
expect(res.body).toEqual({ error: "taxIdentificationNumberTin does not match the tax ID claimed for this subaccount." });
expect(getInstance).not.toHaveBeenCalled();
expect(submit).not.toHaveBeenCalled();
expect(customerUpdate).not.toHaveBeenCalled();
});

it("accepts a formatted TIN equivalent to the claimed CNPJ", async () => {
const { submit } = mockInitialSubmission();

const res = createResponse();
await submitKybLevel1Api(
{
body: { ...validSubmission, taxIdentificationNumberTin: "42.731.085/0001-67" },
query: { subAccountId: "subaccount-1" },
userId: "user-1"
} as any,
res as any
);

expect(res.statusCode).toBe(httpStatus.OK);
expect(submit).toHaveBeenCalledTimes(1);
});

it("submits ready company documents and persists the pending attempt", async () => {
const { caseUpdate, customerUpdate, submit } = mockInitialSubmission();

Expand Down
21 changes: 18 additions & 3 deletions apps/api/src/api/controllers/brla.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,9 @@ export const createSubaccount = async (
res: Response<BrCreateSubaccountResponse | BrErrorResponse>
): Promise<void> => {
try {
const { name, taxId, accountType: requestAccountType } = req.body;
const { taxId, accountType: requestAccountType } = req.body;
// validateSubaccountCreation bounded the trimmed name, so every use below sends that same value.
const name = req.body.name.trim();
const effectiveUserId = getEffectiveUserId(req);

// Reject callers that do not resolve to a user (anonymous requests
Expand Down Expand Up @@ -449,7 +451,7 @@ export const createSubaccount = async (
provider: "avenia",
request: {
accountType,
name: name.trim(),
name,
ownerProfileId: effectiveUserId,
taxReferenceHash
},
Expand All @@ -459,7 +461,7 @@ export const createSubaccount = async (

let companyName: string | null = null;
if (accountType === AveniaAccountType.COMPANY) {
companyName = name.trim();
companyName = name;
try {
const account = await brlaApiService.subaccountInfo(id);
companyName = account?.accountInfo.name?.trim() || account?.accountInfo.fullName?.trim() || companyName;
Expand Down Expand Up @@ -906,6 +908,12 @@ export const newKyc = async (
res.status(httpStatus.BAD_REQUEST).json({ error: "Individual KYC requires an individual customer account." });
return;
}
// The provider approves whoever the submitted documents belong to; the CPF claimed at
// createSubaccount must be that same identity, or the approval would attach to the wrong tax id.
if (typeof req.body.taxIdNumber !== "string" || hashTaxReference(req.body.taxIdNumber) !== record.taxReferenceHash) {
res.status(httpStatus.BAD_REQUEST).json({ error: "taxIdNumber does not match the tax ID claimed for this subaccount." });
return;
}

const response = await submitStandardAveniaKyc({
actorProfileId,
Expand Down Expand Up @@ -1068,6 +1076,13 @@ export const submitKybLevel1Api = async (
): Promise<void> => {
try {
const record = await resolveAveniaKybAccount(req, req.query.subAccountId);
// Same binding as newKyc: the submitted TIN must be the CNPJ claimed for this subaccount.
if (hashTaxReference(req.body.taxIdentificationNumberTin) !== record.taxReferenceHash) {
res
.status(httpStatus.BAD_REQUEST)
.json({ error: "taxIdentificationNumberTin does not match the tax ID claimed for this subaccount." });
return;
}
const subAccountId = record.providerSubaccountId as string;
const brlaApiService = BrlaApiService.getInstance();
if (record.status === VerificationStatus.Approved) {
Expand Down
86 changes: 84 additions & 2 deletions apps/api/src/api/middlewares/validators.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { BrDocumentType, Networks, QuoteError, RampDirection } from "@vortexfi/shared";
import { AveniaAccountType, BrDocumentType, 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";
Expand All @@ -9,7 +9,8 @@ import {
validateAveniaKybLevel1,
validateAveniaKybUbo,
validateCreateBestQuoteInput,
validateKycSubmission
validateKycSubmission,
validateSubaccountCreation
} from "./validators";

function buildRes() {
Expand Down Expand Up @@ -283,3 +284,84 @@ describe("validateKycSubmission", () => {
expect(res.statusCode).toBeUndefined();
});
});

describe("validateSubaccountCreation", () => {
// Synthetic identifiers with valid check digits (never real people or companies).
const VALID_CPF = "52998224725";
const VALID_CPF_FORMATTED = "529.982.247-25";
const VALID_CNPJ = "11222333000181";
const VALID_CNPJ_FORMATTED = "11.222.333/0001-81";

function validate(body: unknown) {
const req = { body } as unknown as Request;
const res = buildRes();
const next = mock(() => undefined) as unknown as NextFunction;

validateSubaccountCreation(req, res, next);

return { next, res };
}

function expectRejected(body: unknown, error: string) {
const { next, res } = validate(body);
expect(res.statusCode).toBe(httpStatus.BAD_REQUEST);
expect(res.body).toEqual({ error });
expect(next).not.toHaveBeenCalled();
}

const individual = { accountType: AveniaAccountType.INDIVIDUAL, name: "Ana Maria Silva", taxId: VALID_CPF };
const company = { accountType: AveniaAccountType.COMPANY, name: "Acme Ltda", taxId: VALID_CNPJ };

it("accepts a valid CPF, plain or formatted, for an individual", () => {
for (const taxId of [VALID_CPF, VALID_CPF_FORMATTED, ` ${VALID_CPF} `]) {
const { next, res } = validate({ ...individual, taxId });
expect(next).toHaveBeenCalledTimes(1);
expect(res.statusCode).toBeUndefined();
}
});

it("accepts a valid CNPJ, plain or formatted, for a company", () => {
for (const taxId of [VALID_CNPJ, VALID_CNPJ_FORMATTED]) {
const { next, res } = validate({ ...company, taxId });
expect(next).toHaveBeenCalledTimes(1);
expect(res.statusCode).toBeUndefined();
}
});

it("rejects checksum-invalid and trivial identifiers", () => {
for (const taxId of ["52998224724", "12345678901", "11111111111", "abc", "", "529.982.247-2"]) {
expectRejected({ ...individual, taxId }, "taxId must be a valid CPF for INDIVIDUAL accounts.");
}
expectRejected({ ...company, taxId: "11222333000182" }, "taxId must be a valid CNPJ for COMPANY accounts.");
});

it("rejects a CPF for a company and a CNPJ for an individual", () => {
expectRejected({ ...company, taxId: VALID_CPF }, "taxId must be a valid CNPJ for COMPANY accounts.");
expectRejected({ ...individual, taxId: VALID_CNPJ }, "taxId must be a valid CPF for INDIVIDUAL accounts.");
});

it("rejects a missing or non-string taxId", () => {
for (const taxId of [undefined, null, 52998224725, [VALID_CPF], { value: VALID_CPF }]) {
expectRejected({ ...individual, taxId }, "taxId must be a valid CPF for INDIVIDUAL accounts.");
}
});

it("rejects a missing, non-string, blank or oversized name", () => {
const error = "name must be a non-empty string of at most 255 characters.";
for (const name of [undefined, null, 42, ["Ana"], "", " ", "x".repeat(256)]) {
expectRejected({ ...individual, name }, error);
}
const { next } = validate({ ...individual, name: "x".repeat(255) });
expect(next).toHaveBeenCalledTimes(1);
});

it("rejects an unknown or missing accountType before looking at the other fields", () => {
for (const accountType of [undefined, "BUSINESS", 1]) {
expectRejected({ ...individual, accountType }, "Invalid accountType.");
}
});

it("rejects a missing body instead of throwing", () => {
expectRejected(undefined, "Invalid accountType.");
});
});
Loading
Loading