diff --git a/apps/api/src/api/controllers/brla.controller.test.ts b/apps/api/src/api/controllers/brla.controller.test.ts index 3a51b3280..29c0561f7 100644 --- a/apps/api/src/api/controllers/brla.controller.test.ts +++ b/apps/api/src/api/controllers/brla.controller.test.ts @@ -1,6 +1,7 @@ import {AveniaAccountType, AveniaDocumentType, BrlaApiError, BrlaApiService, KycAttemptResult, KycAttemptStatus} from "@vortexfi/shared"; import {afterEach, beforeEach, describe, expect, it, mock} from "bun:test"; import httpStatus from "http-status"; +import sequelize from "../../config/database"; import logger from "../../config/logger"; import CustomerEntity from "../../models/customerEntity.model"; import EmailNotification, { NotificationProvider, NotificationType } from "../../models/emailNotification.model"; @@ -8,15 +9,19 @@ import KycCase from "../../models/kycCase.model"; import PartnerManagedProfile from "../../models/partnerManagedProfile.model"; import ProviderCustomer, {VerificationStatus} from "../../models/providerCustomer.model"; import User from "../../models/user.model"; +import { hashAveniaKybSubmission } from "../services/avenia/avenia-kyb.service"; import { SupabaseAuthService } from "../services/auth"; import { createSubaccount, + createKybDocument, + createKybUbo, fetchSubaccountKycStatus, getAveniaUser, getKybAttemptStatus, getUploadUrls, initiateKybLevel1, - recordInitialKycAttempt + recordInitialKycAttempt, + submitKybLevel1Api } from "./brla.controller"; function createResponse() { @@ -455,7 +460,16 @@ describe("Avenia company KYB", () => { const originalEntityFindOrCreate = CustomerEntity.findOrCreate; const originalKycCaseFindOne = KycCase.findOne; const originalKycCaseCreate = KycCase.create; + const originalKycCaseUpdate = KycCase.update; const originalGetInstance = BrlaApiService.getInstance; + const originalTransaction = sequelize.transaction; + let staticCaseUpdate: ReturnType; + + beforeEach(() => { + staticCaseUpdate = mock(async () => [1]); + KycCase.update = staticCaseUpdate as unknown as typeof KycCase.update; + sequelize.transaction = mock(async callback => callback({} as never)) as unknown as typeof sequelize.transaction; + }); afterEach(() => { ProviderCustomer.findOne = originalProviderFindOne; @@ -464,7 +478,9 @@ describe("Avenia company KYB", () => { CustomerEntity.findOrCreate = originalEntityFindOrCreate; KycCase.findOne = originalKycCaseFindOne; KycCase.create = originalKycCaseCreate; + KycCase.update = originalKycCaseUpdate; BrlaApiService.getInstance = originalGetInstance; + sequelize.transaction = originalTransaction; }); it("binds the initiated provider attempt to the owned KYB case", async () => { @@ -619,7 +635,7 @@ describe("Avenia company KYB", () => { expect(initiateMock).not.toHaveBeenCalled(); }); - it("still re-issues KYB links when the live probe is unavailable", async () => { + it("fails closed when the live attempt cannot be checked before re-initiation", async () => { mockEntityPerProfile(); const customerUpdate = mock(async () => undefined); ProviderCustomer.findOne = mock(async () => ({ @@ -654,8 +670,102 @@ describe("Avenia company KYB", () => { const res = createResponse(); await initiateKybLevel1({ query: { subAccountId: "subaccount-1" }, userId: "user-1" } as any, res as any); - expect(res.statusCode).toBe(httpStatus.OK); - expect(initiateMock).toHaveBeenCalled(); + expect(res.statusCode).toBe(httpStatus.BAD_GATEWAY); + expect(initiateMock).not.toHaveBeenCalled(); + }); + + it("does not start the hosted flow while an API submission needs reconciliation", async () => { + mockEntityPerProfile(); + ProviderCustomer.findOne = mock(async () => ({ + customerEntityId: "entity-user-1", + customerType: "business", + id: "customer-1", + providerSubaccountId: "subaccount-1", + status: VerificationStatus.Pending + })) as unknown as typeof ProviderCustomer.findOne; + KycCase.findOne = mock(async () => ({ + id: "case-1", + providerCaseId: null, + submissionStatus: "unknown" + })) as unknown as typeof KycCase.findOne; + const initiateMock = mock(async () => ({ attemptId: "attempt-2" })); + BrlaApiService.getInstance = mock( + () => ({ initiateKybLevel1: initiateMock }) as unknown as BrlaApiService + ); + + const res = createResponse(); + await initiateKybLevel1({ query: { subAccountId: "subaccount-1" }, userId: "user-1" } as any, res as any); + + expect(res.statusCode).toBe(httpStatus.CONFLICT); + expect(initiateMock).not.toHaveBeenCalled(); + }); + + it("does not replace an accepted API attempt with a hosted attempt", async () => { + mockEntityPerProfile(); + ProviderCustomer.findOne = mock(async () => ({ + customerEntityId: "entity-user-1", + customerType: "business", + id: "customer-1", + providerSubaccountId: "subaccount-1", + status: VerificationStatus.Pending + })) as unknown as typeof ProviderCustomer.findOne; + KycCase.findOne = mock(async () => ({ + id: "case-1", + providerCaseId: "api-attempt", + statusExternal: KycAttemptStatus.PENDING, + submissionRequestHash: "api-request-hash", + submissionStatus: "submitted" + })) as unknown as typeof KycCase.findOne; + const providerStatus = mock(async () => ({ attempt: {} })); + const initiateMock = mock(async () => ({ attemptId: "hosted-attempt" })); + BrlaApiService.getInstance = mock( + () => ({ getKybAttemptStatus: providerStatus, initiateKybLevel1: initiateMock }) as unknown as BrlaApiService + ); + + const res = createResponse(); + await initiateKybLevel1({ query: { subAccountId: "subaccount-1" }, userId: "user-1" } as any, res as any); + + expect(res.statusCode).toBe(httpStatus.CONFLICT); + expect(providerStatus).not.toHaveBeenCalled(); + expect(initiateMock).not.toHaveBeenCalled(); + }); + + it("does not replace a non-retryable rejected hosted attempt", async () => { + mockEntityPerProfile(); + ProviderCustomer.findOne = mock(async () => ({ + customerEntityId: "entity-user-1", + customerType: "business", + id: "customer-1", + providerSubaccountId: "subaccount-1", + status: VerificationStatus.Rejected + })) as unknown as typeof ProviderCustomer.findOne; + KycCase.findOne = mock(async () => ({ + id: "case-1", + providerCaseId: "attempt-1", + statusExternal: KycAttemptStatus.COMPLETED, + submissionStatus: "submitted" + })) as unknown as typeof KycCase.findOne; + const initiateMock = mock(async () => ({ attemptId: "attempt-2" })); + BrlaApiService.getInstance = mock( + () => + ({ + getKybAttemptStatus: mock(async () => ({ + attempt: { + id: "attempt-1", + result: KycAttemptResult.REJECTED, + retryable: false, + status: KycAttemptStatus.COMPLETED + } + })), + initiateKybLevel1: initiateMock + }) as unknown as BrlaApiService + ); + + const res = createResponse(); + await initiateKybLevel1({ query: { subAccountId: "subaccount-1" }, userId: "user-1" } as any, res as any); + + expect(res.statusCode).toBe(httpStatus.CONFLICT); + expect(initiateMock).not.toHaveBeenCalled(); }); it("still rejects re-initiation once Avenia is processing the attempt", async () => { @@ -716,6 +826,7 @@ describe("Avenia company KYB", () => { ProviderCustomer.findByPk = mock(async () => ({ customerEntityId: "entity-user-1-individual", provider: "avenia", + providerSubaccountId: "subaccount-1", status: VerificationStatus.Approved })) as unknown as typeof ProviderCustomer.findByPk; @@ -723,7 +834,11 @@ describe("Avenia company KYB", () => { await getKybAttemptStatus({ query: { attemptId: "attempt-1" }, userId: "user-1" } as any, res as any); expect(res.statusCode).toBe(httpStatus.OK); - expect(res.body).toEqual({ result: KycAttemptResult.APPROVED, status: KycAttemptStatus.COMPLETED }); + expect(res.body).toEqual({ + result: KycAttemptResult.APPROVED, + retryable: false, + status: KycAttemptStatus.COMPLETED + }); expect(strayCreate).not.toHaveBeenCalled(); }); @@ -751,13 +866,15 @@ describe("Avenia company KYB", () => { it("persists an approved provider result and returns only normalized browser fields", async () => { mockEntityPerProfile(); const events: string[] = []; - const caseUpdate = mock(async () => { + staticCaseUpdate.mockImplementation(async () => { events.push("caseUpdate"); + return [1]; }); KycCase.findOne = mock(async () => ({ customerEntityId: "entity-user-1", + id: "case-1", + providerCaseId: "attempt-1", providerCustomerId: "customer-1", - update: caseUpdate })) as unknown as typeof KycCase.findOne; const customerUpdate = mock(async () => { events.push("customerUpdate"); @@ -765,6 +882,7 @@ describe("Avenia company KYB", () => { ProviderCustomer.findByPk = mock(async () => ({ customerEntityId: "entity-user-1", provider: "avenia", + providerSubaccountId: "subaccount-1", update: customerUpdate })) as unknown as typeof ProviderCustomer.findByPk; mockApprovedAttempt(); @@ -785,16 +903,20 @@ describe("Avenia company KYB", () => { const res = createResponse(); await getKybAttemptStatus({ query: { attemptId: "attempt-1" }, userId: "user-1" } as any, res as any); - expect(res.body).toEqual({ result: KycAttemptResult.APPROVED, status: KycAttemptStatus.COMPLETED }); + expect(res.body).toEqual({ + result: KycAttemptResult.APPROVED, + retryable: false, + status: KycAttemptStatus.COMPLETED + }); expect(customerUpdate).toHaveBeenCalledWith( - expect.objectContaining({ status: VerificationStatus.Approved, statusExternal: KycAttemptStatus.COMPLETED }) + expect.objectContaining({ status: VerificationStatus.Approved, statusExternal: KycAttemptStatus.COMPLETED }), + expect.anything() ); - expect(caseUpdate).toHaveBeenCalledWith( - expect.objectContaining({ status: VerificationStatus.Approved, statusExternal: KycAttemptStatus.COMPLETED }) + expect(staticCaseUpdate).toHaveBeenCalledWith( + expect.objectContaining({ status: VerificationStatus.Approved, statusExternal: KycAttemptStatus.COMPLETED }), + expect.objectContaining({ where: expect.objectContaining({ id: "case-1", providerCaseId: "attempt-1" }) }) ); - // Enqueue-before-persist: a terminal case is invisible to this route's short-circuit - // and to the KYB worker, so the outcome must be queued before either write. - expect(events).toEqual(["enqueue", "customerUpdate", "caseUpdate"]); + expect(events).toEqual(["caseUpdate", "enqueue", "customerUpdate"]); expect(queuedKeys[0]).toEqual({ provider: NotificationProvider.Avenia, resourceId: "attempt-1", @@ -809,16 +931,17 @@ describe("Avenia company KYB", () => { it("fails the request and skips the terminal writes when the outcome cannot be queued", async () => { mockEntityPerProfile(); - const caseUpdate = mock(async () => undefined); KycCase.findOne = mock(async () => ({ customerEntityId: "entity-user-1", + id: "case-1", + providerCaseId: "attempt-1", providerCustomerId: "customer-1", - update: caseUpdate })) as unknown as typeof KycCase.findOne; const customerUpdate = mock(async () => undefined); ProviderCustomer.findByPk = mock(async () => ({ customerEntityId: "entity-user-1", provider: "avenia", + providerSubaccountId: "subaccount-1", update: customerUpdate })) as unknown as typeof ProviderCustomer.findByPk; mockApprovedAttempt(); @@ -835,11 +958,48 @@ describe("Avenia company KYB", () => { // The case stays non-terminal, so the next poll re-observes the outcome and retries. expect(res.statusCode).toBe(httpStatus.INTERNAL_SERVER_ERROR); expect(customerUpdate).not.toHaveBeenCalled(); - expect(caseUpdate).not.toHaveBeenCalled(); } finally { EmailNotification.findOne = realNotificationFindOne; } }); + + it("does not let an old attempt poll overwrite its replacement", async () => { + mockEntityPerProfile(); + KycCase.findOne = mock(async () => ({ + customerEntityId: "entity-user-1", + id: "case-1", + providerCaseId: "attempt-old", + providerCustomerId: "customer-1" + })) as unknown as typeof KycCase.findOne; + const customerUpdate = mock(async () => undefined); + ProviderCustomer.findByPk = mock(async () => ({ + customerEntityId: "entity-user-1", + provider: "avenia", + providerSubaccountId: "subaccount-1", + update: customerUpdate + })) as unknown as typeof ProviderCustomer.findByPk; + staticCaseUpdate.mockImplementation(async () => [0]); + BrlaApiService.getInstance = mock( + () => + ({ + getKybAttemptStatus: mock(async () => ({ + attempt: { + id: "attempt-old", + result: KycAttemptResult.REJECTED, + resultMessage: "rejected", + retryable: true, + status: KycAttemptStatus.COMPLETED + } + })) + }) as unknown as BrlaApiService + ); + + const res = createResponse(); + await getKybAttemptStatus({ query: { attemptId: "attempt-old" }, userId: "user-1" } as any, res as any); + + expect(res.statusCode).toBe(httpStatus.CONFLICT); + expect(customerUpdate).not.toHaveBeenCalled(); + }); }); describe("createSubaccount", () => { @@ -1146,3 +1306,365 @@ describe("getUploadUrls", () => { expect(uploadUrlsMock).not.toHaveBeenCalled(); }); }); + +describe("Avenia API KYB", () => { + const originals = { + caseCreate: KycCase.create, + caseFindOrCreate: KycCase.findOrCreate, + caseFindOne: KycCase.findOne, + caseUpdate: KycCase.update, + getInstance: BrlaApiService.getInstance, + providerFindOne: ProviderCustomer.findOne, + transaction: sequelize.transaction + }; + + const validSubmission = { + 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" as const, + estimatedMonthlyVolumeUsd: "2000", + numberOfEmployees: "1-10" as const, + reasonForAccountOpening: "receive_payments_for_goods_and_services" as const, + sourceOfFundsAndIncome: "sales_of_goods_and_services" as const, + taxIdentificationDocumentId: "tax-document-1", + taxIdentificationNumberTin: "42731085000167", + uboIds: ["ubo-1"] + }; + + beforeEach(() => { + logger.error = mock(() => logger) as typeof logger.error; + CustomerEntity.findAll = mock(async () => [{ id: "entity-user-1" }]) as unknown as typeof CustomerEntity.findAll; + }); + + afterEach(() => { + KycCase.create = originals.caseCreate; + KycCase.findOrCreate = originals.caseFindOrCreate; + KycCase.findOne = originals.caseFindOne; + KycCase.update = originals.caseUpdate; + BrlaApiService.getInstance = originals.getInstance; + ProviderCustomer.findOne = originals.providerFindOne; + sequelize.transaction = originals.transaction; + }); + + function mockBusinessAccount(update = mock(async () => undefined)) { + ProviderCustomer.findOne = mock(async () => ({ + customerEntityId: "entity-user-1", + customerType: "business", + id: "customer-1", + providerSubaccountId: "subaccount-1", + status: VerificationStatus.Pending, + statusExternal: null, + update + })) as unknown as typeof ProviderCustomer.findOne; + return update; + } + + function documentResponse(id: string) { + const documentType = + id === "certificate-1" + ? AveniaDocumentType.CERTIFICATE_OF_INCORPORATION + : id === "tax-document-1" + ? AveniaDocumentType.COMPANY_TAX_IDENTIFICATION_DOCUMENT + : AveniaDocumentType.PASSPORT; + return { + document: { documentType, id, ready: true, uploadStatusFront: "PROCESSED" } + }; + } + + function mockInitialSubmission(options: { submitError?: Error } = {}) { + const customerUpdate = mockBusinessAccount(); + const caseUpdate = mock(async () => undefined); + KycCase.findOrCreate = mock(async () => [ + { + id: "case-1", + providerCaseId: null, + submissionStatus: "not_started", + update: caseUpdate + }, + false + ]) as unknown as typeof KycCase.findOrCreate; + const claim = mock(async () => [1]); + KycCase.update = claim as unknown as typeof KycCase.update; + sequelize.transaction = mock(async callback => callback({} as never)) as unknown as typeof sequelize.transaction; + const submit = options.submitError + ? mock(async () => { + throw options.submitError; + }) + : mock(async () => ({ id: "attempt-1" })); + BrlaApiService.getInstance = mock( + () => + ({ + getUploadedDocument: mock(async (id: string) => documentResponse(id)), + submitKybLevel1: submit + }) as unknown as BrlaApiService + ); + return { caseUpdate, claim, customerUpdate, submit }; + } + + it("creates a company document for a profile-bound secret key", async () => { + mockBusinessAccount(); + const createDocument = mock(async () => ({ id: "document-1", uploadURLFront: "https://upload.example" })); + BrlaApiService.getInstance = mock( + () => ({ getDocumentUploadUrls: createDocument }) as unknown as BrlaApiService + ); + + const res = createResponse(); + await createKybDocument( + { + body: { documentType: AveniaDocumentType.CERTIFICATE_OF_INCORPORATION }, + credential: { profileId: "user-1" }, + query: { subAccountId: "subaccount-1" } + } as any, + res as any + ); + + expect(res.statusCode).toBe(httpStatus.CREATED); + expect(createDocument).toHaveBeenCalledWith( + AveniaDocumentType.CERTIFICATE_OF_INCORPORATION, + false, + "subaccount-1" + ); + }); + + it("does not expose another profile's subaccount to document creation", async () => { + CustomerEntity.findAll = mock(async () => [{ id: "entity-attacker" }]) as unknown as typeof CustomerEntity.findAll; + mockBusinessAccount(); + const createDocument = mock(async () => ({ id: "document-1" })); + BrlaApiService.getInstance = mock( + () => ({ getDocumentUploadUrls: createDocument }) as unknown as BrlaApiService + ); + + const res = createResponse(); + await createKybDocument( + { + body: { documentType: AveniaDocumentType.CERTIFICATE_OF_INCORPORATION }, + query: { subAccountId: "subaccount-1" }, + userId: "attacker" + } as any, + res as any + ); + + expect(res.statusCode).toBe(httpStatus.FORBIDDEN); + expect(createDocument).not.toHaveBeenCalled(); + }); + + it("rejects UBO creation until its identification document is ready", async () => { + mockBusinessAccount(); + const createUbo = mock(async () => ({ id: "ubo-1" })); + BrlaApiService.getInstance = mock( + () => + ({ + createUbo, + getUploadedDocument: mock(async () => ({ + document: { + documentType: AveniaDocumentType.PASSPORT, + id: "identity-1", + ready: false, + uploadStatusFront: "PROCESSING" + } + })) + }) as unknown as BrlaApiService + ); + + const res = createResponse(); + await createKybUbo( + { + body: { uploadedIdentificationId: "identity-1" }, + query: { subAccountId: "subaccount-1" }, + userId: "user-1" + } as any, + res as any + ); + + expect(res.statusCode).toBe(httpStatus.CONFLICT); + expect(createUbo).not.toHaveBeenCalled(); + }); + + it("submits ready company documents and persists the pending attempt", async () => { + const { caseUpdate, claim, customerUpdate, submit } = mockInitialSubmission(); + + const res = createResponse(); + await submitKybLevel1Api( + { body: validSubmission, query: { subAccountId: "subaccount-1" }, userId: "user-1" } as any, + res as any + ); + + expect(res.statusCode).toBe(httpStatus.OK); + expect(submit).toHaveBeenCalledWith(validSubmission, "subaccount-1"); + expect(claim).toHaveBeenCalledWith( + expect.objectContaining({ submissionStatus: "submitting" }), + expect.objectContaining({ + where: { id: "case-1", providerCaseId: null, submissionStatus: "not_started" } + }) + ); + expect(customerUpdate).toHaveBeenCalledWith( + expect.objectContaining({ status: VerificationStatus.Pending, statusExternal: KycAttemptStatus.PENDING }), + expect.anything() + ); + expect(caseUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + failureReasons: [], + providerCaseId: "attempt-1", + status: VerificationStatus.Pending, + submissionStatus: "submitted" + }), + expect.anything() + ); + }); + + it("allows a new attempt only after a provider-confirmed retryable rejection", async () => { + mockBusinessAccount(); + const caseUpdate = mock(async () => undefined); + KycCase.findOrCreate = mock(async () => [ + { + id: "case-1", + providerCaseId: "attempt-old", + submissionStatus: "submitted", + update: caseUpdate + }, + false + ]) as unknown as typeof KycCase.findOrCreate; + KycCase.update = mock(async () => [1]) as unknown as typeof KycCase.update; + sequelize.transaction = mock(async callback => callback({} as never)) as unknown as typeof sequelize.transaction; + const submit = mock(async () => ({ id: "attempt-new" })); + BrlaApiService.getInstance = mock( + () => + ({ + getKybAttemptStatus: mock(async () => ({ + attempt: { + id: "attempt-old", + result: KycAttemptResult.REJECTED, + retryable: true, + status: KycAttemptStatus.COMPLETED + } + })), + getUploadedDocument: mock(async (id: string) => documentResponse(id)), + submitKybLevel1: submit + }) as unknown as BrlaApiService + ); + + const res = createResponse(); + await submitKybLevel1Api( + { body: validSubmission, query: { subAccountId: "subaccount-1" }, userId: "user-1" } as any, + res as any + ); + + expect(res.statusCode).toBe(httpStatus.OK); + expect(caseUpdate).toHaveBeenCalledWith( + expect.objectContaining({ providerCaseId: "attempt-new", rejectedAt: null }), + expect.anything() + ); + }); + + it("rejects resubmission after a non-retryable provider decision", async () => { + mockBusinessAccount(); + KycCase.findOrCreate = mock(async () => [ + { + id: "case-1", + providerCaseId: "attempt-old", + submissionStatus: "submitted" + }, + false + ]) as unknown as typeof KycCase.findOrCreate; + const submit = mock(async () => ({ id: "attempt-new" })); + BrlaApiService.getInstance = mock( + () => + ({ + getKybAttemptStatus: mock(async () => ({ + attempt: { + id: "attempt-old", + result: KycAttemptResult.REJECTED, + retryable: false, + status: KycAttemptStatus.COMPLETED + } + })), + submitKybLevel1: submit + }) as unknown as BrlaApiService + ); + + const res = createResponse(); + await submitKybLevel1Api( + { body: validSubmission, query: { subAccountId: "subaccount-1" }, userId: "user-1" } as any, + res as any + ); + + expect(res.statusCode).toBe(httpStatus.CONFLICT); + expect(submit).not.toHaveBeenCalled(); + }); + + it("reconciles an accepted attempt after the original response was lost", async () => { + const customerUpdate = mockBusinessAccount(); + const caseUpdate = mock(async () => undefined); + KycCase.findOrCreate = mock(async () => [ + { + id: "case-1", + providerCaseId: null, + submissionRequestHash: hashAveniaKybSubmission(validSubmission), + submissionStartedAt: new Date("2026-08-06T12:00:00.000Z"), + submissionStatus: "unknown", + set: mock(() => undefined), + update: caseUpdate + }, + false + ]) as unknown as typeof KycCase.findOrCreate; + sequelize.transaction = mock(async callback => callback({} as never)) as unknown as typeof sequelize.transaction; + const submit = mock(async () => ({ id: "duplicate-attempt" })); + BrlaApiService.getInstance = mock( + () => + ({ + getKycAttempts: mock(async () => ({ + attempts: [ + { + createdAt: "2026-08-06T12:00:01.000Z", + id: "recovered-attempt", + levelName: "kyb-level-1" + } + ] + })), + submitKybLevel1: submit + }) as unknown as BrlaApiService + ); + + const res = createResponse(); + await submitKybLevel1Api( + { body: validSubmission, query: { subAccountId: "subaccount-1" }, userId: "user-1" } as any, + res as any + ); + + expect(res.statusCode).toBe(httpStatus.OK); + expect(res.body).toEqual({ id: "recovered-attempt" }); + expect(submit).not.toHaveBeenCalled(); + expect(customerUpdate).toHaveBeenCalled(); + expect(caseUpdate).toHaveBeenCalledWith( + expect.objectContaining({ providerCaseId: "recovered-attempt", submissionStatus: "submitted" }), + expect.anything() + ); + }); + + it("marks an ambiguous provider submission failure unknown instead of replayable", async () => { + const providerError = new BrlaApiError({ + endpoint: "/v2/kyc/new-level-1/api", + method: "POST", + responseBody: "connection reset", + status: 0 + }); + const { caseUpdate } = mockInitialSubmission({ submitError: providerError }); + + const res = createResponse(); + await submitKybLevel1Api( + { body: validSubmission, query: { subAccountId: "subaccount-1" }, userId: "user-1" } as any, + res as any + ); + + expect(res.statusCode).toBe(httpStatus.BAD_GATEWAY); + expect(caseUpdate).toHaveBeenCalledWith({ submissionStatus: "unknown" }); + }); +}); diff --git a/apps/api/src/api/controllers/brla.controller.ts b/apps/api/src/api/controllers/brla.controller.ts index 810b7f77d..e1bd15f84 100644 --- a/apps/api/src/api/controllers/brla.controller.ts +++ b/apps/api/src/api/controllers/brla.controller.ts @@ -1,8 +1,13 @@ import { AveniaAccountType, + AveniaDocumentResponse, AveniaDocumentType, AveniaKYCDataUpload, AveniaKYCDataUploadRequest, + AveniaKybLevel1Payload, + AveniaUboPayload, + AveniaUboResponse, + BrlaApiError, BrlaApiService, BrlaCreateSubaccountRequest, BrlaCreateSubaccountResponse, @@ -19,6 +24,8 @@ import { BrlaPostRecordInitialKycAttemptRequest, BrlaValidatePixKeyRequest, BrlaValidatePixKeyResponse, + DocumentUploadRequest, + DocumentUploadResponse, isValidCnpj, isValidCpf, KybAttemptStatusResponse, @@ -33,6 +40,9 @@ import { } from "@vortexfi/shared"; import { Request, Response } from "express"; import httpStatus from "http-status"; +import { Op } from "sequelize"; +import { ZodError } from "zod"; +import sequelize from "../../config/database"; import logger from "../../config/logger"; import CustomerEntity from "../../models/customerEntity.model"; import KycCase from "../../models/kycCase.model"; @@ -49,6 +59,16 @@ import { updateAveniaKycOutcome, upsertAveniaKycCase } from "../services/avenia/avenia-customer.service"; +import { + AVENIA_IDENTITY_DOCUMENT_TYPES, + assertAveniaKybCanSubmit, + claimAveniaKybSubmission, + getOrCreateAveniaKybCase, + hashAveniaKybSubmission, + reconcileAveniaKybSubmission, + requireReadyAveniaDocument, + resolveOwnedAveniaBusinessAccount +} from "../services/avenia/avenia-kyb.service"; import { enqueueVerificationNotification } from "../services/avenia/verification-notifications"; import { resolveAveniaAccountForUser } from "../services/avenia-account"; import { findCustomerEntityIdsForProfile, getOrCreateCustomerEntityForProfile } from "../services/customer-entity.service"; @@ -84,6 +104,16 @@ function handleApiError(error: unknown, res: Response, apiMethod: string): void return; } + if (error instanceof BrlaApiError && error.status !== 400) { + res.status(httpStatus.BAD_GATEWAY).json({ error: "Avenia request failed" }); + return; + } + + if (error instanceof ZodError) { + res.status(httpStatus.BAD_GATEWAY).json({ error: "Avenia returned an invalid response" }); + return; + } + if (error instanceof Error && error.message.includes("status '400'")) { const splitError = error.message.split("Error: ", 2); if (splitError.length > 1) { @@ -720,6 +750,163 @@ export const newKyc = async ( } }; +async function resolveAveniaKybAccount( + req: Pick, + subAccountId: string | undefined +): Promise { + const effectiveUserId = getEffectiveUserId(req); + if (!effectiveUserId) { + throw new APIError({ message: "This endpoint requires authentication.", status: httpStatus.BAD_REQUEST }); + } + return resolveOwnedAveniaBusinessAccount(effectiveUserId, subAccountId); +} + +export const createKybDocument = async ( + req: Request, + res: Response +): Promise => { + try { + const record = await resolveAveniaKybAccount(req, req.query.subAccountId); + const response = await BrlaApiService.getInstance().getDocumentUploadUrls( + req.body.documentType, + req.body.isDoubleSided ?? false, + record.providerSubaccountId as string + ); + res.status(httpStatus.CREATED).json(response); + } catch (error) { + handleApiError(error, res, "createKybDocument"); + } +}; + +export const getKybDocument = async ( + req: Request<{ documentId: string }, unknown, unknown, { subAccountId?: string }>, + res: Response +): Promise => { + try { + const record = await resolveAveniaKybAccount(req, req.query.subAccountId); + const response: AveniaDocumentResponse = await BrlaApiService.getInstance().getUploadedDocument( + req.params.documentId, + record.providerSubaccountId as string + ); + if (response.document.id !== req.params.documentId) { + throw new APIError({ message: "Avenia returned a mismatched document", status: httpStatus.BAD_GATEWAY }); + } + const { document } = response; + res.status(httpStatus.OK).json({ + document: { + documentType: document.documentType, + id: document.id, + ready: document.ready, + ...(document.uploadErrorBack ? { uploadErrorBack: document.uploadErrorBack } : {}), + ...(document.uploadErrorFront ? { uploadErrorFront: document.uploadErrorFront } : {}), + ...(document.uploadStatusBack ? { uploadStatusBack: document.uploadStatusBack } : {}), + uploadStatusFront: document.uploadStatusFront + } + }); + } catch (error) { + handleApiError(error, res, "getKybDocument"); + } +}; + +export const createKybUbo = async ( + req: Request, + res: Response +): Promise => { + try { + const record = await resolveAveniaKybAccount(req, req.query.subAccountId); + const brlaApiService = BrlaApiService.getInstance(); + const subAccountId = record.providerSubaccountId as string; + await requireReadyAveniaDocument( + brlaApiService, + subAccountId, + req.body.uploadedIdentificationId, + AVENIA_IDENTITY_DOCUMENT_TYPES + ); + if (req.body.uploadedSelfieId) { + await requireReadyAveniaDocument(brlaApiService, subAccountId, req.body.uploadedSelfieId, [ + AveniaDocumentType.SELFIE_FROM_LIVENESS + ]); + } + const response = await brlaApiService.createUbo(req.body, subAccountId); + res.status(httpStatus.CREATED).json(response); + } catch (error) { + handleApiError(error, res, "createKybUbo"); + } +}; + +export const submitKybLevel1Api = async ( + req: Request, + res: Response +): Promise => { + try { + const record = await resolveAveniaKybAccount(req, req.query.subAccountId); + const subAccountId = record.providerSubaccountId as string; + const brlaApiService = BrlaApiService.getInstance(); + const kycCase = await getOrCreateAveniaKybCase(record); + const requestHash = hashAveniaKybSubmission(req.body); + const reconciledAttemptId = await reconcileAveniaKybSubmission(brlaApiService, record, kycCase, subAccountId, requestHash); + if (reconciledAttemptId) { + res.status(httpStatus.OK).json({ id: reconciledAttemptId }); + return; + } + await assertAveniaKybCanSubmit(brlaApiService, record, kycCase, subAccountId); + await Promise.all([ + requireReadyAveniaDocument(brlaApiService, subAccountId, req.body.certificateOfIncorporationDocumentId, [ + AveniaDocumentType.CERTIFICATE_OF_INCORPORATION + ]), + requireReadyAveniaDocument(brlaApiService, subAccountId, req.body.taxIdentificationDocumentId, [ + AveniaDocumentType.COMPANY_TAX_IDENTIFICATION_DOCUMENT + ]) + ]); + + await claimAveniaKybSubmission(kycCase, requestHash); + let response: KycLevel1Response; + try { + response = await brlaApiService.submitKybLevel1(req.body, subAccountId); + } catch (error) { + const knownRejection = error instanceof BrlaApiError && error.status === httpStatus.BAD_REQUEST; + await kycCase.update({ + submissionStatus: knownRejection && !kycCase.providerCaseId ? "not_started" : knownRejection ? "submitted" : "unknown" + }); + throw error; + } + + const now = new Date(); + try { + await sequelize.transaction(async transaction => { + await record.update( + { + lastFailureReasons: [], + status: VerificationStatus.Pending, + statusExternal: KycAttemptStatus.PENDING + }, + { transaction } + ); + await kycCase.update( + { + approvedAt: null, + failureReasons: [], + providerCaseId: response.id, + rejectedAt: null, + status: VerificationStatus.Pending, + statusExternal: KycAttemptStatus.PENDING, + submissionStatus: "submitted", + submittedAt: now + }, + { transaction } + ); + }); + } catch (error) { + logger.error("Failed to persist the accepted Avenia KYB attempt", { attemptId: response.id, error }); + await kycCase.update({ submissionStatus: "unknown" }); + throw error; + } + res.status(httpStatus.OK).json(response); + } catch (error) { + handleApiError(error, res, "submitKybLevel1Api"); + } +}; + /** * Initiates KYB Level 1 verification process using the Web SDK * @@ -774,29 +961,37 @@ export const initiateKybLevel1 = async ( const existingKybCase = await KycCase.findOne({ where: { providerCustomerId: record.id, type: "kyb" } }); + const brlaApiService = BrlaApiService.getInstance(); + const kycCase = existingKybCase ?? (await getOrCreateAveniaKybCase(record)); + const requestHash = hashAveniaKybSubmission({ flow: "web-sdk" }); + if (kycCase.submissionRequestHash && kycCase.submissionRequestHash !== requestHash) { + res.status(httpStatus.CONFLICT).json({ error: "An API-based KYB attempt is already bound to this company" }); + return; + } + await reconcileAveniaKybSubmission(brlaApiService, record, kycCase, subAccountId, requestHash); // A PENDING attempt means the user never completed Avenia's hosted steps. The hosted URLs are // not stored, so re-initiation is the only way to surface them again — allow it and rebind the // case to the fresh attempt. Only an attempt Avenia is processing (or has decided) blocks. if ( - existingKybCase?.providerCaseId && + kycCase.providerCaseId && record.status !== VerificationStatus.Rejected && - existingKybCase.statusExternal !== KycAttemptStatus.EXPIRED && - existingKybCase.statusExternal !== KycAttemptStatus.PENDING + kycCase.statusExternal !== KycAttemptStatus.EXPIRED && + kycCase.statusExternal !== KycAttemptStatus.PENDING ) { res.status(httpStatus.CONFLICT).json({ error: "A KYB attempt is already in progress" }); return; } - const brlaApiService = BrlaApiService.getInstance(); - // The stored status can lag (the hosted steps may have just been finished in another tab): // probe the live attempt before re-initiating so a processing/approved attempt is not // orphaned by rebinding the case to a fresh one. A rejected decision stays re-initiable - // (that is the retry path), and a failing probe must not lock the user out of resuming. - if (existingKybCase?.providerCaseId) { + // (that is the retry path). A failed probe must fail closed because rebinding could orphan + // an API or hosted attempt that Avenia already accepted. + if (kycCase.providerCaseId) { try { - const { attempt } = await brlaApiService.getKybAttemptStatus(existingKybCase.providerCaseId); - const decidedRejected = attempt.status === KycAttemptStatus.COMPLETED && attempt.result === KycAttemptResult.REJECTED; + const { attempt } = await brlaApiService.getKybAttemptStatus(kycCase.providerCaseId, subAccountId); + const decidedRejected = + attempt.status === KycAttemptStatus.COMPLETED && attempt.result === KycAttemptResult.REJECTED && attempt.retryable; const resumable = attempt.status === KycAttemptStatus.PENDING || attempt.status === KycAttemptStatus.EXPIRED || decidedRejected; if (!resumable) { @@ -804,11 +999,24 @@ export const initiateKybLevel1 = async ( return; } } catch { - // Re-initiation is the only path back to the hosted steps; keep it available if the probe fails. + throw new APIError({ + message: "Unable to verify the existing KYB attempt before re-initiation", + status: httpStatus.BAD_GATEWAY + }); } } - const response = await brlaApiService.initiateKybLevel1(subAccountId); + await claimAveniaKybSubmission(kycCase, requestHash); + let response: KybLevel1Response; + try { + response = await brlaApiService.initiateKybLevel1(subAccountId); + } catch (error) { + const knownRejection = error instanceof BrlaApiError && error.status === httpStatus.BAD_REQUEST; + await kycCase.update({ + submissionStatus: knownRejection && !kycCase.providerCaseId ? "not_started" : knownRejection ? "submitted" : "unknown" + }); + throw error; + } // The attempt starts PENDING at Avenia — nothing is submitted until the user finishes the hosted // steps — so our status stays pending (dashboard keeps offering Continue). in_review is set only // once Avenia reports PROCESSING. @@ -867,18 +1075,25 @@ export const getKybAttemptStatus = async ( } const record = kycCase.providerCustomerId ? await ProviderCustomer.findByPk(kycCase.providerCustomerId) : null; - if (!record || !ownedEntityIds.includes(record.customerEntityId) || record.provider !== "avenia") { + if ( + !record || + !record.providerSubaccountId || + !ownedEntityIds.includes(record.customerEntityId) || + record.provider !== "avenia" + ) { res.status(httpStatus.NOT_FOUND).json({ error: "KYB account not found" }); return; } if (record.status === VerificationStatus.Approved) { - res.status(httpStatus.OK).json({ result: KycAttemptResult.APPROVED, status: KycAttemptStatus.COMPLETED }); + res + .status(httpStatus.OK) + .json({ result: KycAttemptResult.APPROVED, retryable: false, status: KycAttemptStatus.COMPLETED }); return; } const brlaApiService = BrlaApiService.getInstance(); - const response = await brlaApiService.getKybAttemptStatus(attemptId); + const response = await brlaApiService.getKybAttemptStatus(attemptId, record.providerSubaccountId); const attempt = response.attempt; if (attempt.id !== attemptId) { throw new APIError({ message: "Avenia returned a mismatched KYB attempt", status: httpStatus.BAD_GATEWAY }); @@ -900,29 +1115,58 @@ export const getKybAttemptStatus = async ( ...(approved ? { approvedAt: new Date(), rejectedAt: null } : {}), ...(rejected ? { approvedAt: null, rejectedAt: new Date() } : {}) }; + const nonTerminalStatuses = [VerificationStatus.Pending, VerificationStatus.Started, VerificationStatus.InReview]; + const updateWhere = { + id: kycCase.id, + providerCaseId: attemptId, + status: { [Op.in]: nonTerminalStatuses }, + ...(attempt.status === KycAttemptStatus.PENDING + ? { [Op.or]: [{ statusExternal: null }, { statusExternal: KycAttemptStatus.PENDING }] } + : attempt.status === KycAttemptStatus.PROCESSING + ? { + [Op.or]: [ + { statusExternal: null }, + { statusExternal: { [Op.in]: [KycAttemptStatus.PENDING, KycAttemptStatus.PROCESSING] } } + ] + } + : {}) + }; - // Queue before persisting a terminal status: once the case is Approved/Rejected the - // short-circuit above and the KYB worker's filters both stop observing the attempt, - // so enqueuing afterwards could lose the email forever if the webhook never fired. - // Keyed on the attempt id, so the webhook or worker racing this poll cannot - // double-send; a failed enqueue fails the request and leaves the case pollable. - await enqueueVerificationNotification(attempt, effectiveUserId, "business"); - - await record.update({ - lastFailureReasons: failureReason ? [failureReason] : [], - status: normalizedStatus, - statusExternal: attempt.status - }); - await kycCase.update({ - failureReasons: failureReason ? [failureReason] : [], - status: normalizedStatus, - statusExternal: attempt.status, - ...lifecycle + const persisted = await sequelize.transaction(async transaction => { + const [updatedCases] = await KycCase.update( + { + failureReasons: failureReason ? [failureReason] : [], + status: normalizedStatus, + statusExternal: attempt.status, + ...lifecycle + }, + { transaction, where: updateWhere } + ); + if (updatedCases !== 1) { + return false; + } + + // Queue only after proving this is still the bound attempt. A queue failure rolls + // back the terminal state so a later poll can retry the notification. + await enqueueVerificationNotification(attempt, effectiveUserId, "business"); + await record.update( + { + lastFailureReasons: failureReason ? [failureReason] : [], + status: normalizedStatus, + statusExternal: attempt.status + }, + { transaction } + ); + return true; }); + if (!persisted) { + throw new APIError({ message: "This KYB attempt is no longer current", status: httpStatus.CONFLICT }); + } res.status(httpStatus.OK).json({ ...(failureReason ? { failureReason } : {}), ...(attempt.result ? { result: attempt.result } : {}), + retryable: attempt.retryable, status: attempt.status }); } catch (error) { diff --git a/apps/api/src/api/middlewares/validators.test.ts b/apps/api/src/api/middlewares/validators.test.ts index d8c647b2b..117798d80 100644 --- a/apps/api/src/api/middlewares/validators.test.ts +++ b/apps/api/src/api/middlewares/validators.test.ts @@ -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 & { statusCode?: number; body?: unknown } = {}; @@ -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 = { diff --git a/apps/api/src/api/middlewares/validators.ts b/apps/api/src/api/middlewares/validators.ts index 804851c1d..068d15332 100644 --- a/apps/api/src/api/middlewares/validators.ts +++ b/apps/api/src/api/middlewares/validators.ts @@ -1,5 +1,8 @@ import { + AveniaDocumentType, AveniaKYCDataUploadRequest, + AveniaKybLevel1Payload, + AveniaUboPayload, CreateAveniaSubaccountRequest, CreateBestQuoteRequest, CreateQuoteRequest, @@ -9,6 +12,7 @@ import { getCaseSensitiveNetwork, isSupportedFiatCurrency, isValidAveniaAccountType, + isValidCpf, isValidCurrencyForDirection, isValidDirection, isValidKYCDocType, @@ -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"; @@ -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 = 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 = 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); diff --git a/apps/api/src/api/routes/v1/brla.route.ts b/apps/api/src/api/routes/v1/brla.route.ts index 11c588d43..65d184eca 100644 --- a/apps/api/src/api/routes/v1/brla.route.ts +++ b/apps/api/src/api/routes/v1/brla.route.ts @@ -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 }); @@ -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") diff --git a/apps/api/src/api/services/avenia/avenia-customer.service.ts b/apps/api/src/api/services/avenia/avenia-customer.service.ts index 6f6f7e6d7..9d896d36d 100644 --- a/apps/api/src/api/services/avenia/avenia-customer.service.ts +++ b/apps/api/src/api/services/avenia/avenia-customer.service.ts @@ -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({ @@ -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 }); diff --git a/apps/api/src/api/services/avenia/avenia-kyb.service.ts b/apps/api/src/api/services/avenia/avenia-kyb.service.ts new file mode 100644 index 000000000..d3b2785c7 --- /dev/null +++ b/apps/api/src/api/services/avenia/avenia-kyb.service.ts @@ -0,0 +1,199 @@ +import crypto from "node:crypto"; +import { + AveniaDocument, + AveniaDocumentType, + AveniaKybLevel1Payload, + BrlaApiService, + KycAttemptResult, + KycAttemptStatus +} from "@vortexfi/shared"; +import httpStatus from "http-status"; +import sequelize from "../../../config/database"; +import KycCase from "../../../models/kycCase.model"; +import ProviderCustomer, { VerificationStatus } from "../../../models/providerCustomer.model"; +import { APIError } from "../../errors/api-error"; +import { findCustomerEntityIdsForProfile } from "../customer-entity.service"; +import { findAveniaCustomerBySubaccountId } from "./avenia-customer.service"; + +export async function resolveOwnedAveniaBusinessAccount( + profileId: string, + subAccountId: string | undefined +): Promise { + if (!subAccountId) { + throw new APIError({ message: "Missing subAccountId", status: httpStatus.BAD_REQUEST }); + } + + const record = await findAveniaCustomerBySubaccountId(subAccountId); + if (!record) { + throw new APIError({ message: "Subaccount not found", status: httpStatus.NOT_FOUND }); + } + const ownedEntityIds = await findCustomerEntityIdsForProfile(profileId); + if (!ownedEntityIds.includes(record.customerEntityId)) { + throw new APIError({ message: "This subaccount is not linked to your user profile.", status: httpStatus.FORBIDDEN }); + } + if (record.customerType !== "business") { + throw new APIError({ message: "KYB Level 1 is only available for COMPANY accounts.", status: httpStatus.BAD_REQUEST }); + } + return record; +} + +export async function getOrCreateAveniaKybCase(record: ProviderCustomer): Promise { + const [kycCase] = await KycCase.findOrCreate({ + defaults: { + customerEntityId: record.customerEntityId, + level: "level_1", + provider: "avenia", + status: record.status, + statusExternal: record.statusExternal, + type: "kyb" + }, + where: { providerCustomerId: record.id } + }); + return kycCase; +} + +export async function requireReadyAveniaDocument( + brlaApiService: BrlaApiService, + subAccountId: string, + documentId: string, + allowedTypes: AveniaDocumentType[] +): Promise { + const { document } = await brlaApiService.getUploadedDocument(documentId, subAccountId); + if (document.id !== documentId) { + throw new APIError({ message: "Avenia returned a mismatched document", status: httpStatus.BAD_GATEWAY }); + } + if (!allowedTypes.includes(document.documentType)) { + throw new APIError({ message: "Document type does not match this KYB field", status: httpStatus.BAD_REQUEST }); + } + if (!document.ready) { + throw new APIError({ message: "Document is not ready", status: httpStatus.CONFLICT }); + } + return document; +} + +export async function assertAveniaKybCanSubmit( + brlaApiService: BrlaApiService, + record: ProviderCustomer, + kycCase: KycCase, + subAccountId: string +): Promise { + if (record.status === VerificationStatus.Approved) { + throw new APIError({ message: "This company is already approved", status: httpStatus.CONFLICT }); + } + if (kycCase.submissionStatus === "submitting") { + throw new APIError({ message: "A KYB submission is already in progress", status: httpStatus.CONFLICT }); + } + if (kycCase.submissionStatus === "unknown") { + throw new APIError({ + message: "The previous KYB submission outcome is unknown and must be reconciled before retrying", + status: httpStatus.CONFLICT + }); + } + if (!kycCase.providerCaseId) { + return; + } + + const { attempt } = await brlaApiService.getKybAttemptStatus(kycCase.providerCaseId, subAccountId); + if (attempt.id !== kycCase.providerCaseId) { + throw new APIError({ message: "Avenia returned a mismatched KYB attempt", status: httpStatus.BAD_GATEWAY }); + } + const retryableRejection = + attempt.status === KycAttemptStatus.COMPLETED && attempt.result === KycAttemptResult.REJECTED && attempt.retryable; + if (!retryableRejection) { + throw new APIError({ + message: + attempt.status === KycAttemptStatus.COMPLETED && attempt.result === KycAttemptResult.REJECTED + ? "This KYB rejection is not retryable" + : "A KYB attempt is already in progress or has been approved", + status: httpStatus.CONFLICT + }); + } +} + +export function hashAveniaKybSubmission(payload: AveniaKybLevel1Payload | Record): string { + return crypto.createHash("sha256").update(JSON.stringify(payload), "utf8").digest("hex"); +} + +export async function reconcileAveniaKybSubmission( + brlaApiService: BrlaApiService, + record: ProviderCustomer, + kycCase: KycCase, + subAccountId: string, + requestHash: string +): Promise { + if (kycCase.submissionStatus !== "submitting" && kycCase.submissionStatus !== "unknown") { + return null; + } + if (kycCase.submissionRequestHash && kycCase.submissionRequestHash !== requestHash) { + throw new APIError({ + message: "The previous KYB submission used different input and must be reconciled first", + status: httpStatus.CONFLICT + }); + } + if (!kycCase.submissionStartedAt) { + throw new APIError({ message: "The previous KYB submission must be reconciled first", status: httpStatus.CONFLICT }); + } + + const { attempts } = await brlaApiService.getKycAttempts(subAccountId); + const startedAt = kycCase.submissionStartedAt.getTime(); + const matchingAttempts = attempts.filter( + attempt => attempt.levelName === "kyb-level-1" && new Date(attempt.createdAt).getTime() >= startedAt + ); + if (matchingAttempts.length > 1) { + throw new APIError({ message: "Multiple KYB attempts require manual reconciliation", status: httpStatus.CONFLICT }); + } + if (matchingAttempts.length === 1) { + const attemptId = matchingAttempts[0].id; + await sequelize.transaction(async transaction => { + await record.update( + { lastFailureReasons: [], status: VerificationStatus.Pending, statusExternal: KycAttemptStatus.PENDING }, + { transaction } + ); + await kycCase.update( + { + failureReasons: [], + providerCaseId: attemptId, + status: VerificationStatus.Pending, + statusExternal: KycAttemptStatus.PENDING, + submissionStatus: "submitted", + submittedAt: kycCase.submissionStartedAt + }, + { transaction } + ); + }); + kycCase.set({ + providerCaseId: attemptId, + statusExternal: KycAttemptStatus.PENDING, + submissionStatus: "submitted" + }); + return attemptId; + } + + throw new APIError({ + message: "No matching KYB attempt was found; manual reconciliation is required before retrying", + status: httpStatus.CONFLICT + }); +} + +export async function claimAveniaKybSubmission(kycCase: KycCase, requestHash: string): Promise { + const [claimed] = await KycCase.update( + { submissionRequestHash: requestHash, submissionStartedAt: new Date(), submissionStatus: "submitting" }, + { + where: { + id: kycCase.id, + providerCaseId: kycCase.providerCaseId, + submissionStatus: kycCase.submissionStatus + } + } + ); + if (claimed !== 1) { + throw new APIError({ message: "A KYB submission is already in progress", status: httpStatus.CONFLICT }); + } +} + +export const AVENIA_IDENTITY_DOCUMENT_TYPES = [ + AveniaDocumentType.ID, + AveniaDocumentType.DRIVERS_LICENSE, + AveniaDocumentType.PASSPORT, + AveniaDocumentType.RESIDENCE_PERMIT +]; diff --git a/apps/api/src/database/kyb-submission-migration.test.ts b/apps/api/src/database/kyb-submission-migration.test.ts new file mode 100644 index 000000000..51093f0fd --- /dev/null +++ b/apps/api/src/database/kyb-submission-migration.test.ts @@ -0,0 +1,16 @@ +import { expect, mock, test } from "bun:test"; +import type { QueryInterface } from "sequelize"; +import { up } from "./migrations/062-add-kyb-submission-state"; + +test("fails before schema changes when duplicate Avenia cases exist", async () => { + const addColumn = mock(async () => undefined); + const queryInterface = { + addColumn, + sequelize: { + query: mock(async () => [[{ provider_customer_id: "customer-1" }], undefined]) + } + } as unknown as QueryInterface; + + await expect(up(queryInterface)).rejects.toThrow("duplicate rows exist"); + expect(addColumn).not.toHaveBeenCalled(); +}); diff --git a/apps/api/src/database/migrations/062-add-kyb-submission-state.ts b/apps/api/src/database/migrations/062-add-kyb-submission-state.ts new file mode 100644 index 000000000..261400c0e --- /dev/null +++ b/apps/api/src/database/migrations/062-add-kyb-submission-state.ts @@ -0,0 +1,51 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +export async function up(queryInterface: QueryInterface): Promise { + const [duplicateCases] = await queryInterface.sequelize.query( + `SELECT provider_customer_id + FROM kyc_cases + WHERE provider = 'avenia' AND provider_customer_id IS NOT NULL + GROUP BY provider_customer_id + HAVING COUNT(*) > 1 + LIMIT 1` + ); + if (Array.isArray(duplicateCases) && duplicateCases.length > 0) { + throw new Error("Cannot enforce one Avenia KYC case per provider customer while duplicate rows exist"); + } + + await queryInterface.addColumn("kyc_cases", "submission_status", { + allowNull: false, + defaultValue: "not_started", + type: DataTypes.STRING(16) + }); + await queryInterface.addColumn("kyc_cases", "submission_request_hash", { + allowNull: true, + type: DataTypes.STRING(64) + }); + await queryInterface.addColumn("kyc_cases", "submission_started_at", { + allowNull: true, + type: DataTypes.DATE + }); + await queryInterface.sequelize.query( + "UPDATE kyc_cases SET submission_status = 'submitted' WHERE provider_case_id IS NOT NULL" + ); + await queryInterface.addConstraint("kyc_cases", { + fields: ["submission_status"], + name: "kyc_cases_submission_status_check", + type: "check", + where: { submission_status: ["not_started", "submitting", "submitted", "unknown"] } + }); + await queryInterface.addIndex("kyc_cases", ["provider_customer_id"], { + name: "uniq_kyc_cases_avenia_provider_customer", + unique: true, + where: { provider: "avenia" } + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.removeIndex("kyc_cases", "uniq_kyc_cases_avenia_provider_customer"); + await queryInterface.removeConstraint("kyc_cases", "kyc_cases_submission_status_check"); + await queryInterface.removeColumn("kyc_cases", "submission_started_at"); + await queryInterface.removeColumn("kyc_cases", "submission_request_hash"); + await queryInterface.removeColumn("kyc_cases", "submission_status"); +} diff --git a/apps/api/src/models/kycCase.model.ts b/apps/api/src/models/kycCase.model.ts index d5451ec4a..24356677f 100644 --- a/apps/api/src/models/kycCase.model.ts +++ b/apps/api/src/models/kycCase.model.ts @@ -4,6 +4,7 @@ import type CustomerEntity from "./customerEntity.model"; import type { ProviderName, VerificationStatus } from "./providerCustomer.model"; export type KycCaseType = "kyc" | "kyb"; +export type KycSubmissionStatus = "not_started" | "submitting" | "submitted" | "unknown"; // Unified KYC/KYB verification attempts, independent of the provider account row. // Replaces the dead kyc_level_2 table (no data conversion — it had no readers). @@ -18,6 +19,9 @@ export interface KycCaseAttributes { statusExternal: string | null; providerCaseId: string | null; failureReasons: string[] | null; + submissionStatus: KycSubmissionStatus; + submissionRequestHash: string | null; + submissionStartedAt: Date | null; submittedAt: Date | null; approvedAt: Date | null; rejectedAt: Date | null; @@ -34,6 +38,9 @@ type KycCaseCreationAttributes = Optional< | "statusExternal" | "providerCaseId" | "failureReasons" + | "submissionStatus" + | "submissionRequestHash" + | "submissionStartedAt" | "submittedAt" | "approvedAt" | "rejectedAt" @@ -52,6 +59,9 @@ class KycCase extends Model implem declare statusExternal: string | null; declare providerCaseId: string | null; declare failureReasons: string[] | null; + declare submissionStatus: KycSubmissionStatus; + declare submissionRequestHash: string | null; + declare submissionStartedAt: Date | null; declare submittedAt: Date | null; declare approvedAt: Date | null; declare rejectedAt: Date | null; @@ -135,6 +145,23 @@ KycCase.init( field: "status_external", type: DataTypes.STRING(255) }, + submissionRequestHash: { + allowNull: true, + field: "submission_request_hash", + type: DataTypes.STRING(64) + }, + submissionStartedAt: { + allowNull: true, + field: "submission_started_at", + type: DataTypes.DATE + }, + submissionStatus: { + allowNull: false, + defaultValue: "not_started", + field: "submission_status", + type: DataTypes.STRING(16), + validate: { isIn: [["not_started", "submitting", "submitted", "unknown"]] } + }, submittedAt: { allowNull: true, field: "submitted_at", @@ -161,6 +188,12 @@ KycCase.init( { fields: ["provider_customer_id"], name: "idx_kyc_cases_provider_customer_id" + }, + { + fields: ["provider_customer_id"], + name: "uniq_kyc_cases_avenia_provider_customer", + unique: true, + where: { provider: "avenia" } } ], modelName: "KycCase", diff --git a/apps/api/src/tests/contracts/avenia.contract.test.ts b/apps/api/src/tests/contracts/avenia.contract.test.ts index 1f8e48567..536845d39 100644 --- a/apps/api/src/tests/contracts/avenia.contract.test.ts +++ b/apps/api/src/tests/contracts/avenia.contract.test.ts @@ -15,6 +15,9 @@ * BRLA balance, and reading one needs the id of a real payout. * `createOnchainSwapQuote`/`createOnchainSwapTicket`/`getMainAccountBalance`/ * `getAveniaSwapTicket` have no production consumers and are deliberately uncovered. + * + * TODO: Add sandbox contract coverage for every consumed Avenia KYC/KYB operation and + * complete flow, including documents, UBOs, API submissions, attempts, and status polling. */ import { randomUUID } from "node:crypto"; import { describe, expect, test } from "bun:test"; diff --git a/docs/README.md b/docs/README.md index 47fb83e57..5c057c2e2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -25,6 +25,7 @@ The smaller set of general project documents stays directly in `docs/`: | [`product-dashboard.md`](product-dashboard.md) | Current dashboard product scope and acknowledged gaps | | [`proposal-headless-profiles-and-pricing-plans.md`](proposal-headless-profiles-and-pricing-plans.md) | Active proposal for delegated management of headless customer profiles | | [`proposal-mcp-server.md`](proposal-mcp-server.md) | Active, non-authoritative discussion draft | +| [`proposal-unified-kyc-kyb-api.md`](proposal-unified-kyc-kyb-api.md) | Early proposal for API-driven, provider-neutral customer verification | The root [`README.md`](../README.md) is human onboarding, [`MAP.md`](../MAP.md) is repository wayfinding, and `CLAUDE.md` files contain instructions for coding agents. diff --git a/docs/proposal-unified-kyc-kyb-api.md b/docs/proposal-unified-kyc-kyb-api.md new file mode 100644 index 000000000..c92b6fd9a --- /dev/null +++ b/docs/proposal-unified-kyc-kyb-api.md @@ -0,0 +1,160 @@ +# Proposal: Unified KYC and KYB API + +Status: proposed, early discussion draft. This document currently seeks agreement on +scope, invariants, and delivery order. Exact routes, schemas, and provider-specific field +contracts remain open. Last updated: 2026-08-06. + +Related material: + +- [`Proposal: Managed Headless Profiles`](proposal-headless-profiles-and-pricing-plans.md) +- [`Identity, Customer, and Partner Model`](architecture-identity-model.md) +- [`Avenia KYB Level 1 - API`](https://integration-guide.avenia.io/docs/KYB/kybLevel1Api) +- [`Avenia KYB Level 1 - Web SDK`](https://integration-guide.avenia.io/docs/KYB/kybLevel1) + +## Objective + +Allow a customer, or an authorized manager acting for a managed headless customer, to +complete corridor-supported KYC or KYB through the Vortex API without requiring the +Vortex dashboard, widget, or a provider-hosted onboarding UI. + +In parallel, replace the current collection of provider-named onboarding endpoints with +the smallest practical common API. The API should select the provider from the corridor +and customer type, expose Vortex-owned verification resources and canonical statuses, +and retain provider-specific input only where the underlying checks genuinely differ. + +The first delivery item is Avenia's new API-based Level 1 KYB flow. Today, Vortex starts +Avenia company KYB through the Web SDK endpoint and sends the customer to separate hosted +company and representative URLs. The new provider flow lets Vortex submit company data, +UBOs, and documents server-to-server and track the resulting attempt. + +Avenia is the first vertical slice, not the scope of the unified API. The common envelope +and lifecycle must continue to accommodate Alfredpay and future integrators without making +their callers depend on Avenia-specific routing or identifiers. + +## Initial scope + +- Focus on Avenia and Alfredpay. +- Start with Avenia Level 1 KYB for the BRL corridor. +- Support both self-service profiles and manager-to-child delegated operations as the + managed-headless-profile authorization work becomes available. +- Keep the resource model and provider boundary suitable for another future integrator. +- Do not redesign Monerium or other provider flows in this proposal. +- Do not let callers approve a case, override a provider decision, or write canonical + compliance status directly. + +"API-driven" means that an integrator can collect data in its own experience and perform +the workflow through Vortex API operations. Pre-signed document uploads and unavoidable +identity/liveness steps may still involve a provider-controlled URL, but the flow must not +depend on a Vortex UI. + +## Existing foundation + +The persistence model is already mostly provider-neutral: + +```text +profile + -> customer entity + -> provider customer + -> KYC/KYB case +``` + +`provider_customers` owns the durable corridor/provider account, while `kyc_cases` owns a +verification attempt and its canonical `started`, `pending`, `in_review`, `approved`, or +`rejected` status. This model should be reused rather than introducing a second onboarding +or compliance identity. + +The current API is less unified than the storage model: + +- most Avenia and all Alfredpay KYC/KYB routes require a Supabase browser session; +- route names, request shapes, document handling, retries, and status responses expose + provider workflow details; +- the dashboard orchestrates separate provider XState machines and polls the aggregated + `GET /v1/onboarding/status` read model. + +Authentication, delegated authorization, and manager-to-child ownership are defined by +the [managed-headless-profiles proposal](proposal-headless-profiles-and-pricing-plans.md) +and are not repeated here. This proposal defines the verification workflow applied after +the operation profile has been resolved. + +## Tentative generic flow + +The ideal API exposes the workflow as discoverable stages instead of requiring an +integrator to know a provider's sequence in advance: + +1. **Discover requirements.** The caller requests the requirements for a KYC or KYB by + corridor and customer type. Vortex derives the provider and returns an overview of the + required data fields, document types, and any liveness or selfie requirement. The + requirements are provider- and country-specific even though their envelope is common. + The response includes a stable requirements version. +2. **Create the attempt and submit initial data.** The caller creates a verification case + with the structured data already available, such as personal or company name, address, + tax information, representatives, or beneficial owners. The exact fields follow the + requirements returned for that corridor, and the case pins that requirements version. +3. **Upload documents when required.** The caller creates and uploads each required + document using the mechanism supported by Vortex for that provider. Vortex creates and + returns its own stable identifier for each document or document batch before upload; + upstream identifiers are stored only as internal mappings. +4. **Complete liveness or selfie evidence when required.** The case may return a liveness + continuation step or accept a selfie document upload, depending on the provider and + country. +5. **Submit and track the case.** Once all required stages are complete, Vortex submits or + finalizes the provider attempt and exposes its canonical status until it is approved, + rejected, or requires another supported action. + +Not every provider needs every stage. The requirements response determines which stages +apply and gives API clients enough information to build their own collection experience +without embedding Vortex's dashboard workflow. + +## API principles + +- The server derives the provider from corridor and customer type. A caller cannot select + an arbitrary provider account or provider case belonging to another subject. +- Public responses use Vortex case identifiers and canonical status. Provider identifiers + stay internal unless a specific continuation step requires an opaque reference. +- Provider-specific data is represented explicitly rather than forced into a misleading + lowest-common-denominator schema. +- Document operations are scoped to the subject, provider customer, case, and expected + document type before Vortex issues an upload target or forwards content. +- Provider-confirmed state remains authoritative. Client completion events cannot mark a + case approved. +- Delegated operations retain both the manager actor and child subject for authorization + and audit while keeping the child as the resource owner. +- Case creation, document submission, and final submission define retry-safe behavior so + a client timeout cannot silently create duplicate provider-side effects. + +## Delivery order + +1. Implement Avenia Level 1 KYB through its API flow: create or reuse the company + subaccount, create and upload company and UBO documents, register UBOs, submit the KYB + attempt, and synchronize its result into the existing provider customer and KYB case. +2. Use that vertical slice to define the common Vortex case lifecycle and operations for + starting, continuing, submitting, reading, and retrying verification. +3. Make those operations available to self-service API credentials and to manager + credentials acting on an authorized managed child, without changing resource ownership. +4. Adapt Alfredpay's API-based KYC/KYB flows to the same lifecycle while retaining its + corridor-specific forms, document sets, and hosted-flow exceptions. +5. Migrate first-party UI consumers, then retire provider-named public onboarding routes + only after compatibility requirements are known. + +The first item must not wait for the complete cross-provider API design. It should reuse +the current canonical tables and status rules so the Avenia work becomes the first adapter +behind the unified API rather than a parallel compliance model. + +The first vertical slice does not need to settle every cross-provider resource or webhook +decision. It must preserve the common envelope, pin the requirements version, use Vortex +resource identifiers, keep provider identifiers internal, and make external side effects +safe to retry. + +## First open decisions + +- What is the smallest common resource shape: one provider customer with a current case, + or an explicit append-only list of attempts? +- Which operation vocabulary fits both providers without hiding meaningful differences? +- Should Vortex proxy document bytes, issue provider pre-signed upload URLs, or support + both patterns behind one document resource? +- Which contact data must be supplied as case data when a provider requires it? +- Which status changes should produce API webhooks so headless callers do not have to + poll indefinitely? + +The next revision should answer these questions before fixing exact endpoint paths or +request schemas. diff --git a/docs/security-spec/05-integrations/brla.md b/docs/security-spec/05-integrations/brla.md index 8d17949dd..254b71100 100644 --- a/docs/security-spec/05-integrations/brla.md +++ b/docs/security-spec/05-integrations/brla.md @@ -112,18 +112,19 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou 18. **`/v1/brla/createSubaccount` MUST require an authenticated principal and use only canonical identity** — The route uses `requirePartnerOrUserAuth()` and the controller requires an effective user. Bare partner keys and anonymous callers receive `400`; the Avenia API is not called and no `provider_customers` row is created. Existing-tax-ID conflict and reuse decisions inspect only canonical Avenia `provider_customers` ownership. The controller does not query or adopt rows from `tax_ids`. 19. **BRL quote creation MUST remain anonymous-eligible while register/start remain user-gated** — `POST /v1/quotes` and `POST /v1/quotes/best` accept BRL corridors from anonymous callers and partner-key callers (with or without a `userId` binding). The Avenia `createPayInQuote` calls used by the BRL engines do not require a user-bound principal. The actual Avenia subaccount/taxId resolution still happens server-side at register time via `resolveAveniaAccountForRamp(effectiveUserId, additionalData.taxId)`. `POST /v1/ramp/register` requires Supabase or secret-key credentials, and `RampService.registerRamp` rejects provider-backed ramps without an effective user with `400 Invalid quote`. **An anonymous BRL quote may be claimed by an authenticated caller** (the normal web-app funnel: quote before login, register after) — claiming is not an escalation because the anonymous quote carries no owner and the Avenia identity is derived from the claimer's own KYC records, never from the quote or request body. 20. **`brlaPayoutOnBase` MUST verify the ephemeral's BRLA balance before the first broadcast of the presigned transfer** — The presigned payout is single-use (its nonce is consumed even on revert), so the handler calls `ensurePresignedTransferFunded` before `sendRawTransaction`: sender/token/amount are decoded from the signed raw tx and the ephemeral balance is polled (3-minute timeout); a shortfall raises a recoverable error instead of burning the nonce. The Avenia-side balance poll (invariant 4) runs after this on-chain transfer and does not replace it. See `03-ramp-engine/ramp-phase-flows.md` invariant 12. -21. **Avenia company KYB completion MUST be provider-confirmed and ownership-bound** — `POST /v1/brla/kyb/new-level-1/web-sdk` stores the returned Avenia `attemptId` as the owned business `kyc_cases.provider_case_id`. `GET /v1/brla/kyb/attempt-status` accepts only a case owned by the effective user, queries that exact attempt, persists normalized status on both the case and provider customer, and returns only `status`, optional `result`, and optional normalized `failureReason`. Client-side events cannot assert completion: only provider `COMPLETED` plus `APPROVED` may complete onboarding; `REJECTED`, `EXPIRED`, `PENDING`, and `PROCESSING` must not pass the parent verification gate. -22. **A KYB attempt Avenia has not started processing MUST stay canonical `pending`, never `in_review`** — Company subaccount creation and KYB link initiation record `pending` (the attempt is `PENDING` at Avenia until the user completes the hosted steps); `in_review` is set only once Avenia reports `PROCESSING`. While the bound attempt's stored external status is still `PENDING`, re-initiation by the owner is allowed and rebinds the case to the fresh `attemptId` (the hosted URLs are never stored, so this is the only resume path); the `409` conflict applies once the attempt is `PROCESSING` or decided. Because the stored status can lag, re-initiation additionally probes the live attempt and refuses (`409`) when Avenia reports it processing or approved — a rejected decision stays re-initiable, and a failing probe falls back to allowing the resume. This cannot be used to bypass verification: a fresh attempt restarts at `PENDING` and invariant 21's completion gate is unchanged. To support form-less resume, `GET /v1/onboarding/status` exposes `taxReference` (the CNPJ) for **business** rows only — the response is already scoped to the caller's own entities, and individual CPFs remain unexposed. +21. **Avenia company KYB completion MUST be provider-confirmed and ownership-bound** — `POST /v1/brla/kyb/new-level-1/web-sdk` stores the returned Avenia `attemptId` as the owned business `kyc_cases.provider_case_id`. `GET /v1/brla/kyb/attempt-status` accepts only a case owned by the effective user, queries that exact attempt, persists normalized status on both the case and provider customer, and returns only `status`, `retryable`, optional `result`, and optional normalized `failureReason`. Client-side events cannot assert completion: only provider `COMPLETED` plus `APPROVED` may complete onboarding; `REJECTED`, `EXPIRED`, `PENDING`, and `PROCESSING` must not pass the parent verification gate. +22. **A KYB attempt Avenia has not started processing MUST stay canonical `pending`, never `in_review`** — Company subaccount creation and KYB link initiation record `pending` (the attempt is `PENDING` at Avenia until the user completes the hosted steps); `in_review` is set only once Avenia reports `PROCESSING`. While the bound attempt's stored external status is still `PENDING`, re-initiation by the owner is allowed and rebinds the case to the fresh `attemptId` (the hosted URLs are never stored, so this is the only resume path); the `409` conflict applies once the attempt is `PROCESSING` or decided. Because the stored status can lag, re-initiation additionally probes the live attempt and refuses (`409`) when Avenia reports it processing or approved — a rejected decision stays re-initiable, while a failed live probe fails closed rather than risking a duplicate attempt. This cannot be used to bypass verification: a fresh attempt restarts at `PENDING` and invariant 21's completion gate is unchanged. To support form-less resume, `GET /v1/onboarding/status` exposes `taxReference` (the CNPJ) for **business** rows only — the response is already scoped to the caller's own entities, and individual CPFs remain unexposed. 23. **BRL Base destination variants MUST use token-specific static topology** — Base USDC MUST omit Squid entirely. Other configured non-BRLA Base outputs MUST execute exactly one same-chain `squidRouterSwap` phase before `destinationTransfer`; transaction preparation MUST use the Base builder, omit `squidRouterPay` and backup transactions, and allocate `destinationTransfer` at the nonce immediately after the Squid swap. BRLA remains the direct bypass in invariant 14. 24. **Dashboard BRL BUY confirmation MUST not bypass PIX verification** — The dashboard displays the server-generated `depositQrCode`, keeps the ramp unstarted, and calls `/ramp/start` only after the user confirms submitting PIX. That click is not proof of settlement; `brlaOnrampMint` must still verify the Avenia/Base balance before advancing. 25. **Unified BRL limit reads MUST use the authenticated user's provider account** — `POST /v1/limits` MUST derive the Avenia subaccount through `resolveAveniaAccountForUser`; it MUST NOT accept a caller-supplied tax ID or subaccount. BRL `max`, `used`, year, and month are mapped directly from Avenia's BRL fiat-in/fiat-out limit row. Tax IDs and provider subaccount IDs are never returned. 26. **Managed BRLA operations MUST remain child- and corridor-scoped** — Supported customer, KYC/KYB, and onboarding-status routes may derive the effective user from a verified manager selector or direct child credential. Mutating provider/KYC operations require the controlling manager's current `BR` corridor; status and account reads preserve access after corridor removal. Tax IDs and subaccount IDs still require ownership through the child's customer entities. Subaccount creation MUST reject a requested account type that differs from the child's provisioned customer-entity type before calling Avenia, preventing a managed child from acquiring a second entity. `GET /v1/brla/validatePixKey` remains an anonymous preflight utility rather than a managed-child operation: presenting a credential grants no capability unavailable to an anonymous caller, so managed corridor policy does not apply to it. - -26. **The Avenia webhook MUST reject any body whose RSA-PSS signature does not verify** — Verification runs against the raw request bytes before the payload is parsed or any lookup happens. An absent `Signature` header, a non-buffer body, or a failed verify MUST return 401 and MUST NOT enqueue anything. -27. **The Avenia webhook MUST NOT mutate ramp, quote, or verification state** — Its only effect is an `email_notifications` row. A forged or replayed event therefore cannot advance a ramp, approve a user, or move funds; the worst case is a duplicate-suppressed email. -28. **Webhook-triggered emails MUST remain idempotent under replay** — Avenia's signature carries no timestamp or nonce, so replay is not prevented at the transport level. It is neutralised by the `(provider, type, resource_id)` unique index keyed on the Avenia attempt id: a replayed event, or a poll racing a webhook, cannot produce a second email. -29. **Public-key refetches on a signature miss MUST be bounded** — The route is unauthenticated, so any caller can force a miss. Refetches are coalesced into one in-flight request, rate-limited to one per 30-second cooldown, and aborted after 10 seconds; a miss inside the cooldown is rejected without an outbound call. Key rotation is still picked up (within the cooldown), but forged bodies cannot be amplified into load on Avenia or leave a verifier waiting indefinitely. -30. **The webhook body MUST be runtime-validated before any property is read** — A valid signature proves only that Avenia sent the bytes. `JSON.parse` alone admits `null`, arrays, scalars, and attempts missing the fields an email is rendered from, so the receiver accepts Avenia's two documented envelopes (top-level `subAccountId` or nested `event.accountId`), normalizes them, and validates the account id plus `subscription` and, when one is present, the attempt (`id`, `status`, `updatedAt` as non-empty strings; `result` and `resultMessage` as strings when present) before the first property access or database lookup. Anything failing that returns a deterministic `400` and enqueues nothing. An unrecognised *value* of `status` or `result` is not a validation failure: it is a well-formed event with no email mapped to it, and is acknowledged `200` so Avenia does not retry it indefinitely. +27. **Avenia API KYB mutations MUST be ownership-bound and document-gated** — `/v1/brla/kyb/documents`, `/v1/brla/kyb/ubos`, and `/v1/brla/kyb/new-level-1/api` accept Supabase sessions or profile-bound secret API credentials. Every operation resolves the supplied subaccount to an Avenia business `provider_customers` row owned by one of the effective profile's customer entities before calling Avenia. UBO identification/selfie documents and final-submission corporate documents are fetched from that same subaccount and must be provider-ready with the expected document type. Binary bytes are uploaded directly to Avenia's short-lived pre-signed URL; Vortex does not proxy or persist them. +28. **Avenia API KYB resubmission MUST follow the provider's retry decision and preserve ambiguous outcomes** — A successful API submission binds the returned attempt ID to the existing KYB case, sets both canonical rows to `pending`, records external `PENDING`, and clears prior rejection fields. A bound attempt may be replaced only after Avenia returns `COMPLETED`, `REJECTED`, and `retryable: true`; pending, processing, approved, expired, and non-retryable rejected attempts return `409`. A partial unique index enforces one Avenia case per provider account, and the case's separate submission-operation state serializes API and hosted submissions. A transport, process, or persistence failure whose side effect cannot be disproved leaves that state `submitting` or `unknown`; the next identical request lists attempts created since the claim and binds a unique matching attempt instead of replaying it. Absence from that list does not prove the original side effect cannot arrive later, so automatic replay remains blocked pending manual reconciliation. Ambiguous submission failure MUST NOT be converted into a compliance rejection. Hosted initiation cannot replace an API-originated attempt. Status persistence uses monotonic guards and conditionally updates the still-bound provider attempt so stale or late polls cannot overwrite current state. +29. **The Avenia webhook MUST reject any body whose RSA-PSS signature does not verify** — Verification runs against the raw request bytes before the payload is parsed or any lookup happens. An absent `Signature` header, a non-buffer body, or a failed verify MUST return 401 and MUST NOT enqueue anything. +30. **The Avenia webhook MUST NOT mutate ramp, quote, or verification state** — Its only effect is an `email_notifications` row. A forged or replayed event therefore cannot advance a ramp, approve a user, or move funds; the worst case is a duplicate-suppressed email. +31. **Webhook-triggered emails MUST remain idempotent under replay** — Avenia's signature carries no timestamp or nonce, so replay is not prevented at the transport level. It is neutralised by the `(provider, type, resource_id)` unique index keyed on the Avenia attempt id: a replayed event, or a poll racing a webhook, cannot produce a second email. +32. **Public-key refetches on a signature miss MUST be bounded** — The route is unauthenticated, so any caller can force a miss. Refetches are coalesced into one in-flight request, rate-limited to one per 30-second cooldown, and aborted after 10 seconds; a miss inside the cooldown is rejected without an outbound call. Key rotation is still picked up (within the cooldown), but forged bodies cannot be amplified into load on Avenia or leave a verifier waiting indefinitely. +33. **The webhook body MUST be runtime-validated before any property is read** — A valid signature proves only that Avenia sent the bytes. `JSON.parse` alone admits `null`, arrays, scalars, and attempts missing the fields an email is rendered from, so the receiver accepts Avenia's two documented envelopes (top-level `subAccountId` or nested `event.accountId`), normalizes them, and validates the account id plus `subscription` and, when one is present, the attempt (`id`, `status`, `updatedAt` as non-empty strings; `result` and `resultMessage` as strings when present) before the first property access or database lookup. Anything failing that returns a deterministic `400` and enqueues nothing. An unrecognised *value* of `status` or `result` is not a validation failure: it is a well-formed event with no email mapped to it, and is acknowledged `200` so Avenia does not retry it indefinitely. ## Threat Vectors & Mitigations @@ -148,6 +149,7 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou | **Unknown-subaccount probing** | Attacker uses signed events to enumerate which subaccounts Vortex knows | Requires a valid Avenia signature, so it is not reachable by an external attacker; responses are an identical `200 {received:true}` for known, unknown, and partner-owned subaccounts. | | **Destination-token decimal under-delivery** | A BRL on-ramp targets an 18-decimal token such as BSC USDT, but the quote output is truncated to 6 decimals before `destinationTransfer` raw amount construction. | On-ramp finalization uses destination-token decimals for BRL EVM outputs; Squid metadata preserves destination raw output from `route.estimate.toAmount`. | | **Company KYB status bypass or cross-user attempt lookup** | A browser asserts that hosted verification finished, or probes another user's Avenia attempt ID and receives provider submission metadata. | Initiation binds the attempt to the authenticated user's KYB case; status lookup checks that binding before the provider call, minimizes its response, and the client/parent accept only provider-confirmed `COMPLETED` + `APPROVED`. | +| **Duplicate API KYB attempt after timeout** | A caller retries final KYB submission after Avenia accepted the first call but its response was lost, creating parallel provider attempts. | The case is claimed before the provider call. Ambiguous failures persist `submission_status = 'unknown'` and return an upstream error; an identical retry reconciles Avenia's attempt list before it can replay the submission. Provider-confirmed retryable rejection is the only normal resubmission path. | ## Audit Checklist @@ -170,6 +172,7 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou - [x] PIX deposit details released to user only after presign validation. **PASS** — gated by `ephemeralPresignChecksPass` (see `transaction-validation.md`). - [ ] Avenia interactions logged for reconciliation (amounts, not credentials). **PARTIAL** — info logs include amounts; no formal reconciliation log with structured fields. - [x] **FINDING F-064 (MEDIUM)**: BRLA KYC callback endpoint requires authentication. **PASS (FIXED)** — `/kyc/record-attempt` uses `requirePartnerOrUserAuth()` and delegated requests additionally require active BR authorization. +- [x] Avenia API KYB operations enforce effective-profile ownership, company account type, document readiness/type, and provider-confirmed retryability before final submission. - [x] BRL→BRLA-on-Base on-ramps emit only provider mint, funding, and `destinationTransfer` — no Nabla, fee distribution, Squid, final settlement, or Base cleanup transaction. **PASS** — `phases/blocks/flows/brl-onramp-base-direct.ts`. - [x] The BRL→BRLA direct flow omits Squid and final settlement rather than relying on executor short-circuits. **PASS** — `phases/blocks/flows/brl-onramp-base-direct.ts`. - [x] BRL→EVM destination-token precision preserved. **PASS** — block flow simulation preserves Squid destination raw output and destination-token decimals. diff --git a/packages/shared/src/endpoints/brla.endpoints.ts b/packages/shared/src/endpoints/brla.endpoints.ts index fa7b1d0cd..ef41d52d3 100644 --- a/packages/shared/src/endpoints/brla.endpoints.ts +++ b/packages/shared/src/endpoints/brla.endpoints.ts @@ -56,7 +56,7 @@ export interface BrlaGetKycStatusResponse { type: "KYC"; level: string; status: KycAttemptStatus; - result: KycAttemptResult; + result?: KycAttemptResult; failureReason?: KycFailureReason; } diff --git a/packages/shared/src/services/brla/brlaApiService.test.ts b/packages/shared/src/services/brla/brlaApiService.test.ts index b995f37c4..665c46cea 100644 --- a/packages/shared/src/services/brla/brlaApiService.test.ts +++ b/packages/shared/src/services/brla/brlaApiService.test.ts @@ -1,6 +1,9 @@ -import { afterEach, describe, expect, it, mock } from "bun:test"; +import { afterEach, describe, expect, it, mock, test } from "bun:test"; import { generateKeyPairSync } from "crypto"; +import * as forge from "node-forge"; import { BrlaApiService } from "./brlaApiService"; +import { Endpoint } from "./mappings"; +import { AveniaDocumentType, type AveniaKybLevel1Payload, type AveniaUboPayload } from "./types"; const realFetch = globalThis.fetch; @@ -8,6 +11,140 @@ afterEach(() => { globalThis.fetch = realFetch; }); +function serviceWithMockedRequest() { + const service = Object.create(BrlaApiService.prototype) as BrlaApiService; + const sendRequest = mock(async (endpoint: Endpoint) => { + if (endpoint === Endpoint.GetDocument) { + return { + document: { + documentType: AveniaDocumentType.PASSPORT, + id: "document/1", + ready: true, + uploadStatusFront: "PROCESSED" + } + }; + } + if (endpoint === Endpoint.GetKybAttempt) { + return { + attempt: { + createdAt: "2026-08-06T12:00:00.000Z", + id: "attempt-1", + levelName: "kyb-level-1", + resultMessage: "", + retryable: false, + status: "PENDING", + updatedAt: "2026-08-06T12:00:00.000Z" + } + }; + } + return { id: "provider-id" }; + }); + Object.assign(service, { sendRequest }); + return { sendRequest, service }; +} + +const ubo: AveniaUboPayload = { + city: "Sao Paulo", + country: "BRA", + countryOfTaxId: "BRA", + dateOfBirth: "1988-07-22", + documentCountry: "BRA", + fullName: "UBO NAME", + hasControl: "CEO", + percentageOfOwnership: "100", + state: "SP", + streetLine1: "Rua Aurora 456", + taxIdNumber: "11182159111", + uploadedIdentificationId: "document-1", + zipCode: "01209-001" +}; + +const kyb: AveniaKybLevel1Payload = { + businessActivityDescription: "Software development", + certificateOfIncorporationDocumentId: "document-2", + 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: "document-3", + taxIdentificationNumberTin: "42.731.085/0001-67", + uboIds: ["ubo-1"] +}; + +describe("BrlaApiService Avenia KYB Level 1 mappings", () => { + test("substitutes and encodes provider path parameters before signing the request", async () => { + const service = Object.create(BrlaApiService.prototype) as BrlaApiService; + const keyPair = forge.pki.rsa.generateKeyPair(1024); + Object.assign(service, { apiKey: "test-key", privateKey: forge.pki.privateKeyToPem(keyPair.privateKey) }); + const originalFetch = globalThis.fetch; + const fetchMock = mock(async () => new Response(JSON.stringify({ attempt: {} }), { status: 200 })); + globalThis.fetch = fetchMock as typeof fetch; + + try { + await service.sendRequest(Endpoint.GetKybAttempt, "GET", "subAccountId=sub-1", undefined, "attempt/1"); + expect(String(fetchMock.mock.calls[0][0])).toEndWith( + "/v2/kyc/attempts/attempt%2F1?subAccountId=sub-1" + ); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("maps document readiness and UBO creation to subaccount-scoped endpoints", async () => { + const { sendRequest, service } = serviceWithMockedRequest(); + + await service.getUploadedDocument("document/1", "sub account"); + await service.createUbo(ubo, "sub account"); + + expect(sendRequest.mock.calls[0]).toEqual([ + Endpoint.GetDocument, + "GET", + "subAccountId=sub%20account", + undefined, + "document/1" + ]); + expect(sendRequest.mock.calls[1]).toEqual([Endpoint.Ubos, "POST", "subAccountId=sub%20account", ubo]); + }); + + test("maps API KYB submission and subaccount-scoped attempt polling", async () => { + const { sendRequest, service } = serviceWithMockedRequest(); + + await service.submitKybLevel1(kyb, "sub-1"); + await service.getKybAttemptStatus("attempt-1", "sub-1"); + + expect(sendRequest.mock.calls[0]).toEqual([Endpoint.Level1Api, "POST", "subAccountId=sub-1", kyb]); + expect(sendRequest.mock.calls[1]).toEqual([ + Endpoint.GetKybAttempt, + "GET", + "subAccountId=sub-1", + undefined, + "attempt-1" + ]); + }); + + test("includes the corporate and UBO identification document types", () => { + expect(AveniaDocumentType.CERTIFICATE_OF_INCORPORATION).toBe("CERTIFICATE-OF-INCORPORATION"); + expect(AveniaDocumentType.COMPANY_TAX_IDENTIFICATION_DOCUMENT).toBe("COMPANY-TAX-IDENTIFICATION-DOCUMENT"); + expect(AveniaDocumentType.RESIDENCE_PERMIT).toBe("RESIDENCE-PERMIT"); + }); + + test("rejects malformed successful provider responses", async () => { + const service = Object.create(BrlaApiService.prototype) as BrlaApiService; + Object.assign(service, { sendRequest: mock(async () => ({})) }); + + await expect(service.submitKybLevel1(kyb, "sub-1")).rejects.toThrow(); + }); +}); + describe("BrlaApiService.getAveniaPublicKey", () => { it("bounds the public-key request with an abort signal", async () => { let signal: AbortSignal | null | undefined; @@ -35,10 +172,23 @@ describe("BrlaApiService.sendRequest path templating", () => { globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => { requestedUrl = String(input); signal = init?.signal; - return new Response(JSON.stringify({ attempt: { id: "attempt-9" } }), { + return new Response( + JSON.stringify({ + attempt: { + createdAt: "2026-08-06T12:00:00.000Z", + id: "attempt-9", + levelName: "kyb-level-1", + resultMessage: "", + retryable: false, + status: "PENDING", + updatedAt: "2026-08-06T12:00:00.000Z" + } + }), + { headers: { "Content-Type": "application/json" }, status: 200 - }); + } + ); }); const { privateKey } = generateKeyPairSync("rsa", { diff --git a/packages/shared/src/services/brla/brlaApiService.ts b/packages/shared/src/services/brla/brlaApiService.ts index 2c98667ad..b882d2aa8 100644 --- a/packages/shared/src/services/brla/brlaApiService.ts +++ b/packages/shared/src/services/brla/brlaApiService.ts @@ -3,20 +3,33 @@ import { BRLA_API_KEY, BRLA_BASE_URL, BRLA_PRIVATE_KEY, DocumentUploadRequest, D import logger from "../../logger"; import { ProviderHttpError } from "../providerHttpError"; import { Endpoint, EndpointMethod, EndpointRequestBody, EndpointResponse, Endpoints } from "./mappings"; +import { + aveniaDocumentResponseSchema, + aveniaDocumentUploadResponseSchema, + aveniaKybAttemptStatusSchema, + aveniaKybLevel1ResponseSchema, + aveniaKycAttemptsSchema, + aveniaLevel1ResponseSchema, + aveniaUboResponseSchema +} from "./schemas"; import { AccountLimitsResponse, AveniaAccountBalanceResponse, AveniaAccountInfoResponse, AveniaAccountType, AveniaDocumentGetResponse, + AveniaDocumentResponse, AveniaDocumentType, AveniaKybAttemptStatusResponse, + AveniaKybLevel1Payload, AveniaPayinTicket, AveniaPaymentMethod, AveniaPayoutTicket, AveniaPublicKeyResponse, AveniaQuoteResponse, AveniaSwapTicket, + AveniaUboPayload, + AveniaUboResponse, AveniaWebhookRegistration, AveniaWebhooksListResponse, BlockchainSendMethod, @@ -130,7 +143,10 @@ export class BrlaApiService { // Endpoints that carry a {placeholder} interpolate it; the rest append the segment. // Appending to a templated path would sign and request a literal "{attemptId}". if (pathParam) { - requestUri = requestUri.includes("{") ? requestUri.replace(/\{[^}]+\}/, pathParam) : `${requestUri}/${pathParam}`; + const encodedPathParam = encodeURIComponent(pathParam); + requestUri = requestUri.includes("{") + ? requestUri.replace(/\{[^}]+\}/, encodedPathParam) + : `${requestUri}/${encodedPathParam}`; } if (queryParams) { requestUri += `?${queryParams}`; @@ -230,7 +246,7 @@ export class BrlaApiService { isDoubleSided }; const query = `subAccountId=${encodeURIComponent(subAccountId)}`; - return await this.sendRequest(Endpoint.Documents, "POST", query, payload); + return aveniaDocumentUploadResponseSchema.parse(await this.sendRequest(Endpoint.Documents, "POST", query, payload)); } public async getUploadedDocuments(subAccountId: string): Promise { @@ -238,6 +254,18 @@ export class BrlaApiService { return await this.sendRequest(Endpoint.Documents, "GET", query, undefined); } + public async getUploadedDocument(documentId: string, subAccountId: string): Promise { + const query = `subAccountId=${encodeURIComponent(subAccountId)}`; + return aveniaDocumentResponseSchema.parse( + await this.sendRequest(Endpoint.GetDocument, "GET", query, undefined, documentId) + ); + } + + public async createUbo(payload: AveniaUboPayload, subAccountId: string): Promise { + const query = `subAccountId=${encodeURIComponent(subAccountId)}`; + return aveniaUboResponseSchema.parse(await this.sendRequest(Endpoint.Ubos, "POST", query, payload)); + } + public async createPayInQuote( quoteParams: PayInQuoteParams, options: { useCache?: boolean } = {} @@ -381,12 +409,17 @@ export class BrlaApiService { public async submitKycLevel1(payload: KycLevel1Payload): Promise { const query = `subAccountId=${encodeURIComponent(payload.subAccountId)}`; - return await this.sendRequest(Endpoint.KycLevel1, "POST", query, payload); + return aveniaLevel1ResponseSchema.parse(await this.sendRequest(Endpoint.Level1Api, "POST", query, payload)); + } + + public async submitKybLevel1(payload: AveniaKybLevel1Payload, subAccountId: string): Promise { + const query = `subAccountId=${encodeURIComponent(subAccountId)}`; + return aveniaLevel1ResponseSchema.parse(await this.sendRequest(Endpoint.Level1Api, "POST", query, payload)); } public async getKycAttempts(subAccountId: string): Promise { const query = `subAccountId=${encodeURIComponent(subAccountId)}`; - return await this.sendRequest(Endpoint.GetKycAttempt, "GET", query, undefined); + return aveniaKycAttemptsSchema.parse(await this.sendRequest(Endpoint.GetKycAttempt, "GET", query, undefined)); } /** @@ -398,7 +431,7 @@ export class BrlaApiService { const query = `subAccountId=${encodeURIComponent(subAccountId)}`; // Avenia requires the field to be present but ignores its value for the Web SDK flow. const payload = { redirectUrl: "" }; - return await this.sendRequest(Endpoint.KybLevel1WebSdk, "POST", query, payload); + return aveniaKybLevel1ResponseSchema.parse(await this.sendRequest(Endpoint.KybLevel1WebSdk, "POST", query, payload)); } /** @@ -406,8 +439,11 @@ export class BrlaApiService { * @param attemptId The KYB attempt ID * @returns The KYB attempt status */ - public async getKybAttemptStatus(attemptId: string): Promise { - return await this.sendRequest(Endpoint.GetKybAttempt, "GET", undefined, undefined, attemptId); + public async getKybAttemptStatus(attemptId: string, subAccountId?: string): Promise { + const query = subAccountId ? `subAccountId=${encodeURIComponent(subAccountId)}` : undefined; + return aveniaKybAttemptStatusSchema.parse( + await this.sendRequest(Endpoint.GetKybAttempt, "GET", query, undefined, attemptId) + ); } public async listWebhooks(): Promise { diff --git a/packages/shared/src/services/brla/mappings.ts b/packages/shared/src/services/brla/mappings.ts index ae26232c6..e4e63c2de 100644 --- a/packages/shared/src/services/brla/mappings.ts +++ b/packages/shared/src/services/brla/mappings.ts @@ -4,12 +4,16 @@ import { AveniaAccountInfoResponse, AveniaAccountType, AveniaDocumentGetResponse, + AveniaDocumentResponse, AveniaKybAttemptStatusResponse, + AveniaKybLevel1Payload, AveniaPayinTicket, AveniaPayoutTicket, AveniaQuoteResponse, AveniaSubaccount, AveniaSwapTicket, + AveniaUboPayload, + AveniaUboResponse, AveniaWebhookRegistration, AveniaWebhooksListResponse, DocumentUploadRequest, @@ -30,12 +34,14 @@ export enum Endpoint { GetSubaccount = "/v2/account/sub-accounts", AccountLimits = "/v2/account/limits", PixInfo = "/v2/account/bank-accounts/brl/pix-info", - KycLevel1 = "/v2/kyc/new-level-1/api", + Level1Api = "/v2/kyc/new-level-1/api", KybLevel1WebSdk = "/v2/kyc/new-level-1/web-sdk", FixedRateQuote = "/v2/account/quote/fixed-rate", Tickets = "/v2/account/tickets", AccountInfo = "/v2/account/account-info", Documents = "/v2/documents", + GetDocument = "/v2/documents/{documentId}", + Ubos = "/v2/account/ubos", GetKycAttempt = "/v2/kyc/attempts", GetKybAttempt = "/v2/kyc/attempts/{attemptId}", Balances = "/v2/account/balances", @@ -85,9 +91,9 @@ export interface EndpointMapping { response: undefined; }; }; - [Endpoint.KycLevel1]: { + [Endpoint.Level1Api]: { POST: { - body: KycLevel1Payload; + body: KycLevel1Payload | AveniaKybLevel1Payload; response: KycLevel1Response; }; GET: { @@ -157,6 +163,34 @@ export interface EndpointMapping { response: undefined; }; }; + [Endpoint.GetDocument]: { + POST: { + body: undefined; + response: undefined; + }; + GET: { + body: undefined; + response: AveniaDocumentResponse; + }; + PATCH: { + body: undefined; + response: undefined; + }; + }; + [Endpoint.Ubos]: { + POST: { + body: AveniaUboPayload; + response: AveniaUboResponse; + }; + GET: { + body: undefined; + response: undefined; + }; + PATCH: { + body: undefined; + response: undefined; + }; + }; [Endpoint.GetKycAttempt]: { POST: { body: undefined; diff --git a/packages/shared/src/services/brla/schemas.test.ts b/packages/shared/src/services/brla/schemas.test.ts index 6e5ffef4a..229370613 100644 --- a/packages/shared/src/services/brla/schemas.test.ts +++ b/packages/shared/src/services/brla/schemas.test.ts @@ -3,11 +3,15 @@ import { aveniaAccountBalanceSchema, aveniaAccountInfoSchema, aveniaAccountLimitsSchema, + aveniaDocumentResponseSchema, + aveniaKybAttemptStatusSchema, + aveniaLevel1ResponseSchema, aveniaPayinTicketsSchema, aveniaPayoutTicketSchema, aveniaPixInputTicketSchema, aveniaPixKeyDataSchema, aveniaQuoteResponseSchema, + aveniaUboResponseSchema, aveniaWebhookRegistrationSchema, aveniaWebhooksListSchema } from "./schemas"; @@ -140,6 +144,42 @@ describe("aveniaAccountInfoSchema", () => { }); }); +describe("Avenia KYB Level 1 response schemas", () => { + test("accepts document readiness and identifier responses", () => { + expect(() => + aveniaDocumentResponseSchema.parse({ + document: { + documentType: "CERTIFICATE-OF-INCORPORATION", + id: "document-1", + ready: true, + uploadStatusFront: "PROCESSED" + } + }) + ).not.toThrow(); + expect(() => aveniaDocumentResponseSchema.parse({ document: { id: "document-1", ready: true } })).toThrow(); + expect(() => aveniaUboResponseSchema.parse({ id: "ubo-1" })).not.toThrow(); + expect(() => aveniaLevel1ResponseSchema.parse({ id: "attempt-1" })).not.toThrow(); + }); + + test("accepts the documented completed KYB attempt and pending attempts without a result", () => { + const attempt = { + createdAt: "2026-03-19T22:09:52.629984Z", + id: "attempt-1", + levelName: "kyb-level-1", + result: "APPROVED", + resultMessage: "", + retryable: false, + status: "COMPLETED", + updatedAt: "2026-03-19T22:09:52.629984Z" + }; + expect(() => aveniaKybAttemptStatusSchema.parse({ attempt })).not.toThrow(); + expect(() => + aveniaKybAttemptStatusSchema.parse({ attempt: { ...attempt, result: undefined, status: "PENDING" } }) + ).not.toThrow(); + expect(() => aveniaKybAttemptStatusSchema.parse({ attempt: { ...attempt, status: "APPROVED" } })).toThrow(); + }); +}); + describe("Avenia webhook management schemas", () => { test("accepts the create response's webhookId field", () => { expect(() => aveniaWebhookRegistrationSchema.parse({ webhookId: "webhook-1" })).not.toThrow(); diff --git a/packages/shared/src/services/brla/schemas.ts b/packages/shared/src/services/brla/schemas.ts index 9e5c422e1..af8ff0aea 100644 --- a/packages/shared/src/services/brla/schemas.ts +++ b/packages/shared/src/services/brla/schemas.ts @@ -2,7 +2,10 @@ import { z } from "zod"; import { AveniaAccountBalanceResponse, AveniaAccountInfoResponse, + AveniaDocument, + AveniaDocumentType, AveniaFeeType, + AveniaKybAttemptStatusResponse, AveniaOperationFee, AveniaPayinTicket, AveniaPayoutTicket, @@ -10,9 +13,17 @@ import { AveniaSubaccountAccountInfo, AveniaSubaccountWallet, AveniaTicketStatus, + AveniaUboResponse, AveniaWebhook, AveniaWebhookRegistration, AveniaWebhooksListResponse, + DocumentUploadResponse, + GetKycAttemptResponse, + KybLevel1Response, + KycAttempt, + KycAttemptResult, + KycAttemptStatus, + KycLevel1Response, Limit, PixInputTicketOutput, PixKeyData, @@ -140,6 +151,71 @@ export const aveniaAccountInfoSchema = z.looseObject({ ) }) satisfies z.ZodType; +/** A document after Avenia has processed the bytes uploaded to its pre-signed URL. */ +export const aveniaDocumentResponseSchema = z.looseObject({ + document: z.looseObject({ + createdAt: z.string().min(1).optional(), + documentType: z.enum(AveniaDocumentType), + id: z.string().min(1), + ready: z.boolean(), + updatedAt: z.string().min(1).optional(), + uploadErrorBack: z.string().optional(), + uploadErrorFront: z.string().optional(), + uploadStatusBack: z.string().optional(), + uploadStatusFront: z.string().min(1), + uploadURLBack: z.string().optional(), + uploadURLFront: z.string().optional() + }) +}) satisfies z.ZodType<{ document: AveniaDocument }>; + +/** The upload target returned when an Avenia document record is created. */ +export const aveniaDocumentUploadResponseSchema = z.looseObject({ + id: z.string().min(1), + livenessUrl: z.string().min(1).optional(), + uploadURLBack: z.string().optional(), + uploadURLFront: z.string().min(1), + validateLivenessToken: z.string().min(1).optional() +}) satisfies z.ZodType; + +/** The identifier returned by UBO creation. */ +export const aveniaUboResponseSchema = z.looseObject({ + id: z.string().min(1) +}) satisfies z.ZodType; + +/** The attempt identifier returned by API-based KYC and KYB Level 1 submissions. */ +export const aveniaLevel1ResponseSchema = z.looseObject({ + id: z.string().min(1) +}) satisfies z.ZodType; + +/** The hosted company KYB attempt and continuation URLs. */ +export const aveniaKybLevel1ResponseSchema = z.looseObject({ + attemptId: z.string().min(1), + authorizedRepresentativeUrl: z.string().min(1), + basicCompanyDataUrl: z.string().min(1) +}) satisfies z.ZodType; + +const aveniaAttemptSchema = z.looseObject({ + createdAt: z.string().datetime({ offset: true }), + id: z.string().min(1), + levelName: z.string().min(1), + result: z.enum(KycAttemptResult).optional(), + resultMessage: z.string(), + retryable: z.boolean(), + status: z.enum(KycAttemptStatus), + submissionData: z.record(z.string(), z.unknown()).optional(), + updatedAt: z.string().datetime({ offset: true }) +}) satisfies z.ZodType; + +/** Paginated attempt history used to reconcile an ambiguous submission. */ +export const aveniaKycAttemptsSchema = z.looseObject({ + attempts: z.array(aveniaAttemptSchema) +}) satisfies z.ZodType; + +/** A KYB attempt returned by GET /v2/kyc/attempts/{attemptId}. */ +export const aveniaKybAttemptStatusSchema = z.looseObject({ + attempt: aveniaAttemptSchema +}) satisfies z.ZodType; + /** The body returned after POST /v2/notifications/webhooks. */ export const aveniaWebhookRegistrationSchema = z.looseObject({ webhookId: z.string().min(1) diff --git a/packages/shared/src/services/brla/types.ts b/packages/shared/src/services/brla/types.ts index d4de12889..311c63029 100644 --- a/packages/shared/src/services/brla/types.ts +++ b/packages/shared/src/services/brla/types.ts @@ -348,6 +348,122 @@ export interface KycLevel1Response { id: string; } +export type AveniaUboControlRole = + | "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"; + +export interface AveniaUboPayload { + fullName: string; + dateOfBirth: string; + countryOfTaxId: string; + taxIdNumber: string; + email?: string; + phone?: string; + percentageOfOwnership: string; + hasControl?: AveniaUboControlRole; + uploadedIdentificationId: string; + uploadedSelfieId?: string; + documentCountry: string; + streetLine1: string; + streetLine2?: string; + streetLine3?: string; + city: string; + state: string; + zipCode: string; + country: string; +} + +export interface AveniaUboResponse { + id: string; +} + +export type AveniaKybReasonForAccountOpening = + | "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"; + +export type AveniaKybSourceOfFunds = + | "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"; + +export type AveniaKybNumberOfEmployees = "1-10" | "11-50" | "51-200" | "201-500" | "501-1000" | "1001+"; + +export type AveniaKybAnnualRevenue = + | "less_than_100k" + | "100k_to_1m" + | "1m_to_10m" + | "10m_to_50m" + | "50m_to_100m" + | "more_than_100m"; + +export interface AveniaKybLevel1Payload { + uboIds: string[]; + companyLegalName: string; + companyRegistrationNumber: string; + taxIdentificationNumberTin: string; + businessActivityDescription: string; + reasonForAccountOpening: AveniaKybReasonForAccountOpening; + sourceOfFundsAndIncome: AveniaKybSourceOfFunds; + numberOfEmployees: AveniaKybNumberOfEmployees; + estimatedAnnualRevenueUsd: AveniaKybAnnualRevenue; + estimatedMonthlyVolumeUsd: string; + countryTaxResidence: string; + countrySubdivisionTaxResidence?: string; + companyStreetLine1: string; + companyStreetLine2?: string; + companyStreetLine3?: string; + companyCity: string; + companyState: string; + companyZipCode: string; + companyCountry: string; + certificateOfIncorporationDocumentId: string; + taxIdentificationDocumentId: string; + website?: string; + socialMedia?: string; + emailPixKey?: string; + sandboxReject?: boolean; +} + export interface KybLevel1Response { attemptId: string; authorizedRepresentativeUrl: string; @@ -363,7 +479,7 @@ export interface KybLevel1Response { export interface AveniaVerificationAttempt { id: string; levelName: string; - submissionData: Record; + submissionData?: Record; status: KycAttemptStatus; result?: KycAttemptResult; resultMessage?: string; @@ -375,19 +491,23 @@ export interface AveniaVerificationAttempt { export interface KybAttemptStatusResponse { failureReason?: string; result?: KycAttemptResult; + retryable?: boolean; status: KycAttemptStatus; } export interface AveniaKybAttemptStatusResponse { - attempt: AveniaVerificationAttempt; + attempt: AveniaVerificationAttempt & { resultMessage: string }; } export enum AveniaDocumentType { ID = "ID", DRIVERS_LICENSE = "DRIVERS-LICENSE", PASSPORT = "PASSPORT", + RESIDENCE_PERMIT = "RESIDENCE-PERMIT", SELFIE = "SELFIE", - SELFIE_FROM_LIVENESS = "SELFIE-FROM-LIVENESS" + SELFIE_FROM_LIVENESS = "SELFIE-FROM-LIVENESS", + CERTIFICATE_OF_INCORPORATION = "CERTIFICATE-OF-INCORPORATION", + COMPANY_TAX_IDENTIFICATION_DOCUMENT = "COMPANY-TAX-IDENTIFICATION-DOCUMENT" } export interface DocumentUploadRequest { @@ -403,6 +523,24 @@ export interface DocumentUploadResponse { validateLivenessToken?: string; } +export interface AveniaDocument { + id: string; + documentType: AveniaDocumentType; + uploadURLFront?: string; + uploadStatusFront: string; + uploadErrorFront?: string; + uploadURLBack?: string; + uploadStatusBack?: string; + uploadErrorBack?: string; + ready: boolean; + createdAt?: string; + updatedAt?: string; +} + +export interface AveniaDocumentResponse { + document: AveniaDocument; +} + export enum KycAttemptStatus { PENDING = "PENDING", PROCESSING = "PROCESSING", @@ -417,10 +555,10 @@ export enum KycAttemptResult { export interface KycAttempt { id: string; - levelName: "level-1"; - submissionData: unknown; + levelName: string; + submissionData?: unknown; status: KycAttemptStatus; - result: KycAttemptResult; + result?: KycAttemptResult; resultMessage: string; retryable: boolean; createdAt: string; @@ -438,21 +576,7 @@ export interface CreateAveniaSubaccountRequest { } export interface AveniaDocumentGetResponse { - documents: [ - { - id: string; - documentType: string; - uploadURLFront: string; - uploadStatusFront: string; - uploadErrorFront: string; - uploadURLBack: string; - uploadStatusBack: string; - uploadErrorBack: string; - ready: true; - createdAt: Date; - updatedAt: Date; - } - ]; + documents: AveniaDocument[]; } export interface AveniaAccountBalanceResponse {